diff --git a/.cargo/ci-config.toml b/.cargo/ci-config.toml index d5e312c242..b31b79a59b 100644 --- a/.cargo/ci-config.toml +++ b/.cargo/ci-config.toml @@ -5,12 +5,16 @@ # Arrays are merged together though. See: https://doc.rust-lang.org/cargo/reference/config.html#hierarchical-structure # The intent for this file is to configure CI build process with a divergance from Zed developers experience; for example, in this config file # we use `-D warnings` for rustflags (which makes compilation fail in presence of warnings during build process). Placing that in developers `config.toml` -# would be incovenient. +# would be inconvenient. # The reason for not using the RUSTFLAGS environment variable is that doing so would override all the settings in the config.toml file, even if the contents of the latter are completely nonsensical. See: https://github.com/rust-lang/cargo/issues/5376 # Here, we opted to use `[target.'cfg(all())']` instead of `[build]` because `[target.'**']` is guaranteed to be cumulative. [target.'cfg(all())'] rustflags = ["-D", "warnings"] +# We don't need fullest debug information for dev stuff (tests etc.) in CI. +[profile.dev] +debug = "limited" + # Use Mold on Linux, because it's faster than GNU ld and LLD. # # We no longer set this in the default `config.toml` so that developers can opt in to Wild, which diff --git a/.config/hakari.toml b/.config/hakari.toml deleted file mode 100644 index 1e8386a141..0000000000 --- a/.config/hakari.toml +++ /dev/null @@ -1,42 +0,0 @@ -# This file contains settings for `cargo hakari`. -# See https://docs.rs/cargo-hakari/latest/cargo_hakari/config for a full list of options. - -hakari-package = "workspace-hack" - -resolver = "2" -dep-format-version = "4" -workspace-hack-line-style = "workspace-dotted" - -# this should be the same list as "targets" in ../rust-toolchain.toml -platforms = [ - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", - "x86_64-unknown-linux-musl", # remote server -] - -[traversal-excludes] -workspace-members = [ - "remote_server", -] -third-party = [ - { name = "reqwest", version = "0.11.27" }, - # build of remote_server should not include scap / its x11 dependency - { name = "zed-scap", git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", version = "0.0.8-zed" }, - # build of remote_server should not need to include on libalsa through rodio - { name = "rodio", git = "https://github.com/RustAudio/rodio" }, -] - -[final-excludes] -workspace-members = [ - "zed_extension_api", - - # exclude all extensions - "zed_glsl", - "zed_html", - "zed_proto", - "slash_commands_example", - "zed_test_extension", -] diff --git a/.config/nextest.toml b/.config/nextest.toml index b05d68911f..49fb4d01f7 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -4,3 +4,17 @@ sequential-db-tests = { max-threads = 1 } [[profile.default.overrides]] filter = 'package(db)' test-group = 'sequential-db-tests' + +# Run slowest tests first. +# +[[profile.default.overrides]] +filter = 'package(worktree) and test(test_random_worktree_changes)' +priority = 100 + +[[profile.default.overrides]] +filter = 'package(collab) and (test(random_project_collaboration_tests) or test(random_channel_buffer_tests) or test(test_contact_requests) or test(test_basic_following))' +priority = 99 + +[[profile.default.overrides]] +filter = 'package(extension_host) and test(test_extension_store_with_test_extension)' +priority = 99 diff --git a/.github/ISSUE_TEMPLATE/01_bug_ai.yml b/.github/ISSUE_TEMPLATE/01_bug_ai.yml deleted file mode 100644 index 16bdef6c7e..0000000000 --- a/.github/ISSUE_TEMPLATE/01_bug_ai.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Bug Report (AI) -description: Zed Agent Panel Bugs -type: "Bug" -labels: ["ai"] -title: "AI: " -body: - - type: textarea - attributes: - label: Summary - description: Describe the bug with a one line summary, and provide detailed reproduction steps - value: | - - SUMMARY_SENTENCE_HERE - - ### Description - - Steps to trigger the problem: - 1. - 2. - 3. - - **Expected Behavior**: - **Actual Behavior**: - - ### Model Provider Details - - Provider: (Anthropic via ZedPro, Anthropic via API key, Copilot Chat, Mistral, OpenAI, etc) - - Model Name: - - Mode: (Agent Panel, Inline Assistant, Terminal Assistant or Text Threads) - - Other Details (MCPs, other settings, etc): - validations: - required: true - - - type: textarea - id: environment - attributes: - label: Zed Version and System Specs - description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"' - placeholder: | - Output of "zed: copy system specs into clipboard" - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/04_bug_debugger.yml b/.github/ISSUE_TEMPLATE/04_bug_debugger.yml deleted file mode 100644 index 2682295a43..0000000000 --- a/.github/ISSUE_TEMPLATE/04_bug_debugger.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Bug Report (Debugger) -description: Zed Debugger-Related Bugs -type: "Bug" -labels: ["debugger"] -title: "Debugger: " -body: - - type: textarea - attributes: - label: Summary - description: Describe the bug with a one line summary, and provide detailed reproduction steps - value: | - - SUMMARY_SENTENCE_HERE - - ### Description - - Steps to trigger the problem: - 1. - 2. - 3. - - **Expected Behavior**: - **Actual Behavior**: - - validations: - required: true - - type: textarea - id: environment - attributes: - label: Zed Version and System Specs - description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"' - placeholder: | - Output of "zed: copy system specs into clipboard" - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/07_bug_windows_beta.yml b/.github/ISSUE_TEMPLATE/07_bug_windows_beta.yml deleted file mode 100644 index b2b2a0f9df..0000000000 --- a/.github/ISSUE_TEMPLATE/07_bug_windows_beta.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Bug Report (Windows Beta) -description: Zed Windows Beta Related Bugs -type: "Bug" -labels: ["windows"] -title: "Windows Beta: " -body: - - type: textarea - attributes: - label: Summary - description: Describe the bug with a one-line summary, and provide detailed reproduction steps - value: | - - SUMMARY_SENTENCE_HERE - - ### Description - - Steps to trigger the problem: - 1. - 2. - 3. - - **Expected Behavior**: - **Actual Behavior**: - - validations: - required: true - - type: textarea - id: environment - attributes: - label: Zed Version and System Specs - description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"' - placeholder: | - Output of "zed: copy system specs into clipboard" - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml index 1bf6c80e40..cae10f02ec 100644 --- a/.github/ISSUE_TEMPLATE/10_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -1,58 +1,115 @@ -name: Bug Report (Other) -description: | - Something else is broken in Zed (exclude crashing). -type: "Bug" +name: Report a bug +description: Report a problem with Zed. +type: Bug +labels: "state:needs triage" body: + - type: markdown + attributes: + value: | + Is this bug already reported? Upvote to get it noticed faster. [Here's the search](https://github.com/zed-industries/zed/issues). Upvote means giving it a :+1: reaction. + + Feature request? Please open in [discussions](https://github.com/zed-industries/zed/discussions/new/choose) instead. + + Just have a question or need support? Welcome to [Discord Support Forums](https://discord.com/invite/zedindustries). - type: textarea attributes: - label: Summary - description: Provide a one sentence summary and detailed reproduction steps - value: | - - SUMMARY_SENTENCE_HERE - - ### Description - - - DESCRIPTION_HERE - - Steps to reproduce: - 1. - 2. - 3. - 4. - - **Expected Behavior**: - **Actual Behavior**: - - - + label: Reproduction steps + description: A step-by-step description of how to reproduce the bug from a **clean Zed install**. The more context you provide, the easier it is to find and fix the problem fast. + placeholder: | + 1. Start Zed + 2. Click X validations: required: true + - type: textarea + attributes: + label: Current vs. Expected behavior + description: | + Current behavior (screenshots, videos, etc. are appreciated), vs. what you expected the behavior to be. + placeholder: | + Current behavior: The icon is blue. Expected behavior: The icon should be red because this is what the setting is documented to do. + validations: + required: true - type: textarea id: environment attributes: - label: Zed Version and System Specs + label: Zed version and system specs description: | - Open Zed, from the command palette select "zed: copy system specs into clipboard" + Open the command palette in Zed, then type “zed: copy system specs into clipboard”. placeholder: | - Output of "zed: copy system specs into clipboard" + Zed: v0.215.0 (Zed Nightly bfe141ea79aa4984028934067ba75c48d99136ae) + OS: macOS 15.1 + Memory: 36 GiB + Architecture: aarch64 validations: required: true + - type: textarea + attributes: + label: Attach Zed log file + description: | + Open the command palette in Zed, then type `zed: open log` to see the last 1000 lines. Or type `zed: reveal log in file manager` in the command palette to reveal the log file itself. + value: | +
Zed.log + + + ```log + + ``` + +
+ validations: + required: false + - type: textarea + attributes: + label: Relevant Zed settings + description: | + Open the command palette in Zed, then type “zed: open settings file” and copy/paste any relevant (e.g., LSP-specific) settings. + value: | +
settings.json + + + ```json + + ``` + +
+ validations: + required: false + - type: textarea + attributes: + label: Relevant Keymap + description: | + Open the command palette in Zed, then type “zed: open keymap file” and copy/paste the file's contents. + value: | +
keymap.json + + + ```json + + ``` + +
+ validations: + required: false + - type: textarea + attributes: + label: (for AI issues) Model provider details + placeholder: | + - Provider: (Anthropic via ZedPro, Anthropic via API key, Copilot Chat, Mistral, OpenAI, etc.) + - Model Name: (Claude Sonnet 4.5, Gemini 3 Pro, GPT-5) + - Mode: (Agent Panel, Inline Assistant, Terminal Assistant or Text Threads) + - Other details (ACPs, MCPs, other settings, etc.): + validations: + required: false + - type: dropdown + attributes: + label: If you are using WSL on Windows, what flavor of Linux are you using? + multiple: false + options: + - Arch Linux + - Ubuntu + - Fedora + - Mint + - Pop!_OS + - NixOS + - Other diff --git a/.github/ISSUE_TEMPLATE/11_crash_report.yml b/.github/ISSUE_TEMPLATE/11_crash_report.yml index aa736c7534..a019848e87 100644 --- a/.github/ISSUE_TEMPLATE/11_crash_report.yml +++ b/.github/ISSUE_TEMPLATE/11_crash_report.yml @@ -1,43 +1,35 @@ -name: Crash Report -description: Zed is Crashing or Hanging -type: "Crash" +name: Report a crash +description: Zed is crashing or freezing or hanging. +type: Crash +labels: "state:needs triage" body: - type: textarea attributes: - label: Summary - description: Summarize the issue with detailed reproduction steps - value: | - - SUMMARY_SENTENCE_HERE - - ### Description - - Steps to trigger the problem: - 1. - 2. - 3. - - Actual Behavior: - Expected Behavior: - - validations: - required: true - - type: textarea - id: environment - attributes: - label: Zed Version and System Specs - description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"' + label: Reproduction steps + description: A step-by-step description of how to reproduce the crash from a **clean Zed install**. The more context you provide, the easier it is to find and fix the problem fast. placeholder: | - Output of "zed: copy system specs into clipboard" + 1. Start Zed + 2. Perform an action + 3. Zed crashes validations: required: true - type: textarea attributes: - label: If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue. + label: Zed version and system specs description: | - macOS: `~/Library/Logs/Zed/Zed.log` - Linux: `~/.local/share/zed/logs/Zed.log` or $XDG_DATA_HOME - If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000. + Open the command palette in Zed, then type “zed: copy system specs into clipboard”. + placeholder: | + Zed: v0.215.0 (Zed Nightly bfe141ea79aa4984028934067ba75c48d99136ae) + OS: macOS 15.1 + Memory: 36 GiB + Architecture: aarch64 + validations: + required: true + - type: textarea + attributes: + label: Attach Zed log file + description: | + Open the command palette in Zed, then type `zed: open log` to see the last 1000 lines. Or type `zed: reveal log in file manager` in the command palette to reveal the log file itself. value: |
Zed.log diff --git a/.github/ISSUE_TEMPLATE/99_other.yml b/.github/ISSUE_TEMPLATE/99_other.yml deleted file mode 100644 index 9383a576b1..0000000000 --- a/.github/ISSUE_TEMPLATE/99_other.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Other [Staff Only] -description: Zed Staff Only -body: - - type: textarea - attributes: - label: Summary - value: | - - SUMMARY_SENTENCE_HERE - - ### Description - - IF YOU DO NOT WORK FOR ZED INDUSTRIES DO NOT CREATE ISSUES WITH THIS TEMPLATE. - THEY WILL BE AUTO-CLOSED AND MAY RESULT IN YOU BEING BANNED FROM THE ZED ISSUE TRACKER. - - FEATURE REQUESTS / SUPPORT REQUESTS SHOULD BE OPENED AS DISCUSSIONS: - https://github.com/zed-industries/zed/discussions/new/choose - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3d0b2ce0af..9bf14ce72d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json +# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json blank_issues_enabled: false contact_links: - - name: Feature Request + - name: Feature request url: https://github.com/zed-industries/zed/discussions/new/choose - about: To request a feature, open a new Discussion in one of the appropriate Discussion categories - - name: "Zed Discord" - url: https://zed.dev/community-links - about: Real-time discussion and user support + about: To request a feature, open a new discussion under one of the appropriate categories. + - name: Our Discord community + url: https://discord.com/invite/zedindustries + about: Join our Discord server for real-time discussion and user support. diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml index faf9401797..a071aba3a8 100644 --- a/.github/actions/run_tests/action.yml +++ b/.github/actions/run_tests/action.yml @@ -4,10 +4,8 @@ description: "Runs the tests" runs: using: "composite" steps: - - name: Install Rust - shell: bash -euxo pipefail {0} - run: | - cargo install cargo-nextest --locked + - name: Install nextest + uses: taiki-e/install-action@nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -15,8 +13,11 @@ runs: node-version: "18" - name: Limit target directory size + env: + MAX_SIZE: ${{ runner.os == 'macOS' && 300 || 100 }} shell: bash -euxo pipefail {0} - run: script/clear-target-dir-if-larger-than 100 + # Use the variable in the run command + run: script/clear-target-dir-if-larger-than ${{ env.MAX_SIZE }} - name: Run tests shell: bash -euxo pipefail {0} diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index d85d47cb96..307b73f363 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -11,9 +11,8 @@ runs: using: "composite" steps: - name: Install test runner - shell: powershell working-directory: ${{ inputs.working-directory }} - run: cargo install cargo-nextest --locked + uses: taiki-e/install-action@nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml new file mode 100644 index 0000000000..21b9a8fe0e --- /dev/null +++ b/.github/workflows/after_release.yml @@ -0,0 +1,119 @@ +# Generated from xtask::workflows::after_release +# Rebuild with `cargo xtask workflows`. +name: after_release +on: + release: + types: + - published + workflow_dispatch: + inputs: + tag_name: + description: tag_name + required: true + type: string + prerelease: + description: prerelease + required: true + type: boolean + body: + description: body + type: string + default: '' +jobs: + rebuild_releases_page: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: after_release::rebuild_releases_page::refresh_cloud_releases + run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name || inputs.tag_name }} + shell: bash -euxo pipefail {0} + - name: after_release::rebuild_releases_page::redeploy_zed_dev + run: npm exec --yes -- vercel@37 --token="$VERCEL_TOKEN" --scope zed-industries redeploy https://zed.dev + shell: bash -euxo pipefail {0} + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + post_to_discord: + needs: + - rebuild_releases_page + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - id: get-release-url + name: after_release::post_to_discord::get_release_url + run: | + if [ "${{ github.event.release.prerelease || inputs.prerelease }}" == "true" ]; then + URL="https://zed.dev/releases/preview" + else + URL="https://zed.dev/releases/stable" + fi + + echo "URL=$URL" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} + - id: get-content + name: after_release::post_to_discord::get_content + uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 + with: + stringToTruncate: | + 📣 Zed [${{ github.event.release.tag_name || inputs.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! + + ${{ github.event.release.body || inputs.body }} + maxLength: 2000 + truncationSymbol: '...' + - name: after_release::post_to_discord::discord_webhook_action + uses: tsickert/discord-webhook@c840d45a03a323fbc3f7507ac7769dbd91bfb164 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_RELEASE_NOTES }} + content: ${{ steps.get-content.outputs.string }} + publish_winget: + runs-on: self-32vcpu-windows-2022 + steps: + - id: set-package-name + name: after_release::publish_winget::set_package_name + run: | + if ("${{ github.event.release.prerelease || inputs.prerelease }}" -eq "true") { + $PACKAGE_NAME = "ZedIndustries.Zed.Preview" + } else { + $PACKAGE_NAME = "ZedIndustries.Zed" + } + + echo "PACKAGE_NAME=$PACKAGE_NAME" >> $env:GITHUB_OUTPUT + shell: pwsh + - name: after_release::publish_winget::winget_releaser + uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f + with: + identifier: ${{ steps.set-package-name.outputs.PACKAGE_NAME }} + release-tag: ${{ github.event.release.tag_name || inputs.tag_name }} + max-versions-to-keep: 5 + token: ${{ secrets.WINGET_TOKEN }} + create_sentry_release: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: release::create_sentry_release + uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c + with: + environment: production + env: + SENTRY_ORG: zed-dev + SENTRY_PROJECT: zed + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + notify_on_failure: + needs: + - rebuild_releases_page + - post_to_discord + - publish_winget + - create_sentry_release + if: failure() + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: release::notify_on_failure::notify_slack + run: |- + curl -X POST -H 'Content-type: application/json'\ + --data '{"text":"${{ github.workflow }} failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' "$SLACK_WEBHOOK" + shell: bash -euxo pipefail {0} + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_WORKFLOW_FAILURES }} diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml new file mode 100644 index 0000000000..308849ccbe --- /dev/null +++ b/.github/workflows/autofix_pr.yml @@ -0,0 +1,83 @@ +# Generated from xtask::workflows::autofix_pr +# Rebuild with `cargo xtask workflows`. +name: autofix_pr +run-name: 'autofix PR #${{ inputs.pr_number }}' +on: + workflow_dispatch: + inputs: + pr_number: + description: pr_number + required: true + type: string +jobs: + run_autofix: + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - id: get-app-token + name: autofix_pr::run_autofix::authenticate_as_zippy + uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + - name: steps::checkout_repo_with_token + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + token: ${{ steps.get-app-token.outputs.token }} + - name: autofix_pr::run_autofix::checkout_pr + run: gh pr checkout ${{ inputs.pr_number }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: autofix_pr::run_autofix::run_prettier_fix + run: ./script/prettier --write + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_cargo_fmt + run: cargo fmt --all + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_clippy_fix + run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::commit_and_push + run: | + if git diff --quiet; then + echo "No changes to commit" + else + git add -A + git commit -m "Autofix" + git push + fi + shell: bash -euxo pipefail {0} + env: + GIT_COMMITTER_NAME: Zed Zippy + GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: Zed Zippy + GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index bfaf7a271b..e1ae890043 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -42,7 +42,7 @@ jobs: exit 1 ;; esac - which cargo-set-version > /dev/null || cargo install cargo-edit + which cargo-set-version > /dev/null || cargo install cargo-edit -f --no-default-features --features "set-version" output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')" export GIT_COMMITTER_NAME="Zed Bot" export GIT_COMMITTER_EMAIL="hi@zed.dev" diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml new file mode 100644 index 0000000000..bc01aae17e --- /dev/null +++ b/.github/workflows/cherry_pick.yml @@ -0,0 +1,44 @@ +# Generated from xtask::workflows::cherry_pick +# Rebuild with `cargo xtask workflows`. +name: cherry_pick +run-name: 'cherry_pick to ${{ inputs.channel }} #${{ inputs.pr_number }}' +on: + workflow_dispatch: + inputs: + commit: + description: commit + required: true + type: string + branch: + description: branch + required: true + type: string + channel: + description: channel + required: true + type: string + pr_number: + description: pr_number + required: true + type: string +jobs: + run_cherry_pick: + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - id: get-app-token + name: cherry_pick::run_cherry_pick::authenticate_as_zippy + uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + - name: cherry_pick::run_cherry_pick::cherry_pick + run: ./script/cherry-pick ${{ inputs.branch }} ${{ inputs.commit }} ${{ inputs.channel }} + shell: bash -euxo pipefail {0} + env: + GIT_COMMITTER_NAME: Zed Zippy + GIT_COMMITTER_EMAIL: hi@zed.dev + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 1d2500e2f7..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,903 +0,0 @@ -name: CI - -on: - push: - branches: - - main - - "v[0-9]+.[0-9]+.x" - tags: - - "v*" - - pull_request: - branches: - - "**" - -concurrency: - # Allow only one workflow per any non-`main` branch. - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} - cancel-in-progress: true - -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 }} - ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} - -jobs: - job_spec: - name: Decide which jobs to run - if: github.repository_owner == 'zed-industries' - outputs: - run_tests: ${{ steps.filter.outputs.run_tests }} - run_license: ${{ steps.filter.outputs.run_license }} - run_docs: ${{ steps.filter.outputs.run_docs }} - run_nix: ${{ steps.filter.outputs.run_nix }} - run_actionlint: ${{ steps.filter.outputs.run_actionlint }} - runs-on: - - namespace-profile-2x4-ubuntu-2404 - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - # 350 is arbitrary; ~10days of history on main (5secs); full history is ~25secs - fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} - - name: Fetch git history and generate output filters - id: filter - run: | - if [ -z "$GITHUB_BASE_REF" ]; then - echo "Not in a PR context (i.e., push to main/stable/preview)" - COMPARE_REV="$(git rev-parse HEAD~1)" - else - echo "In a PR context comparing to pull_request.base.ref" - git fetch origin "$GITHUB_BASE_REF" --depth=350 - COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)" - fi - CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})" - - # Specify anything which should potentially skip full test suite in this regex: - # - docs/ - # - script/update_top_ranking_issues/ - # - .github/ISSUE_TEMPLATE/ - # - .github/workflows/ (except .github/workflows/ci.yml) - SKIP_REGEX='^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!ci)))' - - echo "$CHANGED_FILES" | grep -qvP "$SKIP_REGEX" && \ - echo "run_tests=true" >> "$GITHUB_OUTPUT" || \ - echo "run_tests=false" >> "$GITHUB_OUTPUT" - - echo "$CHANGED_FILES" | grep -qP '^docs/' && \ - echo "run_docs=true" >> "$GITHUB_OUTPUT" || \ - echo "run_docs=false" >> "$GITHUB_OUTPUT" - - echo "$CHANGED_FILES" | grep -qP '^\.github/(workflows/|actions/|actionlint.yml)' && \ - echo "run_actionlint=true" >> "$GITHUB_OUTPUT" || \ - echo "run_actionlint=false" >> "$GITHUB_OUTPUT" - - echo "$CHANGED_FILES" | grep -qP '^(Cargo.lock|script/.*licenses)' && \ - echo "run_license=true" >> "$GITHUB_OUTPUT" || \ - echo "run_license=false" >> "$GITHUB_OUTPUT" - - echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \ - echo "$GITHUB_REF_NAME" | grep -qvP '^v[0-9]+\.[0-9]+\.[0-9x](-pre)?$' && \ - echo "run_nix=true" >> "$GITHUB_OUTPUT" || \ - echo "run_nix=false" >> "$GITHUB_OUTPUT" - - migration_checks: - name: Check Postgres and Protobuf migrations, mergability - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - timeout-minutes: 60 - runs-on: - - self-mini-macos - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - fetch-depth: 0 # fetch full history - - - name: Remove untracked files - run: git clean -df - - - name: Find modified migrations - shell: bash -euxo pipefail {0} - run: | - export SQUAWK_GITHUB_TOKEN=${{ github.token }} - . ./script/squawk - - - name: Ensure fresh merge - shell: bash -euxo pipefail {0} - run: | - if [ -z "$GITHUB_BASE_REF" ]; - then - echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV" - else - git checkout -B temp - git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp" - echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV" - fi - - - uses: bufbuild/buf-setup-action@v1 - with: - version: v1.29.0 - - uses: bufbuild/buf-breaking-action@v1 - with: - input: "crates/proto/proto/" - against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/" - - workspace_hack: - timeout-minutes: 60 - name: Check workspace-hack crate - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - runs-on: - - namespace-profile-8x16-ubuntu-2204 - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Install cargo-hakari - uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2 - with: - command: install - args: cargo-hakari@0.9.35 - - - name: Check workspace-hack Cargo.toml is up-to-date - run: | - cargo hakari generate --diff || { - echo "To fix, run script/update-workspace-hack or script/update-workspace-hack.ps1"; - false - } - - name: Check all crates depend on workspace-hack - run: | - cargo hakari manage-deps --dry-run || { - echo "To fix, run script/update-workspace-hack or script/update-workspace-hack.ps1" - false - } - - style: - timeout-minutes: 60 - name: Check formatting and spelling - needs: [job_spec] - if: github.repository_owner == 'zed-industries' - runs-on: - - namespace-profile-4x8-ubuntu-2204 - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 - with: - version: 9 - - - name: Prettier Check on /docs - working-directory: ./docs - run: | - pnpm dlx "prettier@${PRETTIER_VERSION}" . --check || { - echo "To fix, run from the root of the Zed repo:" - echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .." - false - } - env: - PRETTIER_VERSION: 3.5.0 - - - name: Prettier Check on default.json - run: | - pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --check || { - echo "To fix, run from the root of the Zed repo:" - echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --write" - false - } - env: - PRETTIER_VERSION: 3.5.0 - - # To support writing comments that they will certainly be revisited. - - name: Check for todo! and FIXME comments - run: script/check-todos - - - name: Check modifier use in keymaps - run: script/check-keymaps - - - name: Run style checks - uses: ./.github/actions/check_style - - - name: Check for typos - uses: crate-ci/typos@8e6a4285bcbde632c5d79900a7779746e8b7ea3f # v1.24.6 - with: - config: ./typos.toml - - check_docs: - timeout-minutes: 60 - name: Check docs - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - (needs.job_spec.outputs.run_tests == 'true' || needs.job_spec.outputs.run_docs == 'true') - runs-on: - - namespace-profile-8x16-ubuntu-2204 - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Configure CI - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - - name: Build docs - uses: ./.github/actions/build_docs - - actionlint: - runs-on: namespace-profile-2x4-ubuntu-2404 - if: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_actionlint == 'true' - needs: [job_spec] - steps: - - uses: actions/checkout@v4 - - name: Download actionlint - id: get_actionlint - run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) - shell: bash - - name: Check workflow files - run: ${{ steps.get_actionlint.outputs.executable }} -color - shell: bash - - macos_tests: - timeout-minutes: 60 - name: (macOS) Run Clippy and tests - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - runs-on: - - self-mini-macos - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Configure CI - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - - name: Check that Cargo.lock is up to date - run: | - cargo update --locked --workspace - - - name: cargo clippy - run: ./script/clippy - - - name: Install cargo-machete - uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2 - with: - command: install - args: cargo-machete@0.7.0 - - - name: Check unused dependencies - uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2 - with: - command: machete - - - name: Check licenses - run: | - script/check-licenses - if [[ "${{ needs.job_spec.outputs.run_license }}" == "true" ]]; then - script/generate-licenses /tmp/zed_licenses_output - fi - - - name: Check for new vulnerable dependencies - if: github.event_name == 'pull_request' - uses: actions/dependency-review-action@67d4f4bd7a9b17a0db54d2a7519187c65e339de8 # v4 - with: - license-check: false - - - name: Run tests - uses: ./.github/actions/run_tests - - - name: Build collab - run: cargo build -p collab - - - name: Build other binaries and features - run: | - cargo build --workspace --bins --all-features - cargo check -p gpui --features "macos-blade" - cargo check -p workspace - cargo build -p remote_server - cargo check -p gpui --examples - - # Since the macOS runners are stateful, so we need to remove the config file to prevent potential bug. - - name: Clean CI config file - if: always() - run: rm -rf ./../.cargo - - linux_tests: - timeout-minutes: 60 - name: (Linux) Run Clippy and tests - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - runs-on: - - namespace-profile-16x32-ubuntu-2204 - steps: - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Cache dependencies - uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - # cache-provider: "buildjet" - - - name: Install Linux dependencies - run: ./script/linux - - - name: Configure CI - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - - name: cargo clippy - run: ./script/clippy - - - name: Run tests - uses: ./.github/actions/run_tests - - - name: Build other binaries and features - run: | - cargo build -p zed - cargo check -p workspace - cargo check -p gpui --examples - - # Even the Linux runner is not stateful, in theory there is no need to do this cleanup. - # But, to avoid potential issues in the future if we choose to use a stateful Linux runner and forget to add code - # to clean up the config file, I’ve included the cleanup code here as a precaution. - # While it’s not strictly necessary at this moment, I believe it’s better to err on the side of caution. - - name: Clean CI config file - if: always() - run: rm -rf ./../.cargo - - doctests: - # Nextest currently doesn't support doctests, so run them separately and in parallel. - timeout-minutes: 60 - name: (Linux) Run doctests - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - runs-on: - - namespace-profile-16x32-ubuntu-2204 - steps: - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Cache dependencies - uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - # cache-provider: "buildjet" - - - name: Install Linux dependencies - run: ./script/linux - - - name: Configure CI - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - - name: Run doctests - run: cargo test --workspace --doc --no-fail-fast - - - name: Clean CI config file - if: always() - run: rm -rf ./../.cargo - - build_remote_server: - timeout-minutes: 60 - name: (Linux) Build Remote Server - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - runs-on: - - namespace-profile-16x32-ubuntu-2204 - steps: - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Cache dependencies - uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - # cache-provider: "buildjet" - - - name: Install Clang & Mold - run: ./script/remote-server && ./script/install-mold 2.34.0 - - - name: Configure CI - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - - name: Build Remote Server - run: cargo build -p remote_server - - - name: Clean CI config file - if: always() - run: rm -rf ./../.cargo - - windows_tests: - timeout-minutes: 60 - name: (Windows) Run Clippy and tests - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - runs-on: [self-32vcpu-windows-2022] - steps: - - name: Environment Setup - run: | - $RunnerDir = Split-Path -Parent $env:RUNNER_WORKSPACE - Write-Output ` - "RUSTUP_HOME=$RunnerDir\.rustup" ` - "CARGO_HOME=$RunnerDir\.cargo" ` - "PATH=$RunnerDir\.cargo\bin;$env:PATH" ` - >> $env:GITHUB_ENV - - - 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: cargo clippy - run: | - .\script\clippy.ps1 - - - name: Run tests - uses: ./.github/actions/run_tests_windows - - - name: Build Zed - run: cargo build - - - name: Limit target directory size - run: ./script/clear-target-dir-if-larger-than.ps1 250 - - - name: Clean CI config file - if: always() - run: Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue - - tests_pass: - name: Tests Pass - runs-on: namespace-profile-2x4-ubuntu-2404 - needs: - - job_spec - - style - - check_docs - - actionlint - - migration_checks - # run_tests: If adding required tests, add them here and to script below. - - workspace_hack - - linux_tests - - build_remote_server - - macos_tests - - windows_tests - if: | - github.repository_owner == 'zed-industries' && - always() - steps: - - name: Check all tests passed - run: | - # Check dependent jobs... - RET_CODE=0 - # Always check style - [[ "${{ needs.style.result }}" != 'success' ]] && { RET_CODE=1; echo "style tests failed"; } - - if [[ "${{ needs.job_spec.outputs.run_docs }}" == "true" ]]; then - [[ "${{ needs.check_docs.result }}" != 'success' ]] && { RET_CODE=1; echo "docs checks failed"; } - fi - - if [[ "${{ needs.job_spec.outputs.run_actionlint }}" == "true" ]]; then - [[ "${{ needs.actionlint.result }}" != 'success' ]] && { RET_CODE=1; echo "actionlint checks failed"; } - fi - - # Only check test jobs if they were supposed to run - if [[ "${{ needs.job_spec.outputs.run_tests }}" == "true" ]]; then - [[ "${{ needs.workspace_hack.result }}" != 'success' ]] && { RET_CODE=1; echo "Workspace Hack failed"; } - [[ "${{ needs.macos_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "macOS tests failed"; } - [[ "${{ needs.linux_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "Linux tests failed"; } - [[ "${{ needs.windows_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "Windows tests failed"; } - [[ "${{ needs.build_remote_server.result }}" != 'success' ]] && { RET_CODE=1; echo "Remote server build failed"; } - # This check is intentionally disabled. See: https://github.com/zed-industries/zed/pull/28431 - # [[ "${{ needs.migration_checks.result }}" != 'success' ]] && { RET_CODE=1; echo "Migration Checks failed"; } - fi - if [[ "$RET_CODE" -eq 0 ]]; then - echo "All tests passed successfully!" - fi - exit $RET_CODE - - bundle-mac: - timeout-minutes: 120 - name: Create a macOS bundle - runs-on: - - self-mini-macos - if: | - ( startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) - needs: [macos_tests] - env: - MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} - APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - steps: - - name: Install Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "18" - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - # We need to fetch more than one commit so that `script/draft-release-notes` - # is able to diff between the current and previous tag. - # - # 25 was chosen arbitrarily. - fetch-depth: 25 - clean: false - ref: ${{ github.ref }} - - - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 100 - - - name: Determine version and release channel - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - run: | - # This exports RELEASE_CHANNEL into env (GITHUB_ENV) - script/determine-release-channel - - - name: Draft release notes - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - run: | - mkdir -p target/ - # Ignore any errors that occur while drafting release notes to not fail the build. - script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md || true - script/create-draft-release target/release-notes.md - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Create macOS app bundle - run: script/bundle-mac - - - name: Rename binaries - if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} - run: | - mv target/aarch64-apple-darwin/release/Zed.dmg target/aarch64-apple-darwin/release/Zed-aarch64.dmg - mv target/x86_64-apple-darwin/release/Zed.dmg target/x86_64-apple-darwin/release/Zed-x86_64.dmg - - - name: Upload app bundle (aarch64) to workflow run if main branch or specific label - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} - with: - name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-aarch64.dmg - path: target/aarch64-apple-darwin/release/Zed-aarch64.dmg - - - name: Upload app bundle (x86_64) to workflow run if main branch or specific label - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} - with: - name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-x86_64.dmg - path: target/x86_64-apple-darwin/release/Zed-x86_64.dmg - - - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - name: Upload app bundle to release - if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }} - with: - draft: true - prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} - files: | - target/zed-remote-server-macos-x86_64.gz - target/zed-remote-server-macos-aarch64.gz - target/aarch64-apple-darwin/release/Zed-aarch64.dmg - target/x86_64-apple-darwin/release/Zed-x86_64.dmg - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - bundle-linux-x86_x64: - timeout-minutes: 60 - name: Linux x86_x64 release bundle - runs-on: - - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc - if: | - ( startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) - needs: [linux_tests] - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Install Linux dependencies - run: ./script/linux && ./script/install-mold 2.34.0 - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Determine version and release channel - if: startsWith(github.ref, 'refs/tags/v') - run: | - # This exports RELEASE_CHANNEL into env (GITHUB_ENV) - script/determine-release-channel - - - name: Create Linux .tar.gz bundle - run: script/bundle-linux - - - name: Upload Artifact to Workflow - zed (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz - path: target/release/zed-*.tar.gz - - - name: Upload Artifact to Workflow - zed-remote-server (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.gz - path: target/zed-remote-server-linux-x86_64.gz - - - name: Upload Artifacts to release - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }} - with: - draft: true - prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} - files: | - target/zed-remote-server-linux-x86_64.gz - target/release/zed-linux-x86_64.tar.gz - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - bundle-linux-aarch64: # this runs on ubuntu22.04 - timeout-minutes: 60 - name: Linux arm64 release bundle - runs-on: - - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc - if: | - startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') - needs: [linux_tests] - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Install Linux dependencies - run: ./script/linux - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Determine version and release channel - if: startsWith(github.ref, 'refs/tags/v') - run: | - # This exports RELEASE_CHANNEL into env (GITHUB_ENV) - script/determine-release-channel - - - name: Create and upload Linux .tar.gz bundles - run: script/bundle-linux - - - name: Upload Artifact to Workflow - zed (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz - path: target/release/zed-*.tar.gz - - - name: Upload Artifact to Workflow - zed-remote-server (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.gz - path: target/zed-remote-server-linux-aarch64.gz - - - name: Upload Artifacts to release - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }} - with: - draft: true - prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} - files: | - target/zed-remote-server-linux-aarch64.gz - target/release/zed-linux-aarch64.tar.gz - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - freebsd: - timeout-minutes: 60 - runs-on: github-8vcpu-ubuntu-2404 - if: | - false && ( startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) - needs: [linux_tests] - name: Build Zed on FreeBSD - steps: - - uses: actions/checkout@v4 - - name: Build FreeBSD remote-server - id: freebsd-build - uses: vmactions/freebsd-vm@c3ae29a132c8ef1924775414107a97cac042aad5 # v1.2.0 - with: - usesh: true - release: 13.5 - copyback: true - prepare: | - pkg install -y \ - bash curl jq git \ - rustup-init cmake-core llvm-devel-lite pkgconf protobuf # ibx11 alsa-lib rust-bindgen-cli - run: | - freebsd-version - sysctl hw.model - sysctl hw.ncpu - sysctl hw.physmem - sysctl hw.usermem - git config --global --add safe.directory /home/runner/work/zed/zed - rustup-init --profile minimal --default-toolchain none -y - . "$HOME/.cargo/env" - ./script/bundle-freebsd - mkdir -p out/ - mv "target/zed-remote-server-freebsd-x86_64.gz" out/ - rm -rf target/ - cargo clean - - - name: Upload Artifact to Workflow - zed-remote-server (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-freebsd.gz - path: out/zed-remote-server-freebsd-x86_64.gz - - - name: Upload Artifacts to release - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }} - with: - draft: true - prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} - files: | - out/zed-remote-server-freebsd-x86_64.gz - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - nix-build: - name: Build with Nix - uses: ./.github/workflows/nix.yml - needs: [job_spec] - if: github.repository_owner == 'zed-industries' && - (contains(github.event.pull_request.labels.*.name, 'run-nix') || - needs.job_spec.outputs.run_nix == 'true') - secrets: inherit - with: - flake-output: debug - # excludes the final package to only cache dependencies - cachix-filter: "-zed-editor-[0-9.]*-nightly" - - bundle-windows-x64: - timeout-minutes: 120 - name: Create a Windows installer - runs-on: [self-32vcpu-windows-2022] - 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: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Determine version and release channel - working-directory: ${{ env.ZED_WORKSPACE }} - 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 - if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }} - 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, bundle-windows-x64] - runs-on: - - self-mini-macos - steps: - - name: gh release - run: gh release edit "$GITHUB_REF_NAME" --draft=false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Create Sentry release - uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c # v3 - env: - SENTRY_ORG: zed-dev - SENTRY_PROJECT: zed - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - with: - environment: production diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml index c525bf4738..93f1d56023 100644 --- a/.github/workflows/community_champion_auto_labeler.yml +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -13,13 +13,72 @@ jobs: steps: - name: Check if author is a community champion and apply label uses: actions/github-script@v7 + env: + COMMUNITY_CHAMPIONS: | + 0x2CA + 5brian + 5herlocked + abdelq + afgomez + AidanV + akbxr + AlvaroParker + amtoaer + artemevsevev + bajrangCoder + bcomnes + Be-ing + blopker + bnjjj + bobbymannino + CharlesChen0823 + chbk + cppcoffee + davewa + ddoemonn + djsauble + errmayank + fantacell + findrakecil + FloppyDisco + gko + huacnlee + imumesh18 + jacobtread + jansol + jeffreyguenther + jenslys + jongretar + lemorage + lnay + marcocondrache + marius851000 + mikebronner + ognevny + playdohface + RemcoSmitsDev + romaninsh + Simek + someone13574 + sourcefrog + suxiaoshao + Takk8IS + thedadams + tidely + timvermeulen + valentinegb + versecafe + vitallium + warrenjokinen + WhySoBad + ya7010 + Zertsov with: script: | - const communityChampionBody = `${{ secrets.COMMUNITY_CHAMPIONS }}`; - - const communityChampions = communityChampionBody + const communityChampions = process.env.COMMUNITY_CHAMPIONS .split('\n') - .map(handle => handle.trim().toLowerCase()); + .map(handle => handle.trim().toLowerCase()) + .filter(handle => handle.length > 0); let author; if (context.eventName === 'issues') { diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index a38354c317..14c1a0a083 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -1,7 +1,7 @@ name: "Close Stale Issues" on: schedule: - - cron: "0 7,9,11 * * 3" + - cron: "0 8 31 DEC *" workflow_dispatch: jobs: @@ -15,14 +15,15 @@ jobs: stale-issue-message: > Hi there! 👋 - We're working to clean up our issue tracker by closing older issues that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and we will keep it open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, we'll close it in 7 days. + We're working to clean up our issue tracker by closing older bugs that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and it will be kept open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, it will close automatically in 14 days. Thanks for your help! close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please open a new issue with a link to this issue." - days-before-stale: 120 - days-before-close: 7 - any-of-issue-labels: "bug,panic / crash" + days-before-stale: 60 + days-before-close: 14 + only-issue-types: "Bug,Crash" operations-per-run: 1000 ascending: true enable-statistics: true stale-issue-label: "stale" + exempt-issue-labels: "never stale" diff --git a/.github/workflows/community_release_actions.yml b/.github/workflows/community_release_actions.yml deleted file mode 100644 index 4a042a5e06..0000000000 --- a/.github/workflows/community_release_actions.yml +++ /dev/null @@ -1,73 +0,0 @@ -# IF YOU UPDATE THE NAME OF ANY GITHUB SECRET, YOU MUST CHERRY PICK THE COMMIT -# TO BOTH STABLE AND PREVIEW CHANNELS - -name: Release Actions - -on: - release: - types: [published] - -jobs: - discord_release: - if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest - steps: - - name: Get release URL - id: get-release-url - run: | - if [ "${{ github.event.release.prerelease }}" == "true" ]; then - URL="https://zed.dev/releases/preview" - else - URL="https://zed.dev/releases/stable" - fi - - echo "URL=$URL" >> "$GITHUB_OUTPUT" - - name: Get content - uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 # v1.4.1 - id: get-content - with: - stringToTruncate: | - 📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! - - ${{ github.event.release.body }} - maxLength: 2000 - truncationSymbol: "..." - - name: Discord Webhook Action - uses: tsickert/discord-webhook@c840d45a03a323fbc3f7507ac7769dbd91bfb164 # v5.3.0 - with: - webhook-url: ${{ secrets.DISCORD_WEBHOOK_RELEASE_NOTES }} - content: ${{ steps.get-content.outputs.string }} - - send_release_notes_email: - if: false && github.repository_owner == 'zed-industries' && !github.event.release.prerelease - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - fetch-depth: 0 - - - name: Check if release was promoted from preview - id: check-promotion-from-preview - run: | - VERSION="${{ github.event.release.tag_name }}" - PREVIEW_TAG="${VERSION}-pre" - - if git rev-parse "$PREVIEW_TAG" > /dev/null 2>&1; then - echo "was_promoted_from_preview=true" >> "$GITHUB_OUTPUT" - else - echo "was_promoted_from_preview=false" >> "$GITHUB_OUTPUT" - fi - - - name: Send release notes email - if: steps.check-promotion-from-preview.outputs.was_promoted_from_preview == 'true' - run: | - TAG="${{ github.event.release.tag_name }}" - cat << 'EOF' > release_body.txt - ${{ github.event.release.body }} - EOF - jq -n --arg tag "$TAG" --rawfile body release_body.txt '{version: $tag, markdown_body: $body}' \ - > release_data.json - curl -X POST "https://zed.dev/api/send_release_notes_email" \ - -H "Authorization: Bearer ${{ secrets.RELEASE_NOTES_API_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d @release_data.json diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml new file mode 100644 index 0000000000..48fc850f8f --- /dev/null +++ b/.github/workflows/compare_perf.yml @@ -0,0 +1,80 @@ +# Generated from xtask::workflows::compare_perf +# Rebuild with `cargo xtask workflows`. +name: compare_perf +on: + workflow_dispatch: + inputs: + head: + description: head + required: true + type: string + base: + description: base + required: true + type: string + crate_name: + description: crate_name + type: string + default: '' +jobs: + run_perf: + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: compare_perf::run_perf::install_hyperfine + uses: taiki-e/install-action@hyperfine + - name: steps::git_checkout + run: git fetch origin ${{ inputs.base }} && git checkout ${{ inputs.base }} + shell: bash -euxo pipefail {0} + - name: compare_perf::run_perf::cargo_perf_test + run: |2- + + if [ -n "${{ inputs.crate_name }}" ]; then + cargo perf-test -p ${{ inputs.crate_name }} -- --json=${{ inputs.base }}; + else + cargo perf-test -p vim -- --json=${{ inputs.base }}; + fi + shell: bash -euxo pipefail {0} + - name: steps::git_checkout + run: git fetch origin ${{ inputs.head }} && git checkout ${{ inputs.head }} + shell: bash -euxo pipefail {0} + - name: compare_perf::run_perf::cargo_perf_test + run: |2- + + if [ -n "${{ inputs.crate_name }}" ]; then + cargo perf-test -p ${{ inputs.crate_name }} -- --json=${{ inputs.head }}; + else + cargo perf-test -p vim -- --json=${{ inputs.head }}; + fi + shell: bash -euxo pipefail {0} + - name: compare_perf::run_perf::compare_runs + run: cargo perf-compare --save=results.md ${{ inputs.base }} ${{ inputs.head }} + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact results.md' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: results.md + path: results.md + if-no-files-found: error + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 3f84179278..9d6054eb3e 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -1,42 +1,40 @@ -name: Danger - +# Generated from xtask::workflows::danger +# Rebuild with `cargo xtask workflows`. +name: danger on: pull_request: - branches: [main] types: - - opened - - synchronize - - reopened - - edited - + - opened + - synchronize + - reopened + - edited + branches: + - main jobs: danger: - if: github.repository_owner == 'zed-industries' + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-2x4-ubuntu-2404 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 - with: - version: 9 - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "20" - cache: "pnpm" - cache-dependency-path: "script/danger/pnpm-lock.yaml" - - - run: pnpm install --dir script/danger - - - name: Run Danger - run: pnpm run --dir script/danger danger ci - env: - # This GitHub token is not used, but the value needs to be here to prevent - # Danger from throwing an error. - GITHUB_TOKEN: "not_a_real_token" - # All requests are instead proxied through an instance of - # https://github.com/maxdeviant/danger-proxy that allows Danger to securely - # authenticate with GitHub while still being able to run on PRs from forks. - DANGER_GITHUB_API_BASE_URL: "https://danger-proxy.fly.dev/github" + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + cache: pnpm + cache-dependency-path: script/danger/pnpm-lock.yaml + - name: danger::danger_job::install_deps + run: pnpm install --dir script/danger + shell: bash -euxo pipefail {0} + - name: danger::danger_job::run + run: pnpm run --dir script/danger danger ci + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: not_a_real_token + DANGER_GITHUB_API_BASE_URL: https://danger-proxy.fly.dev/github diff --git a/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index df35d44ca9..2650cce140 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -22,6 +22,8 @@ jobs: - name: Build docs uses: ./.github/actions/build_docs + env: + DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }} - name: Deploy Docs uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3 diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index ff2a3589e4..ce0c0eac40 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -43,13 +43,11 @@ jobs: fetch-depth: 0 - name: Install cargo nextest - shell: bash -euxo pipefail {0} - run: | - cargo install cargo-nextest --locked + uses: taiki-e/install-action@nextest - name: Limit target directory size shell: bash -euxo pipefail {0} - run: script/clear-target-dir-if-larger-than 100 + run: script/clear-target-dir-if-larger-than 300 - name: Run tests shell: bash -euxo pipefail {0} diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml deleted file mode 100644 index b5da9e7b7c..0000000000 --- a/.github/workflows/eval.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Run Agent Eval - -on: - schedule: - - cron: "0 0 * * *" - - pull_request: - branches: - - "**" - types: [synchronize, reopened, labeled] - - workflow_dispatch: - -concurrency: - # Allow only one workflow per any non-`main` branch. - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_EVAL_TELEMETRY: 1 - -jobs: - run_eval: - timeout-minutes: 60 - name: Run Agent Eval - if: > - github.repository_owner == 'zed-industries' && - (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-eval')) - runs-on: - - namespace-profile-16x32-ubuntu-2204 - steps: - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Cache dependencies - uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - # cache-provider: "buildjet" - - - name: Install Linux dependencies - run: ./script/linux - - - name: Configure CI - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - - name: Compile eval - run: cargo build --package=eval - - - name: Run eval - run: cargo run --package=eval -- --repetitions=8 --concurrency=1 - - # Even the Linux runner is not stateful, in theory there is no need to do this cleanup. - # But, to avoid potential issues in the future if we choose to use a stateful Linux runner and forget to add code - # to clean up the config file, I’ve included the cleanup code here as a precaution. - # While it’s not strictly necessary at this moment, I believe it’s better to err on the side of caution. - - name: Clean CI config file - if: always() - run: rm -rf ./../.cargo diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml new file mode 100644 index 0000000000..31676e5c91 --- /dev/null +++ b/.github/workflows/extension_bump.yml @@ -0,0 +1,148 @@ +# Generated from xtask::workflows::extension_bump +# Rebuild with `cargo xtask workflows`. +name: extension_bump +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' + CARGO_INCREMENTAL: '0' + ZED_EXTENSION_CLI_SHA: 7cfce605704d41ca247e3f84804bf323f6c6caaf +on: + workflow_call: + inputs: + bump-type: + description: bump-type + type: string + default: patch + force-bump: + description: force-bump + required: true + type: boolean + secrets: + app-id: + description: The app ID used to create the PR + required: true + app-secret: + description: The app secret for the corresponding app ID + required: true +jobs: + check_bump_needed: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - id: compare-versions-check + name: extension_bump::compare_versions + run: | + CURRENT_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)" + PR_PARENT_SHA="${{ github.event.pull_request.head.sha }}" + + if [[ -n "$PR_PARENT_SHA" ]]; then + git checkout "$PR_PARENT_SHA" + elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then + git checkout "$BRANCH_PARENT_SHA" + else + git checkout "$(git log -1 --format=%H)"~1 + fi + + PARENT_COMMIT_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)" + + [[ "$CURRENT_VERSION" == "$PARENT_COMMIT_VERSION" ]] && \ + echo "needs_bump=true" >> "$GITHUB_OUTPUT" || \ + echo "needs_bump=false" >> "$GITHUB_OUTPUT" + + echo "current_version=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} + outputs: + needs_bump: ${{ steps.compare-versions-check.outputs.needs_bump }} + current_version: ${{ steps.compare-versions-check.outputs.current_version }} + timeout-minutes: 1 + bump_extension_version: + needs: + - check_bump_needed + if: |- + (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && + (inputs.force-bump == 'true' || needs.check_bump_needed.outputs.needs_bump == 'true') + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - id: generate-token + name: extension_bump::generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.app-id }} + private-key: ${{ secrets.app-secret }} + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: extension_bump::install_bump_2_version + run: pip install bump2version + shell: bash -euxo pipefail {0} + - id: bump-version + name: extension_bump::bump_version + run: | + OLD_VERSION="${{ needs.check_bump_needed.outputs.current_version }}" + + BUMP_FILES=("extension.toml") + if [[ -f "Cargo.toml" ]]; then + BUMP_FILES+=("Cargo.toml") + fi + + bump2version --verbose --current-version "$OLD_VERSION" --no-configured-files ${{ inputs.bump-type }} "${BUMP_FILES[@]}" + + if [[ -f "Cargo.toml" ]]; then + cargo update --workspace + fi + + NEW_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)" + + echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} + - name: extension_bump::create_pull_request + uses: peter-evans/create-pull-request@v7 + with: + title: Bump version to ${{ steps.bump-version.outputs.new_version }} + body: This PR bumps the version of this extension to v${{ steps.bump-version.outputs.new_version }} + commit-message: Bump version to v${{ steps.bump-version.outputs.new_version }} + branch: zed-zippy-autobump + committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> + base: main + delete-branch: true + token: ${{ steps.generate-token.outputs.token }} + sign-commits: true + assignees: ${{ github.actor }} + timeout-minutes: 1 + create_version_label: + needs: + - check_bump_needed + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check_bump_needed.outputs.needs_bump == 'false' + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - id: generate-token + name: extension_bump::generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.app-id }} + private-key: ${{ secrets.app-secret }} + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: extension_bump::create_version_tag + uses: actions/github-script@v7 + with: + script: |- + github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/v${{ needs.check_bump_needed.outputs.current_version }}', + sha: context.sha + }) + github-token: ${{ steps.generate-token.outputs.token }} + timeout-minutes: 1 +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/extension_release.yml b/.github/workflows/extension_release.yml new file mode 100644 index 0000000000..5212a79c3e --- /dev/null +++ b/.github/workflows/extension_release.yml @@ -0,0 +1,43 @@ +# Generated from xtask::workflows::extension_release +# Rebuild with `cargo xtask workflows`. +name: extension_release +on: + workflow_call: + secrets: + app-id: + description: The app ID used to create the PR + required: true + app-secret: + description: The app secret for the corresponding app ID + required: true +jobs: + create_release: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - id: generate-token + name: extension_bump::generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.app-id }} + private-key: ${{ secrets.app-secret }} + owner: zed-industries + repositories: extensions + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - id: get-extension-id + name: extension_release::get_extension_id + run: | + EXTENSION_ID="$(sed -n 's/id = \"\(.*\)\"/\1/p' < extension.toml)" + + echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} + - name: extension_release::release_action + uses: huacnlee/zed-extension-action@v2 + with: + extension-name: ${{ steps.get-extension-id.outputs.extension_id }} + push-to: zed-industries/extensions + env: + COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml new file mode 100644 index 0000000000..9f0917e388 --- /dev/null +++ b/.github/workflows/extension_tests.yml @@ -0,0 +1,133 @@ +# Generated from xtask::workflows::extension_tests +# Rebuild with `cargo xtask workflows`. +name: extension_tests +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' + CARGO_INCREMENTAL: '0' + ZED_EXTENSION_CLI_SHA: 7cfce605704d41ca247e3f84804bf323f6c6caaf +on: + workflow_call: {} +jobs: + orchestrate: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} + - id: filter + name: filter + run: | + if [ -z "$GITHUB_BASE_REF" ]; then + echo "Not in a PR context (i.e., push to main/stable/preview)" + COMPARE_REV="$(git rev-parse HEAD~1)" + else + echo "In a PR context comparing to pull_request.base.ref" + git fetch origin "$GITHUB_BASE_REF" --depth=350 + COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)" + fi + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})" + + check_pattern() { + local output_name="$1" + local pattern="$2" + local grep_arg="$3" + + echo "$CHANGED_FILES" | grep "$grep_arg" "$pattern" && \ + echo "${output_name}=true" >> "$GITHUB_OUTPUT" || \ + echo "${output_name}=false" >> "$GITHUB_OUTPUT" + } + + check_pattern "check_rust" '^(Cargo.lock|Cargo.toml|.*\.rs)$' -qP + check_pattern "check_extension" '^.*\.scm$' -qP + shell: bash -euxo pipefail {0} + outputs: + check_rust: ${{ steps.filter.outputs.check_rust }} + check_extension: ${{ steps.filter.outputs.check_extension }} + check_rust: + needs: + - orchestrate + if: needs.orchestrate.outputs.check_rust == 'true' + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} + - name: extension_tests::run_clippy + run: cargo clippy --release --all-targets --all-features -- --deny warnings + shell: bash -euxo pipefail {0} + - name: steps::cargo_install_nextest + uses: taiki-e/install-action@nextest + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast + shell: bash -euxo pipefail {0} + env: + NEXTEST_NO_TESTS: warn + timeout-minutes: 3 + check_extension: + needs: + - orchestrate + if: needs.orchestrate.outputs.check_extension == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - id: cache-zed-extension-cli + name: extension_tests::cache_zed_extension_cli + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: zed-extension + key: zed-extension-${{ env.ZED_EXTENSION_CLI_SHA }} + - name: extension_tests::download_zed_extension_cli + if: steps.cache-zed-extension-cli.outputs.cache-hit != 'true' + run: | + wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" + chmod +x zed-extension + shell: bash -euxo pipefail {0} + - name: extension_tests::check + run: | + mkdir -p /tmp/ext-scratch + mkdir -p /tmp/ext-output + ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output + shell: bash -euxo pipefail {0} + timeout-minutes: 2 + tests_pass: + needs: + - orchestrate + - check_rust + - check_extension + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && always() + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: run_tests::tests_pass + run: | + set +x + EXIT_CODE=0 + + check_result() { + echo "* $1: $2" + if [[ "$2" != "skipped" && "$2" != "success" ]]; then EXIT_CODE=1; fi + } + + check_result "orchestrate" "${{ needs.orchestrate.result }}" + check_result "check_rust" "${{ needs.check_rust.result }}" + check_result "check_extension" "${{ needs.check_extension.result }}" + + exit $EXIT_CODE + shell: bash -euxo pipefail {0} +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml deleted file mode 100644 index e682ce5890..0000000000 --- a/.github/workflows/nix.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: "Nix build" - -on: - workflow_call: - inputs: - flake-output: - type: string - default: "default" - cachix-filter: - type: string - default: "" - -jobs: - nix-build: - timeout-minutes: 60 - name: (${{ matrix.system.os }}) Nix Build - continue-on-error: true # TODO: remove when we want this to start blocking CI - strategy: - fail-fast: false - matrix: - system: - - os: x86 Linux - runner: namespace-profile-16x32-ubuntu-2204 - install_nix: true - - os: arm Mac - runner: [macOS, ARM64, test] - install_nix: false - if: github.repository_owner == 'zed-industries' - runs-on: ${{ matrix.system.runner }} - env: - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} - ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} - GIT_LFS_SKIP_SMUDGE: 1 # breaks the livekit rust sdk examples which we don't actually depend on - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - # on our macs we manually install nix. for some reason the cachix action is running - # under a non-login /bin/bash shell which doesn't source the proper script to add the - # nix profile to PATH, so we manually add them here - - name: Set path - if: ${{ ! matrix.system.install_nix }} - run: | - echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" - echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH" - - - uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f # v31 - if: ${{ matrix.system.install_nix }} - with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} - - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 - with: - name: zed - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - pushFilter: "${{ inputs.cachix-filter }}" - cachixArgs: "-v" - - - run: nix build .#${{ inputs.flake-output }} -L --accept-flake-config - - - name: Limit /nix/store to 50GB on macs - if: ${{ ! matrix.system.install_nix }} - run: | - if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then - nix-collect-garbage -d || true - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..7afac285b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,496 @@ +# Generated from xtask::workflows::release +# Rebuild with `cargo xtask workflows`. +name: release +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' +on: + push: + tags: + - v* +jobs: + run_tests_mac: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: self-mini-macos + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + run_tests_linux: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} + - name: steps::cargo_install_nextest + uses: taiki-e/install-action@nextest + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 250 + shell: bash -euxo pipefail {0} + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + run_tests_windows: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: self-32vcpu-windows-2022 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" + shell: pwsh + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy.ps1 + shell: pwsh + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than.ps1 250 + shell: pwsh + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast + shell: pwsh + - name: steps::cleanup_cargo_config + if: always() + run: | + Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue + shell: pwsh + timeout-minutes: 60 + check_scripts: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_tests::check_scripts::run_shellcheck + run: ./script/shellcheck-scripts error + shell: bash -euxo pipefail {0} + - id: get_actionlint + name: run_tests::check_scripts::download_actionlint + run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + shell: bash -euxo pipefail {0} + - name: run_tests::check_scripts::run_actionlint + run: | + ${{ steps.get_actionlint.outputs.executable }} -color + shell: bash -euxo pipefail {0} + - name: run_tests::check_scripts::check_xtask_workflows + run: | + cargo xtask workflows + if ! git diff --exit-code .github; then + echo "Error: .github directory has uncommitted changes after running 'cargo xtask workflows'" + echo "Please run 'cargo xtask workflows' locally and commit the changes" + exit 1 + fi + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + create_draft_release: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 25 + ref: ${{ github.ref }} + - name: script/determine-release-channel + run: script/determine-release-channel + shell: bash -euxo pipefail {0} + - name: mkdir -p target/ + run: mkdir -p target/ + shell: bash -euxo pipefail {0} + - name: release::create_draft_release::generate_release_notes + run: node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md + shell: bash -euxo pipefail {0} + - name: release::create_draft_release::create_release + run: script/create-draft-release target/release-notes.md + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + timeout-minutes: 60 + bundle_linux_aarch64: + needs: + - run_tests_linux + - check_scripts + runs-on: namespace-profile-8x32-ubuntu-2004-arm-m4 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact zed-linux-aarch64.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-linux-aarch64.tar.gz + path: target/release/zed-linux-aarch64.tar.gz + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-linux-aarch64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-linux-aarch64.gz + path: target/zed-remote-server-linux-aarch64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_linux_x86_64: + needs: + - run_tests_linux + - check_scripts + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact zed-linux-x86_64.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-linux-x86_64.tar.gz + path: target/release/zed-linux-x86_64.tar.gz + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-linux-x86_64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-linux-x86_64.gz + path: target/zed-remote-server-linux-x86_64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_mac_aarch64: + needs: + - run_tests_mac + - check_scripts + runs-on: self-mini-macos + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac::bundle_mac + run: ./script/bundle-mac aarch64-apple-darwin + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact Zed-aarch64.dmg' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-aarch64.dmg + path: target/aarch64-apple-darwin/release/Zed-aarch64.dmg + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-macos-aarch64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-macos-aarch64.gz + path: target/zed-remote-server-macos-aarch64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_mac_x86_64: + needs: + - run_tests_mac + - check_scripts + runs-on: self-mini-macos + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac::bundle_mac + run: ./script/bundle-mac x86_64-apple-darwin + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact Zed-x86_64.dmg' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-x86_64.dmg + path: target/x86_64-apple-darwin/release/Zed-x86_64.dmg + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-macos-x86_64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-macos-x86_64.gz + path: target/zed-remote-server-macos-x86_64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_windows_aarch64: + needs: + - run_tests_windows + - check_scripts + runs-on: self-32vcpu-windows-2022 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: run_bundling::bundle_windows::bundle_windows + run: script/bundle-windows.ps1 -Architecture aarch64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: '@actions/upload-artifact Zed-aarch64.exe' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-aarch64.exe + path: target/Zed-aarch64.exe + if-no-files-found: error + timeout-minutes: 60 + bundle_windows_x86_64: + needs: + - run_tests_windows + - check_scripts + runs-on: self-32vcpu-windows-2022 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: run_bundling::bundle_windows::bundle_windows + run: script/bundle-windows.ps1 -Architecture x86_64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: '@actions/upload-artifact Zed-x86_64.exe' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-x86_64.exe + path: target/Zed-x86_64.exe + if-no-files-found: error + timeout-minutes: 60 + upload_release_assets: + needs: + - create_draft_release + - bundle_linux_aarch64 + - bundle_linux_x86_64 + - bundle_mac_aarch64 + - bundle_mac_x86_64 + - bundle_windows_aarch64 + - bundle_windows_x86_64 + runs-on: namespace-profile-4x8-ubuntu-2204 + steps: + - name: release::download_workflow_artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + with: + path: ./artifacts/ + - name: ls -lR ./artifacts + run: ls -lR ./artifacts + shell: bash -euxo pipefail {0} + - name: release::prep_release_artifacts + run: |- + mkdir -p release-artifacts/ + + mv ./artifacts/Zed-aarch64.dmg/Zed-aarch64.dmg release-artifacts/Zed-aarch64.dmg + mv ./artifacts/Zed-x86_64.dmg/Zed-x86_64.dmg release-artifacts/Zed-x86_64.dmg + mv ./artifacts/zed-linux-aarch64.tar.gz/zed-linux-aarch64.tar.gz release-artifacts/zed-linux-aarch64.tar.gz + mv ./artifacts/zed-linux-x86_64.tar.gz/zed-linux-x86_64.tar.gz release-artifacts/zed-linux-x86_64.tar.gz + mv ./artifacts/Zed-x86_64.exe/Zed-x86_64.exe release-artifacts/Zed-x86_64.exe + mv ./artifacts/Zed-aarch64.exe/Zed-aarch64.exe release-artifacts/Zed-aarch64.exe + mv ./artifacts/zed-remote-server-macos-aarch64.gz/zed-remote-server-macos-aarch64.gz release-artifacts/zed-remote-server-macos-aarch64.gz + mv ./artifacts/zed-remote-server-macos-x86_64.gz/zed-remote-server-macos-x86_64.gz release-artifacts/zed-remote-server-macos-x86_64.gz + mv ./artifacts/zed-remote-server-linux-aarch64.gz/zed-remote-server-linux-aarch64.gz release-artifacts/zed-remote-server-linux-aarch64.gz + mv ./artifacts/zed-remote-server-linux-x86_64.gz/zed-remote-server-linux-x86_64.gz release-artifacts/zed-remote-server-linux-x86_64.gz + shell: bash -euxo pipefail {0} + - name: gh release upload "$GITHUB_REF_NAME" --repo=zed-industries/zed release-artifacts/* + run: gh release upload "$GITHUB_REF_NAME" --repo=zed-industries/zed release-artifacts/* + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + auto_release_preview: + needs: + - upload_release_assets + if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false + run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + notify_on_failure: + needs: + - upload_release_assets + - auto_release_preview + if: failure() + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: release::notify_on_failure::notify_slack + run: |- + curl -X POST -H 'Content-type: application/json'\ + --data '{"text":"${{ github.workflow }} failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' "$SLACK_WEBHOOK" + shell: bash -euxo pipefail {0} + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_WORKFLOW_FAILURES }} +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 2026ee7b73..d76244175a 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -1,256 +1,279 @@ -name: Release Nightly - -on: - schedule: - # Fire every day at 7:00am UTC (Roughly before EU workday and after US workday) - - cron: "0 7 * * *" - push: - tags: - - "nightly" - +# Generated from xtask::workflows::release_nightly +# Rebuild with `cargo xtask workflows`. +name: release_nightly env: CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} - + RUST_BACKTRACE: '1' +on: + push: + tags: + - nightly + schedule: + - cron: 0 7 * * * jobs: - style: - timeout-minutes: 60 - name: Check formatting and Clippy lints - if: github.repository_owner == 'zed-industries' - runs-on: - - self-hosted - - macOS + check_style: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: self-mini-macos steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - fetch-depth: 0 - - - name: Run style checks - uses: ./.github/actions/check_style - - - name: Run clippy - run: ./script/clippy - - tests: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} + - name: ./script/clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} timeout-minutes: 60 - name: Run tests - if: github.repository_owner == 'zed-industries' - runs-on: - - self-hosted - - macOS - needs: style + run_tests_windows: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: self-32vcpu-windows-2022 steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Run tests - uses: ./.github/actions/run_tests - - windows-tests: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" + shell: pwsh + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy.ps1 + shell: pwsh + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than.ps1 250 + shell: pwsh + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast + shell: pwsh + - name: steps::cleanup_cargo_config + if: always() + run: | + Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue + shell: pwsh timeout-minutes: 60 - name: Run tests on Windows - if: github.repository_owner == 'zed-industries' - runs-on: [self-32vcpu-windows-2022] - 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 - if: github.repository_owner == 'zed-industries' - runs-on: - - self-mini-macos - needs: tests + bundle_linux_aarch64: + needs: + - check_style + - run_tests_windows + runs-on: namespace-profile-8x32-ubuntu-2004-arm-m4 env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_bundling::set_release_channel_to_nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + shell: bash -euxo pipefail {0} + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact zed-linux-aarch64.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-linux-aarch64.tar.gz + path: target/release/zed-linux-aarch64.tar.gz + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-linux-aarch64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-linux-aarch64.gz + path: target/zed-remote-server-linux-aarch64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_linux_x86_64: + needs: + - check_style + - run_tests_windows + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_bundling::set_release_channel_to_nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + shell: bash -euxo pipefail {0} + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact zed-linux-x86_64.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-linux-x86_64.tar.gz + path: target/release/zed-linux-x86_64.tar.gz + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-linux-x86_64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-linux-x86_64.gz + path: target/zed-remote-server-linux-x86_64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_mac_aarch64: + needs: + - check_style + - run_tests_windows + runs-on: self-mini-macos + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - - name: Install Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "18" - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Set release channel to nightly - run: | - set -eu - version=$(git rev-parse --short HEAD) - echo "Publishing version: ${version} on release channel nightly" - echo "nightly" > crates/zed/RELEASE_CHANNEL - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Create macOS app bundle - run: script/bundle-mac - - - name: Upload Zed Nightly - run: script/upload-nightly macos - - bundle-linux-x86: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_bundling::set_release_channel_to_nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac::bundle_mac + run: ./script/bundle-mac aarch64-apple-darwin + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact Zed-aarch64.dmg' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-aarch64.dmg + path: target/aarch64-apple-darwin/release/Zed-aarch64.dmg + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-macos-aarch64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-macos-aarch64.gz + path: target/zed-remote-server-macos-aarch64.gz + if-no-files-found: error timeout-minutes: 60 - name: Create a Linux *.tar.gz bundle for x86 - if: github.repository_owner == 'zed-industries' - runs-on: - - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc - needs: tests - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Install Linux dependencies - run: ./script/linux && ./script/install-mold 2.34.0 - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 100 - - - name: Set release channel to nightly - run: | - set -euo pipefail - version=$(git rev-parse --short HEAD) - echo "Publishing version: ${version} on release channel nightly" - echo "nightly" > crates/zed/RELEASE_CHANNEL - - - name: Create Linux .tar.gz bundle - run: script/bundle-linux - - - name: Upload Zed Nightly - run: script/upload-nightly linux-targz - - bundle-linux-arm: - timeout-minutes: 60 - name: Create a Linux *.tar.gz bundle for ARM - if: github.repository_owner == 'zed-industries' - runs-on: - - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc - needs: tests - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Install Linux dependencies - run: ./script/linux - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 100 - - - name: Set release channel to nightly - run: | - set -euo pipefail - version=$(git rev-parse --short HEAD) - echo "Publishing version: ${version} on release channel nightly" - echo "nightly" > crates/zed/RELEASE_CHANNEL - - - name: Create Linux .tar.gz bundle - run: script/bundle-linux - - - name: Upload Zed Nightly - run: script/upload-nightly linux-targz - - freebsd: - timeout-minutes: 60 - if: false && github.repository_owner == 'zed-industries' - runs-on: github-8vcpu-ubuntu-2404 - needs: tests - name: Build Zed on FreeBSD - steps: - - uses: actions/checkout@v4 - - name: Build FreeBSD remote-server - id: freebsd-build - uses: vmactions/freebsd-vm@c3ae29a132c8ef1924775414107a97cac042aad5 # v1.2.0 - with: - # envs: "MYTOKEN MYTOKEN2" - usesh: true - release: 13.5 - copyback: true - prepare: | - pkg install -y \ - bash curl jq git \ - rustup-init cmake-core llvm-devel-lite pkgconf protobuf # ibx11 alsa-lib rust-bindgen-cli - run: | - freebsd-version - sysctl hw.model - sysctl hw.ncpu - sysctl hw.physmem - sysctl hw.usermem - git config --global --add safe.directory /home/runner/work/zed/zed - rustup-init --profile minimal --default-toolchain none -y - . "$HOME/.cargo/env" - ./script/bundle-freebsd - mkdir -p out/ - mv "target/zed-remote-server-freebsd-x86_64.gz" out/ - rm -rf target/ - cargo clean - - - name: Upload Zed Nightly - run: script/upload-nightly freebsd - - bundle-nix: - name: Build and cache Nix package - 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-32vcpu-windows-2022] - needs: windows-tests + bundle_mac_x86_64: + needs: + - check_style + - run_tests_windows + runs-on: self-mini-macos env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_bundling::set_release_channel_to_nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac::bundle_mac + run: ./script/bundle-mac x86_64-apple-darwin + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact Zed-x86_64.dmg' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-x86_64.dmg + path: target/x86_64-apple-darwin/release/Zed-x86_64.dmg + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-macos-x86_64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-macos-x86_64.gz + path: target/zed-remote-server-macos-x86_64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_windows_aarch64: + needs: + - check_style + - run_tests_windows + runs-on: self-32vcpu-windows-2022 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} @@ -259,65 +282,229 @@ jobs: ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} FILE_DIGEST: SHA256 TIMESTAMP_DIGEST: SHA256 - TIMESTAMP_SERVER: "http://timestamp.acs.microsoft.com" + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Set release channel to nightly - working-directory: ${{ env.ZED_WORKSPACE }} - run: | - $ErrorActionPreference = "Stop" - $version = git rev-parse --short HEAD - Write-Host "Publishing version: $version on release channel nightly" - "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Build Zed installer - working-directory: ${{ env.ZED_WORKSPACE }} - run: script/bundle-windows.ps1 - - - name: Upload Zed Nightly - working-directory: ${{ env.ZED_WORKSPACE }} - run: script/upload-nightly.ps1 windows - - update-nightly-tag: - name: Update nightly tag - if: github.repository_owner == 'zed-industries' - runs-on: namespace-profile-2x4-ubuntu-2404 + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_bundling::set_release_channel_to_nightly + run: | + $ErrorActionPreference = "Stop" + $version = git rev-parse --short HEAD + Write-Host "Publishing version: $version on release channel nightly" + "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: run_bundling::bundle_windows::bundle_windows + run: script/bundle-windows.ps1 -Architecture aarch64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: '@actions/upload-artifact Zed-aarch64.exe' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-aarch64.exe + path: target/Zed-aarch64.exe + if-no-files-found: error + timeout-minutes: 60 + bundle_windows_x86_64: needs: - - bundle-mac - - bundle-linux-x86 - - bundle-linux-arm - - bundle-windows-x64 + - check_style + - run_tests_windows + runs-on: self-32vcpu-windows-2022 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + 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: - fetch-depth: 0 + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_bundling::set_release_channel_to_nightly + run: | + $ErrorActionPreference = "Stop" + $version = git rev-parse --short HEAD + Write-Host "Publishing version: $version on release channel nightly" + "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: run_bundling::bundle_windows::bundle_windows + run: script/bundle-windows.ps1 -Architecture x86_64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: '@actions/upload-artifact Zed-x86_64.exe' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-x86_64.exe + path: target/Zed-x86_64.exe + if-no-files-found: error + timeout-minutes: 60 + build_nix_linux_x86_64: + needs: + - check_style + - run_tests_windows + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::build_nix::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::build_nix::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + - name: nix_build::build_nix::build + run: nix build .#default -L --accept-flake-config + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + build_nix_mac_aarch64: + needs: + - check_style + - run_tests_windows + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: self-mini-macos + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::build_nix::set_path + run: | + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH" + shell: bash -euxo pipefail {0} + - name: nix_build::build_nix::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + - name: nix_build::build_nix::build + run: nix build .#default -L --accept-flake-config + shell: bash -euxo pipefail {0} + - name: nix_build::build_nix::limit_store + run: |- + if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then + nix-collect-garbage -d || true + fi + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + update_nightly_tag: + needs: + - bundle_linux_aarch64 + - bundle_linux_x86_64 + - bundle_mac_aarch64 + - bundle_mac_x86_64 + - bundle_windows_aarch64 + - bundle_windows_x86_64 + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-4x8-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - name: release::download_workflow_artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + with: + path: ./artifacts/ + - name: ls -lR ./artifacts + run: ls -lR ./artifacts + shell: bash -euxo pipefail {0} + - name: release::prep_release_artifacts + run: |- + mkdir -p release-artifacts/ - - name: Update nightly tag - run: | - if [ "$(git rev-parse nightly)" = "$(git rev-parse HEAD)" ]; then - echo "Nightly tag already points to current commit. Skipping tagging." - exit 0 - fi - git config user.name github-actions - git config user.email github-actions@github.com - git tag -f nightly - git push origin nightly --force - - - name: Create Sentry release - uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c # v3 - env: - SENTRY_ORG: zed-dev - SENTRY_PROJECT: zed - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - with: - environment: production + mv ./artifacts/Zed-aarch64.dmg/Zed-aarch64.dmg release-artifacts/Zed-aarch64.dmg + mv ./artifacts/Zed-x86_64.dmg/Zed-x86_64.dmg release-artifacts/Zed-x86_64.dmg + mv ./artifacts/zed-linux-aarch64.tar.gz/zed-linux-aarch64.tar.gz release-artifacts/zed-linux-aarch64.tar.gz + mv ./artifacts/zed-linux-x86_64.tar.gz/zed-linux-x86_64.tar.gz release-artifacts/zed-linux-x86_64.tar.gz + mv ./artifacts/Zed-x86_64.exe/Zed-x86_64.exe release-artifacts/Zed-x86_64.exe + mv ./artifacts/Zed-aarch64.exe/Zed-aarch64.exe release-artifacts/Zed-aarch64.exe + mv ./artifacts/zed-remote-server-macos-aarch64.gz/zed-remote-server-macos-aarch64.gz release-artifacts/zed-remote-server-macos-aarch64.gz + mv ./artifacts/zed-remote-server-macos-x86_64.gz/zed-remote-server-macos-x86_64.gz release-artifacts/zed-remote-server-macos-x86_64.gz + mv ./artifacts/zed-remote-server-linux-aarch64.gz/zed-remote-server-linux-aarch64.gz release-artifacts/zed-remote-server-linux-aarch64.gz + mv ./artifacts/zed-remote-server-linux-x86_64.gz/zed-remote-server-linux-x86_64.gz release-artifacts/zed-remote-server-linux-x86_64.gz + shell: bash -euxo pipefail {0} + - name: ./script/upload-nightly + run: ./script/upload-nightly + shell: bash -euxo pipefail {0} + env: + DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} + DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + - name: release_nightly::update_nightly_tag_job::update_nightly_tag + run: | + if [ "$(git rev-parse nightly)" = "$(git rev-parse HEAD)" ]; then + echo "Nightly tag already points to current commit. Skipping tagging." + exit 0 + fi + git config user.name github-actions + git config user.email github-actions@github.com + git tag -f nightly + git push origin nightly --force + shell: bash -euxo pipefail {0} + - name: release::create_sentry_release + uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c + with: + environment: production + env: + SENTRY_ORG: zed-dev + SENTRY_PROJECT: zed + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + timeout-minutes: 60 + notify_on_failure: + needs: + - bundle_linux_aarch64 + - bundle_linux_x86_64 + - bundle_mac_aarch64 + - bundle_mac_x86_64 + - bundle_windows_aarch64 + - bundle_windows_x86_64 + if: failure() + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: release::notify_on_failure::notify_slack + run: |- + curl -X POST -H 'Content-type: application/json'\ + --data '{"text":"${{ github.workflow }} failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' "$SLACK_WEBHOOK" + shell: bash -euxo pipefail {0} + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_WORKFLOW_FAILURES }} diff --git a/.github/workflows/run_agent_evals.yml b/.github/workflows/run_agent_evals.yml new file mode 100644 index 0000000000..421d5a1c80 --- /dev/null +++ b/.github/workflows/run_agent_evals.yml @@ -0,0 +1,67 @@ +# Generated from xtask::workflows::run_agent_evals +# Rebuild with `cargo xtask workflows`. +name: run_agent_evals +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} + GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_EVAL_TELEMETRY: '1' + MODEL_NAME: ${{ inputs.model_name }} +on: + workflow_dispatch: + inputs: + model_name: + description: model_name + required: true + type: string +jobs: + agent_evals: + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: cargo build --package=eval + run: cargo build --package=eval + shell: bash -euxo pipefail {0} + - name: run_agent_evals::agent_evals::run_eval + run: cargo run --package=eval -- --repetitions=8 --concurrency=1 --model "${MODEL_NAME}" + shell: bash -euxo pipefail {0} + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} + GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 600 +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml new file mode 100644 index 0000000000..f56e56ac7f --- /dev/null +++ b/.github/workflows/run_bundling.yml @@ -0,0 +1,269 @@ +# Generated from xtask::workflows::run_bundling +# Rebuild with `cargo xtask workflows`. +name: run_bundling +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' +on: + pull_request: + types: + - labeled + - synchronize +jobs: + bundle_linux_aarch64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: namespace-profile-8x32-ubuntu-2004-arm-m4 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact zed-linux-aarch64.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-linux-aarch64.tar.gz + path: target/release/zed-linux-aarch64.tar.gz + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-linux-aarch64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-linux-aarch64.gz + path: target/zed-remote-server-linux-aarch64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_linux_x86_64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact zed-linux-x86_64.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-linux-x86_64.tar.gz + path: target/release/zed-linux-x86_64.tar.gz + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-linux-x86_64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-linux-x86_64.gz + path: target/zed-remote-server-linux-x86_64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_mac_aarch64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: self-mini-macos + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac::bundle_mac + run: ./script/bundle-mac aarch64-apple-darwin + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact Zed-aarch64.dmg' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-aarch64.dmg + path: target/aarch64-apple-darwin/release/Zed-aarch64.dmg + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-macos-aarch64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-macos-aarch64.gz + path: target/zed-remote-server-macos-aarch64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_mac_x86_64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: self-mini-macos + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac::bundle_mac + run: ./script/bundle-mac x86_64-apple-darwin + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact Zed-x86_64.dmg' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-x86_64.dmg + path: target/x86_64-apple-darwin/release/Zed-x86_64.dmg + if-no-files-found: error + - name: '@actions/upload-artifact zed-remote-server-macos-x86_64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-macos-x86_64.gz + path: target/zed-remote-server-macos-x86_64.gz + if-no-files-found: error + timeout-minutes: 60 + bundle_windows_aarch64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: self-32vcpu-windows-2022 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: run_bundling::bundle_windows::bundle_windows + run: script/bundle-windows.ps1 -Architecture aarch64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: '@actions/upload-artifact Zed-aarch64.exe' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-aarch64.exe + path: target/Zed-aarch64.exe + if-no-files-found: error + timeout-minutes: 60 + bundle_windows_x86_64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: self-32vcpu-windows-2022 + env: + CARGO_INCREMENTAL: 0 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: run_bundling::bundle_windows::bundle_windows + run: script/bundle-windows.ps1 -Architecture x86_64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: '@actions/upload-artifact Zed-x86_64.exe' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed-x86_64.exe + path: target/Zed-x86_64.exe + if-no-files-found: error + timeout-minutes: 60 +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml new file mode 100644 index 0000000000..cdfb51cc5b --- /dev/null +++ b/.github/workflows/run_cron_unit_evals.yml @@ -0,0 +1,77 @@ +# Generated from xtask::workflows::run_cron_unit_evals +# Rebuild with `cargo xtask workflows`. +name: run_cron_unit_evals +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} +on: + schedule: + - cron: 47 1 * * 2 + workflow_dispatch: {} +jobs: + cron_unit_evals: + runs-on: namespace-profile-16x32-ubuntu-2204 + strategy: + matrix: + model: + - anthropic/claude-sonnet-4-5-latest + - anthropic/claude-opus-4-5-latest + - google/gemini-3-pro + - openai/gpt-5 + fail-fast: false + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::cargo_install_nextest + uses: taiki-e/install-action@nextest + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 250 + shell: bash -euxo pipefail {0} + - name: ./script/run-unit-evals + run: ./script/run-unit-evals + shell: bash -euxo pipefail {0} + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} + GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} + ZED_AGENT_MODEL: ${{ matrix.model }} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + - name: run_agent_evals::cron_unit_evals::send_failure_to_slack + if: ${{ failure() }} + uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN }} + payload: | + channel: C04UDRNNJFQ + text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}" +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000000..9584d7a0cb --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,578 @@ +# Generated from xtask::workflows::run_tests +# Rebuild with `cargo xtask workflows`. +name: run_tests +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' + CARGO_INCREMENTAL: '0' +on: + pull_request: + branches: + - '**' + push: + branches: + - main + - v[0-9]+.[0-9]+.x +jobs: + orchestrate: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} + - id: filter + name: filter + run: | + if [ -z "$GITHUB_BASE_REF" ]; then + echo "Not in a PR context (i.e., push to main/stable/preview)" + COMPARE_REV="$(git rev-parse HEAD~1)" + else + echo "In a PR context comparing to pull_request.base.ref" + git fetch origin "$GITHUB_BASE_REF" --depth=350 + COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)" + fi + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})" + + check_pattern() { + local output_name="$1" + local pattern="$2" + local grep_arg="$3" + + echo "$CHANGED_FILES" | grep "$grep_arg" "$pattern" && \ + echo "${output_name}=true" >> "$GITHUB_OUTPUT" || \ + echo "${output_name}=false" >> "$GITHUB_OUTPUT" + } + + check_pattern "run_action_checks" '^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/' -qP + check_pattern "run_docs" '^(docs/|crates/.*\.rs)' -qP + check_pattern "run_licenses" '^(Cargo.lock|script/.*licenses)' -qP + check_pattern "run_nix" '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' -qP + check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))' -qvP + shell: bash -euxo pipefail {0} + outputs: + run_action_checks: ${{ steps.filter.outputs.run_action_checks }} + run_docs: ${{ steps.filter.outputs.run_docs }} + run_licenses: ${{ steps.filter.outputs.run_licenses }} + run_nix: ${{ steps.filter.outputs.run_nix }} + run_tests: ${{ steps.filter.outputs.run_tests }} + check_style: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-4x8-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: ./script/prettier + run: ./script/prettier + shell: bash -euxo pipefail {0} + - name: ./script/check-todos + run: ./script/check-todos + shell: bash -euxo pipefail {0} + - name: ./script/check-keymaps + run: ./script/check-keymaps + shell: bash -euxo pipefail {0} + - name: run_tests::check_style::check_for_typos + uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 + with: + config: ./typos.toml + - name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + run_tests_windows: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: self-32vcpu-windows-2022 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" + shell: pwsh + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy.ps1 + shell: pwsh + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than.ps1 250 + shell: pwsh + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast + shell: pwsh + - name: steps::cleanup_cargo_config + if: always() + run: | + Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue + shell: pwsh + timeout-minutes: 60 + run_tests_linux: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} + - name: steps::cargo_install_nextest + uses: taiki-e/install-action@nextest + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 250 + shell: bash -euxo pipefail {0} + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + run_tests_mac: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: self-mini-macos + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + doctests: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - id: run_doctests + name: run_tests::doctests::run_doctests + run: | + cargo test --workspace --doc --no-fail-fast + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + check_workspace_binaries: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: cargo build -p collab + run: cargo build -p collab + shell: bash -euxo pipefail {0} + - name: cargo build --workspace --bins --examples + run: cargo build --workspace --bins --examples + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + check_dependencies: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: run_tests::check_dependencies::install_cargo_machete + uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 + with: + command: install + args: cargo-machete@0.7.0 + - name: run_tests::check_dependencies::run_cargo_machete + uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 + with: + command: machete + - name: run_tests::check_dependencies::check_cargo_lock + run: cargo update --locked --workspace + shell: bash -euxo pipefail {0} + - name: run_tests::check_dependencies::check_vulnerable_dependencies + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@67d4f4bd7a9b17a0db54d2a7519187c65e339de8 + with: + license-check: false + timeout-minutes: 60 + check_docs: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_docs == 'true' + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: run_tests::check_docs::lychee_link_check + uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 + with: + args: --no-progress --exclude '^http' './docs/src/**/*' + fail: true + jobSummary: false + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: run_tests::check_docs::install_mdbook + uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 + with: + mdbook-version: 0.4.37 + - name: run_tests::check_docs::build_docs + run: | + mkdir -p target/deploy + mdbook build ./docs --dest-dir=../target/deploy/docs/ + shell: bash -euxo pipefail {0} + - name: run_tests::check_docs::lychee_link_check + uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 + with: + args: --no-progress --exclude '^http' 'target/deploy/docs' + fail: true + jobSummary: false + timeout-minutes: 60 + check_licenses: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_licenses == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: ./script/check-licenses + run: ./script/check-licenses + shell: bash -euxo pipefail {0} + - name: ./script/generate-licenses + run: ./script/generate-licenses + shell: bash -euxo pipefail {0} + check_scripts: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_action_checks == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_tests::check_scripts::run_shellcheck + run: ./script/shellcheck-scripts error + shell: bash -euxo pipefail {0} + - id: get_actionlint + name: run_tests::check_scripts::download_actionlint + run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + shell: bash -euxo pipefail {0} + - name: run_tests::check_scripts::run_actionlint + run: | + ${{ steps.get_actionlint.outputs.executable }} -color + shell: bash -euxo pipefail {0} + - name: run_tests::check_scripts::check_xtask_workflows + run: | + cargo xtask workflows + if ! git diff --exit-code .github; then + echo "Error: .github directory has uncommitted changes after running 'cargo xtask workflows'" + echo "Please run 'cargo xtask workflows' locally and commit the changes" + exit 1 + fi + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + build_nix_linux_x86_64: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_nix == 'true' + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::build_nix::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::build_nix::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]*-nightly + - name: nix_build::build_nix::build + run: nix build .#debug -L --accept-flake-config + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + build_nix_mac_aarch64: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_nix == 'true' + runs-on: self-mini-macos + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::build_nix::set_path + run: | + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH" + shell: bash -euxo pipefail {0} + - name: nix_build::build_nix::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]*-nightly + - name: nix_build::build_nix::build + run: nix build .#debug -L --accept-flake-config + shell: bash -euxo pipefail {0} + - name: nix_build::build_nix::limit_store + run: |- + if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then + nix-collect-garbage -d || true + fi + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + check_postgres_and_protobuf_migrations: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-16x32-ubuntu-2204 + env: + GIT_AUTHOR_NAME: Protobuf Action + GIT_AUTHOR_EMAIL: ci@zed.dev + GIT_COMMITTER_NAME: Protobuf Action + GIT_COMMITTER_EMAIL: ci@zed.dev + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + - name: run_tests::check_postgres_and_protobuf_migrations::remove_untracked_files + run: git clean -df + shell: bash -euxo pipefail {0} + - name: run_tests::check_postgres_and_protobuf_migrations::ensure_fresh_merge + run: | + if [ -z "$GITHUB_BASE_REF" ]; + then + echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV" + else + git checkout -B temp + git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp" + echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV" + fi + shell: bash -euxo pipefail {0} + - name: run_tests::check_postgres_and_protobuf_migrations::bufbuild_setup_action + uses: bufbuild/buf-setup-action@v1 + with: + version: v1.29.0 + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: run_tests::check_postgres_and_protobuf_migrations::bufbuild_breaking_action + uses: bufbuild/buf-breaking-action@v1 + with: + input: crates/proto/proto/ + against: https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/ + timeout-minutes: 60 + tests_pass: + needs: + - orchestrate + - check_style + - run_tests_windows + - run_tests_linux + - run_tests_mac + - doctests + - check_workspace_binaries + - check_dependencies + - check_docs + - check_licenses + - check_scripts + - build_nix_linux_x86_64 + - build_nix_mac_aarch64 + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && always() + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: run_tests::tests_pass + run: | + set +x + EXIT_CODE=0 + + check_result() { + echo "* $1: $2" + if [[ "$2" != "skipped" && "$2" != "success" ]]; then EXIT_CODE=1; fi + } + + check_result "orchestrate" "${{ needs.orchestrate.result }}" + check_result "check_style" "${{ needs.check_style.result }}" + check_result "run_tests_windows" "${{ needs.run_tests_windows.result }}" + check_result "run_tests_linux" "${{ needs.run_tests_linux.result }}" + check_result "run_tests_mac" "${{ needs.run_tests_mac.result }}" + check_result "doctests" "${{ needs.doctests.result }}" + check_result "check_workspace_binaries" "${{ needs.check_workspace_binaries.result }}" + check_result "check_dependencies" "${{ needs.check_dependencies.result }}" + check_result "check_docs" "${{ needs.check_docs.result }}" + check_result "check_licenses" "${{ needs.check_licenses.result }}" + check_result "check_scripts" "${{ needs.check_scripts.result }}" + check_result "build_nix_linux_x86_64" "${{ needs.build_nix_linux_x86_64.result }}" + check_result "build_nix_mac_aarch64" "${{ needs.build_nix_mac_aarch64.result }}" + + exit $EXIT_CODE + shell: bash -euxo pipefail {0} +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml new file mode 100644 index 0000000000..8f64a5c8bc --- /dev/null +++ b/.github/workflows/run_unit_evals.yml @@ -0,0 +1,69 @@ +# Generated from xtask::workflows::run_unit_evals +# Rebuild with `cargo xtask workflows`. +name: run_unit_evals +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_EVAL_TELEMETRY: '1' + MODEL_NAME: ${{ inputs.model_name }} +on: + workflow_dispatch: + inputs: + model_name: + description: model_name + required: true + type: string + commit_sha: + description: commit_sha + required: true + type: string +jobs: + run_unit_evals: + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::cargo_install_nextest + uses: taiki-e/install-action@nextest + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 250 + shell: bash -euxo pipefail {0} + - name: ./script/run-unit-evals + run: ./script/run-unit-evals + shell: bash -euxo pipefail {0} + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} + GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} + UNIT_EVAL_COMMIT: ${{ inputs.commit_sha }} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }} + cancel-in-progress: true diff --git a/.github/workflows/script_checks.yml b/.github/workflows/script_checks.yml deleted file mode 100644 index 5dbfc9cb7f..0000000000 --- a/.github/workflows/script_checks.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Script - -on: - pull_request: - paths: - - "script/**" - push: - branches: - - main - -jobs: - shellcheck: - name: "ShellCheck Scripts" - if: github.repository_owner == 'zed-industries' - runs-on: namespace-profile-2x4-ubuntu-2404 - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Shellcheck ./scripts - run: | - ./script/shellcheck-scripts error diff --git a/.github/workflows/unit_evals.yml b/.github/workflows/unit_evals.yml deleted file mode 100644 index c03cf8b087..0000000000 --- a/.github/workflows/unit_evals.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Run Unit Evals - -on: - schedule: - # GitHub might drop jobs at busy times, so we choose a random time in the middle of the night. - - cron: "47 1 * * 2" - workflow_dispatch: - -concurrency: - # Allow only one workflow per any non-`main` branch. - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - -jobs: - unit_evals: - if: github.repository_owner == 'zed-industries' - timeout-minutes: 60 - name: Run unit evals - runs-on: - - namespace-profile-16x32-ubuntu-2204 - steps: - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Cache dependencies - uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - # cache-provider: "buildjet" - - - name: Install Linux dependencies - run: ./script/linux - - - name: Configure CI - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - - name: Install Rust - shell: bash -euxo pipefail {0} - run: | - cargo install cargo-nextest --locked - - - name: Install Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "18" - - - name: Limit target directory size - shell: bash -euxo pipefail {0} - run: script/clear-target-dir-if-larger-than 100 - - - name: Run unit evals - shell: bash -euxo pipefail {0} - run: cargo nextest run --workspace --no-fail-fast --features eval --no-capture -E 'test(::eval_)' - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - - - name: Send failure message to Slack channel if needed - if: ${{ failure() }} - uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN }} - payload: | - channel: C04UDRNNJFQ - text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}" - - # Even the Linux runner is not stateful, in theory there is no need to do this cleanup. - # But, to avoid potential issues in the future if we choose to use a stateful Linux runner and forget to add code - # to clean up the config file, I’ve included the cleanup code here as a precaution. - # While it’s not strictly necessary at this moment, I believe it’s better to err on the side of caution. - - name: Clean CI config file - if: always() - run: rm -rf ./../.cargo diff --git a/.gitignore b/.gitignore index d248b1f7e5..54faaf1374 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .DS_Store .blob_store .build +.claude/settings.local.json .envrc .flatpak-builder .idea @@ -25,6 +26,7 @@ /crates/collab/seed.json /crates/theme/schemas/theme.json /crates/zed/resources/flatpak/flatpak-cargo-sources.json +/crates/project_panel/benches/linux_repo_snapshot.txt /dev.zed.Zed*.json /node_modules/ /plugins/bin @@ -38,3 +40,6 @@ xcuserdata/ # Don't commit any secrets to the repo. .env .env.secret.toml + +# `nix build` output +/result diff --git a/.rules b/.rules index 82d15eb9e8..7c98c65d7e 100644 --- a/.rules +++ b/.rules @@ -26,6 +26,12 @@ }); ``` +# Timers in tests + +* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`: + - Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher. + - Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping. + # GPUI GPUI is a UI framework which also provides primitives for state and concurrency management. diff --git a/.zed/settings.json b/.zed/settings.json index 68e05a426f..2760be9581 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -48,7 +48,7 @@ "remove_trailing_whitespace_on_save": true, "ensure_final_newline_on_save": true, "file_scan_exclusions": [ - "crates/assistant_tools/src/edit_agent/evals/fixtures", + "crates/agent/src/edit_agent/evals/fixtures", "crates/eval/worktrees/", "crates/eval/repos/", "**/.git", diff --git a/Cargo.lock b/Cargo.lock index ada56e87e5..b43be6986b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "agent_settings", "anyhow", "buffer_diff", + "collections", "editor", "env_logger 0.11.8", "file_icons", @@ -25,21 +26,21 @@ dependencies = [ "portable-pty", "project", "prompt_store", - "rand 0.9.1", + "rand 0.9.2", "serde", "serde_json", "settings", "smol", "task", + "telemetry", "tempfile", "terminal", "ui", "url", + "util", "uuid", "watch", - "workspace-hack", - "zed-collections", - "zed-util", + "zlog", ] [[package]] @@ -47,6 +48,7 @@ name = "acp_tools" version = "0.1.0" dependencies = [ "agent-client-protocol", + "collections", "gpui", "language", "markdown", @@ -56,10 +58,8 @@ dependencies = [ "settings", "theme", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", ] [[package]] @@ -69,6 +69,7 @@ dependencies = [ "anyhow", "buffer_diff", "clock", + "collections", "ctor", "futures 0.3.31", "gpui", @@ -77,14 +78,13 @@ dependencies = [ "log", "pretty_assertions", "project", - "rand 0.9.1", + "rand 0.9.2", "serde_json", "settings", + "telemetry", "text", + "util", "watch", - "workspace-hack", - "zed-collections", - "zed-util", "zlog", ] @@ -96,33 +96,34 @@ dependencies = [ "auto_update", "editor", "extension_host", + "fs", "futures 0.3.31", "gpui", "language", "project", "proto", "release_channel", + "semver", "smallvec", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", ] [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" @@ -139,100 +140,26 @@ dependencies = [ [[package]] name = "agent" version = "0.1.0" -dependencies = [ - "action_log", - "agent_settings", - "anyhow", - "assistant_context", - "assistant_tool", - "assistant_tools", - "chrono", - "client", - "cloud_llm_client", - "component", - "context_server", - "convert_case 0.8.0", - "fs", - "futures 0.3.31", - "git", - "gpui", - "heed", - "icons", - "indoc", - "itertools 0.14.0", - "language", - "language_model", - "log", - "parking_lot", - "paths", - "postage", - "pretty_assertions", - "project", - "prompt_store", - "rand 0.9.1", - "ref-cast", - "rope", - "schemars 1.0.1", - "serde", - "serde_json", - "settings", - "smol", - "sqlez", - "telemetry", - "text", - "theme", - "thiserror 2.0.12", - "time", - "uuid", - "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", - "zed_env_vars", - "zstd", -] - -[[package]] -name = "agent-client-protocol" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aaa2bd05a2401887945f8bfd70026e90bc3cf96c62ab9eba2779835bf21dc60" -dependencies = [ - "anyhow", - "async-broadcast", - "async-trait", - "futures 0.3.31", - "log", - "parking_lot", - "schemars 1.0.1", - "serde", - "serde_json", -] - -[[package]] -name = "agent2" -version = "0.1.0" dependencies = [ "acp_thread", "action_log", - "agent", "agent-client-protocol", "agent_servers", "agent_settings", "anyhow", - "assistant_context", - "assistant_tool", - "assistant_tools", + "assistant_text_thread", "chrono", "client", "clock", "cloud_llm_client", + "collections", "context_server", "ctor", "db", + "derive_more 0.99.20", "editor", "env_logger 0.11.8", + "eval_utils", "fs", "futures 0.3.31", "git", @@ -240,6 +167,7 @@ dependencies = [ "gpui_tokio", "handlebars 4.5.0", "html_to_markdown", + "http_client", "indoc", "itertools 0.14.0", "language", @@ -253,37 +181,70 @@ dependencies = [ "pretty_assertions", "project", "prompt_store", + "rand 0.9.2", + "regex", "reqwest_client", "rust-embed", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", + "smallvec", "smol", "sqlez", + "streaming_diff", + "strsim", "task", "telemetry", "tempfile", "terminal", "text", "theme", - "thiserror 2.0.12", + "thiserror 2.0.17", "tree-sitter-rust", "ui", "unindent", + "util", "uuid", "watch", "web_search", - "workspace-hack", "worktree", - "zed-collections", - "zed-http-client", - "zed-util", "zed_env_vars", "zlog", "zstd", ] +[[package]] +name = "agent-client-protocol" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ffe7d502c1e451aafc5aff655000f84d09c9af681354ac0012527009b1af13" +dependencies = [ + "agent-client-protocol-schema", + "anyhow", + "async-broadcast", + "async-trait", + "derive_more 2.0.1", + "futures 0.3.31", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8af81cc2d5c3f9c04f73db452efd058333735ba9d51c2cf7ef33c9fee038e7e6" +dependencies = [ + "anyhow", + "derive_more 2.0.1", + "schemars", + "serde", + "serde_json", + "strum 0.27.2", +] + [[package]] name = "agent_servers" version = "0.1.0" @@ -292,15 +253,16 @@ dependencies = [ "acp_tools", "action_log", "agent-client-protocol", - "agent_settings", "anyhow", "async-trait", "client", + "collections", "env_logger 0.11.8", "fs", "futures 0.3.31", "gpui", "gpui_tokio", + "http_client", "indoc", "language", "language_model", @@ -309,6 +271,7 @@ dependencies = [ "log", "nix 0.29.0", "project", + "release_channel", "reqwest_client", "serde", "serde_json", @@ -317,14 +280,11 @@ dependencies = [ "task", "tempfile", "terminal", - "thiserror 2.0.12", + "thiserror 2.0.17", "ui", + "util", "uuid", "watch", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", ] [[package]] @@ -333,20 +293,19 @@ version = "0.1.0" dependencies = [ "anyhow", "cloud_llm_client", + "collections", "convert_case 0.8.0", "fs", "gpui", "language_model", "paths", "project", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "serde_json_lenient", "settings", - "workspace-hack", - "zed-collections", - "zed-util", + "util", ] [[package]] @@ -357,27 +316,28 @@ dependencies = [ "action_log", "agent", "agent-client-protocol", - "agent2", "agent_servers", "agent_settings", "ai_onboarding", "anyhow", "arrayvec", - "assistant_context", "assistant_slash_command", "assistant_slash_commands", - "assistant_tool", - "assistant_tools", + "assistant_text_thread", + "async-fs", "audio", "buffer_diff", "chrono", "client", + "clock", "cloud_llm_client", + "collections", "command_palette_hooks", "component", "context_server", "db", "editor", + "eval_utils", "extension", "extension_host", "feature_flags", @@ -386,7 +346,10 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", + "gpui_tokio", "html_to_markdown", + "http_client", + "image", "indoc", "itertools 0.14.0", "jsonschema", @@ -409,12 +372,14 @@ dependencies = [ "project", "prompt_store", "proto", - "rand 0.9.1", + "rand 0.9.2", "release_channel", + "reqwest_client", "rope", "rules_library", - "schemars 1.0.1", + "schemars", "search", + "semver", "serde", "serde_json", "serde_json_lenient", @@ -423,7 +388,6 @@ dependencies = [ "streaming_diff", "task", "telemetry", - "telemetry_events", "terminal", "terminal_view", "text", @@ -435,40 +399,68 @@ dependencies = [ "ui_input", "unindent", "url", - "urlencoding", + "util", + "uuid", "watch", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", "zed_actions", ] +[[package]] +name = "agent_ui_v2" +version = "0.1.0" +dependencies = [ + "agent", + "agent_servers", + "agent_settings", + "agent_ui", + "anyhow", + "assistant_text_thread", + "chrono", + "db", + "editor", + "feature_flags", + "fs", + "fuzzy", + "gpui", + "menu", + "project", + "prompt_store", + "serde", + "serde_json", + "settings", + "text", + "time", + "time_format", + "ui", + "util", + "workspace", +] + [[package]] name = "ahash" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "version_check", ] [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", - "getrandom 0.2.15", + "getrandom 0.3.4", "once_cell", "serde", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -493,7 +485,6 @@ dependencies = [ "smallvec", "telemetry", "ui", - "workspace-hack", "zed_actions", ] @@ -504,7 +495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cb5f4f1ef69bdb8b2095ddd14b09dd74ee0303aae8bd5372667a54cff689a1b" dependencies = [ "base64 0.22.1", - "bitflags 2.9.0", + "bitflags 2.9.4", "home", "libc", "log", @@ -513,7 +504,7 @@ dependencies = [ "piper", "polling", "regex-automata", - "rustix 1.0.7", + "rustix 1.1.2", "rustix-openpty", "serde", "signal-hook", @@ -530,9 +521,27 @@ checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" [[package]] name = "aligned-vec" -version = "0.5.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] [[package]] name = "allocator-api2" @@ -547,7 +556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -570,23 +579,17 @@ checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" [[package]] name = "ammonia" -version = "4.1.0" +version = "4.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ada2ee439075a3e70b6992fce18ac4e407cd05aea9ca3f75d2c0b0c20bbb364" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" dependencies = [ "cssparser", - "html5ever 0.31.0", + "html5ever 0.35.0", "maplit", "tendril", "url", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -604,9 +607,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -619,37 +622,37 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.60.2", ] [[package]] @@ -659,14 +662,13 @@ dependencies = [ "anyhow", "chrono", "futures 0.3.31", - "schemars 1.0.1", + "http_client", + "schemars", "serde", "serde_json", "settings", - "strum 0.27.1", - "thiserror 2.0.12", - "workspace-hack", - "zed-http-client", + "strum 0.27.2", + "thiserror 2.0.17", ] [[package]] @@ -677,9 +679,9 @@ checksum = "34cd60c5e3152cef0a592f1b296f1cc93715d89d2551d85315828c3a09575ff4" [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "approx" @@ -692,9 +694,9 @@ dependencies = [ [[package]] name = "arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" dependencies = [ "derive_arbitrary", ] @@ -707,7 +709,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -774,13 +776,13 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.9.1", + "rand 0.9.2", "serde", "serde_repr", "url", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.6", + "wayland-protocols 0.32.9", "zbus", ] @@ -795,7 +797,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.9.1", + "rand 0.9.2", "serde", "serde_repr", "url", @@ -813,9 +815,8 @@ dependencies = [ "net", "smol", "tempfile", - "windows 0.61.1", - "workspace-hack", - "zed-util", + "util", + "windows 0.61.3", "zeroize", ] @@ -826,55 +827,6 @@ dependencies = [ "anyhow", "gpui", "rust-embed", - "workspace-hack", -] - -[[package]] -name = "assistant_context" -version = "0.1.0" -dependencies = [ - "agent_settings", - "anyhow", - "assistant_slash_command", - "assistant_slash_commands", - "chrono", - "client", - "clock", - "cloud_llm_client", - "context_server", - "fs", - "futures 0.3.31", - "fuzzy", - "gpui", - "indoc", - "language", - "language_model", - "log", - "open_ai", - "parking_lot", - "paths", - "pretty_assertions", - "project", - "prompt_store", - "proto", - "rand 0.9.1", - "regex", - "rpc", - "serde", - "serde_json", - "settings", - "smallvec", - "smol", - "telemetry_events", - "text", - "ui", - "unindent", - "uuid", - "workspace", - "workspace-hack", - "zed-collections", - "zed-util", - "zed_env_vars", ] [[package]] @@ -883,7 +835,8 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "derive_more", + "collections", + "derive_more 0.99.20", "extension", "futures 0.3.31", "gpui", @@ -894,10 +847,8 @@ dependencies = [ "serde", "serde_json", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", ] [[package]] @@ -907,15 +858,16 @@ dependencies = [ "anyhow", "assistant_slash_command", "chrono", + "collections", "context_server", "editor", "feature_flags", "fs", "futures 0.3.31", "fuzzy", - "globset", "gpui", "html_to_markdown", + "http_client", "language", "pretty_assertions", "project", @@ -927,113 +879,58 @@ dependencies = [ "smol", "text", "ui", + "util", "workspace", - "workspace-hack", "worktree", - "zed-collections", - "zed-http-client", - "zed-util", "zlog", ] [[package]] -name = "assistant_tool" +name = "assistant_text_thread" version = "0.1.0" dependencies = [ - "action_log", - "anyhow", - "buffer_diff", - "clock", - "ctor", - "derive_more", - "gpui", - "icons", - "indoc", - "language", - "language_model", - "log", - "parking_lot", - "pretty_assertions", - "project", - "rand 0.9.1", - "regex", - "serde", - "serde_json", - "settings", - "text", - "workspace", - "workspace-hack", - "zed-collections", - "zed-util", - "zlog", -] - -[[package]] -name = "assistant_tools" -version = "0.1.0" -dependencies = [ - "action_log", "agent_settings", "anyhow", - "assistant_tool", - "buffer_diff", + "assistant_slash_command", + "assistant_slash_commands", "chrono", "client", "clock", "cloud_llm_client", - "component", - "derive_more", - "diffy", - "editor", - "feature_flags", + "collections", + "context_server", "fs", "futures 0.3.31", + "fuzzy", "gpui", - "gpui_tokio", - "handlebars 4.5.0", - "html_to_markdown", "indoc", "itertools 0.14.0", "language", "language_model", - "language_models", "log", - "lsp", - "markdown", - "open", + "open_ai", + "parking_lot", "paths", - "portable-pty", "pretty_assertions", "project", "prompt_store", - "rand 0.9.1", + "proto", + "rand 0.9.2", "regex", - "reqwest_client", - "rust-embed", - "schemars 1.0.1", + "rpc", "serde", "serde_json", "settings", "smallvec", "smol", - "streaming_diff", - "strsim", - "task", - "tempfile", - "terminal", - "terminal_view", - "theme", - "tree-sitter-rust", + "telemetry", + "text", "ui", "unindent", - "watch", - "web_search", + "util", + "uuid", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", - "zlog", + "zed_env_vars", ] [[package]] @@ -1052,7 +949,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -1071,9 +968,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -1083,9 +980,9 @@ dependencies = [ [[package]] name = "async-compat" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bab94bde396a3f7b4962e396fdad640e241ed797d4d8d77fc8c237d14c58fc0" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" dependencies = [ "futures-core", "futures-io", @@ -1096,15 +993,14 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.22" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a194f9d963d8099596278594b3107448656ba73831c9d8c783e613ce86da64" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" dependencies = [ - "deflate64", - "flate2", + "compression-codecs", + "compression-core", "futures-core", "futures-io", - "memchr", "pin-project-lite", ] @@ -1120,26 +1016,27 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", "fastrand 2.3.0", - "futures-lite 2.6.0", + "futures-lite 2.6.1", + "pin-project-lite", "slab", ] [[package]] name = "async-fs" -version = "2.1.3" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ - "async-lock", + "async-lock 3.4.1", "blocking", - "futures-lite 2.6.0", + "futures-lite 2.6.1", ] [[package]] @@ -1148,31 +1045,40 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", "async-io", - "async-lock", + "async-lock 3.4.1", "blocking", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "once_cell", ] [[package]] name = "async-io" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "parking", "polling", - "rustix 1.0.7", + "rustix 1.1.2", "slab", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", ] [[package]] @@ -1181,7 +1087,7 @@ version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] @@ -1194,7 +1100,7 @@ checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ "async-io", "blocking", - "futures-lite 2.6.0", + "futures-lite 2.6.1", ] [[package]] @@ -1208,21 +1114,20 @@ dependencies = [ [[package]] name = "async-process" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-io", - "async-lock", + "async-lock 3.4.1", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.0", - "futures-lite 2.6.0", - "rustix 0.38.44", - "tracing", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.2", ] [[package]] @@ -1233,44 +1138,44 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "async-signal" -version = "0.2.10" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", - "async-lock", + "async-lock 3.4.1", "atomic-waker", "cfg-if", "futures-core", "futures-io", - "rustix 0.38.44", + "rustix 1.1.2", "signal-hook-registry", "slab", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "async-std" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" dependencies = [ "async-attributes", "async-channel 1.9.0", "async-global-executor", "async-io", - "async-lock", + "async-lock 3.4.1", "async-process", "crossbeam-utils", "futures-channel", "futures-core", "futures-io", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "gloo-timers", "kv-log-macro", "log", @@ -1301,14 +1206,14 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "async-tar" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42f905d4f623faf634bbd1e001e84e0efc24694afa64be9ad239bf6ca49e1f8" +checksum = "d1937db2d56578aa3919b9bdb0e5100693fd7d1c0f145c53eb81fbb03e217550" dependencies = [ "async-std", "filetime", @@ -1332,14 +1237,14 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "async-tungstenite" -version = "0.29.1" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef0f7efedeac57d9b26170f72965ecfd31473ca52ca7a64e925b0b6f5f079886" +checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" dependencies = [ "atomic-waker", "futures-core", @@ -1351,20 +1256,20 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", - "tungstenite 0.26.2", + "tungstenite 0.27.0", ] [[package]] name = "async_zip" -version = "0.0.17" +version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" dependencies = [ "async-compression", "crc32fast", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "pin-project", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] @@ -1407,6 +1312,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-tar", + "collections", "crossbeam", "denoise", "gpui", @@ -1417,10 +1323,8 @@ dependencies = [ "serde", "settings", "smol", - "thiserror 2.0.12", - "workspace-hack", - "zed-collections", - "zed-util", + "thiserror 2.0.17", + "util", ] [[package]] @@ -1441,20 +1345,26 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "clock", + "ctor", "db", + "futures 0.3.31", "gpui", + "http_client", "log", + "parking_lot", "paths", "release_channel", + "semver", "serde", "serde_json", "settings", "smol", "tempfile", + "util", "which 6.0.3", "workspace", - "workspace-hack", - "zed-http-client", + "zlog", ] [[package]] @@ -1464,9 +1374,9 @@ dependencies = [ "anyhow", "log", "simplelog", - "windows 0.61.1", + "tempfile", + "windows 0.61.3", "winresource", - "workspace-hack", ] [[package]] @@ -1478,28 +1388,28 @@ dependencies = [ "client", "editor", "gpui", + "http_client", "markdown_preview", "release_channel", + "semver", "serde", "serde_json", "smol", + "util", "workspace", - "workspace-hack", - "zed-http-client", - "zed-util", ] [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "av1-grain" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf" +checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" dependencies = [ "anyhow", "arrayvec", @@ -1511,18 +1421,18 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" dependencies = [ "arrayvec", ] [[package]] name = "aws-config" -version = "1.6.1" +version = "1.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c39646d1a6b51240a1a23bb57ea4eebede7e16fbc237fdc876980233dcecb4f" +checksum = "37cf2b6af2a95a20e266782b4f76f1a5e12bf412a9db2de9c1e9123b9d8c0ad8" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1550,9 +1460,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.2" +version = "1.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4471bef4c22a06d2c7a1b6492493d3fdf24a805323109d6874f9c94d5906ac14" +checksum = "faf26925f4a5b59eb76722b63c2892b1d70d06fa053c72e4a100ec308c1d47bc" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -1562,21 +1472,22 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.13.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" +checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.29.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61b1d86e7705efe1be1b569bab41d4fa1e14e220b60a160f78de2db687add079" +checksum = "107a4e9d9cab9963e04e84bb8dee0e25f2a987f9a8bad5ed054abd439caa8f8c" dependencies = [ - "bindgen 0.69.5", + "bindgen 0.72.1", "cc", "cmake", "dunce", @@ -1585,9 +1496,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.6" +version = "1.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aff45ffe35196e593ea3b9dd65b320e51e2dda95aff4390bc459e461d09c6ad" +checksum = "bfa006bb32360ed90ac51203feafb9d02e3d21046e1fd3a450a404b90ea73e5d" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -1602,7 +1513,6 @@ dependencies = [ "fastrand 2.3.0", "http 0.2.12", "http-body 0.4.6", - "once_cell", "percent-encoding", "pin-project-lite", "tracing", @@ -1611,9 +1521,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.82.0" +version = "1.109.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb95f77abd4321348dd2f52a25e1de199732f54d2a35860ad20f5df21c66b44" +checksum = "fbfdfd941dcb253c17bf70baddbf1e5b22f19e29d313d2e049bad4b1dadb2011" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1630,16 +1540,15 @@ dependencies = [ "fastrand 2.3.0", "http 0.2.12", "hyper 0.14.32", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-kinesis" -version = "1.66.0" +version = "1.91.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e43e5fb05c78cdad4fef5be4503465e4b42292f472fc991823ea4c50078208e4" +checksum = "699a3d645a2ab5cb12ca02eb23979753953414429fd6584ea8841af6bc4e0516" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1654,16 +1563,15 @@ dependencies = [ "bytes 1.10.1", "fastrand 2.3.0", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-s3" -version = "1.82.0" +version = "1.108.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6eab2900764411ab01c8e91a76fd11a63b4e12bc3da97d9e14a0ce1343d86d3" +checksum = "200be4aed61e3c0669f7268bacb768f283f1c32a7014ce57225e1160be2f6ccb" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1686,7 +1594,6 @@ dependencies = [ "http 1.3.1", "http-body 0.4.6", "lru", - "once_cell", "percent-encoding", "regex-lite", "sha2", @@ -1696,9 +1603,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.64.0" +version = "1.86.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d4bdb0e5f80f0689e61c77ab678b2b9304af329616af38aef5b6b967b8e736" +checksum = "4a0abbfab841446cce6e87af853a3ba2cc1bc9afcd3f3550dd556c43d434c86d" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1712,16 +1619,15 @@ dependencies = [ "bytes 1.10.1", "fastrand 2.3.0", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.65.0" +version = "1.88.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbbb3ce8da257aedbccdcb1aadafbbb6a5fe9adf445db0e1ea897bdc7e22d08" +checksum = "9a68d675582afea0e94d38b6ca9c5aaae4ca14f1d36faa6edb19b42e687e70d7" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1735,16 +1641,15 @@ dependencies = [ "bytes 1.10.1", "fastrand 2.3.0", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.65.0" +version = "1.88.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a78a8f50a1630db757b60f679c8226a8a70ee2ab5f5e6e51dc67f6c61c7cfd" +checksum = "d30990923f4f675523c51eb1c0dec9b752fb267b36a61e83cbc219c9d86da715" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1759,16 +1664,15 @@ dependencies = [ "aws-types", "fastrand 2.3.0", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.3.0" +version = "1.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d03c3c05ff80d54ff860fe38c726f6f494c639ae975203a101335f223386db" +checksum = "bffc03068fbb9c8dd5ce1c6fb240678a5cffb86fb2b7b1985c999c4b83c8df68" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -1782,7 +1686,6 @@ dependencies = [ "hmac", "http 0.2.12", "http 1.3.1", - "once_cell", "p256", "percent-encoding", "ring", @@ -1795,9 +1698,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.5" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e190749ea56f8c42bf15dd76c65e14f8f765233e6df9b0506d9d934ebef867c" +checksum = "127fcfad33b7dfc531141fda7e1c402ac65f88aca5511a4d31e2e3d2cd01ce9c" dependencies = [ "futures-util", "pin-project-lite", @@ -1806,16 +1709,14 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.63.1" +version = "0.63.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d21e1ba6f2cdec92044f904356a19f5ad86961acf015741106cdfafd747c0" +checksum = "165d8583d8d906e2fb5511d29201d447cc710864f075debcdd9c31c265412806" dependencies = [ "aws-smithy-http", "aws-smithy-types", "bytes 1.10.1", - "crc32c", - "crc32fast", - "crc64fast-nvme", + "crc-fast", "hex", "http 0.2.12", "http-body 0.4.6", @@ -1828,9 +1729,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.8" +version = "0.60.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c45d3dddac16c5c59d553ece225a88870cf81b7b813c9cc17b78cf4685eac7a" +checksum = "9656b85088f8d9dc7ad40f9a6c7228e1e8447cdf4b046c87e152e0805dea02fa" dependencies = [ "aws-smithy-types", "bytes 1.10.1", @@ -1839,9 +1740,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.62.0" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5949124d11e538ca21142d1fba61ab0a2a2c1bc3ed323cdb3e4b878bfb83166" +checksum = "3feafd437c763db26aa04e0cc7591185d0961e64c61885bece0fb9d50ceac671" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -1852,7 +1753,6 @@ dependencies = [ "http 0.2.12", "http 1.3.1", "http-body 0.4.6", - "once_cell", "percent-encoding", "pin-project-lite", "pin-utils", @@ -1861,56 +1761,57 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.0.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aff1159006441d02e57204bf57a1b890ba68bedb6904ffd2873c1c4c11c546b" +checksum = "1053b5e587e6fa40ce5a79ea27957b04ba660baa02b28b7436f64850152234f1" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.4.9", + "h2 0.3.27", + "h2 0.4.12", "http 0.2.12", "http 1.3.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-rustls 0.24.2", - "hyper-rustls 0.27.5", + "hyper-rustls 0.27.7", "hyper-util", "pin-project-lite", "rustls 0.21.12", - "rustls 0.23.26", - "rustls-native-certs 0.8.1", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", "rustls-pki-types", "tokio", + "tokio-rustls 0.26.2", "tower 0.5.2", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.61.3" +version = "0.61.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92144e45819cae7dc62af23eac5a038a58aa544432d2102609654376a900bd07" +checksum = "cff418fc8ec5cadf8173b10125f05c2e7e1d46771406187b2c878557d4503390" dependencies = [ "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445d065e76bc1ef54963db400319f1dd3ebb3e0a74af20f7f7630625b0cc7cc0" +checksum = "2d1881b1ea6d313f9890710d65c158bdab6fb08c91ea825f74c1c8c357baf4cc" dependencies = [ "aws-smithy-runtime-api", - "once_cell", ] [[package]] name = "aws-smithy-query" -version = "0.60.7" +version = "0.60.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fbd61ceb3fe8a1cb7352e42689cec5335833cd9f94103a61e98f9bb61c64bb" +checksum = "d28a63441360c477465f80c7abac3b9c4d075ca638f982e605b7dc2a2c7156c9" dependencies = [ "aws-smithy-types", "urlencoding", @@ -1918,9 +1819,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.8.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0152749e17ce4d1b47c7747bdfec09dac1ccafdcbc741ebf9daa2a373356730f" +checksum = "40ab99739082da5347660c556689256438defae3bcefd66c52b095905730e404" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1934,7 +1835,6 @@ dependencies = [ "http 1.3.1", "http-body 0.4.6", "http-body 1.0.1", - "once_cell", "pin-project-lite", "pin-utils", "tokio", @@ -1943,9 +1843,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.7.4" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da37cf5d57011cb1753456518ec76e31691f1f474b73934a284eb2a1c76510f" +checksum = "3683c5b152d2ad753607179ed71988e8cfd52964443b4f74fd8e552d0bbfeb46" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -1960,9 +1860,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.3.0" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836155caafba616c0ff9b07944324785de2ab016141c3550bd1c07882f8cee8f" +checksum = "9f5b3a7486f6690ba25952cabf1e7d75e34d69eaff5081904a47bc79074d6457" dependencies = [ "base64-simd", "bytes 1.10.1", @@ -1986,18 +1886,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.9" +version = "0.60.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab0b0166827aa700d3dc519f72f8b3a91c35d0b8d042dc5d643a91e6f80648fc" +checksum = "e9c34127e8c624bc2999f3b657e749c1393bedc9cd97b92a804db8ced4d2e163" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.6" +version = "1.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3873f8deed8927ce8d04487630dc9ff73193bab64742a61d050e57a68dec4125" +checksum = "e2fd329bf0e901ff3f60425691410c69094dc2a1f34b331f37bfc4e9ac1565a1" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -2013,8 +1913,7 @@ version = "0.1.0" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "workspace-hack", - "zed-http-client", + "http_client", ] [[package]] @@ -2093,17 +1992,17 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -2136,9 +2035,9 @@ dependencies = [ [[package]] name = "base64ct" -version = "1.7.3" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bedrock" @@ -2148,12 +2047,11 @@ dependencies = [ "aws-sdk-bedrockruntime", "aws-smithy-types", "futures 0.3.31", - "schemars 1.0.1", + "schemars", "serde", "serde_json", - "strum 0.27.1", - "thiserror 2.0.12", - "workspace-hack", + "strum 0.27.2", + "thiserror 2.0.17", ] [[package]] @@ -2179,57 +2077,16 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.9.0", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.101", - "which 4.4.2", -] - -[[package]] -name = "bindgen" -version = "0.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" -dependencies = [ - "bitflags 2.9.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.101", -] - [[package]] name = "bindgen" version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -2237,16 +2094,27 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] -name = "bit-set" -version = "0.5.3" +name = "bindgen" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bit-vec 0.6.3", + "bitflags 2.9.4", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.106", ] [[package]] @@ -2255,15 +2123,9 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec 0.8.0", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bit-vec" version = "0.8.0" @@ -2272,9 +2134,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bit_field" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" @@ -2284,9 +2146,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" dependencies = [ "serde", ] @@ -2317,9 +2179,9 @@ checksum = "e4deb8f595ce7f00dee3543ebf6fd9a20ea86fc421ab79600dac30876250bdae" dependencies = [ "ash", "ash-window", - "bitflags 2.9.0", + "bitflags 2.9.4", "bytemuck", - "codespan-reporting", + "codespan-reporting 0.12.0", "glow", "gpu-alloc", "gpu-alloc-ash", @@ -2352,7 +2214,7 @@ checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -2393,31 +2255,45 @@ dependencies = [ [[package]] name = "block2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ "objc2", ] [[package]] name = "blocking" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-task", "futures-io", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "piper", ] [[package]] -name = "borrow-or-share" -version = "0.2.2" +name = "bm25" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" +checksum = "1cbd8ffdfb7b4c2ff038726178a780a94f90525ed0ad264c0afaa75dd8c18a64" +dependencies = [ + "cached", + "deunicode", + "fxhash", + "rust-stemmers", + "stop-words", + "unicode-segmentation", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "borsh" @@ -2439,7 +2315,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -2453,10 +2329,30 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", "zed_actions", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.12.0" @@ -2481,14 +2377,14 @@ dependencies = [ "language", "log", "pretty_assertions", - "rand 0.9.1", + "rand 0.9.2", "rope", "serde_json", + "settings", + "sum_tree", "text", "unindent", - "workspace-hack", - "zed-sum-tree", - "zed-util", + "util", "zlog", ] @@ -2500,9 +2396,9 @@ checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" dependencies = [ "allocator-api2", ] @@ -2537,28 +2433,28 @@ dependencies = [ [[package]] name = "bytecount" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.9.3" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -2619,6 +2515,39 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "cached" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" +dependencies = [ + "ahash 0.8.12", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.15.5", + "once_cell", + "thiserror 2.0.17", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + [[package]] name = "call" version = "0.1.0" @@ -2626,11 +2555,13 @@ dependencies = [ "anyhow", "audio", "client", + "collections", "feature_flags", "fs", "futures 0.3.31", "gpui", "gpui_tokio", + "http_client", "language", "livekit_client", "log", @@ -2639,45 +2570,40 @@ dependencies = [ "serde", "settings", "telemetry", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", + "util", ] [[package]] name = "calloop" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +version = "0.14.3" +source = "git+https://github.com/zed-industries/calloop#eb6b4fd17b9af5ecc226546bdd04185391b3e265" dependencies = [ - "bitflags 2.9.0", - "log", + "bitflags 2.9.4", "polling", - "rustix 0.38.44", + "rustix 1.1.2", "slab", - "thiserror 1.0.69", + "tracing", ] [[package]] name = "calloop-wayland-source" -version = "0.3.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop", - "rustix 0.38.44", + "rustix 1.1.2", "wayland-backend", "wayland-client", ] [[package]] name = "camino" -version = "1.1.9" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -2692,13 +2618,13 @@ dependencies = [ "memmap2", "num-traits", "num_cpus", - "rand 0.9.1", + "rand 0.9.2", "rand_distr", "rayon", "safetensors", "thiserror 1.0.69", "ug", - "yoke", + "yoke 0.7.5", "zip 1.1.4", ] @@ -2747,7 +2673,7 @@ checksum = "9f83833816c66c986e913b22ac887cec216ea09301802054316fc5301809702c" dependencies = [ "cap-primitives", "cap-std", - "rustix 1.0.7", + "rustix 1.1.2", "smallvec", ] @@ -2763,7 +2689,7 @@ dependencies = [ "io-lifetimes", "ipnet", "maybe-owned", - "rustix 1.0.7", + "rustix 1.1.2", "rustix-linux-procfs", "windows-sys 0.59.0", "winx", @@ -2788,7 +2714,7 @@ dependencies = [ "cap-primitives", "io-extras", "io-lifetimes", - "rustix 1.0.7", + "rustix 1.1.2", ] [[package]] @@ -2801,7 +2727,7 @@ dependencies = [ "cap-primitives", "iana-time-zone", "once_cell", - "rustix 1.0.7", + "rustix 1.1.2", "winx", ] @@ -2825,7 +2751,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -2835,7 +2761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fbd1fe9db3ebf71b89060adaf7b0504c2d6a425cf061313099547e382c2e472" dependencies = [ "serde", - "toml 0.8.20", + "toml 0.8.23", ] [[package]] @@ -2860,23 +2786,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" dependencies = [ "heck 0.4.1", - "indexmap 2.9.0", + "indexmap", "log", "proc-macro2", "quote", "serde", "serde_json", - "syn 2.0.101", + "syn 2.0.106", "tempfile", - "toml 0.8.20", + "toml 0.8.23", ] [[package]] name = "cc" -version = "1.2.19" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -2909,9 +2836,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -2941,35 +2868,34 @@ dependencies = [ "anyhow", "client", "clock", + "collections", "futures 0.3.31", "gpui", + "http_client", "language", "log", "postage", "release_channel", "rpc", + "semver", "settings", "text", "time", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", + "util", ] [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.1.1", + "windows-link 0.2.1", ] [[package]] @@ -3018,9 +2944,9 @@ dependencies = [ [[package]] name = "circular-buffer" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23bdce1da528cadbac4654b5632bfcd8c6c63e25b1d42cea919a95958790b51d" +checksum = "14c638459986b83c2b885179bd4ea6a2cbb05697b001501a56adb3a3d230803b" [[package]] name = "clang-sys" @@ -3035,9 +2961,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.37" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f" dependencies = [ "clap_builder", "clap_derive", @@ -3045,9 +2971,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730" dependencies = [ "anstream", "anstyle", @@ -3058,30 +2984,30 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.47" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06f5378ea264ad4f82bbc826628b5aad714a75abf6ece087e923010eb937fb6" +checksum = "2348487adcd4631696ced64ccdb40d38ac4d31cae7f2eec8817fcea1b9d1c43c" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cli" @@ -3090,6 +3016,7 @@ dependencies = [ "anyhow", "askpass", "clap", + "collections", "core-foundation 0.10.0", "core-services", "exec", @@ -3098,13 +3025,13 @@ dependencies = [ "parking_lot", "paths", "plist", + "rayon", "release_channel", "serde", + "serde_json", "tempfile", - "windows 0.61.1", - "workspace-hack", - "zed-collections", - "zed-util", + "util", + "windows 0.61.3", ] [[package]] @@ -3118,13 +3045,15 @@ dependencies = [ "clock", "cloud_api_client", "cloud_llm_client", + "collections", "credentials_provider", - "derive_more", + "derive_more 0.99.20", "feature_flags", "fs", "futures 0.3.31", "gpui", "gpui_tokio", + "http_client", "http_client_tls", "httparse", "log", @@ -3132,11 +3061,12 @@ dependencies = [ "parking_lot", "paths", "postage", - "rand 0.9.1", + "rand 0.9.2", "regex", "release_channel", "rpc", "rustls-pki-types", + "semver", "serde", "serde_json", "serde_urlencoded", @@ -3146,7 +3076,7 @@ dependencies = [ "telemetry", "telemetry_events", "text", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tiny_http", "tokio", @@ -3154,12 +3084,9 @@ dependencies = [ "tokio-rustls 0.26.2", "tokio-socks", "url", - "windows 0.61.1", - "workspace-hack", + "util", + "windows 0.61.3", "worktree", - "zed-collections", - "zed-http-client", - "zed-util", ] [[package]] @@ -3169,7 +3096,6 @@ dependencies = [ "parking_lot", "serde", "smallvec", - "workspace-hack", ] [[package]] @@ -3181,11 +3107,10 @@ dependencies = [ "futures 0.3.31", "gpui", "gpui_tokio", + "http_client", "parking_lot", "serde_json", - "workspace-hack", "yawc", - "zed-http-client", ] [[package]] @@ -3199,7 +3124,6 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "workspace-hack", ] [[package]] @@ -3208,42 +3132,31 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "indoc", "pretty_assertions", "serde", "serde_json", - "strum 0.27.1", + "strum 0.27.2", "uuid", - "workspace-hack", -] - -[[package]] -name = "cloud_zeta2_prompt" -version = "0.1.0" -dependencies = [ - "anyhow", - "cloud_llm_client", - "indoc", - "ordered-float 2.10.1", - "rustc-hash 2.1.1", - "serde", - "strum 0.27.1", - "workspace-hack", ] [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" dependencies = [ "cc", ] [[package]] name = "cobs" -version = "0.2.3" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] [[package]] name = "cocoa" @@ -3267,7 +3180,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block", "cocoa-foundation 0.2.0", "core-foundation 0.10.0", @@ -3297,7 +3210,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block", "core-foundation 0.10.0", "core-graphics-types 0.2.0", @@ -3316,14 +3229,45 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "codespan-reporting" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba7a06c0b31fff5ff2e1e7d37dbf940864e2a974b336e1a2938d10af6e8fb283" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "codestral" +version = "0.1.0" +dependencies = [ + "anyhow", + "edit_prediction_context", + "edit_prediction_types", + "futures 0.3.31", + "gpui", + "http_client", + "language", + "language_models", + "log", + "mistral", + "serde", + "serde_json", + "smol", + "text", +] + [[package]] name = "collab" version = "0.44.0" dependencies = [ "agent_settings", "anyhow", - "assistant_context", "assistant_slash_command", + "assistant_text_thread", "async-trait", "async-tungstenite", "audio", @@ -3340,6 +3284,7 @@ dependencies = [ "client", "clock", "collab_ui", + "collections", "command_palette_hooks", "context_server", "ctor", @@ -3360,6 +3305,7 @@ dependencies = [ "gpui", "gpui_tokio", "hex", + "http_client", "hyper 0.14.32", "indoc", "language", @@ -3379,7 +3325,7 @@ dependencies = [ "prometheus", "prompt_store", "prost 0.9.0", - "rand 0.9.1", + "rand 0.9.2", "recent_projects", "release_channel", "remote", @@ -3389,6 +3335,7 @@ dependencies = [ "rpc", "scrypt", "sea-orm", + "sea-orm-macros", "semver", "serde", "serde_json", @@ -3397,7 +3344,7 @@ dependencies = [ "sha2", "smol", "sqlx", - "strum 0.27.1", + "strum 0.27.2", "subtle", "supermaven_api", "task", @@ -3406,20 +3353,16 @@ dependencies = [ "theme", "time", "tokio", - "toml 0.8.20", + "toml 0.8.23", "tower 0.4.13", "tower-http 0.4.4", "tracing", "tracing-subscriber", "unindent", + "util", "uuid", "workspace", - "workspace-hack", "worktree", - "zed-collections", - "zed-http-client", - "zed-semantic-version", - "zed-util", "zlog", ] @@ -3432,11 +3375,13 @@ dependencies = [ "channel", "chrono", "client", + "collections", "db", "editor", "futures 0.3.31", "fuzzy", "gpui", + "http_client", "log", "menu", "notifications", @@ -3457,11 +3402,16 @@ dependencies = [ "title_bar", "tree-sitter-md", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", +] + +[[package]] +name = "collections" +version = "0.1.0" +dependencies = [ + "indexmap", + "rustc-hash 2.1.1", ] [[package]] @@ -3472,9 +3422,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "combine" @@ -3488,12 +3438,12 @@ dependencies = [ [[package]] name = "command-fds" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ec1052629a80c28594777d1252efc8a6b005d13f9edfd8c3fc0f44d5b32489a" +checksum = "f849b92c694fe237ecd8fafd1ba0df7ae0d45c1df6daeb7f68ed4220d51640bd" dependencies = [ "nix 0.30.1", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -3502,6 +3452,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "collections", "command_palette_hooks", "ctor", "db", @@ -3523,10 +3474,8 @@ dependencies = [ "theme", "time", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", ] @@ -3534,26 +3483,43 @@ dependencies = [ name = "command_palette_hooks" version = "0.1.0" dependencies = [ - "derive_more", + "collections", + "derive_more 0.99.20", "gpui", - "workspace-hack", - "zed-collections", + "workspace", ] [[package]] name = "component" version = "0.1.0" dependencies = [ + "collections", "documented", "gpui", "inventory", "parking_lot", - "strum 0.27.1", + "strum 0.27.2", "theme", - "workspace-hack", - "zed-collections", ] +[[package]] +name = "compression-codecs" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +dependencies = [ + "compression-core", + "deflate64", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -3597,11 +3563,31 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "tiny-keccak", ] +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -3614,22 +3600,23 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "collections", "futures 0.3.31", "gpui", + "http_client", "log", "net", "parking_lot", "postage", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", "smol", "tempfile", + "terminal", "url", - "workspace-hack", - "zed-collections", - "zed-util", + "util", ] [[package]] @@ -3638,15 +3625,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "convert_case" version = "0.8.0" @@ -3665,14 +3643,16 @@ dependencies = [ "chrono", "client", "clock", + "collections", "command_palette_hooks", "ctor", "dirs 4.0.0", - "edit_prediction", + "edit_prediction_types", "editor", "fs", "futures 0.3.31", "gpui", + "http_client", "indoc", "itertools 0.14.0", "language", @@ -3688,15 +3668,12 @@ dependencies = [ "serde", "serde_json", "settings", + "sum_tree", "task", "theme", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-sum-tree", - "zed-util", "zlog", ] @@ -3745,7 +3722,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.10.0", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -3758,7 +3735,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.9.4", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -3782,7 +3759,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.10.0", "libc", ] @@ -3793,7 +3770,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block", "cfg-if", "core-foundation 0.10.0", @@ -3871,20 +3848,20 @@ dependencies = [ [[package]] name = "coreaudio-sys" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" dependencies = [ - "bindgen 0.70.1", + "bindgen 0.72.1", ] [[package]] name = "cosmic-text" -version = "0.14.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1ecbb5db9a4c2ee642df67bcfa8f044dd867dbbaa21bfab139cbc204ffbf67" +checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "fontdb 0.16.2", "log", "rangemap", @@ -3913,7 +3890,7 @@ dependencies = [ "jni", "js-sys", "libc", - "mach2 0.4.2", + "mach2 0.4.3", "ndk", "ndk-context", "num-derive", @@ -3929,9 +3906,9 @@ dependencies = [ [[package]] name = "cpp_demangle" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e58d342ad113c2b878f16d5d034c03be492ae460cdbc02b7f0f2284d310c7d" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" dependencies = [ "cfg-if", ] @@ -3978,7 +3955,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", + "gimli 0.31.1", "hashbrown 0.14.5", "log", "postcard", @@ -3988,7 +3965,7 @@ dependencies = [ "serde_derive", "sha2", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", ] [[package]] @@ -4035,7 +4012,7 @@ dependencies = [ "cranelift-codegen", "log", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", ] [[package]] @@ -4052,7 +4029,7 @@ checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" dependencies = [ "cranelift-codegen", "libc", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", ] [[package]] @@ -4063,7 +4040,7 @@ checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" dependencies = [ "cfg-if", "libc", - "mach2 0.4.2", + "mach2 0.4.3", ] [[package]] @@ -4075,7 +4052,7 @@ dependencies = [ "cfg-if", "crash-context", "libc", - "mach2 0.4.2", + "mach2 0.4.3", "parking_lot", ] @@ -4086,6 +4063,7 @@ dependencies = [ "bincode", "cfg-if", "crash-handler", + "extension_host", "log", "mach2 0.5.0", "minidumper", @@ -4095,7 +4073,7 @@ dependencies = [ "serde_json", "smol", "system_specs", - "workspace-hack", + "windows 0.61.3", "zstd", ] @@ -4115,32 +4093,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] -name = "crc32c" -version = "0.6.8" +name = "crc-fast" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +checksum = "6bf62af4cc77d8fe1c22dde4e721d87f2f54056139d8c412e1366b740305f56f" dependencies = [ - "rustc_version", + "crc", + "digest", + "libc", + "rand 0.9.2", + "regex", ] [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] -[[package]] -name = "crc64fast-nvme" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4955638f00a809894c947f85a024020a20815b65a5eea633798ea7924edab2b3" -dependencies = [ - "crc", -] - [[package]] name = "credentials_provider" version = "0.1.0" @@ -4152,7 +4125,6 @@ dependencies = [ "release_channel", "serde", "serde_json", - "workspace-hack", ] [[package]] @@ -4249,9 +4221,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" @@ -4295,7 +4267,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.11.3", "smallvec", ] @@ -4306,14 +4278,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "ctor" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4735f265ba6a1188052ca32d461028a7d1125868be18e287e756019da7607b5" +checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" dependencies = [ "ctor-proc-macro", "dtor", @@ -4321,83 +4293,87 @@ dependencies = [ [[package]] name = "ctor-proc-macro" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" [[package]] name = "ctrlc" -version = "3.4.6" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c" +checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3" dependencies = [ - "nix 0.29.0", - "windows-sys 0.59.0", + "dispatch", + "nix 0.30.1", + "windows-sys 0.61.2", ] [[package]] name = "cursor-icon" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "cxx" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6354e975ea4ec28033ec3a36fa9baa1a02e3eb22ad740eeb4929370d4f5ba8" +checksum = "d8465678d499296e2cbf9d3acf14307458fd69b471a31b65b3c519efe8b5e187" dependencies = [ "cc", + "cxx-build", "cxxbridge-cmd", "cxxbridge-flags", "cxxbridge-macro", - "foldhash", + "foldhash 0.2.0", "link-cplusplus", ] [[package]] name = "cxx-build" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4400e26ea4b99417e4263b1ce2d8452404d750ba0809a7bd043072593d430d" +checksum = "d74b6bcf49ebbd91f1b1875b706ea46545032a14003b5557b7dfa4bbeba6766e" dependencies = [ "cc", - "codespan-reporting", + "codespan-reporting 0.13.0", + "indexmap", "proc-macro2", "quote", "scratch", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "cxxbridge-cmd" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31860c98f69fc14da5742c5deaf78983e846c7b27804ca8c8319e32eef421bde" +checksum = "94ca2ad69673c4b35585edfa379617ac364bccd0ba0adf319811ba3a74ffa48a" dependencies = [ "clap", - "codespan-reporting", + "codespan-reporting 0.13.0", + "indexmap", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "cxxbridge-flags" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0402a66013f3b8d3d9f2d7c9994656cc81e671054822b0728d7454d9231892f" +checksum = "d29b52102aa395386d77d322b3a0522f2035e716171c2c60aa87cc5e9466e523" [[package]] name = "cxxbridge-macro" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c0b38f32d68f3324a981645ee39b2d686af36d03c98a386df3716108c9feae" +checksum = "2a8ebf0b6138325af3ec73324cb3a48b64d57721f17291b151206782e61f66cd" dependencies = [ + "indexmap", "proc-macro2", "quote", - "rustversion", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -4410,10 +4386,12 @@ dependencies = [ "async-tar", "async-trait", "client", + "collections", "dap-types", "fs", "futures 0.3.31", "gpui", + "http_client", "language", "libc", "log", @@ -4421,7 +4399,7 @@ dependencies = [ "parking_lot", "paths", "proto", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", @@ -4431,10 +4409,7 @@ dependencies = [ "telemetry", "tree-sitter", "tree-sitter-go", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", + "util", "zlog", ] @@ -4443,7 +4418,7 @@ name = "dap-types" version = "0.0.1" source = "git+https://github.com/zed-industries/dap-types?rev=1b461b310481d01e02b2603c16d7144b926339f8#1b461b310481d01e02b2603c16d7144b926339f8" dependencies = [ - "schemars 1.0.1", + "schemars", "serde", "serde_json", ] @@ -4454,23 +4429,24 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "collections", "dap", "dotenvy", "fs", "futures 0.3.31", "gpui", + "http_client", "json_dotpath", "language", "log", + "node_runtime", "paths", "serde", "serde_json", - "shlex", + "settings", "smol", "task", - "workspace-hack", - "zed-collections", - "zed-util", + "util", ] [[package]] @@ -4494,7 +4470,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -4505,7 +4481,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -4549,9 +4525,9 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "data-url" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "db" @@ -4567,20 +4543,19 @@ dependencies = [ "sqlez", "sqlez_macros", "tempfile", - "workspace-hack", - "zed-util", + "util", "zed_env_vars", ] [[package]] name = "dbus" -version = "0.9.7" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +checksum = "190b6255e8ab55a7b568df5a883e9497edc3e4821c06396612048b430e5ad1e9" dependencies = [ "libc", "libdbus-sys", - "winapi", + "windows-sys 0.59.0", ] [[package]] @@ -4589,13 +4564,13 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "collections", "dap", "extension", "gpui", "serde_json", "task", - "workspace-hack", - "zed-util", + "util", ] [[package]] @@ -4611,9 +4586,8 @@ dependencies = [ "serde_json", "settings", "smol", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] @@ -4622,14 +4596,16 @@ version = "0.1.0" dependencies = [ "alacritty_terminal", "anyhow", - "bitflags 2.9.0", + "bitflags 2.9.4", "client", + "collections", "command_palette_hooks", "dap", "dap_adapters", "db", "debugger_tools", "editor", + "feature_flags", "file_icons", "futures 0.3.31", "fuzzy", @@ -4648,13 +4624,12 @@ dependencies = [ "pretty_assertions", "project", "rpc", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "serde_json_lenient", "settings", - "shlex", - "sysinfo", + "sysinfo 0.37.2", "task", "tasks_ui", "telemetry", @@ -4665,11 +4640,10 @@ dependencies = [ "tree-sitter-go", "tree-sitter-json", "ui", + "ui_input", "unindent", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", "zlog", ] @@ -4689,18 +4663,17 @@ version = "0.1.0" dependencies = [ "anyhow", "futures 0.3.31", - "schemars 1.0.1", + "http_client", + "schemars", "serde", "serde_json", - "workspace-hack", - "zed-http-client", ] [[package]] name = "deflate64" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" [[package]] name = "denoise" @@ -4712,8 +4685,7 @@ dependencies = [ "realfft", "rodio", "rustfft", - "thiserror 2.0.12", - "workspace-hack", + "thiserror 2.0.17", ] [[package]] @@ -4739,12 +4711,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -4755,40 +4727,90 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "derive_more" -version = "0.99.19" +version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", - "syn 2.0.101", + "syn 2.0.106", ] +[[package]] +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.106", + "unicode-xid", +] + +[[package]] +name = "derive_refineable" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_setters" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5c625eda104c228c06ecaf988d1c60e542176bd7a490e60eeda3493244c0c9" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "diagnostics" version = "0.1.0" dependencies = [ "anyhow", "client", + "collections", "component", "ctor", "editor", "gpui", "indoc", + "itertools 0.14.0", "language", "log", "lsp", "markdown", "pretty_assertions", "project", - "rand 0.9.1", + "rand 0.9.2", "serde", "serde_json", "settings", @@ -4796,10 +4818,8 @@ dependencies = [ "theme", "ui", "unindent", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zlog", ] @@ -4902,8 +4922,8 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.0", - "windows-sys 0.61.0", + "redox_users 0.5.2", + "windows-sys 0.61.2", ] [[package]] @@ -4918,7 +4938,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", ] @@ -4930,7 +4950,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -4954,36 +4974,37 @@ dependencies = [ "serde", "serde_json", "settings", - "workspace-hack", + "task", + "theme", + "util", "zed", - "zed-util", "zlog", ] [[package]] name = "documented" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6db32f0995bc4553d2de888999075acd0dbeef75ba923503f6a724263dc6f3" +checksum = "ed6b3e31251e87acd1b74911aed84071c8364fc9087972748ade2f1094ccce34" dependencies = [ "documented-macros", - "phf", - "thiserror 1.0.69", + "phf 0.12.1", + "thiserror 2.0.17", ] [[package]] name = "documented-macros" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a394bb35929b58f9a5fd418f7c6b17a4b616efcc1e53e6995ca123948f87e5fa" +checksum = "1149cf7462e5e79e17a3c05fd5b1f9055092bbfa95e04c319395c3beacc9370f" dependencies = [ - "convert_case 0.6.0", - "itertools 0.13.0", + "convert_case 0.8.0", + "itertools 0.14.0", "optfield", "proc-macro2", "quote", - "strum 0.26.3", - "syn 2.0.101", + "strum 0.27.2", + "syn 2.0.106", ] [[package]] @@ -5004,7 +5025,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" dependencies = [ - "phf", + "phf 0.11.3", ] [[package]] @@ -5045,9 +5066,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dwrote" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe1f192fcce01590bd8d839aca53ce0d11d803bf291b2a6c4ad925a8f0024be" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" dependencies = [ "lazy_static", "libc", @@ -5057,9 +5078,9 @@ dependencies = [ [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dyn-stack" @@ -5073,13 +5094,20 @@ dependencies = [ [[package]] name = "dyn-stack" -version = "0.13.0" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490bd48eb68fffcfed519b4edbfd82c69cbe741d175b84f0e0cbe8c57cbe0bdd" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" dependencies = [ "bytemuck", + "dyn-stack-macros", ] +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + [[package]] name = "ec4rs" version = "1.2.0" @@ -5102,42 +5130,105 @@ dependencies = [ name = "edit_prediction" version = "0.1.0" dependencies = [ - "client", - "gpui", - "language", - "workspace-hack", -] - -[[package]] -name = "edit_prediction_button" -version = "0.1.0" -dependencies = [ + "ai_onboarding", "anyhow", + "arrayvec", + "brotli", "client", + "clock", + "cloud_api_types", "cloud_llm_client", + "collections", "copilot", - "edit_prediction", - "editor", + "ctor", + "db", + "edit_prediction_context", + "edit_prediction_types", "feature_flags", "fs", "futures 0.3.31", "gpui", "indoc", + "itertools 0.14.0", "language", + "language_model", + "log", "lsp", - "paths", + "menu", + "open_ai", + "parking_lot", + "postage", + "pretty_assertions", "project", + "pulldown-cmark 0.12.2", + "rand 0.9.2", "regex", + "release_channel", + "semver", + "serde", "serde_json", "settings", - "supermaven", + "strum 0.27.2", "telemetry", - "theme", + "telemetry_events", + "thiserror 2.0.17", "ui", + "util", + "uuid", "workspace", - "workspace-hack", + "worktree", "zed_actions", - "zeta", + "zeta_prompt", + "zlog", +] + +[[package]] +name = "edit_prediction_cli" +version = "0.1.0" +dependencies = [ + "anthropic", + "anyhow", + "chrono", + "clap", + "client", + "cloud_llm_client", + "collections", + "debug_adapter_extension", + "dirs 4.0.0", + "edit_prediction", + "extension", + "fs", + "futures 0.3.31", + "gpui", + "gpui_tokio", + "http_client", + "indoc", + "language", + "language_extension", + "language_model", + "language_models", + "languages", + "libc", + "log", + "node_runtime", + "paths", + "pretty_assertions", + "project", + "prompt_store", + "release_channel", + "reqwest_client", + "serde", + "serde_json", + "settings", + "shellexpand 2.1.2", + "smol", + "sqlez", + "sqlez_macros", + "terminal_view", + "util", + "wasmtime", + "watch", + "zeta_prompt", ] [[package]] @@ -5145,34 +5236,82 @@ name = "edit_prediction_context" version = "0.1.0" dependencies = [ "anyhow", - "arrayvec", - "clap", "cloud_llm_client", + "collections", + "env_logger 0.11.8", "futures 0.3.31", "gpui", - "hashbrown 0.15.3", "indoc", - "itertools 0.14.0", "language", "log", - "ordered-float 2.10.1", - "postage", + "lsp", + "parking_lot", "pretty_assertions", "project", - "regex", "serde", "serde_json", "settings", - "slotmap", - "strum 0.27.1", + "smallvec", "text", "tree-sitter", - "workspace-hack", - "zed-collections", - "zed-util", + "util", + "zeta_prompt", "zlog", ] +[[package]] +name = "edit_prediction_types" +version = "0.1.0" +dependencies = [ + "client", + "gpui", + "language", + "text", +] + +[[package]] +name = "edit_prediction_ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "buffer_diff", + "client", + "cloud_llm_client", + "codestral", + "command_palette_hooks", + "copilot", + "edit_prediction", + "edit_prediction_types", + "editor", + "feature_flags", + "fs", + "futures 0.3.31", + "git", + "gpui", + "indoc", + "language", + "log", + "lsp", + "markdown", + "menu", + "multi_buffer", + "paths", + "project", + "regex", + "serde_json", + "settings", + "supermaven", + "telemetry", + "text", + "theme", + "time", + "ui", + "util", + "workspace", + "zed_actions", + "zeta_prompt", +] + [[package]] name = "editor" version = "0.1.0" @@ -5183,19 +5322,22 @@ dependencies = [ "buffer_diff", "client", "clock", + "collections", "convert_case 0.8.0", "criterion", "ctor", "dap", "db", - "edit_prediction", + "edit_prediction_types", "emojis", + "feature_flags", "file_icons", "fs", "futures 0.3.31", "fuzzy", "git", "gpui", + "http_client", "indoc", "itertools 0.14.0", "language", @@ -5210,26 +5352,31 @@ dependencies = [ "parking_lot", "pretty_assertions", "project", - "rand 0.9.1", + "rand 0.9.2", "regex", "release_channel", + "rope", "rpc", - "schemars 1.0.1", + "schemars", + "semver", "serde", "serde_json", "settings", "smallvec", "smol", "snippet", + "sum_tree", "task", "telemetry", "tempfile", "text", "theme", "time", + "tracing", "tree-sitter-bash", "tree-sitter-c", "tree-sitter-html", + "tree-sitter-md", "tree-sitter-python", "tree-sitter-rust", "tree-sitter-typescript", @@ -5239,16 +5386,13 @@ dependencies = [ "unicode-segmentation", "unindent", "url", + "util", "uuid", "vim_mode_setting", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-sum-tree", - "zed-util", "zed_actions", "zlog", + "ztracing", ] [[package]] @@ -5303,16 +5447,16 @@ dependencies = [ [[package]] name = "embed-resource" -version = "3.0.2" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbc6e0d8e0c03a655b53ca813f0463d2c956bc4db8138dbc89f120b066551e3" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.8.20", + "toml 0.9.8", "vswhom", - "winreg 0.52.0", + "winreg 0.55.0", ] [[package]] @@ -5333,7 +5477,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" dependencies = [ - "phf", + "phf 0.11.3", ] [[package]] @@ -5366,14 +5510,14 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "enumflags2" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", "serde", @@ -5381,20 +5525,20 @@ dependencies = [ [[package]] name = "enumflags2_derive" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "env_filter" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", @@ -5435,6 +5579,26 @@ dependencies = [ "serde", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -5443,11 +5607,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" dependencies = [ "serde", + "serde_core", "typeid", ] @@ -5464,12 +5629,12 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5516,18 +5681,18 @@ dependencies = [ name = "eval" version = "0.1.0" dependencies = [ + "acp_thread", "agent", + "agent-client-protocol", "agent_settings", "agent_ui", "anyhow", - "assistant_tool", - "assistant_tools", "async-trait", "buffer_diff", "chrono", "clap", "client", - "cloud_llm_client", + "collections", "debug_adapter_extension", "dirs 4.0.0", "dotenvy", @@ -5550,6 +5715,7 @@ dependencies = [ "pretty_assertions", "project", "prompt_store", + "rand 0.9.2", "regex", "release_channel", "reqwest_client", @@ -5557,16 +5723,22 @@ dependencies = [ "serde_json", "settings", "shellexpand 2.1.2", - "smol", "telemetry", "terminal_view", - "toml 0.8.20", + "toml 0.8.23", "unindent", + "util", "uuid", "watch", - "workspace-hack", - "zed-collections", - "zed-util", +] + +[[package]] +name = "eval_utils" +version = "0.1.0" +dependencies = [ + "gpui", + "serde", + "smol", ] [[package]] @@ -5577,9 +5749,9 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -5592,7 +5764,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "pin-project-lite", ] @@ -5610,10 +5782,9 @@ dependencies = [ name = "explorer_command_injector" version = "0.1.0" dependencies = [ - "windows 0.61.1", - "windows-core 0.61.0", - "windows-registry 0.5.1", - "workspace-hack", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-registry 0.5.3", ] [[package]] @@ -5642,31 +5813,31 @@ name = "extension" version = "0.1.0" dependencies = [ "anyhow", - "async-compression", - "async-tar", "async-trait", + "collections", "dap", "fs", "futures 0.3.31", "gpui", "heck 0.5.0", + "http_client", + "indoc", "language", "log", "lsp", "parking_lot", "pretty_assertions", + "proto", + "semver", "serde", "serde_json", "task", - "toml 0.8.20", + "tempfile", + "toml 0.8.23", "url", + "util", "wasm-encoder 0.221.3", "wasmparser 0.221.3", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-semantic-version", - "zed-util", ] [[package]] @@ -5687,10 +5858,9 @@ dependencies = [ "serde_json", "theme", "tokio", - "toml 0.8.20", + "toml 0.8.23", "tree-sitter", "wasmtime", - "workspace-hack", ] [[package]] @@ -5702,6 +5872,7 @@ dependencies = [ "async-tar", "async-trait", "client", + "collections", "criterion", "ctor", "dap", @@ -5709,6 +5880,8 @@ dependencies = [ "fs", "futures 0.3.31", "gpui", + "gpui_tokio", + "http_client", "language", "language_extension", "log", @@ -5718,10 +5891,11 @@ dependencies = [ "parking_lot", "paths", "project", - "rand 0.9.1", + "rand 0.9.2", "release_channel", "remote", "reqwest_client", + "semver", "serde", "serde_json", "serde_json_lenient", @@ -5731,16 +5905,12 @@ dependencies = [ "tempfile", "theme", "theme_extension", - "toml 0.8.20", + "toml 0.8.23", "url", + "util", "wasmparser 0.221.3", "wasmtime", "wasmtime-wasi", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-semantic-version", - "zed-util", "zlog", ] @@ -5750,6 +5920,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "collections", "db", "editor", "extension", @@ -5763,19 +5934,17 @@ dependencies = [ "picker", "project", "release_channel", + "semver", "serde", "settings", "smallvec", - "strum 0.27.1", + "strum 0.27.2", "telemetry", "theme", "ui", + "util", "vim_mode_setting", "workspace", - "workspace-hack", - "zed-collections", - "zed-semantic-version", - "zed-util", "zed_actions", ] @@ -5787,22 +5956,11 @@ checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] name = "fancy-regex" -version = "0.13.0" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set 0.5.3", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fancy-regex" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" -dependencies = [ - "bit-set 0.8.0", + "bit-set", "regex-automata", "regex-syntax", ] @@ -5828,6 +5986,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "fd-lock" version = "4.0.4" @@ -5835,7 +6013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.7", + "rustix 1.1.2", "windows-sys 0.59.0", ] @@ -5855,7 +6033,6 @@ dependencies = [ "futures 0.3.31", "gpui", "smol", - "workspace-hack", ] [[package]] @@ -5864,13 +6041,10 @@ version = "0.1.0" dependencies = [ "editor", "gpui", - "menu", "system_specs", - "ui", "urlencoding", + "util", "workspace", - "workspace-hack", - "zed-util", "zed_actions", ] @@ -5889,6 +6063,7 @@ name = "file_finder" version = "0.1.0" dependencies = [ "anyhow", + "collections", "ctor", "editor", "file_icons", @@ -5900,7 +6075,7 @@ dependencies = [ "picker", "pretty_assertions", "project", - "schemars 1.0.1", + "schemars", "search", "serde", "serde_json", @@ -5908,10 +6083,8 @@ dependencies = [ "text", "theme", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zlog", ] @@ -5921,10 +6094,8 @@ version = "0.1.0" dependencies = [ "gpui", "serde", - "settings", "theme", - "workspace-hack", - "zed-util", + "util", ] [[package]] @@ -5940,16 +6111,22 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -5958,9 +6135,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" dependencies = [ "crc32fast", "miniz_oxide", @@ -5986,7 +6163,7 @@ checksum = "4203231de188ebbdfb85c11f3c20ca2b063945710de04e7b59268731e728b462" dependencies = [ "half", "num-traits", - "rand 0.9.1", + "rand 0.9.2", "rand_distr", ] @@ -5998,9 +6175,9 @@ checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" [[package]] name = "fluent-uri" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" dependencies = [ "borrow-or-share", "ref-cast", @@ -6016,7 +6193,7 @@ dependencies = [ "futures-core", "futures-sink", "nanorand", - "spin", + "spin 0.9.8", ] [[package]] @@ -6032,19 +6209,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "font-types" -version = "0.8.4" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "511e2c18a516c666d27867d2f9821f76e7d591f762e9fc41dd6cc5c90fe54b0b" dependencies = [ "bytemuck", ] [[package]] name = "fontconfig-parser" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fcfcd44ca6e90c921fee9fa665d530b21ef1327a4c1a6c5250ea44b776ada7" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ "roxmltree", ] @@ -6104,7 +6287,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -6121,18 +6304,18 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "fork" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05dc8b302e04a1c27f4fe694439ef0f29779ca4edc205b7b58f00db04e29656d" +checksum = "30268f1eefccc9d72f43692e8b89e659aeb52e84016c3b32b6e7e9f1c8f38f94" dependencies = [ "libc", ] [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -6167,14 +6350,16 @@ dependencies = [ "async-tar", "async-trait", "cocoa 0.26.0", + "collections", "fsevent", "futures 0.3.31", "git", "gpui", "ignore", + "is_executable", "libc", "log", - "notify 8.0.0", + "notify 8.2.0", "objc", "parking_lot", "paths", @@ -6186,10 +6371,8 @@ dependencies = [ "tempfile", "text", "time", - "windows 0.61.1", - "workspace-hack", - "zed-collections", - "zed-util", + "util", + "windows 0.61.3", ] [[package]] @@ -6199,7 +6382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes", - "rustix 1.0.7", + "rustix 1.1.2", "windows-sys 0.59.0", ] @@ -6213,6 +6396,14 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs_benchmarks" +version = "0.1.0" +dependencies = [ + "fs", + "gpui", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -6223,13 +6414,12 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" name = "fsevent" version = "0.1.0" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.10.0", "fsevent-sys 3.1.0", "log", "parking_lot", "tempfile", - "workspace-hack", ] [[package]] @@ -6348,9 +6538,9 @@ dependencies = [ [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand 2.3.0", "futures-core", @@ -6367,7 +6557,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -6408,8 +6598,7 @@ version = "0.1.0" dependencies = [ "gpui", "log", - "workspace-hack", - "zed-util", + "util", ] [[package]] @@ -6421,6 +6610,15 @@ dependencies = [ "thread_local", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "gemm" version = "0.17.1" @@ -6447,7 +6645,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" dependencies = [ - "dyn-stack 0.13.0", + "dyn-stack 0.13.2", "gemm-c32 0.18.2", "gemm-c64 0.18.2", "gemm-common 0.18.2", @@ -6482,7 +6680,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" dependencies = [ - "dyn-stack 0.13.0", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "num-complex", "num-traits", @@ -6512,7 +6710,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" dependencies = [ - "dyn-stack 0.13.0", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "num-complex", "num-traits", @@ -6548,7 +6746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" dependencies = [ "bytemuck", - "dyn-stack 0.13.0", + "dyn-stack 0.13.2", "half", "libm", "num-complex", @@ -6586,7 +6784,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" dependencies = [ - "dyn-stack 0.13.0", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "gemm-f32 0.18.2", "half", @@ -6619,7 +6817,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" dependencies = [ - "dyn-stack 0.13.0", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "num-complex", "num-traits", @@ -6649,7 +6847,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" dependencies = [ - "dyn-stack 0.13.0", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "num-complex", "num-traits", @@ -6660,16 +6858,16 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" dependencies = [ "cc", "cfg-if", "libc", "log", "rustversion", - "windows 0.61.1", + "windows 0.61.3", ] [[package]] @@ -6684,46 +6882,73 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.4.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.1.2", + "windows-link 0.2.1", ] [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] +[[package]] +name = "gh-workflow" +version = "0.8.0" +source = "git+https://github.com/zed-industries/gh-workflow?rev=09acfdf2bd5c1d6254abefd609c808ff73547b2c#09acfdf2bd5c1d6254abefd609c808ff73547b2c" +dependencies = [ + "async-trait", + "derive_more 2.0.1", + "derive_setters", + "gh-workflow-macros", + "indexmap", + "merge", + "serde", + "serde_json", + "serde_yaml", + "strum_macros 0.27.2", +] + +[[package]] +name = "gh-workflow-macros" +version = "0.8.0" +source = "git+https://github.com/zed-industries/gh-workflow?rev=09acfdf2bd5c1d6254abefd609c808ff73547b2c#09acfdf2bd5c1d6254abefd609c808ff73547b2c" +dependencies = [ + "heck 0.5.0", + "quote", + "syn 2.0.106", +] + [[package]] name = "gif" -version = "0.13.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", "weezl", @@ -6736,10 +6961,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" dependencies = [ "fallible-iterator", - "indexmap 2.9.0", + "indexmap", "stable_deref_trait", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "git" version = "0.1.0" @@ -6747,41 +6978,42 @@ dependencies = [ "anyhow", "askpass", "async-trait", - "derive_more", + "collections", + "derive_more 0.99.20", "futures 0.3.31", "git2", "gpui", + "http_client", + "itertools 0.14.0", "log", "parking_lot", "pretty_assertions", - "rand 0.9.1", + "rand 0.9.2", "regex", "rope", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "smol", + "sum_tree", "tempfile", "text", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "unindent", "url", + "urlencoding", + "util", "uuid", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-sum-tree", - "zed-util", ] [[package]] name = "git2" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5220b8ba44c68a9a7f7a7659e864dd73692e417ef0211bea133c7b74e031eeb9" +checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "libc", "libgit2-sys", "log", @@ -6797,16 +7029,17 @@ dependencies = [ "futures 0.3.31", "git", "gpui", + "http_client", "indoc", + "itertools 0.14.0", "pretty_assertions", "regex", "serde", "serde_json", "settings", "url", - "workspace-hack", - "zed-http-client", - "zed-util", + "urlencoding", + "util", ] [[package]] @@ -6818,8 +7051,8 @@ dependencies = [ "askpass", "buffer_diff", "call", - "chrono", "cloud_llm_client", + "collections", "command_palette_hooks", "component", "ctor", @@ -6828,6 +7061,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "git", + "git_hosting_providers", "gpui", "indoc", "itertools 0.14.0", @@ -6841,42 +7075,46 @@ dependencies = [ "notifications", "panel", "picker", - "postage", "pretty_assertions", "project", - "schemars 1.0.1", + "prompt_store", + "rand 0.9.2", + "recent_projects", + "remote", + "schemars", "serde", "serde_json", "settings", - "strum 0.27.1", + "smol", + "strum 0.27.2", "telemetry", "theme", "time", "time_format", + "tracing", "ui", "unindent", + "util", "watch", - "windows 0.61.1", + "windows 0.61.3", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", "zeroize", "zlog", + "ztracing", ] [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305" dependencies = [ "aho-corasick", "bstr", @@ -6928,9 +7166,8 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-typescript", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] @@ -6950,13 +7187,12 @@ version = "0.1.0" dependencies = [ "anyhow", "futures 0.3.31", - "schemars 1.0.1", + "http_client", + "schemars", "serde", "serde_json", "settings", - "strum 0.27.1", - "workspace-hack", - "zed-http-client", + "strum 0.27.2", ] [[package]] @@ -6965,7 +7201,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "gpu-alloc-types", ] @@ -6986,12 +7222,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] name = "gpui" -version = "0.1.0" +version = "0.2.2" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -6999,6 +7235,7 @@ dependencies = [ "async-task", "backtrace", "bindgen 0.71.1", + "bitflags 2.9.4", "blade-graphics", "blade-macros", "blade-util", @@ -7007,7 +7244,10 @@ dependencies = [ "calloop", "calloop-wayland-source", "cbindgen", + "circular-buffer", "cocoa 0.26.0", + "cocoa-foundation 0.2.0", + "collections", "core-foundation 0.10.0", "core-foundation-sys", "core-graphics 0.24.0", @@ -7015,7 +7255,7 @@ dependencies = [ "core-video", "cosmic-text", "ctor", - "derive_more", + "derive_more 0.99.20", "embed-resource", "env_logger 0.11.8", "etagere", @@ -7023,13 +7263,16 @@ dependencies = [ "flume", "foreign-types 0.5.0", "futures 0.3.31", - "gpui-macros", + "gpui_macros", + "http_client", "image", "inventory", "itertools 0.14.0", "libc", "log", "lyon", + "mach2 0.5.0", + "media", "metal", "naga", "num_cpus", @@ -7041,26 +7284,33 @@ dependencies = [ "parking", "parking_lot", "pathfinder_geometry", + "pin-project", "postage", "pretty_assertions", "profiling", - "rand 0.9.1", + "rand 0.9.2", "raw-window-handle", + "refineable", "reqwest_client", "resvg", - "schemars 1.0.1", + "schemars", "seahash", + "semver", "serde", "serde_json", "slotmap", "smallvec", "smol", + "spin 0.10.0", "stacksafe", - "strum 0.27.1", + "strum 0.27.2", + "sum_tree", "taffy", - "thiserror 2.0.12", + "thiserror 2.0.17", "unicode-segmentation", "usvg", + "util", + "util_macros", "uuid", "waker-fn", "wayland-backend", @@ -7068,37 +7318,28 @@ dependencies = [ "wayland-cursor", "wayland-protocols 0.31.2", "wayland-protocols-plasma", - "windows 0.61.1", - "windows-core 0.61.0", + "wayland-protocols-wlr", + "windows 0.61.3", + "windows-core 0.61.2", "windows-numerics", - "windows-registry 0.5.1", - "workspace-hack", + "windows-registry 0.5.3", "x11-clipboard", "x11rb", "xkbcommon", - "zed-collections", "zed-font-kit", - "zed-http-client", - "zed-media", - "zed-refineable", "zed-scap", - "zed-semantic-version", - "zed-sum-tree", - "zed-util", - "zed-util-macros", "zed-xim", ] [[package]] -name = "gpui-macros" +name = "gpui_macros" version = "0.1.0" dependencies = [ "gpui", "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.101", - "workspace-hack", + "syn 2.0.106", ] [[package]] @@ -7108,8 +7349,7 @@ dependencies = [ "anyhow", "gpui", "tokio", - "workspace-hack", - "zed-util", + "util", ] [[package]] @@ -7131,9 +7371,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes 1.10.1", "fnv", @@ -7141,7 +7381,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.9.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -7150,9 +7390,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75249d144030531f8dee69fe9cea04d3edf809a017ae445e2abdff6629e86633" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes 1.10.1", @@ -7160,7 +7400,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap 2.9.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -7169,16 +7409,17 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "bytemuck", "cfg-if", "crunchy", "num-traits", - "rand 0.9.1", + "rand 0.9.2", "rand_distr", + "zerocopy", ] [[package]] @@ -7225,22 +7466,33 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "allocator-api2", ] [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", "serde", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.8.4" @@ -7256,7 +7508,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.3", + "hashbrown 0.15.5", ] [[package]] @@ -7313,7 +7565,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd54745cfacb7b97dee45e8fdb91814b62bccddb481debb7de0f9ee6b7bf5b43" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "heed-traits", "heed-types", @@ -7347,15 +7599,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -7424,18 +7670,17 @@ dependencies = [ "markup5ever 0.12.1", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "html5ever" -version = "0.31.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ "log", - "mac", - "markup5ever 0.16.1", + "markup5ever 0.35.0", "match_token", ] @@ -7449,7 +7694,6 @@ dependencies = [ "markup5ever_rcdom", "pretty_assertions", "regex", - "workspace-hack", ] [[package]] @@ -7514,13 +7758,36 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" +[[package]] +name = "http_client" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-fs", + "async-tar", + "bytes 1.10.1", + "derive_more 0.99.20", + "futures 0.3.31", + "http 1.3.1", + "http-body 1.0.1", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "sha2", + "tempfile", + "url", + "util", +] + [[package]] name = "http_client_tls" version = "0.1.0" dependencies = [ - "rustls 0.23.26", + "rustls 0.23.33", "rustls-platform-verifier", - "workspace-hack", ] [[package]] @@ -7543,9 +7810,9 @@ checksum = "91f255a4535024abf7640cb288260811fc14794f62b063652ed349f9a6c2348e" [[package]] name = "humantime" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" @@ -7557,14 +7824,14 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -7573,19 +7840,21 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes 1.10.1", "futures-channel", - "futures-util", - "h2 0.4.9", + "futures-core", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -7609,16 +7878,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", "http 1.3.1", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", - "rustls 0.23.26", - "rustls-native-certs 0.8.1", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", @@ -7640,19 +7908,23 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ + "base64 0.22.1", "bytes 1.10.1", "futures-channel", + "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.7.0", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -7660,9 +7932,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -7670,7 +7942,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.0", + "windows-core 0.62.2", ] [[package]] @@ -7687,27 +7959,27 @@ name = "icons" version = "0.1.0" dependencies = [ "serde", - "strum 0.27.1", - "workspace-hack", + "strum 0.27.2", ] [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", - "yoke", + "potential_utf", + "yoke 0.8.0", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -7716,31 +7988,11 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -7748,67 +8000,54 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", - "yoke", + "yoke 0.8.0", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "id-arena" version = "2.2.1" @@ -7823,9 +8062,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -7834,9 +8073,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -7844,9 +8083,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.23" +version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +checksum = "81776e6f9464432afcc28d03e52eb101c93b6f0566f52aef2427663e700f0403" dependencies = [ "crossbeam-deque", "globset", @@ -7860,9 +8099,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", @@ -7870,8 +8109,9 @@ dependencies = [ "exr", "gif", "image-webp", + "moxcms", "num-traits", - "png", + "png 0.18.0", "qoi", "ravif", "rayon", @@ -7883,9 +8123,9 @@ dependencies = [ [[package]] name = "image-webp" -version = "0.2.1" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", "quick-error", @@ -7907,9 +8147,8 @@ dependencies = [ "settings", "theme", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] @@ -7924,35 +8163,25 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" dependencies = [ - "hashbrown 0.15.3", + "hashbrown 0.15.5", ] [[package]] name = "imgref" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "indexmap" -version = "1.9.3" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown 0.16.1", "serde", + "serde_core", ] [[package]] @@ -7963,13 +8192,13 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "inherent" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -7989,7 +8218,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "inotify-sys", "libc", ] @@ -8027,11 +8256,11 @@ dependencies = [ "serde_json", "serde_json_lenient", "theme", + "title_bar", "ui", + "util", + "util_macros", "workspace", - "workspace-hack", - "zed-util", - "zed-util-macros", "zed_actions", ] @@ -8044,9 +8273,8 @@ dependencies = [ "gpui", "release_channel", "smol", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] @@ -8066,14 +8294,14 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "inventory" -version = "0.3.20" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" dependencies = [ "rustversion", ] @@ -8096,15 +8324,14 @@ checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" [[package]] name = "io-surface" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8283575d5f0b2e7447ec0840363879d71c0fa325d4c699d5b45208ea4a51f45e" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" dependencies = [ "cgl", "core-foundation 0.10.0", "core-foundation-sys", "leaky-cow", - "libc", ] [[package]] @@ -8127,7 +8354,7 @@ dependencies = [ "fnv", "lazy_static", "libc", - "mio 1.0.3", + "mio 1.1.0", "rand 0.8.5", "serde", "tempfile", @@ -8141,6 +8368,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -8156,7 +8393,7 @@ version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ - "hermit-abi 0.5.0", + "hermit-abi", "libc", "windows-sys 0.59.0", ] @@ -8171,6 +8408,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -8204,15 +8450,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -8230,9 +8467,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.10" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" dependencies = [ "jiff-static", "log", @@ -8243,13 +8480,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.10" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -8276,11 +8513,11 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", "libc", ] @@ -8297,20 +8534,13 @@ dependencies = [ "settings", "shellexpand 2.1.2", "workspace", - "workspace-hack", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" - [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -8339,41 +8569,41 @@ dependencies = [ "language", "paths", "project", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", "snippet_provider", "task", "theme", - "workspace-hack", - "zed-util", + "util", ] [[package]] name = "jsonschema" -version = "0.30.0" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" +checksum = "73c9ffb2b5c56d58030e1b532d8e8389da94590515f118cf35b5cb68e4764a7e" dependencies = [ - "ahash 0.8.11", - "base64 0.22.1", + "ahash 0.8.12", "bytecount", + "data-encoding", "email_address", - "fancy-regex 0.14.0", + "fancy-regex", "fraction", + "getrandom 0.3.4", "idna", "itoa", "num-cmp", "num-traits", - "once_cell", "percent-encoding", "referencing", "regex", "regex-syntax", - "reqwest 0.12.15", + "reqwest 0.12.24", "serde", "serde_json", + "unicode-general-category", "uuid-simd", ] @@ -8394,23 +8624,25 @@ dependencies = [ [[package]] name = "jupyter-protocol" -version = "0.6.0" -source = "git+https://github.com/ConradIrwin/runtimed?rev=7130c804216b6914355d15d0b91ea91f6babd734#7130c804216b6914355d15d0b91ea91f6babd734" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c047f6b5e551563af2ddb13dafed833f0ec5a5b0f9621d5ad740a9ff1e1095" dependencies = [ - "anyhow", "async-trait", "bytes 1.10.1", "chrono", "futures 0.3.31", "serde", "serde_json", + "thiserror 2.0.17", "uuid", ] [[package]] name = "jupyter-websocket-client" -version = "0.9.0" -source = "git+https://github.com/ConradIrwin/runtimed?rev=7130c804216b6914355d15d0b91ea91f6babd734#7130c804216b6914355d15d0b91ea91f6babd734" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4197fa926a6b0bddfed7377d9fed3d00a0dec44a1501e020097bd26604699cae" dependencies = [ "anyhow", "async-trait", @@ -8419,6 +8651,7 @@ dependencies = [ "jupyter-protocol", "serde", "serde_json", + "tokio", "url", "uuid", ] @@ -8428,6 +8661,7 @@ name = "keymap_editor" version = "0.1.0" dependencies = [ "anyhow", + "collections", "command_palette", "component", "db", @@ -8454,11 +8688,8 @@ dependencies = [ "tree-sitter-rust", "ui", "ui_input", - "vim", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", ] @@ -8474,9 +8705,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.0.8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" dependencies = [ "kqueue-sys", "libc", @@ -8494,11 +8725,12 @@ dependencies = [ [[package]] name = "kurbo" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89234b2cc610a7dd927ebde6b41dd1a5d4214cffaef4cf1fb2195d592f92518f" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" dependencies = [ "arrayvec", + "euclid", "smallvec", ] @@ -8518,6 +8750,7 @@ dependencies = [ "anyhow", "async-trait", "clock", + "collections", "ctor", "diffy", "ec4rs", @@ -8526,6 +8759,7 @@ dependencies = [ "fuzzy", "globset", "gpui", + "http_client", "imara-diff", "indoc", "itertools 0.14.0", @@ -8534,10 +8768,10 @@ dependencies = [ "parking_lot", "postage", "pretty_assertions", - "rand 0.9.1", + "rand 0.9.2", "regex", "rpc", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", @@ -8546,10 +8780,11 @@ dependencies = [ "smol", "streaming-iterator", "strsim", + "sum_tree", "task", "text", "theme", - "toml 0.8.20", + "toml 0.8.23", "tree-sitter", "tree-sitter-elixir", "tree-sitter-embedded-template", @@ -8563,12 +8798,8 @@ dependencies = [ "tree-sitter-typescript", "unicase", "unindent", + "util", "watch", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-sum-tree", - "zed-util", "zlog", ] @@ -8578,6 +8809,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "collections", "extension", "fs", "futures 0.3.31", @@ -8588,9 +8820,7 @@ dependencies = [ "project", "serde", "serde_json", - "workspace-hack", - "zed-collections", - "zed-util", + "util", ] [[package]] @@ -8603,25 +8833,26 @@ dependencies = [ "client", "cloud_api_types", "cloud_llm_client", + "collections", + "credentials_provider", "futures 0.3.31", "gpui", + "http_client", "icons", "image", "log", + "open_ai", "open_router", "parking_lot", "proto", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", "smol", - "telemetry_events", - "thiserror 2.0.12", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", + "thiserror 2.0.17", + "util", + "zed_env_vars", ] [[package]] @@ -8638,6 +8869,7 @@ dependencies = [ "chrono", "client", "cloud_llm_client", + "collections", "component", "convert_case 0.8.0", "copilot", @@ -8649,6 +8881,7 @@ dependencies = [ "google_ai", "gpui", "gpui_tokio", + "http_client", "language", "language_model", "lmstudio", @@ -8661,24 +8894,21 @@ dependencies = [ "partial-json-fixer", "project", "release_channel", - "schemars 1.0.1", + "schemars", + "semver", "serde", "serde_json", "settings", "smol", - "strum 0.27.1", - "thiserror 2.0.12", + "strum 0.27.2", + "thiserror 2.0.17", "tiktoken-rs", "tokio", "ui", "ui_input", + "util", "vercel", - "workspace-hack", "x_ai", - "zed-collections", - "zed-http-client", - "zed-util", - "zed_env_vars", ] [[package]] @@ -8691,7 +8921,6 @@ dependencies = [ "project", "ui", "workspace", - "workspace-hack", ] [[package]] @@ -8709,9 +8938,8 @@ dependencies = [ "project", "settings", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] @@ -8720,6 +8948,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "collections", "command_palette_hooks", "copilot", "editor", @@ -8731,15 +8960,14 @@ dependencies = [ "project", "proto", "release_channel", + "semver", "serde_json", "settings", "theme", "tree-sitter", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", "zlog", ] @@ -8754,8 +8982,11 @@ dependencies = [ "async-tar", "async-trait", "chrono", + "collections", "futures 0.3.31", + "globset", "gpui", + "http_client", "itertools 0.14.0", "json_schema_store", "language", @@ -8779,12 +9010,14 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", - "shlex", + "smallvec", "smol", + "snippet", "task", + "terminal", "text", "theme", - "toml 0.8.20", + "toml 0.8.23", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", @@ -8805,11 +9038,8 @@ dependencies = [ "tree-sitter-yaml", "unindent", "url", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", ] [[package]] @@ -8818,15 +9048,9 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "leak" version = "0.1.2" @@ -8856,21 +9080,21 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lebe" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libdbus-sys" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +checksum = "5cbe856efeb50e4681f010e9aaa2bf0a644e10139e54cde10fc83a307c23bd9f" dependencies = [ "cc", "pkg-config", @@ -8878,9 +9102,9 @@ dependencies = [ [[package]] name = "libfuzzer-sys" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" dependencies = [ "arbitrary", "cc", @@ -8888,9 +9112,9 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.18.1+1.9.0" +version = "0.18.2+1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e" +checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222" dependencies = [ "cc", "libc", @@ -8900,25 +9124,25 @@ dependencies = [ [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] name = "libm" -version = "0.2.11" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libmimalloc-sys" -version = "0.1.42" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9d6fac27761dabcd4ee73571cdb06b7022dc99089acbe5435691edffaac0f4" +checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" dependencies = [ "cc", "libc", @@ -8926,13 +9150,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "libc", - "redox_syscall 0.5.11", + "redox_syscall 0.5.18", ] [[package]] @@ -8991,16 +9215,15 @@ dependencies = [ "picker", "project", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] name = "link-cplusplus" -version = "1.0.10" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" dependencies = [ "cc", ] @@ -9022,15 +9245,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "livekit" @@ -9068,7 +9291,7 @@ dependencies = [ "parking_lot", "pbjson-types", "prost 0.12.6", - "rand 0.9.1", + "rand 0.9.2", "reqwest 0.11.27", "scopeguard", "serde", @@ -9117,7 +9340,6 @@ dependencies = [ "prost-build 0.9.0", "prost-types 0.9.0", "serde", - "workspace-hack", "zed-reqwest", ] @@ -9128,6 +9350,7 @@ dependencies = [ "anyhow", "async-trait", "audio", + "collections", "core-foundation 0.10.0", "core-video", "coreaudio-rs 0.12.1", @@ -9155,10 +9378,8 @@ dependencies = [ "smallvec", "tokio-tungstenite 0.26.2", "ui", - "workspace-hack", - "zed-collections", + "util", "zed-scap", - "zed-util", ] [[package]] @@ -9178,28 +9399,26 @@ version = "0.1.0" dependencies = [ "anyhow", "futures 0.3.31", - "schemars 1.0.1", + "http_client", + "schemars", "serde", "serde_json", - "workspace-hack", - "zed-http-client", ] [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" dependencies = [ "serde", "value-bag", @@ -9233,15 +9452,22 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.3", + "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lsp" version = "0.1.0" dependencies = [ "anyhow", "async-pipe", + "collections", "ctor", "futures 0.3.31", "gpui", @@ -9250,20 +9476,19 @@ dependencies = [ "parking_lot", "postage", "release_channel", - "schemars 1.0.1", + "schemars", + "semver", "serde", "serde_json", "smol", - "workspace-hack", - "zed-collections", - "zed-util", + "util", "zlog", ] [[package]] name = "lsp-types" version = "0.95.1" -source = "git+https://github.com/zed-industries/lsp-types?rev=0874f8742fe55b4dc94308c1e3c0069710d8eeaf#0874f8742fe55b4dc94308c1e3c0069710d8eeaf" +source = "git+https://github.com/zed-industries/lsp-types?rev=b71ab4eeb27d9758be8092020a46fe33fbca4e33#b71ab4eeb27d9758be8092020a46fe33fbca4e33" dependencies = [ "bitflags 1.3.2", "serde", @@ -9273,9 +9498,9 @@ dependencies = [ [[package]] name = "lyon" -version = "1.0.1" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7f9cda98b5430809e63ca5197b06c7d191bf7e26dfc467d5a3f0290e2a74f" +checksum = "dbcb7d54d54c8937364c9d41902d066656817dce1e03a44e5533afebd1ef4352" dependencies = [ "lyon_algorithms", "lyon_extra", @@ -9284,9 +9509,9 @@ dependencies = [ [[package]] name = "lyon_algorithms" -version = "1.0.5" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f13c9be19d257c7d37e70608ed858e8eab4b2afcea2e3c9a622e892acbf43c08" +checksum = "f4c0829e28c4f336396f250d850c3987e16ce6db057ffe047ce0dd54aab6b647" dependencies = [ "lyon_path", "num-traits", @@ -9304,9 +9529,9 @@ dependencies = [ [[package]] name = "lyon_geom" -version = "1.0.6" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8af69edc087272df438b3ee436c4bb6d7c04aa8af665cfd398feae627dbd8570" +checksum = "4e16770d760c7848b0c1c2d209101e408207a65168109509f8483837a36cf2e7" dependencies = [ "arrayvec", "euclid", @@ -9315,9 +9540,9 @@ dependencies = [ [[package]] name = "lyon_path" -version = "1.0.7" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0047f508cd7a85ad6bad9518f68cce7b1bf6b943fb71f6da0ee3bc1e8cb75f25" +checksum = "1aeca86bcfd632a15984ba029b539ffb811e0a70bf55e814ef8b0f54f506fdeb" dependencies = [ "lyon_geom", "num-traits", @@ -9325,9 +9550,9 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579d42360a4b09846eff2feef28f538696c7d6c7439bfa65874ff3cbe0951b2c" +checksum = "f3f586142e1280335b1bc89539f7c97dd80f08fc43e9ab1b74ef0a42b04aa353" dependencies = [ "float_next_after", "lyon_path", @@ -9342,9 +9567,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mach2" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] @@ -9379,6 +9604,7 @@ version = "0.1.0" dependencies = [ "assets", "base64 0.22.1", + "collections", "env_logger 0.11.8", "fs", "futures 0.3.31", @@ -9390,12 +9616,10 @@ dependencies = [ "node_runtime", "pulldown-cmark 0.12.2", "settings", + "sum_tree", "theme", "ui", - "workspace-hack", - "zed-collections", - "zed-sum-tree", - "zed-util", + "util", ] [[package]] @@ -9404,6 +9628,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-recursion", + "collections", "editor", "fs", "gpui", @@ -9417,10 +9642,9 @@ dependencies = [ "settings", "theme", "ui", + "urlencoding", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", ] [[package]] @@ -9430,7 +9654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", - "phf", + "phf 0.11.3", "phf_codegen", "string_cache", "string_cache_codegen", @@ -9439,9 +9663,9 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.16.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a8096766c229e8c88a3900c9b44b7e06aa7f7343cc229158c3e58ef8f9973a" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" dependencies = [ "log", "tendril", @@ -9462,13 +9686,13 @@ dependencies = [ [[package]] name = "match_token" -version = "0.1.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -9548,26 +9772,40 @@ dependencies = [ "warp", ] +[[package]] +name = "media" +version = "0.1.0" +dependencies = [ + "anyhow", + "bindgen 0.71.1", + "core-foundation 0.10.0", + "core-video", + "ctor", + "foreign-types 0.5.0", + "metal", + "objc", +] + [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memfd" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cffa4ad52c6f791f4f8b15f0c05f9824b2ced1160e88cc393d64fff9a8ac64" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix 0.38.44", + "rustix 1.1.2", ] [[package]] name = "memmap2" -version = "0.9.5" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" dependencies = [ "libc", "stable_deref_trait", @@ -9587,7 +9825,28 @@ name = "menu" version = "0.1.0" dependencies = [ "gpui", - "workspace-hack", +] + +[[package]] +name = "merge" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10bbef93abb1da61525bbc45eeaff6473a41907d19f8f9aa5168d214e10693e9" +dependencies = [ + "merge_derive", + "num-traits", +] + +[[package]] +name = "merge_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209d075476da2e63b4b29e72a2ef627b840589588e71400a25e3565c4f849d07" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -9596,7 +9855,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -9610,25 +9869,24 @@ name = "migrator" version = "0.1.0" dependencies = [ "anyhow", + "collections", "convert_case 0.8.0", "log", "pretty_assertions", "serde_json", "serde_json_lenient", - "settings", + "settings_json", "streaming-iterator", "tree-sitter", "tree-sitter-json", "unindent", - "workspace-hack", - "zed-collections", ] [[package]] name = "mimalloc" -version = "0.1.46" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "995942f432bbb4822a7e9c3faa87a695185b0d09273ba85f097b54f4e458f2af" +checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" dependencies = [ "libmimalloc-sys", ] @@ -9655,7 +9913,7 @@ version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "debugid", "num-derive", "num-traits", @@ -9670,14 +9928,14 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "cfg-if", "crash-context", "goblin", "libc", "log", - "mach2 0.4.2", + "mach2 0.4.3", "memmap2", "memoffset", "minidump-common", @@ -9712,11 +9970,23 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniprofiler_ui" +version = "0.1.0" +dependencies = [ + "gpui", + "serde_json", + "smol", + "util", + "workspace", + "zed_actions", +] + [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", @@ -9736,29 +10006,29 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.48.0", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] name = "miow" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359f76430b20a79f9e20e115b3428614e654f04fab314482fc0fda0ebd3c6044" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -9767,33 +10037,41 @@ version = "0.1.0" dependencies = [ "anyhow", "futures 0.3.31", - "schemars 1.0.1", + "http_client", + "schemars", "serde", "serde_json", - "strum 0.27.1", - "workspace-hack", - "zed-http-client", + "strum 0.27.2", ] [[package]] name = "moka" -version = "0.12.10" +version = "0.12.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" dependencies = [ "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", - "loom", + "equivalent", "parking_lot", "portable-atomic", "rustc_version", "smallvec", "tagptr", - "thiserror 1.0.69", "uuid", ] +[[package]] +name = "moxcms" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "msvc_spectre_libs" version = "0.1.3" @@ -9810,6 +10088,7 @@ dependencies = [ "anyhow", "buffer_diff", "clock", + "collections", "ctor", "gpui", "indoc", @@ -9819,20 +10098,20 @@ dependencies = [ "parking_lot", "pretty_assertions", "project", - "rand 0.9.1", + "rand 0.9.2", "rope", "serde", "settings", "smallvec", "smol", + "sum_tree", "text", "theme", + "tracing", "tree-sitter", - "workspace-hack", - "zed-collections", - "zed-sum-tree", - "zed-util", + "util", "zlog", + "ztracing", ] [[package]] @@ -9841,6 +10120,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "naga" version = "25.0.1" @@ -9848,21 +10133,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", - "bit-set 0.8.0", - "bitflags 2.9.0", + "bit-set", + "bitflags 2.9.4", "cfg_aliases 0.2.1", - "codespan-reporting", + "codespan-reporting 0.12.0", "half", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "hexf-parse", - "indexmap 2.9.0", + "indexmap", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", "strum 0.26.3", - "thiserror 2.0.12", + "thiserror 2.0.17", "unicode-ident", ] @@ -9881,7 +10166,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", ] [[package]] @@ -9903,8 +10188,9 @@ dependencies = [ [[package]] name = "nbformat" -version = "0.10.0" -source = "git+https://github.com/ConradIrwin/runtimed?rev=7130c804216b6914355d15d0b91ea91f6babd734#7130c804216b6914355d15d0b91ea91f6babd734" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89c7229d604d847227002715e1235cd84e81919285d904ccb290a42ecc409348" dependencies = [ "anyhow", "chrono", @@ -9923,7 +10209,6 @@ dependencies = [ "futures 0.3.31", "net", "smol", - "workspace-hack", ] [[package]] @@ -9932,7 +10217,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "jni-sys", "log", "ndk-sys", @@ -9963,8 +10248,7 @@ dependencies = [ "async-io", "smol", "tempfile", - "windows 0.61.1", - "workspace-hack", + "windows 0.61.3", ] [[package]] @@ -9979,7 +10263,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -9991,7 +10275,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -10003,7 +10287,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -10020,17 +10304,16 @@ dependencies = [ "async-tar", "async-trait", "futures 0.3.31", + "http_client", "log", "paths", "semver", "serde", "serde_json", "smol", + "util", "watch", "which 6.0.3", - "workspace-hack", - "zed-http-client", - "zed-util", ] [[package]] @@ -10060,11 +10343,11 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "normpath" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10074,18 +10357,17 @@ dependencies = [ "anyhow", "channel", "client", + "collections", "component", "db", "gpui", "rpc", "settings", + "sum_tree", "time", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-sum-tree", - "zed-util", "zed_actions", ] @@ -10095,7 +10377,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "crossbeam-channel", "filetime", "fsevent-sys 4.1.0", @@ -10110,20 +10392,19 @@ dependencies = [ [[package]] name = "notify" -version = "8.0.0" -source = "git+https://github.com/zed-industries/notify.git?rev=bbb9ea5ae52b253e095737847e367c30653a2e96#bbb9ea5ae52b253e095737847e367c30653a2e96" +version = "8.2.0" +source = "git+https://github.com/zed-industries/notify.git?rev=b4588b2e5aee68f4c0e100f140e808cbce7b1419#b4588b2e5aee68f4c0e100f140e808cbce7b1419" dependencies = [ - "bitflags 2.9.0", - "filetime", + "bitflags 2.9.4", "fsevent-sys 4.1.0", "inotify 0.11.0", "kqueue", "libc", "log", - "mio 1.0.3", + "mio 1.1.0", "notify-types", "walkdir", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -10140,7 +10421,7 @@ dependencies = [ [[package]] name = "notify-types" version = "2.0.0" -source = "git+https://github.com/zed-industries/notify.git?rev=bbb9ea5ae52b253e095737847e367c30653a2e96#bbb9ea5ae52b253e095737847e367c30653a2e96" +source = "git+https://github.com/zed-industries/notify.git?rev=b4588b2e5aee68f4c0e100f140e808cbce7b1419#b4588b2e5aee68f4c0e100f140e808cbce7b1419" [[package]] name = "ntapi" @@ -10153,11 +10434,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10186,11 +10467,10 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", @@ -10232,7 +10512,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -10288,33 +10568,34 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", ] [[package]] name = "num_enum" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" dependencies = [ "num_enum_derive", + "rustversion", ] [[package]] name = "num_enum_derive" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -10363,9 +10644,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", ] @@ -10376,7 +10657,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -10389,7 +10670,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "libc", "objc2", "objc2-core-audio", @@ -10400,9 +10681,9 @@ dependencies = [ [[package]] name = "objc2-core-audio" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", "objc2", @@ -10412,21 +10693,21 @@ dependencies = [ [[package]] name = "objc2-core-audio-types" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", ] [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "dispatch2", "objc2", ] @@ -10443,18 +10724,28 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f246c183239540aab1782457b35ab2040d4259175bd1d0c58e46ada7b47a874" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block2", "dispatch2", "objc2", @@ -10468,7 +10759,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -10481,7 +10772,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -10513,8 +10804,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "crc32fast", - "hashbrown 0.15.3", - "indexmap 2.9.0", + "hashbrown 0.15.5", + "indexmap", + "memchr", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ "memchr", ] @@ -10524,47 +10824,39 @@ version = "0.1.0" dependencies = [ "anyhow", "futures 0.3.31", - "schemars 1.0.1", + "http_client", + "schemars", "serde", "serde_json", "settings", - "workspace-hack", - "zed-http-client", ] [[package]] name = "onboarding" version = "0.1.0" dependencies = [ - "ai_onboarding", "anyhow", "client", "component", "db", "documented", - "editor", "fs", "fuzzy", "git", "gpui", - "itertools 0.14.0", - "language", - "language_model", "menu", "notifications", "picker", "project", - "schemars 1.0.1", + "schemars", "serde", "settings", "telemetry", "theme", "ui", - "ui_input", + "util", "vim_mode_setting", "workspace", - "workspace-hack", - "zed-util", "zed_actions", "zlog", ] @@ -10575,6 +10867,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "oo7" version = "0.5.0" @@ -10585,22 +10883,22 @@ dependencies = [ "ashpd 0.12.0", "async-fs", "async-io", - "async-lock", + "async-lock 3.4.1", "blocking", "cbc", "cipher", "digest", "endi", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "futures-util", - "getrandom 0.3.2", + "getrandom 0.3.4", "hkdf", "hmac", "md-5", "num", "num-bigint-dig", "pbkdf2 0.12.2", - "rand 0.9.1", + "rand 0.9.2", "serde", "sha2", "subtle", @@ -10633,14 +10931,14 @@ version = "0.1.0" dependencies = [ "anyhow", "futures 0.3.31", + "http_client", "log", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", - "strum 0.27.1", - "workspace-hack", - "zed-http-client", + "strum 0.27.2", + "thiserror 2.0.17", ] [[package]] @@ -10649,14 +10947,13 @@ version = "0.1.0" dependencies = [ "anyhow", "futures 0.3.31", - "schemars 1.0.1", + "http_client", + "schemars", "serde", "serde_json", "settings", - "strum 0.27.1", - "thiserror 2.0.12", - "workspace-hack", - "zed-http-client", + "strum 0.27.2", + "thiserror 2.0.17", ] [[package]] @@ -10673,11 +10970,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.72" +version = "0.10.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "foreign-types 0.3.2", "libc", @@ -10694,7 +10991,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -10705,9 +11002,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.107" +version = "0.9.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" dependencies = [ "cc", "libc", @@ -10717,13 +11014,13 @@ dependencies = [ [[package]] name = "optfield" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa59f025cde9c698fcb4fcb3533db4621795374065bee908215263488f2d2a1d" +checksum = "969ccca8ffc4fb105bd131a228107d5c9dd89d9d627edf3295cbe979156f9712" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -10781,7 +11078,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -10805,9 +11102,8 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-typescript", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", "zed_actions", ] @@ -10816,6 +11112,7 @@ name = "outline_panel" version = "0.1.0" dependencies = [ "anyhow", + "collections", "db", "editor", "file_icons", @@ -10836,11 +11133,9 @@ dependencies = [ "smol", "theme", "ui", + "util", "workspace", - "workspace-hack", "worktree", - "zed-collections", - "zed-util", "zed_actions", ] @@ -10891,7 +11186,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -10904,7 +11199,6 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", ] [[package]] @@ -10915,9 +11209,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -10925,15 +11219,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.11", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -11010,8 +11304,7 @@ version = "0.1.0" dependencies = [ "dirs 4.0.0", "ignore", - "workspace-hack", - "zed-util", + "util", ] [[package]] @@ -11081,12 +11374,12 @@ checksum = "0008e816fcdaf229cdd540e9b6ca2dc4a10d65c31624abb546c6420a02846e61" [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] @@ -11100,26 +11393,34 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "perf" +version = "0.1.0" +dependencies = [ + "collections", + "serde", + "serde_json", +] [[package]] name = "pest" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" dependencies = [ "memchr", - "thiserror 2.0.12", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" dependencies = [ "pest", "pest_generator", @@ -11127,24 +11428,23 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "pest_meta" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" dependencies = [ - "once_cell", "pest", "sha2", ] @@ -11152,7 +11452,7 @@ dependencies = [ [[package]] name = "pet" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "clap", "env_logger 0.10.2", @@ -11177,6 +11477,7 @@ dependencies = [ "pet-python-utils", "pet-reporter", "pet-telemetry", + "pet-uv", "pet-venv", "pet-virtualenv", "pet-virtualenvwrapper", @@ -11189,7 +11490,7 @@ dependencies = [ [[package]] name = "pet-conda" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11208,7 +11509,7 @@ dependencies = [ [[package]] name = "pet-core" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "clap", "lazy_static", @@ -11223,7 +11524,7 @@ dependencies = [ [[package]] name = "pet-env-var-path" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11239,7 +11540,7 @@ dependencies = [ [[package]] name = "pet-fs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11248,7 +11549,7 @@ dependencies = [ [[package]] name = "pet-global-virtualenvs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11261,7 +11562,7 @@ dependencies = [ [[package]] name = "pet-homebrew" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11279,7 +11580,7 @@ dependencies = [ [[package]] name = "pet-jsonrpc" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "log", @@ -11292,7 +11593,7 @@ dependencies = [ [[package]] name = "pet-linux-global-python" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11305,7 +11606,7 @@ dependencies = [ [[package]] name = "pet-mac-commandlinetools" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11318,7 +11619,7 @@ dependencies = [ [[package]] name = "pet-mac-python-org" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11331,7 +11632,7 @@ dependencies = [ [[package]] name = "pet-mac-xcode" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11344,7 +11645,7 @@ dependencies = [ [[package]] name = "pet-pipenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11357,7 +11658,7 @@ dependencies = [ [[package]] name = "pet-pixi" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11369,7 +11670,7 @@ dependencies = [ [[package]] name = "pet-poetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "base64 0.22.1", "lazy_static", @@ -11384,13 +11685,13 @@ dependencies = [ "serde", "serde_json", "sha2", - "toml 0.8.20", + "toml 0.8.23", ] [[package]] name = "pet-pyenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11408,7 +11709,7 @@ dependencies = [ [[package]] name = "pet-python-utils" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11425,7 +11726,7 @@ dependencies = [ [[package]] name = "pet-reporter" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "log", @@ -11439,7 +11740,7 @@ dependencies = [ [[package]] name = "pet-telemetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11451,10 +11752,22 @@ dependencies = [ "regex", ] +[[package]] +name = "pet-uv" +version = "0.1.0" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" +dependencies = [ + "log", + "pet-core", + "pet-python-utils", + "serde", + "toml 0.9.8", +] + [[package]] name = "pet-venv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11466,7 +11779,7 @@ dependencies = [ [[package]] name = "pet-virtualenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11478,7 +11791,7 @@ dependencies = [ [[package]] name = "pet-virtualenvwrapper" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11491,7 +11804,7 @@ dependencies = [ [[package]] name = "pet-windows-registry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11509,7 +11822,7 @@ dependencies = [ [[package]] name = "pet-windows-store" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11529,14 +11842,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.9.0", + "indexmap", ] [[package]] name = "pgvector" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0e8871b6d7ca78348c6cd29b911b94851f3429f0cd403130ca17f26c1fb91a6" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" dependencies = [ "serde", ] @@ -11547,8 +11860,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_macros 0.12.1", + "phf_shared 0.12.1", ] [[package]] @@ -11557,8 +11880,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -11567,21 +11890,44 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.12.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", +] + +[[package]] +name = "phf_macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" +dependencies = [ + "phf_generator 0.12.1", + "phf_shared 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -11593,6 +11939,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "picker" version = "0.1.0" @@ -11603,13 +11958,12 @@ dependencies = [ "env_logger 0.11.8", "gpui", "menu", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "theme", "ui", "workspace", - "workspace-hack", ] [[package]] @@ -11635,7 +11989,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -11706,13 +12060,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.9.0", - "quick-xml 0.32.0", + "indexmap", + "quick-xml 0.38.3", "serde", "time", ] @@ -11758,6 +12112,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.9.4", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -11766,10 +12133,10 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi 0.5.0", + "hermit-abi", "pin-project-lite", - "rustix 1.0.7", - "windows-sys 0.61.0", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -11779,10 +12146,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" [[package]] -name = "portable-atomic" -version = "1.11.0" +name = "pori" +version = "0.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-atomic-util" @@ -11833,9 +12209,9 @@ dependencies = [ [[package]] name = "postcard" -version = "1.1.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" dependencies = [ "cobs", "embedded-io 0.4.0", @@ -11843,6 +12219,15 @@ dependencies = [ "serde", ] +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -11855,7 +12240,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.24", + "zerocopy", ] [[package]] @@ -11869,6 +12254,7 @@ name = "prettier" version = "0.1.0" dependencies = [ "anyhow", + "collections", "fs", "gpui", "language", @@ -11879,9 +12265,7 @@ dependencies = [ "paths", "serde", "serde_json", - "workspace-hack", - "zed-collections", - "zed-util", + "util", ] [[package]] @@ -11896,12 +12280,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.32" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -11915,11 +12299,35 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.7", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", ] [[package]] @@ -11941,14 +12349,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -11961,7 +12369,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "version_check", "yansi", ] @@ -11972,27 +12380,27 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "hex", ] [[package]] name = "profiling" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -12008,12 +12416,12 @@ dependencies = [ "circular-buffer", "client", "clock", + "collections", "context_server", "dap", "dap_adapters", "extension", - "fancy-regex 0.14.0", - "feature_flags", + "fancy-regex", "fs", "futures 0.3.31", "fuzzy", @@ -12022,8 +12430,9 @@ dependencies = [ "git_hosting_providers", "globset", "gpui", + "http_client", "image", - "indexmap 2.9.0", + "indexmap", "itertools 0.14.0", "language", "log", @@ -12035,40 +12444,56 @@ dependencies = [ "postage", "prettier", "pretty_assertions", - "rand 0.9.1", + "rand 0.9.2", "regex", "release_channel", "remote", "rpc", - "schemars 1.0.1", + "schemars", "semver", "serde", "serde_json", "settings", "sha2", "shellexpand 2.1.2", - "shlex", "smallvec", "smol", "snippet", "snippet_provider", + "sum_tree", "task", "tempfile", "terminal", "text", - "toml 0.8.20", + "toml 0.8.23", + "tracing", "unindent", "url", + "util", "watch", + "wax", "which 6.0.3", - "workspace-hack", "worktree", - "zed-collections", - "zed-http-client", - "zed-sum-tree", - "zed-util", "zeroize", "zlog", + "ztracing", +] + +[[package]] +name = "project_benchmarks" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "client", + "futures 0.3.31", + "gpui", + "http_client", + "language", + "node_runtime", + "project", + "settings", + "watch", ] [[package]] @@ -12077,6 +12502,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "collections", "command_palette_hooks", "criterion", "db", @@ -12090,20 +12516,19 @@ dependencies = [ "pretty_assertions", "project", "rayon", - "schemars 1.0.1", + "schemars", "search", "serde", "serde_json", "settings", "smallvec", "telemetry", + "tempfile", "theme", "ui", + "util", "workspace", - "workspace-hack", "worktree", - "zed-collections", - "zed-util", "zed_actions", ] @@ -12122,12 +12547,12 @@ dependencies = [ "picker", "project", "release_channel", + "semver", "serde_json", "settings", "theme", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] @@ -12142,7 +12567,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -12152,6 +12577,7 @@ dependencies = [ "anyhow", "assets", "chrono", + "collections", "fs", "futures 0.3.31", "fuzzy", @@ -12164,12 +12590,9 @@ dependencies = [ "paths", "rope", "serde", - "serde_json", "text", + "util", "uuid", - "workspace-hack", - "zed-collections", - "zed-util", ] [[package]] @@ -12203,7 +12626,7 @@ dependencies = [ "itertools 0.10.5", "lazy_static", "log", - "multimap", + "multimap 0.8.3", "petgraph", "prost 0.9.0", "prost-types 0.9.0", @@ -12222,14 +12645,14 @@ dependencies = [ "heck 0.5.0", "itertools 0.12.1", "log", - "multimap", + "multimap 0.10.1", "once_cell", "petgraph", "prettyplease", "prost 0.12.6", "prost-types 0.12.6", "regex", - "syn 2.0.101", + "syn 2.0.106", "tempfile", ] @@ -12256,7 +12679,7 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -12283,12 +12706,11 @@ name = "proto" version = "0.1.0" dependencies = [ "anyhow", + "collections", "prost 0.9.0", "prost-build 0.9.0", "serde", "typed-path", - "workspace-hack", - "zed-collections", ] [[package]] @@ -12313,9 +12735,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.25" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58e5423e24c18cc840e1c98370b3993c6649cd1678b4d24318bcf0a083cbe88" +checksum = "e66fcd288453b748497d8fb18bccc83a16b0518e3906d4b8df0a8d42d93dbb1c" dependencies = [ "cc", ] @@ -12346,7 +12768,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "memchr", "pulldown-cmark-escape", "unicase", @@ -12358,7 +12780,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "memchr", "unicase", ] @@ -12407,6 +12829,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + [[package]] name = "qoi" version = "0.4.1" @@ -12433,27 +12864,27 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.32.0" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", ] [[package]] name = "quick-xml" -version = "0.37.4" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.7" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes 1.10.1", "cfg_aliases 0.2.1", @@ -12461,9 +12892,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls 0.23.26", - "socket2", - "thiserror 2.0.12", + "rustls 0.23.33", + "socket2 0.6.1", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -12471,19 +12902,20 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.10" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes 1.10.1", - "getrandom 0.3.2", - "rand 0.9.1", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", "ring", "rustc-hash 2.1.1", - "rustls 0.23.26", + "rustls 0.23.33", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -12491,32 +12923,32 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.11" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2", + "socket2 0.6.1", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radium" @@ -12537,9 +12969,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -12571,7 +13003,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", ] [[package]] @@ -12580,7 +13012,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", ] [[package]] @@ -12590,7 +13022,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.1", + "rand 0.9.2", ] [[package]] @@ -12604,9 +13036,9 @@ dependencies = [ [[package]] name = "rangemap" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60fcc7d6849342eff22c4350c8b9a989ee8ceabc4b481253e8946b9fe83d684" +checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" [[package]] name = "rav1e" @@ -12645,9 +13077,9 @@ dependencies = [ [[package]] name = "ravif" -version = "0.11.12" +version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6a5f31fcf7500f9401fea858ea4ab5525c99f2322cfcee732c0e6c74208c0c6" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" dependencies = [ "avif-serialize", "imgref", @@ -12673,7 +13105,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -12696,9 +13128,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -12706,9 +13138,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -12716,9 +13148,9 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.25.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f9e8a4f503e5c8750e4cd3b32a4e090035c46374b305a15c70bad833dca05f" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", "font-types", @@ -12747,6 +13179,7 @@ dependencies = [ "askpass", "auto_update", "dap", + "db", "editor", "extension_host", "file_finder", @@ -12758,12 +13191,14 @@ dependencies = [ "log", "markdown", "menu", + "node_runtime", "ordered-float 2.10.1", "paths", "picker", "project", "release_channel", "remote", + "semver", "serde", "serde_json", "settings", @@ -12772,10 +13207,10 @@ dependencies = [ "telemetry", "theme", "ui", - "windows-registry 0.6.0", + "util", + "windows-registry 0.6.1", "workspace", - "workspace-hack", - "zed-util", + "worktree", "zed_actions", ] @@ -12790,11 +13225,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.11" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -12803,56 +13238,64 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "libredox", "thiserror 1.0.69", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] name = "ref-cast" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "referencing" -version = "0.30.0" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" +checksum = "4283168a506f0dcbdce31c9f9cce3129c924da4c6bca46e46707fcb746d2d70c" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "fluent-uri", - "once_cell", + "getrandom 0.3.4", + "hashbrown 0.16.1", "parking_lot", "percent-encoding", "serde_json", ] +[[package]] +name = "refineable" +version = "0.1.0" +dependencies = [ + "derive_refineable", +] + [[package]] name = "regalloc2" version = "0.11.2" @@ -12861,7 +13304,7 @@ checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "log", "rustc-hash 2.1.1", "serde", @@ -12870,9 +13313,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -12882,9 +13325,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -12893,22 +13336,22 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "release_channel" version = "0.1.0" dependencies = [ "gpui", - "workspace-hack", + "semver", ] [[package]] @@ -12918,6 +13361,7 @@ dependencies = [ "anyhow", "askpass", "async-trait", + "collections", "fs", "futures 0.3.31", "gpui", @@ -12927,18 +13371,17 @@ dependencies = [ "prost 0.9.0", "release_channel", "rpc", + "schemars", + "semver", "serde", "serde_json", "settings", - "shlex", "smol", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.17", "urlencoding", + "util", "which 6.0.3", - "workspace-hack", - "zed-collections", - "zed-util", ] [[package]] @@ -12946,14 +13389,14 @@ name = "remote_server" version = "0.1.0" dependencies = [ "action_log", + "agent", "anyhow", "askpass", - "assistant_tool", - "assistant_tools", "cargo_toml", "clap", "client", "clock", + "collections", "crash-handler", "crashes", "dap", @@ -12971,6 +13414,8 @@ dependencies = [ "git_hosting_providers", "gpui", "gpui_tokio", + "http_client", + "image", "json_schema_store", "language", "language_extension", @@ -12984,27 +13429,30 @@ dependencies = [ "paths", "pretty_assertions", "project", + "prompt_store", "proto", + "rayon", "release_channel", "remote", "reqwest_client", "rpc", "rust-embed", + "semver", "serde", "serde_json", "settings", "shellexpand 2.1.2", "smol", - "sysinfo", - "thiserror 2.0.12", - "toml 0.8.20", + "sysinfo 0.37.2", + "task", + "theme", + "thiserror 2.0.17", + "toml 0.8.23", "unindent", + "util", "watch", "workspace", "worktree", - "zed-collections", - "zed-http-client", - "zed-util", "zlog", ] @@ -13027,6 +13475,7 @@ dependencies = [ "async-tungstenite", "base64 0.22.1", "client", + "collections", "command_palette_hooks", "editor", "env_logger 0.11.8", @@ -13034,6 +13483,7 @@ dependencies = [ "file_icons", "futures 0.3.31", "gpui", + "http_client", "image", "indoc", "jupyter-protocol", @@ -13060,12 +13510,9 @@ dependencies = [ "tree-sitter-python", "tree-sitter-typescript", "ui", + "util", "uuid", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", ] [[package]] @@ -13079,7 +13526,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -13114,9 +13561,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes 1.10.1", @@ -13126,13 +13573,10 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "once_cell", "percent-encoding", "pin-project-lite", "serde", @@ -13141,12 +13585,12 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tower 0.5.2", + "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-registry 0.4.0", ] [[package]] @@ -13157,13 +13601,12 @@ dependencies = [ "bytes 1.10.1", "futures 0.3.31", "gpui", + "http_client", "http_client_tls", "log", "regex", "serde", "tokio", - "workspace-hack", - "zed-http-client", "zed-reqwest", ] @@ -13194,9 +13637,9 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" dependencies = [ "bytemuck", ] @@ -13212,8 +13655,7 @@ dependencies = [ "pulldown-cmark 0.12.2", "theme", "ui", - "workspace-hack", - "zed-util", + "util", ] [[package]] @@ -13224,9 +13666,9 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -13283,7 +13725,7 @@ dependencies = [ [[package]] name = "rodio" version = "0.21.1" -source = "git+https://github.com/RustAudio/rodio#e2074c6c2acf07b57cf717e076bdda7a9ac6e70b" +source = "git+https://github.com/RustAudio/rodio?rev=e2074c6c2acf07b57cf717e076bdda7a9ac6e70b#e2074c6c2acf07b57cf717e076bdda7a9ac6e70b" dependencies = [ "cpal", "dasp_sample", @@ -13291,7 +13733,7 @@ dependencies = [ "num-rational", "rtrb", "symphonia", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -13303,14 +13745,14 @@ dependencies = [ "ctor", "gpui", "log", - "rand 0.9.1", + "rand 0.9.2", "rayon", - "smallvec", + "sum_tree", + "tracing", "unicode-segmentation", - "workspace-hack", - "zed-sum-tree", - "zed-util", + "util", "zlog", + "ztracing", ] [[package]] @@ -13327,29 +13769,28 @@ dependencies = [ "async-tungstenite", "base64 0.22.1", "chrono", + "collections", "futures 0.3.31", "gpui", "parking_lot", "proto", - "rand 0.9.1", + "rand 0.9.2", "rsa", "serde", "serde_json", "sha2", - "strum 0.27.1", + "strum 0.27.2", "tracing", - "workspace-hack", - "zed-collections", - "zed-util", + "util", "zlog", "zstd", ] [[package]] name = "rsa" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" dependencies = [ "const-oid", "digest", @@ -13376,6 +13817,7 @@ name = "rules_library" version = "0.1.0" dependencies = [ "anyhow", + "collections", "editor", "gpui", "language", @@ -13391,43 +13833,42 @@ dependencies = [ "theme", "title_bar", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", ] [[package]] name = "runtimelib" -version = "0.25.0" -source = "git+https://github.com/ConradIrwin/runtimed?rev=7130c804216b6914355d15d0b91ea91f6babd734#7130c804216b6914355d15d0b91ea91f6babd734" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481b48894073a0096f28cbe9860af01fc1b861e55b3bc96afafc645ee3de62dc" dependencies = [ - "anyhow", "async-dispatcher", "async-std", + "aws-lc-rs", "base64 0.22.1", "bytes 1.10.1", "chrono", "data-encoding", - "dirs 5.0.1", + "dirs 6.0.0", "futures 0.3.31", "glob", "jupyter-protocol", - "ring", "serde", "serde_json", "shellexpand 3.1.1", "smol", + "thiserror 2.0.17", "uuid", "zeromq", ] [[package]] name = "rust-embed" -version = "8.7.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fbc0ee50fcb99af7cebb442e5df7b5b45e9460ffa3f8f549cd26b862bec49d" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -13436,22 +13877,22 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.7.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf418c9a2e3f6663ca38b8a7134cc2c2167c9d69688860e8961e3faa731702e" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.101", + "syn 2.0.106", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.7.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d55b95147fe01265d06b3955db798bdaed52e60e2211c41137701b3aba8e21" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" dependencies = [ "globset", "sha2", @@ -13459,10 +13900,20 @@ dependencies = [ ] [[package]] -name = "rust_decimal" -version = "1.38.0" +name = "rust-stemmers" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8975fc98059f365204d635119cf9c5a60ae67b841ed49b5422a9a7e56cdfac0" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rust_decimal" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" dependencies = [ "arrayvec", "borsh", @@ -13476,9 +13927,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -13503,9 +13954,9 @@ dependencies = [ [[package]] name = "rustfft" -version = "6.4.0" +version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f140db74548f7c9d7cce60912c9ac414e74df5e718dc947d514b051b42f3f4" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" dependencies = [ "num-complex", "num-integer", @@ -13521,8 +13972,8 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.0", - "errno 0.3.11", + "bitflags 2.9.4", + "errno 0.3.14", "libc", "linux-raw-sys 0.4.15", "windows-sys 0.59.0", @@ -13530,15 +13981,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.0", - "errno 0.3.11", + "bitflags 2.9.4", + "errno 0.3.14", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -13548,7 +13999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix 1.0.7", + "rustix 1.1.2", ] [[package]] @@ -13557,9 +14008,9 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" dependencies = [ - "errno 0.3.11", + "errno 0.3.14", "libc", - "rustix 1.0.7", + "rustix 1.1.2", ] [[package]] @@ -13576,16 +14027,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.26" +version = "0.23.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" +checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.1", + "rustls-webpki 0.103.7", "subtle", "zeroize", ] @@ -13604,14 +14055,14 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", + "security-framework 3.5.1", ] [[package]] @@ -13644,20 +14095,20 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", "jni", "log", "once_cell", - "rustls 0.23.26", - "rustls-native-certs 0.8.1", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", "rustls-platform-verifier-android", - "rustls-webpki 0.103.1", - "security-framework 3.2.0", + "rustls-webpki 0.103.7", + "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", "windows-sys 0.59.0", @@ -13676,26 +14127,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] name = "rustls-webpki" -version = "0.103.1" +version = "0.103.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustybuzz" @@ -13703,7 +14154,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "bytemuck", "libm", "smallvec", @@ -13720,7 +14171,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "bytemuck", "core_maths", "log", @@ -13768,11 +14219,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -13784,8 +14235,7 @@ dependencies = [ "chrono", "futures 0.3.31", "parking_lot", - "rand 0.9.1", - "workspace-hack", + "rand 0.9.2", ] [[package]] @@ -13795,33 +14245,20 @@ dependencies = [ "anyhow", "clap", "env_logger 0.11.8", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "theme", - "workspace-hack", ] [[package]] name = "schemars" -version = "0.9.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" -dependencies = [ - "dyn-clone", - "indexmap 2.9.0", + "indexmap", "ref-cast", "schemars_derive", "serde", @@ -13830,14 +14267,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ca9fcb757952f8e8629b9ab066fc62da523c46c2b247b1708a3be06dd82530b" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -13854,9 +14291,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scratch" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6280af86e5f559536da57a45ebc84948833b3bee313a7dd25232e09c878a52" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" [[package]] name = "screencapturekit" @@ -13898,7 +14335,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -13920,7 +14357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -13933,7 +14370,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -13958,7 +14395,7 @@ dependencies = [ "serde_json", "sqlx", "strum 0.26.3", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "url", @@ -13975,15 +14412,15 @@ dependencies = [ "proc-macro2", "quote", "sea-bae", - "syn 2.0.101", + "syn 2.0.106", "unicode-ident", ] [[package]] name = "sea-query" -version = "0.32.4" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d99447c24da0cded00089e2021e1624af90878c65f7534319448d01da3df869d" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" dependencies = [ "bigdecimal", "chrono", @@ -14023,27 +14460,32 @@ version = "0.1.0" dependencies = [ "any_vec", "anyhow", - "bitflags 2.9.0", + "bitflags 2.9.4", "client", + "collections", "editor", "futures 0.3.31", "gpui", + "itertools 0.14.0", "language", + "lsp", "menu", + "pretty_assertions", "project", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", "smol", "theme", + "tracing", "ui", "unindent", + "util", + "util_macros", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", + "ztracing", ] [[package]] @@ -14066,7 +14508,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -14075,11 +14517,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.10.0", "core-foundation-sys", "libc", @@ -14088,9 +14530,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -14104,11 +14546,12 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" dependencies = [ "serde", + "serde_core", ] [[package]] @@ -14119,9 +14562,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.221" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "341877e04a22458705eb4e131a1508483c877dca2792b3781d4e5d8a6019ec43" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -14129,22 +14572,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.221" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c459bc0a14c840cb403fc14b148620de1e0778c96ecd6e0c8c3cacb6d8d00fe" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.221" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6185cf75117e20e62b1ff867b9518577271e58abe0037c40bb4794969355ab0" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -14155,7 +14598,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -14169,14 +14612,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.144" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56177480b00303e689183f110b4e727bb4211d692c62d4fcd16d02be93077d40" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.9.0", + "indexmap", "itoa", "memchr", "ryu", + "serde", "serde_core", ] @@ -14186,7 +14630,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" dependencies = [ - "indexmap 2.9.0", + "indexmap", "itoa", "memchr", "ryu", @@ -14195,12 +14639,13 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -14211,18 +14656,27 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "serde_spanned" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -14236,41 +14690,23 @@ dependencies = [ ] [[package]] -name = "serde_with" -version = "3.13.0" +name = "serde_yaml" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.9.0", - "schemars 0.9.0", + "indexmap", + "itoa", + "ryu", "serde", - "serde_derive", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.101", + "unsafe-libyaml", ] [[package]] name = "serial2" -version = "0.2.29" +version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1d08630509d69f90eff4afcd02c3bd974d979225cbd815ff5942351b14375" +checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" dependencies = [ "cfg-if", "libc", @@ -14284,9 +14720,8 @@ dependencies = [ "db", "gpui", "serde_json", + "util", "uuid", - "workspace-hack", - "zed-util", ] [[package]] @@ -14294,7 +14729,8 @@ name = "settings" version = "0.1.0" dependencies = [ "anyhow", - "derive_more", + "collections", + "derive_more 0.99.20", "ec4rs", "fs", "futures 0.3.31", @@ -14302,27 +14738,39 @@ dependencies = [ "indoc", "inventory", "log", + "migrator", "paths", "pretty_assertions", "release_channel", "rust-embed", - "schemars 1.0.1", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "serde_repr", + "settings_json", + "settings_macros", + "smallvec", + "strum 0.27.2", + "unindent", + "util", + "zlog", +] + +[[package]] +name = "settings_json" +version = "0.1.0" +dependencies = [ + "anyhow", + "pretty_assertions", "serde", "serde_json", "serde_json_lenient", "serde_path_to_error", - "serde_repr", - "serde_with", - "settings_macros", - "smallvec", - "strum 0.27.1", "tree-sitter", "tree-sitter-json", "unindent", - "workspace-hack", - "zed-collections", - "zed-util", - "zlog", + "util", ] [[package]] @@ -14331,8 +14779,7 @@ version = "0.1.0" dependencies = [ "quote", "settings", - "syn 2.0.101", - "workspace-hack", + "syn 2.0.106", ] [[package]] @@ -14352,7 +14799,6 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", "zed_actions", ] @@ -14362,8 +14808,10 @@ version = "0.1.0" dependencies = [ "anyhow", "assets", + "bm25", "client", - "command_palette_hooks", + "copilot", + "edit_prediction", "editor", "feature_flags", "fs", @@ -14372,23 +14820,28 @@ dependencies = [ "gpui", "heck 0.5.0", "language", + "language_models", + "log", "menu", "node_runtime", "paths", + "picker", "pretty_assertions", "project", - "schemars 1.0.1", + "release_channel", + "schemars", "search", "serde", "session", "settings", - "strum 0.27.1", + "strum 0.27.2", + "telemetry", "theme", + "title_bar", "ui", "ui_input", + "util", "workspace", - "workspace-hack", - "zed-util", "zed_actions", "zlog", ] @@ -14472,9 +14925,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", "signal-hook-registry", @@ -14482,9 +14935,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -14538,7 +14991,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", ] @@ -14570,9 +15023,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.26.6" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc1aa86c26dbb1b63875a7180aa0819709b33348eb5b1491e4321fae388179d" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", "read-fonts", @@ -14580,12 +15033,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slash_commands_example" @@ -14605,9 +15055,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] @@ -14620,7 +15070,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -14629,15 +15079,15 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", "async-fs", "async-io", - "async-lock", + "async-lock 3.4.1", "async-net", "async-process", "blocking", - "futures-lite 2.6.0", + "futures-lite 2.6.1", ] [[package]] @@ -14652,7 +15102,6 @@ version = "0.1.0" dependencies = [ "anyhow", "smallvec", - "workspace-hack", ] [[package]] @@ -14660,6 +15109,7 @@ name = "snippet_provider" version = "0.1.0" dependencies = [ "anyhow", + "collections", "extension", "fs", "futures 0.3.31", @@ -14667,14 +15117,12 @@ dependencies = [ "indoc", "parking_lot", "paths", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "serde_json_lenient", "snippet", - "workspace-hack", - "zed-collections", - "zed-util", + "util", ] [[package]] @@ -14690,26 +15138,35 @@ dependencies = [ "picker", "settings", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] name = "socket2" -version = "0.5.9" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] -name = "spdx" -version = "0.10.8" +name = "socket2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b69356da67e2fc1f542c71ea7e654a361a79c938e4424392ecf4fa065d2193" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" dependencies = [ "smallvec", ] @@ -14723,13 +15180,22 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -14763,6 +15229,7 @@ name = "sqlez" version = "0.1.0" dependencies = [ "anyhow", + "collections", "futures 0.3.31", "indoc", "libsqlite3-sys", @@ -14771,10 +15238,8 @@ dependencies = [ "smol", "sqlformat", "thread_local", + "util", "uuid", - "workspace-hack", - "zed-collections", - "zed-util", ] [[package]] @@ -14783,8 +15248,7 @@ version = "0.1.0" dependencies = [ "sqlez", "sqlformat", - "syn 2.0.101", - "workspace-hack", + "syn 2.0.106", ] [[package]] @@ -14799,9 +15263,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -14812,9 +15276,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64 0.22.1", "bigdecimal", @@ -14823,25 +15287,25 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener 5.4.0", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.9.0", + "indexmap", "log", "memchr", "once_cell", "percent-encoding", "rust_decimal", - "rustls 0.23.26", + "rustls 0.23.33", "serde", "serde_json", "sha2", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tokio", "tokio-stream", @@ -14853,22 +15317,22 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "sqlx-macros-core" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -14884,22 +15348,21 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.101", - "tempfile", + "syn 2.0.106", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "bytes 1.10.1", "chrono", @@ -14930,7 +15393,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "uuid", @@ -14939,14 +15402,14 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "chrono", "crc", @@ -14973,7 +15436,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "uuid", @@ -14982,9 +15445,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", @@ -15000,7 +15463,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "url", @@ -15009,15 +15472,15 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" dependencies = [ "cc", "cfg-if", @@ -15044,7 +15507,7 @@ checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" dependencies = [ "proc-macro-error2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -15053,6 +15516,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stop-words" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645a3d441ccf4bf47f2e4b7681461986681a6eeea9937d4c3bc9febd61d17c71" +dependencies = [ + "serde_json", +] + [[package]] name = "story" version = "0.1.0" @@ -15060,7 +15532,6 @@ dependencies = [ "gpui", "itertools 0.14.0", "smallvec", - "workspace-hack", ] [[package]] @@ -15080,18 +15551,15 @@ dependencies = [ "log", "menu", "picker", - "project", "reqwest_client", "rust-embed", "settings", "simplelog", "story", - "strum 0.27.1", + "strum 0.27.2", "theme", "title_bar", "ui", - "workspace", - "workspace-hack", ] [[package]] @@ -15105,10 +15573,9 @@ name = "streaming_diff" version = "0.1.0" dependencies = [ "ordered-float 2.10.1", - "rand 0.9.1", + "rand 0.9.2", "rope", - "workspace-hack", - "zed-util", + "util", ] [[package]] @@ -15134,7 +15601,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", "serde", ] @@ -15145,8 +15612,8 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", "proc-macro2", "quote", ] @@ -15179,11 +15646,11 @@ dependencies = [ [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.1", + "strum_macros 0.27.2", ] [[package]] @@ -15196,20 +15663,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -15218,17 +15684,33 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sum_tree" +version = "0.1.0" +dependencies = [ + "arrayvec", + "ctor", + "log", + "rand 0.9.2", + "rayon", + "tracing", + "zlog", + "ztracing", +] + [[package]] name = "supermaven" version = "0.1.0" dependencies = [ "anyhow", "client", - "edit_prediction", + "collections", + "edit_prediction_types", "editor", "env_logger 0.11.8", "futures 0.3.31", "gpui", + "http_client", "language", "log", "postage", @@ -15242,10 +15724,7 @@ dependencies = [ "theme", "ui", "unicode-segmentation", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", + "util", ] [[package]] @@ -15254,26 +15733,25 @@ version = "0.1.0" dependencies = [ "anyhow", "futures 0.3.31", + "http_client", "paths", "serde", "serde_json", "smol", - "workspace-hack", - "zed-http-client", - "zed-util", + "util", ] [[package]] name = "sval" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc9739f56c5d0c44a5ed45473ec868af02eb896af8c05f616673a31e1d1bb09" +checksum = "d94c4464e595f0284970fd9c7e9013804d035d4a61ab74b113242c874c05814d" [[package]] name = "sval_buffer" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f39b07436a8c271b34dad5070c634d1d3d76d6776e938ee97b4a66a5e8003d0b" +checksum = "a0f46e34b20a39e6a2bf02b926983149b3af6609fd1ee8a6e63f6f340f3e2164" dependencies = [ "sval", "sval_ref", @@ -15281,18 +15759,18 @@ dependencies = [ [[package]] name = "sval_dynamic" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffcb072d857431bf885580dacecf05ed987bac931230736739a79051dbf3499b" +checksum = "03d0970e53c92ab5381d3b2db1828da8af945954d4234225f6dd9c3afbcef3f5" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f214f427ad94a553e5ca5514c95c6be84667cbc5568cce957f03f3477d03d5c" +checksum = "43e5e6e1613e1e7fc2e1a9fdd709622e54c122ceb067a60d170d75efd491a839" dependencies = [ "itoa", "ryu", @@ -15301,9 +15779,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ed34b32e638dec9a99c8ac92d0aa1220d40041026b625474c2b6a4d6f4feb" +checksum = "aec382f7bfa6e367b23c9611f129b94eb7daaf3d8fae45a8d0a0211eb4d4c8e6" dependencies = [ "itoa", "ryu", @@ -15312,9 +15790,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14bae8fcb2f24fee2c42c1f19037707f7c9a29a0cda936d2188d48a961c4bb2a" +checksum = "3049d0f99ce6297f8f7d9953b35a0103b7584d8f638de40e64edb7105fa578ae" dependencies = [ "sval", "sval_buffer", @@ -15323,20 +15801,20 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4eaea3821d3046dcba81d4b8489421da42961889902342691fb7eab491d79e" +checksum = "f88913e77506085c0a8bf6912bb6558591a960faf5317df6c1d9b227224ca6e1" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172dd4aa8cb3b45c8ac8f3b4111d644cd26938b0643ede8f93070812b87fb339" +checksum = "f579fd7254f4be6cd7b450034f856b78523404655848789c451bacc6aa8b387d" dependencies = [ - "serde", + "serde_core", "sval", "sval_nested", ] @@ -15351,13 +15829,12 @@ checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" name = "svg_preview" version = "0.1.0" dependencies = [ - "editor", "file_icons", "gpui", + "language", "multi_buffer", "ui", "workspace", - "workspace-hack", ] [[package]] @@ -15372,9 +15849,9 @@ dependencies = [ [[package]] name = "swash" -version = "0.2.2" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae9a562c7b46107d9c78cd78b75bbe1e991c16734c0aee8ff0ee711fb8b620a" +checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" dependencies = [ "skrifa", "yazi", @@ -15383,9 +15860,9 @@ dependencies = [ [[package]] name = "symphonia" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ "lazy_static", "symphonia-bundle-flac", @@ -15402,9 +15879,9 @@ dependencies = [ [[package]] name = "symphonia-bundle-flac" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" dependencies = [ "log", "symphonia-core", @@ -15414,9 +15891,9 @@ dependencies = [ [[package]] name = "symphonia-bundle-mp3" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" dependencies = [ "lazy_static", "log", @@ -15426,9 +15903,9 @@ dependencies = [ [[package]] name = "symphonia-codec-aac" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbf25b545ad0d3ee3e891ea643ad115aff4ca92f6aec472086b957a58522f70" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" dependencies = [ "lazy_static", "log", @@ -15437,9 +15914,9 @@ dependencies = [ [[package]] name = "symphonia-codec-pcm" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" dependencies = [ "log", "symphonia-core", @@ -15447,9 +15924,9 @@ dependencies = [ [[package]] name = "symphonia-codec-vorbis" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" dependencies = [ "log", "symphonia-core", @@ -15458,9 +15935,9 @@ dependencies = [ [[package]] name = "symphonia-core" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" dependencies = [ "arrayvec", "bitflags 1.3.2", @@ -15471,9 +15948,9 @@ dependencies = [ [[package]] name = "symphonia-format-isomp4" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abfdf178d697e50ce1e5d9b982ba1b94c47218e03ec35022d9f0e071a16dc844" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" dependencies = [ "encoding_rs", "log", @@ -15484,9 +15961,9 @@ dependencies = [ [[package]] name = "symphonia-format-ogg" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" dependencies = [ "log", "symphonia-core", @@ -15496,9 +15973,9 @@ dependencies = [ [[package]] name = "symphonia-format-riff" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" dependencies = [ "extended", "log", @@ -15508,9 +15985,9 @@ dependencies = [ [[package]] name = "symphonia-metadata" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" dependencies = [ "encoding_rs", "lazy_static", @@ -15520,9 +15997,9 @@ dependencies = [ [[package]] name = "symphonia-utils-xiph" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" dependencies = [ "symphonia-core", "symphonia-metadata", @@ -15541,9 +16018,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.101" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -15576,13 +16053,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -15600,7 +16077,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "enum-as-inner", "libc", @@ -15614,7 +16091,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "enum-as-inner", "libc", @@ -15636,6 +16113,20 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -15653,7 +16144,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -15687,7 +16178,7 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.20", + "toml 0.8.23", "version-compare", ] @@ -15697,7 +16188,7 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cap-fs-ext", "cap-std", "fd-lock", @@ -15717,9 +16208,9 @@ dependencies = [ "human_bytes", "pciid-parser", "release_channel", + "semver", "serde", - "sysinfo", - "workspace-hack", + "sysinfo 0.37.2", ] [[package]] @@ -15727,6 +16218,7 @@ name = "tab_switcher" version = "0.1.0" dependencies = [ "anyhow", + "collections", "ctor", "editor", "fuzzy", @@ -15735,17 +16227,15 @@ dependencies = [ "menu", "picker", "project", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "settings", "smol", "theme", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zlog", ] @@ -15799,15 +16289,16 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "target-lexicon" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "task" version = "0.1.0" dependencies = [ "anyhow", + "collections", "futures 0.3.31", "gpui", "hex", @@ -15815,15 +16306,13 @@ dependencies = [ "parking_lot", "pretty_assertions", "proto", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "serde_json_lenient", "sha2", "shellexpand 2.1.2", - "workspace-hack", - "zed-collections", - "zed-util", + "util", "zed_actions", ] @@ -15832,6 +16321,7 @@ name = "tasks_ui" version = "0.1.0" dependencies = [ "anyhow", + "collections", "editor", "file_icons", "fuzzy", @@ -15847,10 +16337,8 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-typescript", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", ] @@ -15862,30 +16350,28 @@ dependencies = [ "serde", "serde_json", "telemetry_events", - "workspace-hack", ] [[package]] name = "telemetry_events" version = "0.1.0" dependencies = [ + "semver", "serde", "serde_json", - "workspace-hack", - "zed-semantic-version", ] [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand 2.3.0", - "getrandom 0.3.2", + "getrandom 0.3.4", "once_cell", - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -15914,38 +16400,39 @@ version = "0.1.0" dependencies = [ "alacritty_terminal", "anyhow", + "collections", "futures 0.3.31", "gpui", "itertools 0.14.0", "libc", "log", - "rand 0.9.1", + "rand 0.9.2", "regex", "release_channel", - "schemars 1.0.1", + "schemars", "serde", + "serde_json", "settings", "smol", - "sysinfo", + "sysinfo 0.37.2", "task", "theme", - "thiserror 2.0.12", + "thiserror 2.0.17", "url", "urlencoding", - "windows 0.61.1", - "workspace-hack", - "zed-collections", - "zed-util", + "util", + "util_macros", + "windows 0.61.3", ] [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.60.2", ] [[package]] @@ -15957,6 +16444,7 @@ dependencies = [ "async-recursion", "breadcrumbs", "client", + "collections", "db", "dirs 4.0.0", "editor", @@ -15967,23 +16455,20 @@ dependencies = [ "log", "pretty_assertions", "project", - "rand 0.9.1", + "rand 0.9.2", "regex", - "schemars 1.0.1", + "schemars", "search", "serde", "serde_json", "settings", "shellexpand 2.1.2", - "smol", "task", "terminal", "theme", "ui", + "util", "workspace", - "workspace-hack", - "zed-collections", - "zed-util", "zed_actions", ] @@ -15993,20 +16478,19 @@ version = "0.1.0" dependencies = [ "anyhow", "clock", + "collections", "ctor", "gpui", + "http_client", "log", "parking_lot", "postage", - "rand 0.9.1", + "rand 0.9.2", "regex", "rope", "smallvec", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-sum-tree", - "zed-util", + "sum_tree", + "util", "zlog", ] @@ -16015,25 +16499,24 @@ name = "theme" version = "0.1.0" dependencies = [ "anyhow", - "derive_more", + "collections", + "derive_more 0.99.20", "fs", "futures 0.3.31", "gpui", "log", "palette", "parking_lot", - "schemars 1.0.1", + "refineable", + "schemars", "serde", "serde_json", "serde_json_lenient", "settings", - "strum 0.27.1", - "thiserror 2.0.12", + "strum 0.27.2", + "thiserror 2.0.17", + "util", "uuid", - "workspace-hack", - "zed-collections", - "zed-refineable", - "zed-util", ] [[package]] @@ -16045,7 +16528,6 @@ dependencies = [ "fs", "gpui", "theme", - "workspace-hack", ] [[package]] @@ -16054,19 +16536,18 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "collections", "gpui", - "indexmap 2.9.0", + "indexmap", "log", "palette", "serde", "serde_json", "serde_json_lenient", "simplelog", - "strum 0.27.1", + "strum 0.27.2", "theme", "vscode_theme", - "workspace-hack", - "zed-collections", ] [[package]] @@ -16083,9 +16564,8 @@ dependencies = [ "telemetry", "theme", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", "zed_actions", ] @@ -16100,11 +16580,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.17", ] [[package]] @@ -16115,50 +16595,52 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "tiff" -version = "0.9.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ + "fax", "flate2", - "jpeg-decoder", + "half", + "quick-error", "weezl", + "zune-jpeg", ] [[package]] name = "tiktoken-rs" -version = "0.8.0" -source = "git+https://github.com/zed-industries/tiktoken-rs?rev=30c32a4522751699adeda0d5840c71c3b75ae73d#30c32a4522751699adeda0d5840c71c3b75ae73d" +version = "0.9.1" +source = "git+https://github.com/zed-industries/tiktoken-rs?rev=2570c4387a8505fb8f1d3f3557454b474f1e8271#2570c4387a8505fb8f1d3f3557454b474f1e8271" dependencies = [ "anyhow", "base64 0.22.1", "bstr", - "fancy-regex 0.13.0", + "fancy-regex", "lazy_static", "regex", "rustc-hash 1.1.0", @@ -16166,9 +16648,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", @@ -16183,15 +16665,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -16205,7 +16687,6 @@ dependencies = [ "core-foundation-sys", "sys-locale", "time", - "workspace-hack", ] [[package]] @@ -16228,7 +16709,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png", + "png 0.17.16", "tiny-skia-path", ] @@ -16258,9 +16739,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -16278,9 +16759,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -16298,17 +16779,20 @@ dependencies = [ "anyhow", "auto_update", "call", + "channel", "chrono", "client", "cloud_llm_client", + "collections", "db", "gpui", + "http_client", "notifications", "pretty_assertions", "project", "remote", "rpc", - "schemars 1.0.1", + "schemars", "serde", "settings", "smallvec", @@ -16317,31 +16801,27 @@ dependencies = [ "theme", "tree-sitter-md", "ui", - "windows 0.61.1", + "util", + "windows 0.61.3", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", "zed_actions", ] [[package]] name = "tokio" -version = "1.44.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes 1.10.1", "libc", - "mio 1.0.3", + "mio 1.1.0", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -16357,13 +16837,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -16392,7 +16872,7 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ - "rustls 0.23.26", + "rustls 0.23.33", "tokio", ] @@ -16452,7 +16932,7 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls 0.23.26", + "rustls 0.23.33", "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", @@ -16461,9 +16941,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes 1.10.1", "futures-core", @@ -16484,44 +16964,95 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.20" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.9" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] -name = "toml_edit" -version = "0.22.26" +name = "toml_datetime" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ - "indexmap 2.9.0", + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.1" +name = "toml_edit" +version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime 0.7.3", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "toolchain_selector" @@ -16539,9 +17070,8 @@ dependencies = [ "picker", "project", "ui", + "util", "workspace", - "workspace-hack", - "zed-util", ] [[package]] @@ -16605,7 +17135,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "bytes 1.10.1", "futures-core", "futures-util", @@ -16618,6 +17148,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.4", + "bytes 1.10.1", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -16632,9 +17180,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -16644,20 +17192,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -16686,9 +17234,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -16705,6 +17253,38 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-tracy" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eaa1852afa96e0fe9e44caa53dc0bd2d9d05e0f2611ce09f97f8677af56e4ba" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracy-client", +] + +[[package]] +name = "tracy-client" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d722a05fe49b31fef971c4732a7d4aa6a18283d9ba46abddab35f484872947" +dependencies = [ + "loom", + "once_cell", + "tracy-client-sys", +] + +[[package]] +name = "tracy-client-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" +dependencies = [ + "cc", + "windows-targets 0.52.6", +] + [[package]] name = "trait-variant" version = "0.1.2" @@ -16713,7 +17293,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -16743,9 +17323,9 @@ dependencies = [ [[package]] name = "tree-sitter-bash" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "871b0606e667e98a1237ebdc1b0d7056e0aebfdc3141d12b399865d4cb6ed8a6" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" dependencies = [ "cc", "tree-sitter-language", @@ -16832,7 +17412,7 @@ dependencies = [ [[package]] name = "tree-sitter-gomod" version = "1.1.1" -source = "git+https://github.com/camdencheek/tree-sitter-go-mod?rev=6efb59652d30e0e9cd5f3b3a669afd6f1a926d3c#6efb59652d30e0e9cd5f3b3a669afd6f1a926d3c" +source = "git+https://github.com/camdencheek/tree-sitter-go-mod?rev=2e886870578eeba1927a2dc4bd2e2b3f598c5f9a#2e886870578eeba1927a2dc4bd2e2b3f598c5f9a" dependencies = [ "cc", "tree-sitter-language", @@ -16944,8 +17524,7 @@ dependencies = [ [[package]] name = "tree-sitter-typescript" version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +source = "git+https://github.com/zed-industries/tree-sitter-typescript?rev=e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899#e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" dependencies = [ "cc", "tree-sitter-language", @@ -17036,11 +17615,30 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand 0.9.1", - "rustls 0.23.26", + "rand 0.9.2", + "rustls 0.23.33", "rustls-pki-types", "sha1", - "thiserror 2.0.12", + "thiserror 2.0.17", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes 1.10.1", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.2", + "rustls 0.23.33", + "rustls-pki-types", + "sha1", + "thiserror 2.0.17", "utf-8", ] @@ -17058,9 +17656,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -17106,7 +17704,7 @@ dependencies = [ "serde", "thiserror 1.0.69", "tracing", - "yoke", + "yoke 0.7.5", ] [[package]] @@ -17117,21 +17715,20 @@ dependencies = [ "component", "documented", "gpui", - "gpui-macros", + "gpui_macros", "icons", "itertools 0.14.0", "menu", - "schemars 1.0.1", + "schemars", "serde", "settings", "smallvec", "story", - "strum 0.27.1", + "strum 0.27.2", "theme", "ui_macros", - "windows 0.61.1", - "workspace-hack", - "zed-util", + "util", + "windows 0.61.3", ] [[package]] @@ -17140,14 +17737,11 @@ version = "0.1.0" dependencies = [ "component", "editor", - "fuzzy", "gpui", "menu", - "picker", "settings", "theme", "ui", - "workspace-hack", ] [[package]] @@ -17156,9 +17750,8 @@ version = "0.1.0" dependencies = [ "component", "quote", - "syn 2.0.101", + "syn 2.0.106", "ui", - "workspace-hack", ] [[package]] @@ -17172,7 +17765,6 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", ] [[package]] @@ -17212,10 +17804,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] -name = "unicode-ident" -version = "1.0.18" +name = "unicode-general-category" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" @@ -17258,9 +17856,9 @@ checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -17280,6 +17878,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -17288,9 +17898,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -17337,12 +17947,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -17356,14 +17960,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "uuid" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +name = "util" +version = "0.1.0" dependencies = [ - "getrandom 0.3.2", + "anyhow", + "async-fs", + "async_zip", + "collections", + "command-fds", + "dirs 4.0.0", + "dunce", + "futures 0.3.31", + "futures-lite 1.13.0", + "git2", + "globset", + "indoc", + "itertools 0.14.0", + "libc", + "log", + "mach2 0.5.0", + "nix 0.29.0", + "pretty_assertions", + "rand 0.9.2", + "regex", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "shlex", + "smol", + "take-until", + "tempfile", + "tendril", + "unicase", + "util_macros", + "walkdir", + "which 6.0.3", +] + +[[package]] +name = "util_macros" +version = "0.1.0" +dependencies = [ + "perf", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", "serde", "sha1_smol", + "wasm-bindgen", ] [[package]] @@ -17373,15 +18028,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" dependencies = [ "outref", - "uuid", "vsimd", ] [[package]] name = "v_frame" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" dependencies = [ "aligned-vec", "num-traits", @@ -17441,10 +18095,9 @@ name = "vercel" version = "0.1.0" dependencies = [ "anyhow", - "schemars 1.0.1", + "schemars", "serde", - "strum 0.27.1", - "workspace-hack", + "strum 0.27.2", ] [[package]] @@ -17467,12 +18120,14 @@ dependencies = [ "assets", "async-compat", "async-trait", + "collections", "command_palette", "command_palette_hooks", "db", "editor", "env_logger 0.11.8", "futures 0.3.31", + "fuzzy", "git_ui", "gpui", "indoc", @@ -17480,32 +18135,34 @@ dependencies = [ "language", "log", "lsp", + "markdown_preview", "menu", "multi_buffer", "nvim-rs", + "outline_panel", "parking_lot", + "perf", "picker", "project", "project_panel", "regex", "release_channel", - "schemars 1.0.1", + "schemars", "search", + "semver", "serde", "serde_json", "settings", + "settings_ui", "task", "text", "theme", "tokio", "ui", + "util", + "util_macros", "vim_mode_setting", "workspace", - "workspace-hack", - "zed-collections", - "zed-perf", - "zed-util", - "zed-util-macros", "zed_actions", ] @@ -17513,9 +18170,7 @@ dependencies = [ name = "vim_mode_setting" version = "0.1.0" dependencies = [ - "gpui", "settings", - "workspace-hack", ] [[package]] @@ -17560,7 +18215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags 2.9.0", + "bitflags 2.9.4", "cursor-icon", "log", "memchr", @@ -17622,17 +18277,17 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt 0.39.0", + "wit-bindgen 0.46.0", ] [[package]] @@ -17643,35 +18298,36 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -17682,9 +18338,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -17692,22 +18348,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -17748,7 +18404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fd83062c17b9f4985d438603cde0a5e8c5c8198201a6937f778b607924c7da2" dependencies = [ "anyhow", - "indexmap 2.9.0", + "indexmap", "serde", "serde_derive", "serde_json", @@ -17766,7 +18422,7 @@ dependencies = [ "anyhow", "auditable-serde", "flate2", - "indexmap 2.9.0", + "indexmap", "serde", "serde_derive", "serde_json", @@ -17795,8 +18451,8 @@ version = "0.201.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" dependencies = [ - "bitflags 2.9.0", - "indexmap 2.9.0", + "bitflags 2.9.4", + "indexmap", "semver", ] @@ -17806,9 +18462,9 @@ version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ - "bitflags 2.9.0", - "hashbrown 0.15.3", - "indexmap 2.9.0", + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap", "semver", "serde", ] @@ -17819,9 +18475,9 @@ version = "0.227.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" dependencies = [ - "bitflags 2.9.0", - "hashbrown 0.15.3", - "indexmap 2.9.0", + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap", "semver", ] @@ -17844,18 +18500,18 @@ checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" dependencies = [ "anyhow", "async-trait", - "bitflags 2.9.0", + "bitflags 2.9.4", "bumpalo", "cc", "cfg-if", "encoding_rs", "hashbrown 0.14.5", - "indexmap 2.9.0", + "indexmap", "libc", "log", - "mach2 0.4.2", + "mach2 0.4.3", "memfd", - "object", + "object 0.36.7", "once_cell", "paste", "postcard", @@ -17868,7 +18524,7 @@ dependencies = [ "serde_derive", "smallvec", "sptr", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", "trait-variant", "wasmparser 0.221.3", "wasmtime-asm-macros", @@ -17926,7 +18582,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wasmtime-component-util", "wasmtime-wit-bindgen", "wit-parser 0.221.3", @@ -17951,12 +18607,12 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", + "gimli 0.31.1", "itertools 0.12.1", "log", - "object", + "object 0.36.7", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", "thiserror 1.0.69", "wasmparser 0.221.3", "wasmtime-environ", @@ -17973,17 +18629,17 @@ dependencies = [ "cpp_demangle", "cranelift-bitset", "cranelift-entity", - "gimli", - "indexmap 2.9.0", + "gimli 0.31.1", + "indexmap", "log", - "object", + "object 0.36.7", "postcard", "rustc-demangle", "semver", "serde", "serde_derive", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", "wasm-encoder 0.221.3", "wasmparser 0.221.3", "wasmprinter", @@ -18040,7 +18696,7 @@ checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -18051,7 +18707,7 @@ checksum = "8d1be69bfcab1bdac74daa7a1f9695ab992b9c8e21b9b061e7d66434097e0ca4" dependencies = [ "anyhow", "async-trait", - "bitflags 2.9.0", + "bitflags 2.9.4", "bytes 1.10.1", "cap-fs-ext", "cap-net-ext", @@ -18082,9 +18738,9 @@ checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f" dependencies = [ "anyhow", "cranelift-codegen", - "gimli", - "object", - "target-lexicon 0.13.2", + "gimli 0.31.1", + "object 0.36.7", + "target-lexicon 0.13.3", "wasmparser 0.221.3", "wasmtime-cranelift", "wasmtime-environ", @@ -18099,7 +18755,7 @@ checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.9.0", + "indexmap", "wit-parser 0.221.3", ] @@ -18120,20 +18776,34 @@ dependencies = [ "futures 0.3.31", "gpui", "parking_lot", - "rand 0.9.1", - "workspace-hack", + "rand 0.9.2", "zlog", ] [[package]] -name = "wayland-backend" -version = "0.3.8" +name = "wax" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7208998eaa3870dad37ec8836979581506e0c5c64c20c9e79e9d2a10d6f47bf" +checksum = "8d12a78aa0bab22d2f26ed1a96df7ab58e8a93506a3e20adb47c51a93b4e1357" +dependencies = [ + "const_format", + "itertools 0.11.0", + "nom 7.1.3", + "pori", + "regex", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.44", + "rustix 1.1.2", "scoped-tls", "smallvec", "wayland-sys", @@ -18141,23 +18811,23 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2120de3d33638aaef5b9f4472bff75f07c56379cf76ea320bd3a3d65ecaf73f" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.0", - "rustix 0.38.44", + "bitflags 2.9.4", + "rustix 1.1.2", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-cursor" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93029cbb6650748881a00e4922b076092a6a08c11e7fbdb923f064b23968c5d" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 0.38.44", + "rustix 1.1.2", "wayland-client", "xcursor", ] @@ -18168,7 +18838,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-scanner", @@ -18176,11 +18846,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.6" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0781cf46869b37e36928f7b432273c0995aa8aed9552c556fb18754420541efc" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-scanner", @@ -18192,7 +18862,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-protocols 0.31.2", @@ -18200,21 +18870,34 @@ dependencies = [ ] [[package]] -name = "wayland-scanner" -version = "0.31.6" +name = "wayland-protocols-wlr" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.9", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", - "quick-xml 0.37.4", + "quick-xml 0.37.5", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "dlib", "log", @@ -18224,9 +18907,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -18244,11 +18927,11 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.1.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ - "phf", + "phf 0.11.3", "phf_codegen", "string_cache", "string_cache_codegen", @@ -18260,10 +18943,9 @@ version = "0.1.0" dependencies = [ "anyhow", "cloud_llm_client", + "collections", "gpui", "serde", - "workspace-hack", - "zed-collections", ] [[package]] @@ -18275,12 +18957,11 @@ dependencies = [ "cloud_llm_client", "futures 0.3.31", "gpui", + "http_client", "language_model", "serde", "serde_json", "web_search", - "workspace-hack", - "zed-http-client", ] [[package]] @@ -18329,9 +19010,9 @@ dependencies = [ [[package]] name = "weezl" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "which" @@ -18359,11 +19040,11 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "redox_syscall 0.5.11", + "libredox", "wasite", ] @@ -18375,7 +19056,7 @@ checksum = "4b9af35bc9629c52c261465320a9a07959164928b4241980ba1cf923b9e6751d" dependencies = [ "anyhow", "async-trait", - "bitflags 2.9.0", + "bitflags 2.9.4", "thiserror 1.0.69", "tracing", "wasmtime", @@ -18393,7 +19074,7 @@ dependencies = [ "proc-macro2", "quote", "shellexpand 2.1.2", - "syn 2.0.101", + "syn 2.0.106", "witx", ] @@ -18405,7 +19086,7 @@ checksum = "08c5c473d4198e6c2d377f3809f713ff0c110cab88a0805ae099a82119ee250c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wiggle-generate", ] @@ -18427,11 +19108,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -18448,10 +19129,10 @@ checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c" dependencies = [ "anyhow", "cranelift-codegen", - "gimli", + "gimli 0.31.1", "regalloc2", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", "thiserror 1.0.69", "wasmparser 0.221.3", "wasmtime-cranelift", @@ -18490,14 +19171,14 @@ dependencies = [ [[package]] name = "windows" -version = "0.61.1" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.0", + "windows-core 0.61.2", "windows-future", - "windows-link 0.1.1", + "windows-link 0.1.3", "windows-numerics", ] @@ -18510,8 +19191,8 @@ dependencies = [ "ctrlc", "parking_lot", "rayon", - "thiserror 2.0.12", - "windows 0.61.1", + "thiserror 2.0.17", + "windows 0.61.3", "windows-future", ] @@ -18521,7 +19202,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.0", + "windows-core 0.61.2", ] [[package]] @@ -18561,25 +19242,39 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", - "windows-link 0.1.1", - "windows-result 0.3.2", - "windows-strings 0.4.0", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] name = "windows-future" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.0", - "windows-link 0.1.1", + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -18590,7 +19285,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -18601,18 +19296,18 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -18623,7 +19318,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -18634,31 +19329,31 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" @@ -18666,8 +19361,8 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.0", - "windows-link 0.1.1", + "windows-core 0.61.2", + "windows-link 0.1.3", ] [[package]] @@ -18676,31 +19371,31 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" dependencies = [ - "windows-result 0.3.2", + "windows-result 0.3.4", "windows-strings 0.3.1", - "windows-targets 0.53.2", + "windows-targets 0.53.5", ] [[package]] name = "windows-registry" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link 0.1.1", - "windows-result 0.3.2", - "windows-strings 0.4.0", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] name = "windows-registry" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f91f87ce112ffb7275000ea98eb1940912c21c1567c9312fde20261f3eadd29" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.2.0", - "windows-result 0.4.0", - "windows-strings 0.5.0", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -18723,20 +19418,20 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link 0.1.1", + "windows-link 0.1.3", ] [[package]] name = "windows-result" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -18755,25 +19450,25 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-link 0.1.1", + "windows-link 0.1.3", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link 0.1.1", + "windows-link 0.1.3", ] [[package]] name = "windows-strings" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -18818,16 +19513,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.5", ] [[package]] name = "windows-sys" -version = "0.61.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -18878,18 +19573,28 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -18912,9 +19617,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -18936,9 +19641,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -18960,9 +19665,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -18972,9 +19677,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -18996,9 +19701,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -19020,9 +19725,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -19044,9 +19749,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -19068,15 +19773,15 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.6" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -19100,16 +19805,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" @@ -19122,11 +19817,11 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.20" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4a67c78ee5782c0c1cb41bebc7e12c6e79644daa1650ebbc1de5d5b08593f7" +checksum = "edcacf11b6f48dd21b9ba002f991bdd5de29b2da8cc2800412f4b80f677e4957" dependencies = [ - "toml 0.8.20", + "toml 0.8.23", "version_check", ] @@ -19142,7 +19837,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "windows-sys 0.59.0", ] @@ -19161,7 +19856,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "288f992ea30e6b5c531b52cdd5f3be81c148554b09ea416f058d16556ba92c27" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "wit-bindgen-rt 0.22.0", "wit-bindgen-rust-macro 0.22.0", ] @@ -19176,6 +19871,12 @@ dependencies = [ "wit-bindgen-rust-macro 0.41.0", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wit-bindgen-core" version = "0.22.0" @@ -19203,22 +19904,13 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcb8738270f32a2d6739973cbbb7c1b6dd8959ce515578a6e19165853272ee64" -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.0", -] - [[package]] name = "wit-bindgen-rt" version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "futures 0.3.31", "once_cell", ] @@ -19231,7 +19923,7 @@ checksum = "d8a39a15d1ae2077688213611209849cad40e9e5cccf6e61951a425850677ff3" dependencies = [ "anyhow", "heck 0.4.1", - "indexmap 2.9.0", + "indexmap", "wasm-metadata 0.201.0", "wit-bindgen-core 0.22.0", "wit-component 0.201.0", @@ -19245,9 +19937,9 @@ checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.9.0", + "indexmap", "prettyplease", - "syn 2.0.101", + "syn 2.0.106", "wasm-metadata 0.227.1", "wit-bindgen-core 0.41.0", "wit-component 0.227.1", @@ -19262,7 +19954,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wit-bindgen-core 0.22.0", "wit-bindgen-rust 0.22.0", ] @@ -19277,7 +19969,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wit-bindgen-core 0.41.0", "wit-bindgen-rust 0.41.0", ] @@ -19289,8 +19981,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" dependencies = [ "anyhow", - "bitflags 2.9.0", - "indexmap 2.9.0", + "bitflags 2.9.4", + "indexmap", "log", "serde", "serde_derive", @@ -19308,8 +20000,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" dependencies = [ "anyhow", - "bitflags 2.9.0", - "indexmap 2.9.0", + "bitflags 2.9.4", + "indexmap", "log", "serde", "serde_derive", @@ -19328,7 +20020,7 @@ checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" dependencies = [ "anyhow", "id-arena", - "indexmap 2.9.0", + "indexmap", "log", "semver", "serde", @@ -19346,7 +20038,7 @@ checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" dependencies = [ "anyhow", "id-arena", - "indexmap 2.9.0", + "indexmap", "log", "semver", "serde", @@ -19364,7 +20056,7 @@ checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", - "indexmap 2.9.0", + "indexmap", "log", "semver", "serde", @@ -19396,12 +20088,15 @@ dependencies = [ "call", "client", "clock", + "collections", "component", "dap", "db", + "feature_flags", "fs", "futures 0.3.31", "gpui", + "http_client", "itertools 0.14.0", "language", "log", @@ -19412,237 +20107,41 @@ dependencies = [ "pretty_assertions", "project", "remote", - "schemars 1.0.1", + "schemars", "serde", "serde_json", "session", "settings", "smallvec", "sqlez", - "strum 0.27.1", + "strum 0.27.2", "task", "telemetry", "tempfile", "theme", "ui", + "util", "uuid", - "windows 0.61.1", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-util", + "windows 0.61.3", "zed_actions", "zlog", ] -[[package]] -name = "workspace-hack" -version = "0.1.0" -dependencies = [ - "aes", - "ahash 0.8.11", - "aho-corasick", - "anstream", - "arrayvec", - "ashpd 0.11.0", - "async-compression", - "async-std", - "async-tungstenite", - "aws-config", - "aws-credential-types", - "aws-runtime", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "base64 0.22.1", - "base64ct", - "bigdecimal", - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.9.0", - "bstr", - "bytemuck", - "byteorder", - "bytes 1.10.1", - "cc", - "chrono", - "cipher", - "clap", - "clap_builder", - "codespan-reporting", - "concurrent-queue", - "core-foundation 0.9.4", - "core-foundation-sys", - "cranelift-codegen", - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "crypto-common", - "deranged", - "digest", - "either", - "euclid", - "event-listener 5.4.0", - "event-listener-strategy", - "flate2", - "flume", - "foldhash", - "form_urlencoded", - "futures 0.3.31", - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", - "getrandom 0.2.15", - "getrandom 0.3.2", - "gimli", - "half", - "handlebars 4.5.0", - "hashbrown 0.14.5", - "hashbrown 0.15.3", - "heck 0.4.1", - "hmac", - "hyper 0.14.32", - "hyper-rustls 0.27.5", - "idna", - "indexmap 2.9.0", - "inout", - "itertools 0.12.1", - "itertools 0.13.0", - "lazy_static", - "libc", - "libsqlite3-sys", - "linux-raw-sys 0.4.15", - "linux-raw-sys 0.9.4", - "livekit-runtime", - "log", - "lyon", - "lyon_path", - "md-5", - "memchr", - "memmap2", - "mime_guess", - "miniz_oxide", - "mio 1.0.3", - "naga", - "nix 0.28.0", - "nix 0.29.0", - "nix 0.30.1", - "nom 7.1.3", - "num-bigint", - "num-bigint-dig", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "object", - "once_cell", - "percent-encoding", - "phf", - "phf_shared", - "prettyplease", - "proc-macro2", - "prost 0.12.6", - "prost 0.9.0", - "prost-types 0.9.0", - "quote", - "rand 0.8.5", - "rand 0.9.1", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "rand_distr", - "regalloc2", - "regex", - "regex-automata", - "regex-syntax", - "ring", - "rust_decimal", - "rustc-hash 1.1.0", - "rustix 0.38.44", - "rustix 1.0.7", - "rustls 0.23.26", - "rustls-webpki 0.103.1", - "scopeguard", - "sea-orm", - "sea-query-binder", - "security-framework 3.2.0", - "security-framework-sys", - "semver", - "serde", - "serde_core", - "serde_json", - "simd-adler32", - "smallvec", - "spin", - "sqlx", - "sqlx-macros", - "sqlx-macros-core", - "sqlx-postgres", - "sqlx-sqlite", - "stable_deref_trait", - "strum 0.26.3", - "subtle", - "syn 1.0.109", - "syn 2.0.101", - "sync_wrapper 1.0.2", - "thiserror 2.0.12", - "time", - "time-macros", - "tokio", - "tokio-rustls 0.26.2", - "tokio-socks", - "tokio-stream", - "tokio-util", - "toml_datetime", - "toml_edit", - "tower 0.5.2", - "tracing", - "tracing-core", - "tungstenite 0.26.2", - "unicode-properties", - "url", - "uuid", - "wasmparser 0.221.3", - "wasmtime", - "wasmtime-cranelift", - "wasmtime-environ", - "wayland-backend", - "wayland-sys", - "winapi", - "windows-core 0.61.0", - "windows-numerics", - "windows-sys 0.48.0", - "windows-sys 0.52.0", - "windows-sys 0.59.0", - "windows-sys 0.61.0", - "zbus_macros", - "zeroize", - "zvariant", -] - [[package]] name = "worktree" version = "0.1.0" dependencies = [ "anyhow", + "async-lock 2.8.0", "clock", + "collections", "fs", "futures 0.3.31", "fuzzy", "git", "git2", "gpui", + "http_client", "ignore", "language", "log", @@ -19650,33 +20149,24 @@ dependencies = [ "paths", "postage", "pretty_assertions", - "rand 0.9.1", + "rand 0.9.2", "rpc", "serde", "serde_json", "settings", "smallvec", "smol", + "sum_tree", "text", - "workspace-hack", - "zed-collections", - "zed-http-client", - "zed-sum-tree", - "zed-util", + "util", "zlog", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "wyz" @@ -19709,32 +20199,32 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "rustix 0.38.44", + "rustix 1.1.2", "x11rb-protocol", + "xcursor", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "x_ai" version = "0.1.0" dependencies = [ "anyhow", - "schemars 1.0.1", + "schemars", "serde", - "strum 0.27.1", - "workspace-hack", + "strum 0.27.2", ] [[package]] @@ -19760,9 +20250,9 @@ dependencies = [ [[package]] name = "xcursor" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" [[package]] name = "xim-ctext" @@ -19777,7 +20267,7 @@ name = "xim-parser" version = "0.2.1" source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -19826,13 +20316,17 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "backtrace", "cargo_metadata", "cargo_toml", "clap", + "gh-workflow", + "indexmap", "indoc", - "toml 0.8.20", - "toml_edit", - "workspace-hack", + "serde", + "serde_json", + "toml 0.8.23", + "toml_edit 0.22.27", ] [[package]] @@ -19863,7 +20357,7 @@ dependencies = [ "flate2", "futures 0.3.31", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "js-sys", "nom 8.0.0", @@ -19906,7 +20400,19 @@ checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", - "yoke-derive", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.8.0", "zerofrom", ] @@ -19918,29 +20424,41 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", "synstructure", ] [[package]] name = "zbus" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", "async-executor", "async-io", - "async-lock", + "async-lock 3.4.1", "async-process", "async-recursion", "async-task", "async-trait", "blocking", "enumflags2", - "event-listener 5.4.0", + "event-listener 5.4.1", "futures-core", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "hex", "nix 0.30.1", "ordered-stream", @@ -19948,7 +20466,8 @@ dependencies = [ "serde_repr", "tracing", "uds_windows", - "windows-sys 0.60.2", + "uuid", + "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", @@ -19957,14 +20476,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "zbus_names", "zvariant", "zvariant_utils", @@ -19984,30 +20503,31 @@ dependencies = [ [[package]] name = "zed" -version = "0.208.0" +version = "0.218.0" dependencies = [ "acp_tools", "activity_indicator", - "agent", "agent_settings", "agent_ui", + "agent_ui_v2", "anyhow", "ashpd 0.11.0", "askpass", "assets", - "assistant_tools", "audio", "auto_update", "auto_update_ui", - "backtrace", "bincode", "breadcrumbs", "call", "channel", + "chrono", "clap", "cli", "client", + "codestral", "collab_ui", + "collections", "command_palette", "component", "copilot", @@ -20019,7 +20539,8 @@ dependencies = [ "debugger_tools", "debugger_ui", "diagnostics", - "edit_prediction_button", + "edit_prediction", + "edit_prediction_ui", "editor", "env_logger 0.11.8", "extension", @@ -20036,6 +20557,7 @@ dependencies = [ "go_to_line", "gpui", "gpui_tokio", + "http_client", "image_viewer", "inspector_ui", "install_cli", @@ -20058,8 +20580,8 @@ dependencies = [ "menu", "migrator", "mimalloc", + "miniprofiler_ui", "nc", - "nix 0.29.0", "node_runtime", "notifications", "onboarding", @@ -20075,6 +20597,7 @@ dependencies = [ "project_symbols", "prompt_store", "proto", + "rayon", "recent_projects", "release_channel", "remote", @@ -20082,6 +20605,7 @@ dependencies = [ "reqwest_client", "rope", "search", + "semver", "serde", "serde_json", "session", @@ -20094,13 +20618,12 @@ dependencies = [ "snippets_ui", "supermaven", "svg_preview", - "sysinfo", + "sysinfo 0.37.2", "system_specs", "tab_switcher", "task", "tasks_ui", "telemetry", - "telemetry_events", "terminal_view", "theme", "theme_extension", @@ -20108,6 +20631,7 @@ dependencies = [ "time", "title_bar", "toolchain_selector", + "tracing", "tree-sitter-md", "tree-sitter-rust", "ui", @@ -20115,46 +20639,22 @@ dependencies = [ "ui_prompt", "url", "urlencoding", + "util", "uuid", "vim", "vim_mode_setting", "watch", "web_search", "web_search_providers", - "windows 0.61.1", + "windows 0.61.3", "winresource", "workspace", - "workspace-hack", - "zed-collections", - "zed-http-client", "zed-reqwest", - "zed-util", "zed_actions", "zed_env_vars", - "zeta", - "zeta2", - "zeta2_tools", "zlog", "zlog_settings", -] - -[[package]] -name = "zed-collections" -version = "0.1.0" -dependencies = [ - "indexmap 2.9.0", - "rustc-hash 2.1.1", - "workspace-hack", -] - -[[package]] -name = "zed-derive-refineable" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", - "workspace-hack", + "ztracing", ] [[package]] @@ -20162,7 +20662,7 @@ name = "zed-font-kit" version = "0.14.1-zed" source = "git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5#110523127440aefb11ce0cf280ae7c5071337ec5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "core-foundation 0.10.0", "core-graphics 0.24.0", @@ -20181,64 +20681,6 @@ dependencies = [ "yeslogic-fontconfig-sys", ] -[[package]] -name = "zed-http-client" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-compression", - "async-fs", - "async-tar", - "bytes 1.10.1", - "derive_more", - "futures 0.3.31", - "http 1.3.1", - "http-body 1.0.1", - "log", - "parking_lot", - "serde", - "serde_json", - "sha2", - "tempfile", - "url", - "workspace-hack", - "zed-reqwest", - "zed-util", -] - -[[package]] -name = "zed-media" -version = "0.1.0" -dependencies = [ - "anyhow", - "bindgen 0.71.1", - "core-foundation 0.10.0", - "core-video", - "ctor", - "foreign-types 0.5.0", - "metal", - "objc", - "workspace-hack", -] - -[[package]] -name = "zed-perf" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "workspace-hack", - "zed-collections", -] - -[[package]] -name = "zed-refineable" -version = "0.1.0" -dependencies = [ - "workspace-hack", - "zed-derive-refineable", -] - [[package]] name = "zed-reqwest" version = "0.12.15-zed" @@ -20249,12 +20691,12 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.9", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", - "hyper-rustls 0.27.5", + "hyper 1.7.0", + "hyper-rustls 0.27.7", "hyper-util", "ipnet", "js-sys", @@ -20265,8 +20707,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.26", - "rustls-native-certs 0.8.1", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", @@ -20301,92 +20743,20 @@ dependencies = [ "rand 0.8.5", "screencapturekit", "screencapturekit-sys", - "sysinfo", + "sysinfo 0.31.4", "tao-core-video-sys", - "windows 0.61.1", + "windows 0.61.3", "windows-capture", "x11", "xcb", ] -[[package]] -name = "zed-semantic-version" -version = "0.1.0" -dependencies = [ - "anyhow", - "serde", - "workspace-hack", -] - -[[package]] -name = "zed-sum-tree" -version = "0.1.0" -dependencies = [ - "arrayvec", - "ctor", - "log", - "rand 0.9.1", - "rayon", - "workspace-hack", - "zlog", -] - -[[package]] -name = "zed-util" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-fs", - "async_zip", - "command-fds", - "dirs 4.0.0", - "dunce", - "futures 0.3.31", - "futures-lite 1.13.0", - "git2", - "globset", - "indoc", - "itertools 0.14.0", - "libc", - "log", - "nix 0.29.0", - "pretty_assertions", - "rand 0.9.1", - "regex", - "rust-embed", - "schemars 1.0.1", - "serde", - "serde_json", - "serde_json_lenient", - "shlex", - "smol", - "take-until", - "tempfile", - "tendril", - "unicase", - "walkdir", - "which 6.0.3", - "workspace-hack", - "zed-collections", - "zed-util-macros", -] - -[[package]] -name = "zed-util-macros" -version = "0.1.0" -dependencies = [ - "quote", - "syn 2.0.101", - "workspace-hack", - "zed-perf", -] - [[package]] name = "zed-xim" version = "0.4.0-zed" source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "hashbrown 0.14.5", "log", "x11rb", @@ -20399,10 +20769,9 @@ name = "zed_actions" version = "0.1.0" dependencies = [ "gpui", - "schemars 1.0.1", + "schemars", "serde", "uuid", - "workspace-hack", ] [[package]] @@ -20410,7 +20779,6 @@ name = "zed_env_vars" version = "0.1.0" dependencies = [ "gpui", - "workspace-hack", ] [[package]] @@ -20427,6 +20795,8 @@ dependencies = [ [[package]] name = "zed_extension_api" version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" dependencies = [ "serde", "serde_json", @@ -20435,9 +20805,7 @@ dependencies = [ [[package]] name = "zed_extension_api" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" +version = "0.8.0" dependencies = [ "serde", "serde_json", @@ -20453,69 +20821,49 @@ dependencies = [ [[package]] name = "zed_html" -version = "0.2.3" +version = "0.3.0" dependencies = [ - "zed_extension_api 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "zed_extension_api 0.7.0", ] [[package]] name = "zed_proto" -version = "0.2.2" +version = "0.3.0" dependencies = [ - "zed_extension_api 0.1.0", + "zed_extension_api 0.7.0", ] [[package]] name = "zed_test_extension" version = "0.1.0" dependencies = [ - "zed_extension_api 0.7.0", + "zed_extension_api 0.8.0", ] [[package]] name = "zeno" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0de2315dc13d00e5df3cd6b8d2124a6eaec6a2d4b6a1c5f37b7efad17fcc17" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive 0.8.24", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -20535,15 +20883,15 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] @@ -20556,7 +20904,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -20584,190 +20932,43 @@ dependencies = [ ] [[package]] -name = "zerovec" -version = "0.10.4" +name = "zerotrie" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" dependencies = [ - "yoke", + "displaydoc", + "yoke 0.8.0", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke 0.8.0", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] -name = "zeta" +name = "zeta_prompt" version = "0.1.0" dependencies = [ - "ai_onboarding", - "anyhow", - "arrayvec", - "call", - "client", - "clock", - "cloud_api_types", - "cloud_llm_client", - "command_palette_hooks", - "copilot", - "ctor", - "db", - "edit_prediction", - "editor", - "feature_flags", - "fs", - "futures 0.3.31", - "gpui", - "indoc", - "itertools 0.14.0", - "language", - "language_model", - "log", - "menu", - "parking_lot", - "postage", - "project", - "rand 0.9.1", - "regex", - "release_channel", - "reqwest_client", - "rpc", "serde", - "serde_json", - "settings", - "strum 0.27.1", - "telemetry", - "telemetry_events", - "theme", - "thiserror 2.0.12", - "tree-sitter-go", - "tree-sitter-rust", - "ui", - "uuid", - "workspace", - "workspace-hack", - "worktree", - "zed-collections", - "zed-http-client", - "zed-util", - "zed_actions", - "zlog", -] - -[[package]] -name = "zeta2" -version = "0.1.0" -dependencies = [ - "anyhow", - "arrayvec", - "chrono", - "client", - "clock", - "cloud_llm_client", - "cloud_zeta2_prompt", - "edit_prediction", - "edit_prediction_context", - "futures 0.3.31", - "gpui", - "indoc", - "language", - "language_model", - "log", - "lsp", - "pretty_assertions", - "project", - "release_channel", - "serde_json", - "settings", - "thiserror 2.0.12", - "uuid", - "workspace", - "workspace-hack", - "worktree", - "zed-util", -] - -[[package]] -name = "zeta2_tools" -version = "0.1.0" -dependencies = [ - "chrono", - "clap", - "client", - "cloud_llm_client", - "edit_prediction_context", - "editor", - "futures 0.3.31", - "gpui", - "indoc", - "language", - "log", - "pretty_assertions", - "project", - "serde", - "serde_json", - "settings", - "text", - "ui", - "ui_input", - "workspace", - "workspace-hack", - "zed-collections", - "zed-util", - "zeta2", - "zlog", -] - -[[package]] -name = "zeta_cli" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "client", - "cloud_llm_client", - "cloud_zeta2_prompt", - "debug_adapter_extension", - "edit_prediction_context", - "extension", - "fs", - "futures 0.3.31", - "gpui", - "gpui_tokio", - "language", - "language_extension", - "language_model", - "language_models", - "languages", - "log", - "node_runtime", - "ordered-float 2.10.1", - "paths", - "project", - "prompt_store", - "release_channel", - "reqwest_client", - "serde", - "serde_json", - "settings", - "shellexpand 2.1.2", - "smol", - "terminal_view", - "watch", - "workspace-hack", - "zed-util", - "zeta", - "zeta2", - "zlog", ] [[package]] @@ -20800,7 +21001,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "displaydoc", - "indexmap 2.9.0", + "indexmap", "num_enum", "thiserror 1.0.69", ] @@ -20811,20 +21012,18 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "collections", "log", "tempfile", - "workspace-hack", - "zed-collections", ] [[package]] name = "zlog_settings" version = "0.1.0" dependencies = [ + "collections", "gpui", "settings", - "workspace-hack", - "zed-collections", "zlog", ] @@ -20849,14 +21048,29 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", ] +[[package]] +name = "ztracing" +version = "0.1.0" +dependencies = [ + "tracing", + "tracing-subscriber", + "tracing-tracy", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" + [[package]] name = "zune-core" version = "0.4.12" @@ -20874,18 +21088,18 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.4.14" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] [[package]] name = "zvariant" -version = "5.7.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" dependencies = [ "endi", "enumflags2", @@ -20898,27 +21112,26 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.7.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" dependencies = [ "proc-macro2", "quote", "serde", - "static_assertions", - "syn 2.0.101", + "syn 2.0.106", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index da7a892515..903d17fc33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,19 +6,17 @@ members = [ "crates/action_log", "crates/activity_indicator", "crates/agent", - "crates/agent2", "crates/agent_servers", "crates/agent_settings", "crates/agent_ui", + "crates/agent_ui_v2", "crates/ai_onboarding", "crates/anthropic", "crates/askpass", "crates/assets", - "crates/assistant_context", + "crates/assistant_text_thread", "crates/assistant_slash_command", "crates/assistant_slash_commands", - "crates/assistant_tool", - "crates/assistant_tools", "crates/audio", "crates/auto_update", "crates/auto_update_helper", @@ -35,7 +33,6 @@ members = [ "crates/cloud_api_client", "crates/cloud_api_types", "crates/cloud_llm_client", - "crates/cloud_zeta2_prompt", "crates/collab", "crates/collab_ui", "crates/collections", @@ -57,11 +54,12 @@ members = [ "crates/diagnostics", "crates/docs_preprocessor", "crates/edit_prediction", - "crates/edit_prediction_button", + "crates/edit_prediction_types", + "crates/edit_prediction_ui", "crates/edit_prediction_context", - "crates/zeta2_tools", "crates/editor", "crates/eval", + "crates/eval_utils", "crates/explorer_command_injector", "crates/extension", "crates/extension_api", @@ -73,6 +71,7 @@ members = [ "crates/file_finder", "crates/file_icons", "crates/fs", + "crates/fs_benchmarks", "crates/fsevent", "crates/fuzzy", "crates/git", @@ -112,6 +111,7 @@ members = [ "crates/menu", "crates/migrator", "crates/mistral", + "crates/miniprofiler_ui", "crates/multi_buffer", "crates/nc", "crates/net", @@ -128,6 +128,7 @@ members = [ "crates/picker", "crates/prettier", "crates/project", + "crates/project_benchmarks", "crates/project_panel", "crates/project_symbols", "crates/prompt_store", @@ -147,9 +148,9 @@ members = [ "crates/rules_library", "crates/schema_generator", "crates/search", - "crates/semantic_version", "crates/session", "crates/settings", + "crates/settings_json", "crates/settings_macros", "crates/settings_profile_selector", "crates/settings_ui", @@ -164,6 +165,7 @@ members = [ "crates/sum_tree", "crates/supermaven", "crates/supermaven_api", + "crates/codestral", "crates/svg_preview", "crates/system_specs", "crates/tab_switcher", @@ -199,11 +201,12 @@ members = [ "crates/zed", "crates/zed_actions", "crates/zed_env_vars", - "crates/zeta", - "crates/zeta2", - "crates/zeta_cli", + "crates/edit_prediction_cli", + "crates/zeta_prompt", "crates/zlog", "crates/zlog_settings", + "crates/ztracing", + "crates/ztracing_macro", # # Extensions @@ -220,7 +223,6 @@ members = [ # "tooling/perf", - "tooling/workspace-hack", "tooling/xtask", ] default-members = ["crates/zed"] @@ -239,24 +241,20 @@ acp_tools = { path = "crates/acp_tools" } acp_thread = { path = "crates/acp_thread" } action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } -agent2 = { path = "crates/agent2" } activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } +agent_ui_v2 = { path = "crates/agent_ui_v2" } agent_settings = { path = "crates/agent_settings" } agent_servers = { path = "crates/agent_servers" } -ai = { path = "crates/ai" } ai_onboarding = { path = "crates/ai_onboarding" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } assets = { path = "crates/assets" } -assistant_context = { path = "crates/assistant_context" } +assistant_text_thread = { path = "crates/assistant_text_thread" } assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_slash_commands = { path = "crates/assistant_slash_commands" } -assistant_tool = { path = "crates/assistant_tool" } -assistant_tools = { path = "crates/assistant_tools" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } -auto_update_helper = { path = "crates/auto_update_helper" } auto_update_ui = { path = "crates/auto_update_ui" } aws_http_client = { path = "crates/aws_http_client" } bedrock = { path = "crates/bedrock" } @@ -270,10 +268,8 @@ clock = { path = "crates/clock" } cloud_api_client = { path = "crates/cloud_api_client" } cloud_api_types = { path = "crates/cloud_api_types" } cloud_llm_client = { path = "crates/cloud_llm_client" } -cloud_zeta2_prompt = { path = "crates/cloud_zeta2_prompt" } -collab = { path = "crates/collab" } collab_ui = { path = "crates/collab_ui" } -collections = { path = "crates/collections", package = "zed-collections", version = "0.1.0" } +collections = { path = "crates/collections", version = "0.1.0" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } component = { path = "crates/component" } @@ -289,9 +285,10 @@ debug_adapter_extension = { path = "crates/debug_adapter_extension" } debugger_tools = { path = "crates/debugger_tools" } debugger_ui = { path = "crates/debugger_ui" } deepseek = { path = "crates/deepseek" } -derive_refineable = { path = "crates/refineable/derive_refineable", package = "zed-derive-refineable", version = "0.1.0" } +derive_refineable = { path = "crates/refineable/derive_refineable" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } +eval_utils = { path = "crates/eval_utils" } extension = { path = "crates/extension" } extension_host = { path = "crates/extension_host" } extensions_ui = { path = "crates/extensions_ui" } @@ -308,17 +305,16 @@ git_ui = { path = "crates/git_ui" } go_to_line = { path = "crates/go_to_line" } google_ai = { path = "crates/google_ai" } gpui = { path = "crates/gpui", default-features = false } -gpui_macros = { path = "crates/gpui_macros", package = "gpui-macros", version = "0.1.0" } +gpui_macros = { path = "crates/gpui_macros" } gpui_tokio = { path = "crates/gpui_tokio" } html_to_markdown = { path = "crates/html_to_markdown" } -http_client = { path = "crates/http_client", package = "zed-http-client", version = "0.1.0" } +http_client = { path = "crates/http_client" } http_client_tls = { path = "crates/http_client_tls" } icons = { path = "crates/icons" } image_viewer = { path = "crates/image_viewer" } -edit_prediction = { path = "crates/edit_prediction" } -edit_prediction_button = { path = "crates/edit_prediction_button" } +edit_prediction_types = { path = "crates/edit_prediction_types" } +edit_prediction_ui = { path = "crates/edit_prediction_ui" } edit_prediction_context = { path = "crates/edit_prediction_context" } -zeta2_tools = { path = "crates/zeta2_tools" } inspector_ui = { path = "crates/inspector_ui" } install_cli = { path = "crates/install_cli" } journal = { path = "crates/journal" } @@ -340,11 +336,12 @@ lsp = { path = "crates/lsp" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } svg_preview = { path = "crates/svg_preview" } -media = { path = "crates/media", package = "zed-media", version = "0.1.0" } +media = { path = "crates/media" } menu = { path = "crates/menu" } migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } +miniprofiler_ui = { path = "crates/miniprofiler_ui" } nc = { path = "crates/nc" } net = { path = "crates/net" } node_runtime = { path = "crates/node_runtime" } @@ -357,10 +354,8 @@ outline = { path = "crates/outline" } outline_panel = { path = "crates/outline_panel" } panel = { path = "crates/panel" } paths = { path = "crates/paths" } -perf = { path = "tooling/perf", package = "zed-perf", version = "0.1.0" } +perf = { path = "tooling/perf" } picker = { path = "crates/picker" } -plugin = { path = "crates/plugin" } -plugin_macros = { path = "crates/plugin_macros" } prettier = { path = "crates/prettier" } settings_profile_selector = { path = "crates/settings_profile_selector" } project = { path = "crates/project" } @@ -369,22 +364,20 @@ project_symbols = { path = "crates/project_symbols" } prompt_store = { path = "crates/prompt_store" } proto = { path = "crates/proto" } recent_projects = { path = "crates/recent_projects" } -refineable = { path = "crates/refineable", package = "zed-refineable", version = "0.1.0" } +refineable = { path = "crates/refineable" } release_channel = { path = "crates/release_channel" } -scheduler = { path = "crates/scheduler" } remote = { path = "crates/remote" } remote_server = { path = "crates/remote_server" } repl = { path = "crates/repl" } reqwest_client = { path = "crates/reqwest_client" } -rich_text = { path = "crates/rich_text" } -rodio = { git = "https://github.com/RustAudio/rodio" } +rodio = { git = "https://github.com/RustAudio/rodio", rev ="e2074c6c2acf07b57cf717e076bdda7a9ac6e70b", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } rules_library = { path = "crates/rules_library" } search = { path = "crates/search" } -semantic_version = { path = "crates/semantic_version", package = "zed-semantic-version", version = "0.1.0" } session = { path = "crates/session" } settings = { path = "crates/settings" } +settings_json = { path = "crates/settings_json" } settings_macros = { path = "crates/settings_macros" } settings_ui = { path = "crates/settings_ui" } snippet = { path = "crates/snippet" } @@ -393,11 +386,11 @@ snippets_ui = { path = "crates/snippets_ui" } sqlez = { path = "crates/sqlez" } sqlez_macros = { path = "crates/sqlez_macros" } story = { path = "crates/story" } -storybook = { path = "crates/storybook" } streaming_diff = { path = "crates/streaming_diff" } -sum_tree = { path = "crates/sum_tree", package = "zed-sum-tree", version = "0.1.0" } +sum_tree = { path = "crates/sum_tree" } supermaven = { path = "crates/supermaven" } supermaven_api = { path = "crates/supermaven_api" } +codestral = { path = "crates/codestral" } system_specs = { path = "crates/system_specs" } tab_switcher = { path = "crates/tab_switcher" } task = { path = "crates/task" } @@ -409,7 +402,6 @@ terminal_view = { path = "crates/terminal_view" } text = { path = "crates/text" } theme = { path = "crates/theme" } theme_extension = { path = "crates/theme_extension" } -theme_importer = { path = "crates/theme_importer" } theme_selector = { path = "crates/theme_selector" } time_format = { path = "crates/time_format" } title_bar = { path = "crates/title_bar" } @@ -418,8 +410,8 @@ ui = { path = "crates/ui" } ui_input = { path = "crates/ui_input" } ui_macros = { path = "crates/ui_macros" } ui_prompt = { path = "crates/ui_prompt" } -util = { path = "crates/util", package = "zed-util", version = "0.1.0" } -util_macros = { path = "crates/util_macros", package = "zed-util-macros", version = "0.1.0" } +util = { path = "crates/util" } +util_macros = { path = "crates/util_macros" } vercel = { path = "crates/vercel" } vim = { path = "crates/vim" } vim_mode_setting = { path = "crates/vim_mode_setting" } @@ -433,16 +425,18 @@ x_ai = { path = "crates/x_ai" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } zed_env_vars = { path = "crates/zed_env_vars" } -zeta = { path = "crates/zeta" } -zeta2 = { path = "crates/zeta2" } +edit_prediction = { path = "crates/edit_prediction" } +zeta_prompt = { path = "crates/zeta_prompt" } zlog = { path = "crates/zlog" } zlog_settings = { path = "crates/zlog_settings" } +ztracing = { path = "crates/ztracing" } +ztracing_macro = { path = "crates/ztracing_macro" } # # External crates # -agent-client-protocol = { version = "0.4.3", features = ["unstable"] } +agent-client-protocol = { version = "=0.9.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = "0.25.1-rc1" any_vec = "0.14" @@ -453,13 +447,14 @@ async-compat = "0.2.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-dispatcher = "0.1" async-fs = "2.1" +async-lock = "2.1" async-pipe = { git = "https://github.com/zed-industries/async-pipe-rs", rev = "82d00a04211cf4e1236029aa03e6b6ce2a74c553" } async-recursion = "1.0.0" -async-tar = "0.5.0" +async-tar = "0.5.1" async-task = "4.7" async-trait = "0.1" -async-tungstenite = "0.29.1" -async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] } +async-tungstenite = "0.31.0" +async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] } aws-config = { version = "1.6.1", features = ["behavior-version-latest"] } aws-credential-types = { version = "1.2.2", features = [ "hardcoded-credentials", @@ -476,6 +471,7 @@ bitflags = "2.6.0" blade-graphics = { version = "0.7.0" } blade-macros = { version = "0.3.0" } blade-util = { version = "0.3.0" } +brotli = "8.0.2" bytes = "1.0" cargo_metadata = "0.19" cargo_toml = "0.21" @@ -483,11 +479,11 @@ cfg-if = "1.0.3" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" circular-buffer = "1.0" -clap = { version = "4.4", features = ["derive"] } -cocoa = "0.26" -cocoa-foundation = "0.2.0" +clap = { version = "4.4", features = ["derive", "wrap_help"] } +cocoa = "=0.26.0" +cocoa-foundation = "=0.2.0" convert_case = "0.8.0" -core-foundation = "0.10.0" +core-foundation = "=0.10.0" core-foundation-sys = "0.8.6" core-video = { version = "0.4.3", features = ["metal"] } cpal = "0.16" @@ -504,15 +500,14 @@ ec4rs = "1.1" emojis = "0.6.1" env_logger = "0.11" exec = "0.3.1" -fancy-regex = "0.14.0" -fork = "0.2.0" +fancy-regex = "0.16.0" +fork = "0.4.0" futures = "0.3" -futures-batch = "0.6.1" futures-lite = "1.13" +gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "09acfdf2bd5c1d6254abefd609c808ff73547b2c" } git2 = { version = "0.20.1", default-features = false } globset = "0.4" handlebars = "4.3" -hashbrown = "0.15.3" heck = "0.5" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } hex = "0.4.3" @@ -529,15 +524,15 @@ indoc = "2" inventory = "0.3.19" itertools = "0.14.0" json_dotpath = "1.1" -jsonschema = "0.30.0" +jsonschema = "0.37.0" jsonwebtoken = "9.3" -jupyter-protocol = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" } -jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed" ,rev = "7130c804216b6914355d15d0b91ea91f6babd734" } +jupyter-protocol = "0.10.0" +jupyter-websocket-client = "0.15.0" 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 = "0874f8742fe55b4dc94308c1e3c0069710d8eeaf" } +lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "b71ab4eeb27d9758be8092020a46fe33fbca4e33" } mach2 = "0.5" markup5ever_rcdom = "0.3.0" metal = "0.29" @@ -545,12 +540,11 @@ minidumper = "0.8" moka = { version = "0.12.10", features = ["sync"] } naga = { version = "25.0", features = ["wgsl-in"] } nanoid = "0.4" -nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" } +nbformat = "0.15.0" nix = "0.29" num-format = "0.4.4" -num-traits = "0.2" objc = "0.2" -objc2-foundation = { version = "0.3", default-features = false, features = [ +objc2-foundation = { version = "=0.3.1", default-features = false, features = [ "NSArray", "NSAttributedString", "NSBundle", @@ -583,14 +577,13 @@ partial-json-fixer = "0.5.3" parse_int = "0.9" pciid-parser = "0.8.0" pathdiff = "0.2" -pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-pixi = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } +pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } portable-pty = "0.9.0" postage = { version = "0.5", features = ["futures-traits"] } pretty_assertions = { version = "1.3.0", features = ["unstable"] } @@ -603,7 +596,6 @@ pulldown-cmark = { version = "0.12.0", default-features = false } quote = "1.0.9" rand = "0.9" rayon = "1.8" -ref-cast = "1.0.24" regex = "1.5" # WARNING: If you change this, you must also publish a new version of zed-reqwest to crates.io reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662463bda39148ba154100dd44d3fba5873a4", default-features = false, features = [ @@ -616,8 +608,8 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662 "stream", ], package = "zed-reqwest", version = "0.12.15-zed" } rsa = "0.9.6" -runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [ - "async-dispatcher-runtime", +runtimelib = { version = "0.30.0", default-features = false, features = [ + "async-dispatcher-runtime", "aws-lc-rs" ] } rust-embed = { version = "8.4", features = ["include-exclude"] } rustc-hash = "2.1.0" @@ -626,7 +618,7 @@ rustls-platform-verifier = "0.5.0" # WARNING: If you change this, you must also publish a new version of zed-scap to crates.io scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } schemars = { version = "1.0", features = ["indexmap2"] } -semver = "1.0" +semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0.221", features = ["derive", "rc"] } serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } serde_json_lenient = { version = "0.2", features = [ @@ -636,42 +628,43 @@ serde_json_lenient = { version = "0.2", features = [ serde_path_to_error = "0.1.17" serde_repr = "0.1" serde_urlencoded = "0.7" -serde_with = "3.4.0" sha2 = "0.10" shellexpand = "2.1.0" shlex = "1.3.0" simplelog = "0.12.2" slotmap = "1.0.6" -smallvec = { version = "1.6", features = ["union"] } +smallvec = { version = "1.6", features = ["union", "const_new"] } smol = "2.0" sqlformat = "0.2" stacksafe = "0.1" streaming-iterator = "0.1" strsim = "0.11" -strum = { version = "0.27.0", features = ["derive"] } +strum = { version = "0.27.2", features = ["derive"] } subtle = "2.5.0" syn = { version = "2.0.101", features = ["full", "extra-traits", "visit-mut"] } sys-locale = "0.3.1" -sysinfo = "0.31.0" +sysinfo = "0.37.0" take-until = "0.2.0" tempfile = "3.20.0" thiserror = "2.0.12" -tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "30c32a4522751699adeda0d5840c71c3b75ae73d" } +tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "2570c4387a8505fb8f1d3f3557454b474f1e8271" } time = { version = "0.3", features = [ "macros", "parsing", "serde", "serde-well-known", "formatting", + "local-offset", ] } tiny_http = "0.8" tokio = { version = "1" } tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } +tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io", "tokio"] } toml = "0.8" toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } tower-http = "0.4.4" tree-sitter = { version = "0.25.10", features = ["wasm"] } -tree-sitter-bash = "0.25.0" +tree-sitter-bash = "0.25.1" tree-sitter-c = "0.23" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } tree-sitter-css = "0.23" @@ -680,7 +673,7 @@ tree-sitter-elixir = "0.3" tree-sitter-embedded-template = "0.23.0" tree-sitter-gitcommit = { git = "https://github.com/zed-industries/tree-sitter-git-commit", rev = "88309716a69dd13ab83443721ba6e0b491d37ee9" } tree-sitter-go = "0.23" -tree-sitter-go-mod = { git = "https://github.com/camdencheek/tree-sitter-go-mod", rev = "6efb59652d30e0e9cd5f3b3a669afd6f1a926d3c", package = "tree-sitter-gomod" } +tree-sitter-go-mod = { git = "https://github.com/camdencheek/tree-sitter-go-mod", rev = "2e886870578eeba1927a2dc4bd2e2b3f598c5f9a", package = "tree-sitter-gomod" } tree-sitter-gowork = { git = "https://github.com/zed-industries/tree-sitter-go-work", rev = "acb0617bf7f4fda02c6217676cc64acb89536dc7" } tree-sitter-heex = { git = "https://github.com/zed-industries/tree-sitter-heex", rev = "1dd45142fbb05562e35b2040c6129c9bca346592" } tree-sitter-html = "0.23" @@ -691,8 +684,9 @@ tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" tree-sitter-rust = "0.24" -tree-sitter-typescript = "0.23" +tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } +tracing = "0.1.40" unicase = "2.6" unicode-script = "0.5.7" unicode-segmentation = "1.10" @@ -713,14 +707,14 @@ wasmtime = { version = "29", default-features = false, features = [ "parallel-compilation", ] } wasmtime-wasi = "29" +wax = "0.6" which = "6.0.0" windows-core = "0.61" -wit-component = "0.221" -workspace-hack = "0.1.0" yawc = "0.2.5" zeroize = "1.8" zstd = "0.11" + [workspace.dependencies.windows] version = "0.61" features = [ @@ -773,15 +767,15 @@ features = [ ] [patch.crates-io] -notify = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" } -notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" } +notify = { git = "https://github.com/zed-industries/notify.git", rev = "b4588b2e5aee68f4c0e100f140e808cbce7b1419" } +notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "b4588b2e5aee68f4c0e100f140e808cbce7b1419" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } - -# Makes the workspace hack crate refer to the local one, but only when you're building locally -workspace-hack = { path = "tooling/workspace-hack" } +calloop = { git = "https://github.com/zed-industries/calloop" } [profile.dev] split-debuginfo = "unpacked" +# https://github.com/rust-lang/cargo/issues/16104 +incremental = false codegen-units = 16 # mirror configuration for crates compiled for the build platform @@ -790,28 +784,33 @@ codegen-units = 16 codegen-units = 16 [profile.dev.package] +# proc-macros start +gpui_macros = { opt-level = 3 } +derive_refineable = { opt-level = 3 } +settings_macros = { opt-level = 3 } +sqlez_macros = { opt-level = 3, codegen-units = 1 } +ui_macros = { opt-level = 3 } +util_macros = { opt-level = 3 } +quote = { opt-level = 3 } +syn = { opt-level = 3 } +proc-macro2 = { opt-level = 3 } +# proc-macros end + taffy = { opt-level = 3 } -cranelift-codegen = { opt-level = 3 } -cranelift-codegen-meta = { opt-level = 3 } -cranelift-codegen-shared = { opt-level = 3 } resvg = { opt-level = 3 } -rustybuzz = { opt-level = 3 } -ttf-parser = { opt-level = 3 } -wasmtime-cranelift = { opt-level = 3 } wasmtime = { opt-level = 3 } # Build single-source-file crates with cg=1 as it helps make `cargo build` of a whole workspace a bit faster activity_indicator = { codegen-units = 1 } assets = { codegen-units = 1 } breadcrumbs = { codegen-units = 1 } -zed-collections = { codegen-units = 1 } +collections = { codegen-units = 1 } command_palette = { codegen-units = 1 } command_palette_hooks = { codegen-units = 1 } -extension_cli = { codegen-units = 1 } feature_flags = { codegen-units = 1 } file_icons = { codegen-units = 1 } fsevent = { codegen-units = 1 } image_viewer = { codegen-units = 1 } -edit_prediction_button = { codegen-units = 1 } +edit_prediction_ui = { codegen-units = 1 } install_cli = { codegen-units = 1 } journal = { codegen-units = 1 } json_schema_store = { codegen-units = 1 } @@ -823,15 +822,12 @@ outline = { codegen-units = 1 } paths = { codegen-units = 1 } prettier = { codegen-units = 1 } project_symbols = { codegen-units = 1 } -zed-refineable = { codegen-units = 1 } +refineable = { codegen-units = 1 } release_channel = { codegen-units = 1 } reqwest_client = { codegen-units = 1 } -rich_text = { codegen-units = 1 } -zed-semantic-version = { codegen-units = 1 } session = { codegen-units = 1 } snippet = { codegen-units = 1 } snippets_ui = { codegen-units = 1 } -sqlez_macros = { codegen-units = 1 } story = { codegen-units = 1 } supermaven_api = { codegen-units = 1 } telemetry_events = { codegen-units = 1 } @@ -861,8 +857,6 @@ unexpected_cfgs = { level = "allow" } dbg_macro = "deny" todo = "deny" -# This is not a style lint, see https://github.com/rust-lang/rust-clippy/pull/15454 -# Remove when the lint gets promoted to `suspicious`. declare_interior_mutable_const = "deny" redundant_clone = "deny" @@ -907,5 +901,5 @@ ignored = [ "serde", "component", "documented", - "workspace-hack", + "sea-orm-macros", ] diff --git a/Cross.toml b/Cross.toml deleted file mode 100644 index b5f0f1103a..0000000000 --- a/Cross.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -dockerfile = "Dockerfile-cross" diff --git a/Dockerfile-collab b/Dockerfile-collab index a85fe93f19..68f898618a 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.90-bookworm as builder +FROM rust:1.91.1-bookworm as builder WORKDIR app COPY . . @@ -34,8 +34,4 @@ RUN apt-get update; \ linux-perf binutils WORKDIR app COPY --from=builder /app/collab /app/collab -COPY --from=builder /app/crates/collab/migrations /app/migrations -COPY --from=builder /app/crates/collab/migrations_llm /app/migrations_llm -ENV MIGRATIONS_PATH=/app/migrations -ENV LLM_DATABASE_MIGRATIONS_PATH=/app/migrations_llm ENTRYPOINT ["/app/collab"] diff --git a/Dockerfile-cross b/Dockerfile-cross deleted file mode 100644 index 488309641c..0000000000 --- a/Dockerfile-cross +++ /dev/null @@ -1,17 +0,0 @@ -# syntax=docker/dockerfile:1 - -ARG CROSS_BASE_IMAGE -FROM ${CROSS_BASE_IMAGE} -WORKDIR /app -ARG TZ=Etc/UTC \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 \ - DEBIAN_FRONTEND=noninteractive -ENV CARGO_TERM_COLOR=always - -COPY script/install-mold script/ -RUN ./script/install-mold "2.34.0" -COPY script/remote-server script/ -RUN ./script/remote-server - -COPY . . diff --git a/Procfile.postgrest b/Procfile.postgrest deleted file mode 100644 index acab58e086..0000000000 --- a/Procfile.postgrest +++ /dev/null @@ -1,2 +0,0 @@ -app: postgrest crates/collab/postgrest_app.conf -llm: postgrest crates/collab/postgrest_llm.conf diff --git a/Procfile.web b/Procfile.web index 8140555144..63190fc2ee 100644 --- a/Procfile.web +++ b/Procfile.web @@ -1,2 +1 @@ -postgrest_llm: postgrest crates/collab/postgrest_llm.conf website: cd ../zed.dev; npm run dev -- --port=3000 diff --git a/README.md b/README.md index 38547c1ca4..d3a5fd2052 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Zed [![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev) -[![CI](https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg)](https://github.com/zed-industries/zed/actions/workflows/ci.yml) +[![CI](https://github.com/zed-industries/zed/actions/workflows/run_tests.yml/badge.svg)](https://github.com/zed-industries/zed/actions/workflows/run_tests.yml) Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter). @@ -9,11 +9,10 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of ### Installation -On macOS and Linux you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager). +On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or install Zed via your local package manager ([macOS](https://zed.dev/docs/installation#macos)/[Linux](https://zed.dev/docs/linux#installing-via-a-package-manager)/[Windows](https://zed.dev/docs/windows#package-managers)). Other platforms are not yet available: -- Windows ([tracking issue](https://github.com/zed-industries/zed/issues/5394)) - Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396)) ### Developing Zed diff --git a/REVIEWERS.conl b/REVIEWERS.conl new file mode 100644 index 0000000000..45155ba346 --- /dev/null +++ b/REVIEWERS.conl @@ -0,0 +1,133 @@ +; This file contains a list of people who're interested in reviewing pull requests +; to certain parts of the code-base. +; +; This is mostly used internally for PR assignment, and may change over time. +; +; If you have permission to merge PRs (mostly equivalent to "do you work at Zed Industries"), +; we strongly encourage you to put your name in the "all" bucket, but you can also add yourself +; to other areas too. + + + = @cole-miller + = @ConradIrwin + = @danilo-leal + = @dinocosta + = @HactarCE + = @kubkon + = @maxdeviant + = @p1n3appl3 + = @probably-neb + = @smitbarmase + = @SomeoneToIgnore + = @Veykril + +ai + = @benbrandt + = @bennetbo + = @danilo-leal + = @rtfeldman + +audio + = @dvdsk + +crashes + = @p1n3appl3 + = @Veykril + +debugger + = @Anthony-Eid + = @kubkon + = @osiewicz + +design + = @danilo-leal + +docs + = @miguelraz + = @probably-neb + = @yeskunall + +extension + = @kubkon + +git + = @cole-miller + = @danilo-leal + = @dvdsk + = @kubkon + = @Anthony-Eid + = @cameron1024 + +gpui + = @Anthony-Eid + = @cameron1024 + = @mikayla-maki + = @probably-neb + +helix + = @kubkon + +languages + = @osiewicz + = @probably-neb + = @smitbarmase + = @SomeoneToIgnore + = @Veykril + +linux + = @cole-miller + = @dvdsk + = @p1n3appl3 + = @probably-neb + = @smitbarmase + +lsp + = @osiewicz + = @smitbarmase + = @SomeoneToIgnore + = @Veykril + +multi_buffer + = @Veykril + = @SomeoneToIgnore + +pickers + = @dvdsk + = @p1n3appl3 + = @SomeoneToIgnore + +project_panel + = @smitbarmase + +settings_ui + = @Anthony-Eid + = @danilo-leal + = @probably-neb + +sum_tree + = @Veykril + +support + = @miguelraz + +tasks + = @SomeoneToIgnore + = @Veykril + +terminal + = @kubkon + = @Veykril + +text + = @Veykril + +vim + = @ConradIrwin + = @dinocosta + = @p1n3appl3 + = @probably-neb + +windows + = @localcc + = @reflectronic + = @Veykril diff --git a/assets/icons/at_sign.svg b/assets/icons/at_sign.svg new file mode 100644 index 0000000000..531c10c8dc --- /dev/null +++ b/assets/icons/at_sign.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/box.svg b/assets/icons/box.svg new file mode 100644 index 0000000000..7e1276c629 --- /dev/null +++ b/assets/icons/box.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/chevron_down_up.svg b/assets/icons/chevron_down_up.svg new file mode 100644 index 0000000000..340b8d1ad9 --- /dev/null +++ b/assets/icons/chevron_down_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/debug_step_back.svg b/assets/icons/debug_step_back.svg deleted file mode 100644 index 61d45866f6..0000000000 --- a/assets/icons/debug_step_back.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/debug_step_into.svg b/assets/icons/debug_step_into.svg index 9a517fc7ca..0a58823543 100644 --- a/assets/icons/debug_step_into.svg +++ b/assets/icons/debug_step_into.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/debug_step_out.svg b/assets/icons/debug_step_out.svg index 147a44f930..c128f56111 100644 --- a/assets/icons/debug_step_out.svg +++ b/assets/icons/debug_step_out.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/debug_step_over.svg b/assets/icons/debug_step_over.svg index 336abc11de..5d8ccd5b7a 100644 --- a/assets/icons/debug_step_over.svg +++ b/assets/icons/debug_step_over.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/editor_cursor.svg b/assets/icons/editor_cursor.svg index 338697be8a..e20013917d 100644 --- a/assets/icons/editor_cursor.svg +++ b/assets/icons/editor_cursor.svg @@ -1,9 +1,3 @@ - - - - - - - + diff --git a/assets/icons/file_icons/odin.svg b/assets/icons/file_icons/odin.svg new file mode 100644 index 0000000000..3b4ef89319 --- /dev/null +++ b/assets/icons/file_icons/odin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/git_branch_plus.svg b/assets/icons/git_branch_plus.svg new file mode 100644 index 0000000000..cf60ce66b4 --- /dev/null +++ b/assets/icons/git_branch_plus.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/inception.svg b/assets/icons/inception.svg new file mode 100644 index 0000000000..77a96c0b39 --- /dev/null +++ b/assets/icons/inception.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/link.svg b/assets/icons/link.svg new file mode 100644 index 0000000000..739d41b231 --- /dev/null +++ b/assets/icons/link.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/sweep_ai.svg b/assets/icons/sweep_ai.svg new file mode 100644 index 0000000000..bf3459c7ea --- /dev/null +++ b/assets/icons/sweep_ai.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/undo.svg b/assets/icons/undo.svg index c714b58747..ccd45e246c 100644 --- a/assets/icons/undo.svg +++ b/assets/icons/undo.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/zed_agent_two.svg b/assets/icons/zed_agent_two.svg new file mode 100644 index 0000000000..c352be84d2 --- /dev/null +++ b/assets/icons/zed_agent_two.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/zed_mcp_custom.svg b/assets/icons/zed_src_custom.svg similarity index 100% rename from assets/icons/zed_mcp_custom.svg rename to assets/icons/zed_src_custom.svg diff --git a/assets/icons/zed_mcp_extension.svg b/assets/icons/zed_src_extension.svg similarity index 100% rename from assets/icons/zed_mcp_extension.svg rename to assets/icons/zed_src_extension.svg diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 1176faf03f..aac9dcf706 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -25,13 +25,14 @@ "ctrl-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", "open": "workspace::Open", - "ctrl-o": "workspace::Open", + "ctrl-o": "workspace::OpenFiles", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], - "ctrl-,": "zed::OpenSettingsEditor", - "ctrl-alt-,": "zed::OpenSettings", + "ctrl-,": "zed::OpenSettings", + "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", "shift-f5": "debugger::Stop", @@ -41,17 +42,17 @@ "ctrl-f11": "debugger::StepInto", "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", - "ctrl-alt-z": "edit_prediction::RateCompletions", + "ctrl-alt-z": "edit_prediction::RatePredictions", "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", - "ctrl-alt-l": "lsp_tool::ToggleMenu" - } + "ctrl-alt-l": "lsp_tool::ToggleMenu", + }, }, { "context": "Picker || menu", "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -62,7 +63,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -124,8 +124,8 @@ "shift-f10": "editor::OpenContextMenu", "ctrl-alt-shift-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -139,49 +139,49 @@ "find": "buffer_search::Deploy", "ctrl-f": "buffer_search::Deploy", "ctrl-h": "buffer_search::DeployReplace", - "ctrl->": "agent::QuoteSelection", + "ctrl->": "agent::AddSelectionToThread", "ctrl-<": "assistant::InsertIntoEditor", "ctrl-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "bindings": { "copy": "markdown::Copy", "ctrl-insert": "markdown::Copy", - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -189,8 +189,8 @@ "ctrl-k ctrl-r": "git::Restore", "ctrl-alt-y": "git::ToggleStaged", "alt-y": "git::StageAndNext", - "alt-shift-y": "git::UnstageAndNext" - } + "alt-shift-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -199,8 +199,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,8 +208,8 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", @@ -225,8 +225,8 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + }, }, { "context": "AgentPanel", @@ -235,53 +235,52 @@ "ctrl-alt-n": "agent::NewTextThread", "ctrl-shift-h": "agent::OpenHistory", "ctrl-alt-c": "agent::OpenSettings", - "ctrl-alt-p": "agent::OpenRulesLibrary", + "ctrl-alt-p": "agent::ManageProfiles", + "ctrl-alt-l": "agent::OpenRulesLibrary", "ctrl-i": "agent::ToggleProfileSelector", "ctrl-alt-/": "agent::ToggleModelSelector", - "ctrl-shift-a": "agent::ToggleContextPicker", "ctrl-shift-j": "agent::ToggleNavigationMenu", - "ctrl-shift-i": "agent::ToggleOptionsMenu", + "ctrl-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", - "ctrl->": "agent::QuoteSelection", - "ctrl-alt-e": "agent::RemoveAllContext", + "ctrl->": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", "super-ctrl-b": "agent::ToggleBurnMode", "alt-enter": "agent::ContinueWithBurnMode", "ctrl-y": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "ctrl-alt-z": "agent::RejectOnce" - } + "ctrl-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "bindings": { "copy": "markdown::CopyAsMarkdown", "ctrl-insert": "markdown::CopyAsMarkdown", - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { - "context": "AgentPanel && prompt_editor", + "context": "AgentPanel && text_thread", "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { - "context": "AgentPanel && external_agent_thread", + "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -291,8 +290,8 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -302,41 +301,30 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "EditMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } - }, - { - "context": "ContextStrip", - "bindings": { - "up": "agent::FocusUp", - "right": "agent::FocusRight", - "left": "agent::FocusLeft", - "down": "agent::FocusDown", - "backspace": "agent::RemoveFocusedContext", - "enter": "agent::AcceptSuggestedContext" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -345,8 +333,8 @@ "enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -356,23 +344,23 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "ThreadHistory", "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { - "context": "PromptLibrary", + "context": "RulesLibrary", "bindings": { "new": "rules_library::NewRule", "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -385,47 +373,48 @@ "find": "search::FocusSearch", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", "bindings": { "escape": "project_search::ToggleFocus", "shift-find": "search::FocusSearch", + "shift-enter": "project_search::ToggleAllSearchResults", "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -433,8 +422,8 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -479,11 +468,12 @@ "alt-w": "search::ToggleWholeWord", "alt-find": "project_search::ToggleFilters", "alt-ctrl-f": "project_search::ToggleFilters", + "shift-enter": "project_search::ToggleAllSearchResults", "ctrl-alt-shift-r": "search::ToggleRegex", "ctrl-alt-shift-x": "search::ToggleRegex", "alt-r": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -491,8 +481,8 @@ "bindings": { "ctrl-[": "editor::Outdent", "ctrl-]": "editor::Indent", - "shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above - "shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below + "shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above + "shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below "ctrl-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", @@ -510,6 +500,7 @@ "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -527,15 +518,15 @@ "ctrl-k ctrl-l": "editor::ToggleFold", "ctrl-k ctrl-[": "editor::FoldRecursive", "ctrl-k ctrl-]": "editor::UnfoldRecursive", - "ctrl-k ctrl-1": ["editor::FoldAtLevel", 1], - "ctrl-k ctrl-2": ["editor::FoldAtLevel", 2], - "ctrl-k ctrl-3": ["editor::FoldAtLevel", 3], - "ctrl-k ctrl-4": ["editor::FoldAtLevel", 4], - "ctrl-k ctrl-5": ["editor::FoldAtLevel", 5], - "ctrl-k ctrl-6": ["editor::FoldAtLevel", 6], - "ctrl-k ctrl-7": ["editor::FoldAtLevel", 7], - "ctrl-k ctrl-8": ["editor::FoldAtLevel", 8], - "ctrl-k ctrl-9": ["editor::FoldAtLevel", 9], + "ctrl-k ctrl-1": "editor::FoldAtLevel_1", + "ctrl-k ctrl-2": "editor::FoldAtLevel_2", + "ctrl-k ctrl-3": "editor::FoldAtLevel_3", + "ctrl-k ctrl-4": "editor::FoldAtLevel_4", + "ctrl-k ctrl-5": "editor::FoldAtLevel_5", + "ctrl-k ctrl-6": "editor::FoldAtLevel_6", + "ctrl-k ctrl-7": "editor::FoldAtLevel_7", + "ctrl-k ctrl-8": "editor::FoldAtLevel_8", + "ctrl-k ctrl-9": "editor::FoldAtLevel_9", "ctrl-k ctrl-0": "editor::FoldAll", "ctrl-k ctrl-j": "editor::UnfoldAll", "ctrl-space": "editor::ShowCompletions", @@ -546,31 +537,31 @@ "ctrl-\\": "pane::SplitRight", "ctrl-alt-shift-c": "editor::DisplayCursorNames", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -609,7 +600,7 @@ "ctrl-alt-b": "workspace::ToggleRightDock", "ctrl-b": "workspace::ToggleLeftDock", "ctrl-j": "workspace::ToggleBottomDock", - "ctrl-alt-y": "workspace::CloseAllDocks", + "ctrl-alt-y": "workspace::ToggleAllDocks", "ctrl-alt-0": "workspace::ResetActiveDockSize", // For 0px parameter, uses UI font size value. "ctrl-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }], @@ -621,13 +612,13 @@ "ctrl-shift-f": "pane::DeploySearch", "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-shift-t": "pane::ReopenClosedItem", - "ctrl-k ctrl-s": "zed::OpenKeymapEditor", + "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "ctrl-e": "file_finder::Toggle", "f1": "command_palette::Toggle", "ctrl-shift-p": "command_palette::Toggle", @@ -664,28 +655,28 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -703,8 +694,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -713,23 +704,37 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, + }, + { + "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "tab": "editor::NextSnippetTabstop", + }, + }, + { + "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -741,22 +746,24 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -766,29 +773,29 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { "bindings": { "ctrl-alt-shift-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "ctrl-alt-i": "dev::ToggleInspector" - } + "ctrl-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -800,16 +807,17 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", "ctrl-]": "agent::CycleNextInlineAssist", - "ctrl-alt-e": "agent::RemoveAllContext" - } + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -817,14 +825,14 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -841,13 +849,14 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", "bindings": { "left": "project_panel::CollapseSelectedEntry", + "ctrl-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", "new": "project_panel::NewFile", "ctrl-n": "project_panel::NewFile", @@ -879,14 +888,14 @@ "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", @@ -907,15 +916,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -924,8 +933,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -933,6 +942,7 @@ "ctrl-g ctrl-g": "git::Fetch", "ctrl-g up": "git::Push", "ctrl-g down": "git::Pull", + "ctrl-g shift-down": "git::PullRebase", "ctrl-g shift-up": "git::ForcePush", "ctrl-g d": "git::Diff", "ctrl-g backspace": "git::RestoreTrackedFiles", @@ -940,8 +950,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -949,14 +959,14 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -968,16 +978,16 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -989,8 +999,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -998,34 +1008,35 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", "bindings": { "alt-up": "collab_panel::MoveChannelUp", - "alt-down": "collab_panel::MoveChannelDown" - } + "alt-down": "collab_panel::MoveChannelDown", + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1034,29 +1045,29 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1065,8 +1076,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1074,14 +1085,15 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "bindings": { - "ctrl-shift-backspace": "stash_picker::DropStashItem" - } + "ctrl-shift-backspace": "stash_picker::DropStashItem", + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1125,65 +1137,70 @@ "ctrl-shift-space": "terminal::ToggleViMode", "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", - "alt-t": "terminal::RerunTask" - } + "alt-t": "terminal::RerunTask", + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "ZedPredictModal", "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1197,8 +1214,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1206,50 +1223,73 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", "use_key_equivalents": true, "bindings": { - "ctrl-1": "onboarding::ActivateBasicsPage", - "ctrl-2": "onboarding::ActivateEditingPage", - "ctrl-3": "onboarding::ActivateAISetupPage", + "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], + "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], + "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, + }, + { + "context": "Welcome", + "use_key_equivalents": true, + "bindings": { + "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], + "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], + "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, + }, + { + "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", "use_key_equivalents": true, "bindings": { "ctrl-w": "workspace::CloseWindow", + "escape": "workspace::CloseWindow", + "ctrl-m": "settings_editor::Minimize", "ctrl-f": "search::FocusSearch", + "ctrl-,": "settings_editor::OpenCurrentFile", + "left": "settings_editor::ToggleFocusNav", "ctrl-shift-e": "settings_editor::ToggleFocusNav", // todo(settings_ui): cut this down based on the max files and overflow UI "ctrl-1": ["settings_editor::FocusFile", 0], @@ -1263,7 +1303,46 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, + }, + { + "context": "StashDiff > Editor", + "bindings": { + "ctrl-space": "git::ApplyCurrentStash", + "ctrl-shift-space": "git::PopCurrentStash", + "ctrl-shift-backspace": "git::DropCurrentStash", + }, + }, + { + "context": "SettingsWindow > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "up": "settings_editor::FocusPreviousNavEntry", + "shift-tab": "settings_editor::FocusPreviousNavEntry", + "down": "settings_editor::FocusNextNavEntry", + "tab": "settings_editor::FocusNextNavEntry", + "right": "settings_editor::ExpandNavEntry", + "left": "settings_editor::CollapseNavEntry", + "pageup": "settings_editor::FocusPreviousRootNavEntry", + "pagedown": "settings_editor::FocusNextRootNavEntry", + "home": "settings_editor::FocusFirstNavEntry", + "end": "settings_editor::FocusLastNavEntry", + }, + }, + { + "context": "EditPredictionContext > Editor", + "bindings": { + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, + }, + { + "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-backspace": "branch_picker::DeleteBranch", + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f2b0c0d318..224f675546 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -39,18 +39,19 @@ "cmd-+": ["zed::IncreaseBufferFontSize", { "persist": false }], "cmd--": ["zed::DecreaseBufferFontSize", { "persist": false }], "cmd-0": ["zed::ResetBufferFontSize", { "persist": false }], - "cmd-,": "zed::OpenSettingsEditor", - "cmd-alt-,": "zed::OpenSettings", + "cmd-,": "zed::OpenSettings", + "cmd-alt-,": "zed::OpenSettingsFile", "cmd-q": "zed::Quit", "cmd-h": "zed::Hide", "alt-cmd-h": "zed::HideOthers", "cmd-m": "zed::Minimize", "fn-f": "zed::ToggleFullScreen", "ctrl-cmd-f": "zed::ToggleFullScreen", - "ctrl-cmd-z": "edit_prediction::RateCompletions", + "ctrl-cmd-z": "edit_prediction::RatePredictions", "ctrl-cmd-i": "edit_prediction::ToggleMenu", - "ctrl-cmd-l": "lsp_tool::ToggleMenu" - } + "ctrl-cmd-l": "lsp_tool::ToggleMenu", + "ctrl-cmd-c": "editor::DisplayCursorNames", + }, }, { "context": "Editor", @@ -147,8 +148,8 @@ "shift-f9": "editor::EditLogBreakpoint", "ctrl-f12": "editor::GoToDeclaration", "alt-ctrl-f12": "editor::GoToDeclarationSplit", - "ctrl-cmd-e": "editor::ToggleEditPrediction" - } + "ctrl-cmd-e": "editor::ToggleEditPrediction", + }, }, { "context": "Editor && mode == full", @@ -163,11 +164,11 @@ "cmd-alt-f": "buffer_search::DeployReplace", "cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }], "cmd-e": ["buffer_search::Deploy", { "focus": false }], - "cmd->": "agent::QuoteSelection", + "cmd->": "agent::AddSelectionToThread", "cmd-<": "assistant::InsertIntoEditor", "cmd-alt-e": "editor::SelectEnclosingSymbol", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && multibuffer", @@ -176,23 +177,23 @@ "cmd-up": "editor::MoveToStartOfExcerpt", "cmd-down": "editor::MoveToStartOfNextExcerpt", "cmd-shift-up": "editor::SelectToStartOfExcerpt", - "cmd-shift-down": "editor::SelectToStartOfNextExcerpt" - } + "cmd-shift-down": "editor::SelectToStartOfNextExcerpt", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::NextEditPrediction", - "alt-shift-tab": "editor::PreviousEditPrediction" - } + "alt-shift-tab": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-tab": "editor::ShowEditPrediction" - } + "alt-tab": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -200,23 +201,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::Copy" - } + "cmd-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff && !AgentPanel", @@ -225,8 +226,8 @@ "cmd-alt-z": "git::Restore", "cmd-alt-y": "git::ToggleStaged", "cmd-y": "git::StageAndNext", - "cmd-shift-y": "git::UnstageAndNext" - } + "cmd-shift-y": "git::UnstageAndNext", + }, }, { "context": "AgentDiff", @@ -235,8 +236,8 @@ "cmd-y": "agent::Keep", "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "Editor && editor_agent_diff", @@ -246,8 +247,8 @@ "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "ContextEditor > Editor", @@ -263,8 +264,8 @@ "cmd-k c": "assistant::CopyCode", "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPreviousMatch", - "cmd-k l": "agent::OpenRulesLibrary" - } + "cmd-k l": "agent::OpenRulesLibrary", + }, }, { "context": "AgentPanel", @@ -274,53 +275,52 @@ "cmd-alt-n": "agent::NewTextThread", "cmd-shift-h": "agent::OpenHistory", "cmd-alt-c": "agent::OpenSettings", - "cmd-alt-p": "agent::OpenRulesLibrary", + "cmd-alt-l": "agent::OpenRulesLibrary", + "cmd-alt-p": "agent::ManageProfiles", "cmd-i": "agent::ToggleProfileSelector", "cmd-alt-/": "agent::ToggleModelSelector", - "cmd-shift-a": "agent::ToggleContextPicker", "cmd-shift-j": "agent::ToggleNavigationMenu", - "cmd-shift-i": "agent::ToggleOptionsMenu", + "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", - "cmd->": "agent::QuoteSelection", - "cmd-alt-e": "agent::RemoveAllContext", + "cmd->": "agent::AddSelectionToThread", "cmd-shift-e": "project_panel::ToggleFocus", "cmd-ctrl-b": "agent::ToggleBurnMode", "cmd-shift-enter": "agent::ContinueThread", "alt-enter": "agent::ContinueWithBurnMode", "cmd-y": "agent::AllowOnce", "cmd-alt-y": "agent::AllowAlways", - "cmd-alt-z": "agent::RejectOnce" - } + "cmd-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::CopyAsMarkdown" - } + "cmd-c": "markdown::CopyAsMarkdown", + }, }, { - "context": "AgentPanel && prompt_editor", + "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewTextThread", - "cmd-alt-t": "agent::NewThread" - } + "cmd-alt-n": "agent::NewExternalAgentThread", + }, }, { - "context": "AgentPanel && external_agent_thread", + "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewExternalAgentThread", - "cmd-alt-t": "agent::NewThread" - } + "cmd-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -331,8 +331,8 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -343,8 +343,8 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "EditMessageEditor > Editor", @@ -352,8 +352,8 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -361,32 +361,20 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } - }, - { - "context": "ContextStrip", - "use_key_equivalents": true, - "bindings": { - "up": "agent::FocusUp", - "right": "agent::FocusRight", - "left": "agent::FocusLeft", - "down": "agent::FocusDown", - "backspace": "agent::RemoveFocusedContext", - "enter": "agent::AcceptSuggestedContext" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentConfiguration", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -396,8 +384,8 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -407,29 +395,29 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "ThreadHistory", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "ThreadHistory > Editor", "bindings": { - "shift-backspace": "agent::RemoveSelectedThread" - } + "shift-backspace": "agent::RemoveSelectedThread", + }, }, { - "context": "PromptLibrary", + "context": "RulesLibrary", "use_key_equivalents": true, "bindings": { "cmd-n": "rules_library::NewRule", "cmd-shift-s": "rules_library::ToggleDefaultRule", - "cmd-w": "workspace::CloseWindow" - } + "cmd-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -443,24 +431,24 @@ "cmd-f": "search::FocusSearch", "cmd-alt-f": "search::ToggleReplace", "cmd-alt-l": "search::ToggleSelection", - "cmd-shift-o": "outline::Toggle" - } + "cmd-shift-o": "outline::Toggle", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -468,27 +456,28 @@ "bindings": { "escape": "project_search::ToggleFocus", "cmd-shift-j": "project_search::ToggleFilters", + "shift-enter": "project_search::ToggleAllSearchResults", "cmd-shift-f": "search::FocusSearch", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -496,10 +485,11 @@ "bindings": { "escape": "project_search::ToggleFocus", "cmd-shift-j": "project_search::ToggleFilters", + "shift-enter": "project_search::ToggleAllSearchResults", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -529,8 +519,8 @@ "alt-cmd-w": "search::ToggleWholeWord", "alt-cmd-f": "project_search::ToggleFilters", "alt-cmd-x": "search::ToggleRegex", - "cmd-k shift-enter": "pane::TogglePinTab" - } + "cmd-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -539,10 +529,10 @@ "bindings": { "cmd-[": "editor::Outdent", "cmd-]": "editor::Indent", - "cmd-ctrl-p": "editor::AddSelectionAbove", // Insert cursor above - "cmd-alt-up": "editor::AddSelectionAbove", - "cmd-ctrl-n": "editor::AddSelectionBelow", // Insert cursor below - "cmd-alt-down": "editor::AddSelectionBelow", + "cmd-ctrl-p": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }], // Insert cursor above + "cmd-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], + "cmd-ctrl-n": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }], // Insert cursor below + "cmd-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], "cmd-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", @@ -582,15 +572,15 @@ "cmd-k cmd-l": "editor::ToggleFold", "cmd-k cmd-[": "editor::FoldRecursive", "cmd-k cmd-]": "editor::UnfoldRecursive", - "cmd-k cmd-1": ["editor::FoldAtLevel", 1], - "cmd-k cmd-2": ["editor::FoldAtLevel", 2], - "cmd-k cmd-3": ["editor::FoldAtLevel", 3], - "cmd-k cmd-4": ["editor::FoldAtLevel", 4], - "cmd-k cmd-5": ["editor::FoldAtLevel", 5], - "cmd-k cmd-6": ["editor::FoldAtLevel", 6], - "cmd-k cmd-7": ["editor::FoldAtLevel", 7], - "cmd-k cmd-8": ["editor::FoldAtLevel", 8], - "cmd-k cmd-9": ["editor::FoldAtLevel", 9], + "cmd-k cmd-1": "editor::FoldAtLevel_1", + "cmd-k cmd-2": "editor::FoldAtLevel_2", + "cmd-k cmd-3": "editor::FoldAtLevel_3", + "cmd-k cmd-4": "editor::FoldAtLevel_4", + "cmd-k cmd-5": "editor::FoldAtLevel_5", + "cmd-k cmd-6": "editor::FoldAtLevel_6", + "cmd-k cmd-7": "editor::FoldAtLevel_7", + "cmd-k cmd-8": "editor::FoldAtLevel_8", + "cmd-k cmd-9": "editor::FoldAtLevel_9", "cmd-k cmd-0": "editor::FoldAll", "cmd-k cmd-j": "editor::UnfoldAll", // Using `ctrl-space` / `ctrl-shift-space` in Zed requires disabling the macOS global shortcut. @@ -601,24 +591,23 @@ "cmd-k r": "editor::RevealInFileManager", "cmd-k p": "editor::CopyPath", "cmd-\\": "pane::SplitRight", - "ctrl-cmd-c": "editor::DisplayCursorNames" - } + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "cmd-k v": "markdown::OpenPreviewToTheSide", - "cmd-shift-v": "markdown::OpenPreview" - } + "cmd-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "cmd-k v": "svg::OpenPreviewToTheSide", - "cmd-shift-v": "svg::OpenPreview" - } + "cmd-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", @@ -627,8 +616,8 @@ "cmd-shift-o": "outline::Toggle", "ctrl-g": "go_to_line::Toggle", "cmd-shift-backspace": "editor::GoToPreviousChange", - "cmd-shift-alt-backspace": "editor::GoToNextChange" - } + "cmd-shift-alt-backspace": "editor::GoToNextChange", + }, }, { "context": "Pane", @@ -646,8 +635,8 @@ "ctrl-0": "pane::ActivateLastItem", "ctrl--": "pane::GoBack", "ctrl-_": "pane::GoForward", - "cmd-shift-f": "pane::DeploySearch" - } + "cmd-shift-f": "pane::DeploySearch", + }, }, { "context": "Workspace", @@ -679,7 +668,7 @@ "cmd-alt-b": "workspace::ToggleRightDock", "cmd-r": "workspace::ToggleRightDock", "cmd-j": "workspace::ToggleBottomDock", - "alt-cmd-y": "workspace::CloseAllDocks", + "alt-cmd-y": "workspace::ToggleAllDocks", // For 0px parameter, uses UI font size value. "ctrl-alt-0": "workspace::ResetActiveDockSize", "ctrl-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }], @@ -690,13 +679,13 @@ "cmd-shift-f": "pane::DeploySearch", "cmd-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "cmd-shift-t": "pane::ReopenClosedItem", - "cmd-k cmd-s": "zed::OpenKeymapEditor", + "cmd-k cmd-s": "zed::OpenKeymap", "cmd-k cmd-t": "theme_selector::Toggle", "ctrl-alt-cmd-p": "settings_profile_selector::Toggle", "cmd-t": "project_symbols::Toggle", "cmd-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", "cmd-shift-e": "project_panel::ToggleFocus", @@ -718,8 +707,8 @@ "cmd-k shift-down": "workspace::SwapPaneDown", "cmd-shift-x": "zed::Extensions", "f5": "debugger::Rerun", - "cmd-w": "workspace::CloseActiveDock" - } + "cmd-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && !Terminal", @@ -730,26 +719,27 @@ // All task parameters are captured and unchanged between reruns by default. // Use the `"reevaluate_context"` parameter to control this. "cmd-alt-r": ["task::Rerun", { "reevaluate_context": false }], - "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }] + "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }], // also possible to spawn tasks by name: // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] // or by tag: // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], - } + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + "f11": "debugger::StepInto", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, // Bindings from Sublime Text { @@ -770,8 +760,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -781,16 +771,16 @@ "cmd-k up": "pane::SplitUp", "cmd-k down": "pane::SplitDown", "cmd-k left": "pane::SplitLeft", - "cmd-k right": "pane::SplitRight" - } + "cmd-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -798,31 +788,47 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, + }, + { + "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "tab": "editor::NextSnippetTabstop", + }, + }, + { + "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, { "context": "Editor && edit_prediction", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -833,15 +839,15 @@ "down": "editor::ContextMenuNext", "ctrl-n": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -851,8 +857,8 @@ // TODO: Move this to a dock open action "cmd-shift-c": "collab_panel::ToggleFocus", // Only available in debug builds: opens an element inspector for development. - "cmd-alt-i": "dev::ToggleInspector" - } + "cmd-alt-i": "dev::ToggleInspector", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -865,19 +871,19 @@ "cmd-f8": "editor::GoToHunk", "cmd-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "agent::ToggleContextPicker", "cmd-alt-/": "agent::ToggleModelSelector", - "cmd-alt-e": "agent::RemoveAllContext", "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "cmd-shift-enter": "inline_assistant::ThumbsUpResult", + "cmd-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -886,15 +892,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "cmd-enter": "project_search::SearchInNew" - } + "cmd-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -910,14 +916,15 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "cmd-alt-enter": "editor::OpenExcerptsSplit" - } + "cmd-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", "use_key_equivalents": true, "bindings": { "left": "project_panel::CollapseSelectedEntry", + "cmd-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", "cmd-n": "project_panel::NewFile", "cmd-d": "project_panel::Duplicate", @@ -940,15 +947,15 @@ "cmd-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "VariableList", @@ -961,8 +968,8 @@ "cmd-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "GitPanel && ChangesList", @@ -985,15 +992,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], - "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }] - } + "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitDiff > Editor", @@ -1002,8 +1009,8 @@ "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", "cmd-ctrl-y": "git::StageAll", - "cmd-ctrl-shift-y": "git::UnstageAll" - } + "cmd-ctrl-shift-y": "git::UnstageAll", + }, }, { "context": "CommitEditor > Editor", @@ -1016,8 +1023,8 @@ "shift-tab": "git_panel::FocusChanges", "alt-up": "git_panel::FocusChanges", "shift-escape": "git::ExpandCommitEditor", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -1026,6 +1033,7 @@ "ctrl-g ctrl-g": "git::Fetch", "ctrl-g up": "git::Push", "ctrl-g down": "git::Pull", + "ctrl-g shift-down": "git::PullRebase", "ctrl-g shift-up": "git::ForcePush", "ctrl-g d": "git::Diff", "ctrl-g backspace": "git::RestoreTrackedFiles", @@ -1033,8 +1041,8 @@ "cmd-ctrl-y": "git::StageAll", "cmd-ctrl-shift-y": "git::UnstageAll", "cmd-enter": "git::Commit", - "cmd-shift-enter": "git::Amend" - } + "cmd-shift-enter": "git::Amend", + }, }, { "context": "GitCommit > Editor", @@ -1044,16 +1052,16 @@ "escape": "menu::Cancel", "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "cmd-t": "debugger::ToggleThreadPicker", "cmd-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "BreakpointList", @@ -1061,38 +1069,39 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", "use_key_equivalents": true, "bindings": { "alt-up": "collab_panel::MoveChannelUp", - "alt-down": "collab_panel::MoveChannelDown" - } + "alt-down": "collab_panel::MoveChannelDown", + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1103,30 +1112,30 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], - "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }] - } + "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "toolchain::AddToolchain" - } + "cmd-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu" - } + "cmd-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1136,8 +1145,8 @@ "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", - "cmd-l": "pane::SplitRight" - } + "cmd-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1146,15 +1155,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "stash_picker::DropStashItem" - } + "ctrl-shift-backspace": "stash_picker::DropStashItem", + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1208,35 +1218,36 @@ "ctrl-alt-down": "pane::SplitDown", "ctrl-alt-left": "pane::SplitLeft", "ctrl-alt-right": "pane::SplitRight", - "cmd-alt-r": "terminal::RerunTask" - } + "cmd-d": "pane::SplitRight", + "cmd-alt-r": "terminal::RerunTask", + }, }, { - "context": "RateCompletionModal", + "context": "RatePredictionsModal", "use_key_equivalents": true, "bindings": { - "cmd-shift-enter": "zeta::ThumbsUpActiveCompletion", - "cmd-shift-backspace": "zeta::ThumbsDownActiveCompletion", + "cmd-shift-enter": "zeta::ThumbsUpActivePrediction", + "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", "shift-down": "zeta::NextEdit", "shift-up": "zeta::PreviousEdit", - "right": "zeta::PreviewCompletion" - } + "right": "zeta::PreviewPrediction", + }, }, { - "context": "RateCompletionModal > Editor", + "context": "RatePredictionsModal > Editor", "use_key_equivalents": true, "bindings": { - "escape": "zeta::FocusCompletions", - "cmd-shift-enter": "zeta::ThumbsUpActiveCompletion", - "cmd-shift-backspace": "zeta::ThumbsDownActiveCompletion" - } + "escape": "zeta::FocusPredictions", + "cmd-shift-enter": "zeta::ThumbsUpActivePrediction", + "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1244,52 +1255,56 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1302,8 +1317,8 @@ "alt-enter": "keymap_editor::CreateBinding", "cmd-c": "keymap_editor::CopyAction", "cmd-shift-c": "keymap_editor::CopyContext", - "cmd-t": "keymap_editor::ShowMatchingKeybinds" - } + "cmd-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1311,50 +1326,73 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "cmd-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", "use_key_equivalents": true, "bindings": { - "cmd-1": "onboarding::ActivateBasicsPage", - "cmd-2": "onboarding::ActivateEditingPage", - "cmd-3": "onboarding::ActivateAISetupPage", - "cmd-escape": "onboarding::Finish", + "cmd-=": ["zed::IncreaseUiFontSize", { "persist": false }], + "cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }], + "cmd--": ["zed::DecreaseUiFontSize", { "persist": false }], + "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], + "cmd-enter": "onboarding::Finish", "alt-tab": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, + }, + { + "context": "Welcome", + "use_key_equivalents": true, + "bindings": { + "cmd-=": ["zed::IncreaseUiFontSize", { "persist": false }], + "cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }], + "cmd--": ["zed::DecreaseUiFontSize", { "persist": false }], + "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, + }, + { + "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", "use_key_equivalents": true, "bindings": { "cmd-w": "workspace::CloseWindow", + "escape": "workspace::CloseWindow", + "cmd-m": "settings_editor::Minimize", "cmd-f": "search::FocusSearch", + "cmd-,": "settings_editor::OpenCurrentFile", + "left": "settings_editor::ToggleFocusNav", "cmd-shift-e": "settings_editor::ToggleFocusNav", // todo(settings_ui): cut this down based on the max files and overflow UI "ctrl-1": ["settings_editor::FocusFile", 0], @@ -1368,7 +1406,47 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "cmd-{": "settings_editor::FocusPreviousFile", - "cmd-}": "settings_editor::FocusNextFile" - } - } + "cmd-}": "settings_editor::FocusNextFile", + }, + }, + { + "context": "StashDiff > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-space": "git::ApplyCurrentStash", + "ctrl-shift-space": "git::PopCurrentStash", + "ctrl-shift-backspace": "git::DropCurrentStash", + }, + }, + { + "context": "SettingsWindow > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "up": "settings_editor::FocusPreviousNavEntry", + "shift-tab": "settings_editor::FocusPreviousNavEntry", + "down": "settings_editor::FocusNextNavEntry", + "tab": "settings_editor::FocusNextNavEntry", + "right": "settings_editor::ExpandNavEntry", + "left": "settings_editor::CollapseNavEntry", + "pageup": "settings_editor::FocusPreviousRootNavEntry", + "pagedown": "settings_editor::FocusNextRootNavEntry", + "home": "settings_editor::FocusFirstNavEntry", + "end": "settings_editor::FocusLastNavEntry", + }, + }, + { + "context": "EditPredictionContext > Editor", + "bindings": { + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, + }, + { + "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-backspace": "branch_picker::DeleteBranch", + "cmd-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index d5c93e0da4..5626309ecb 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -24,33 +24,34 @@ "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }], "ctrl-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", - "ctrl-o": "workspace::Open", + "ctrl-o": "workspace::OpenFiles", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl-shift-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], - "ctrl-,": "zed::OpenSettingsEditor", - "ctrl-alt-,": "zed::OpenSettings", + "ctrl-,": "zed::OpenSettings", + "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", "shift-f5": "debugger::Stop", "ctrl-shift-f5": "debugger::RerunSession", "f6": "debugger::Pause", - "f7": "debugger::StepOver", - "ctrl-f11": "debugger::StepInto", + "f10": "debugger::StepOver", "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-shift-i": "edit_prediction::ToggleMenu", - "shift-alt-l": "lsp_tool::ToggleMenu" - } + "shift-alt-l": "lsp_tool::ToggleMenu", + "ctrl-shift-alt-c": "editor::DisplayCursorNames", + }, }, { "context": "Picker || menu", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -62,7 +63,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -117,10 +117,10 @@ "alt-g m": "git::OpenModifiedFiles", "menu": "editor::OpenContextMenu", "shift-f10": "editor::OpenContextMenu", - "ctrl-shift-e": "editor::ToggleEditPrediction", + "ctrl-alt-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -134,28 +134,28 @@ "ctrl-k z": "editor::ToggleSoftWrap", "ctrl-f": "buffer_search::Deploy", "ctrl-h": "buffer_search::DeployReplace", - "ctrl-shift-.": "assistant::QuoteSelection", + "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-,": "assistant::InsertIntoEditor", "shift-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -163,23 +163,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -187,8 +187,8 @@ "bindings": { "ctrl-k ctrl-r": "git::Restore", "alt-y": "git::StageAndNext", - "shift-alt-y": "git::UnstageAndNext" - } + "shift-alt-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -198,8 +198,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "ctrl-shift-r": "agent::OpenAgentDiff" - } + "ctrl-shift-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,14 +208,14 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "assistant::Assist", + "ctrl-i": "assistant::Assist", "ctrl-s": "workspace::Save", "ctrl-shift-,": "assistant::InsertIntoEditor", "shift-enter": "assistant::Split", @@ -225,8 +225,8 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + }, }, { "context": "AgentPanel", @@ -236,54 +236,53 @@ "shift-alt-n": "agent::NewTextThread", "ctrl-shift-h": "agent::OpenHistory", "shift-alt-c": "agent::OpenSettings", - "shift-alt-p": "agent::OpenRulesLibrary", + "shift-alt-l": "agent::OpenRulesLibrary", + "shift-alt-p": "agent::ManageProfiles", "ctrl-i": "agent::ToggleProfileSelector", "shift-alt-/": "agent::ToggleModelSelector", - "ctrl-shift-a": "agent::ToggleContextPicker", - "ctrl-shift-j": "agent::ToggleNavigationMenu", - "ctrl-shift-i": "agent::ToggleOptionsMenu", - // "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", + "shift-alt-j": "agent::ToggleNavigationMenu", + "shift-alt-i": "agent::ToggleOptionsMenu", + "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", - "ctrl-shift-.": "assistant::QuoteSelection", - "shift-alt-e": "agent::RemoveAllContext", + "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", "super-ctrl-b": "agent::ToggleBurnMode", "alt-enter": "agent::ContinueWithBurnMode", - "ctrl-y": "agent::AllowOnce", + "shift-alt-a": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "ctrl-alt-z": "agent::RejectOnce" - } + "shift-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "use_key_equivalents": true, "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { - "context": "AgentPanel && prompt_editor", + "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { - "context": "AgentPanel && external_agent_thread", + "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -294,8 +293,8 @@ "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -306,8 +305,8 @@ "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "EditMessageEditor > Editor", @@ -315,8 +314,8 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -324,26 +323,14 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } - }, - { - "context": "ContextStrip", - "use_key_equivalents": true, - "bindings": { - "up": "agent::FocusUp", - "right": "agent::FocusRight", - "left": "agent::FocusLeft", - "down": "agent::FocusDown", - "backspace": "agent::RemoveFocusedContext", - "enter": "agent::AcceptSuggestedContext" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -353,8 +340,8 @@ "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -364,24 +351,24 @@ "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "ThreadHistory", "use_key_equivalents": true, "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { - "context": "PromptLibrary", + "context": "RulesLibrary", "use_key_equivalents": true, "bindings": { "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -394,24 +381,24 @@ "alt-enter": "search::SelectAllMatches", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -420,24 +407,24 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -445,8 +432,8 @@ "bindings": { "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "Pane", @@ -477,8 +464,10 @@ "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", "back": "pane::GoBack", "alt--": "pane::GoBack", + "alt-left": "pane::GoBack", "forward": "pane::GoForward", "alt-=": "pane::GoForward", + "alt-right": "pane::GoForward", "f3": "search::SelectNextMatch", "shift-f3": "search::SelectPreviousMatch", "ctrl-shift-f": "project_search::ToggleFocus", @@ -488,10 +477,11 @@ "alt-c": "search::ToggleCaseSensitive", "alt-w": "search::ToggleWholeWord", "alt-f": "project_search::ToggleFilters", + "shift-enter": "project_search::ToggleAllSearchResults", "alt-r": "search::ToggleRegex", // "ctrl-shift-alt-x": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -500,8 +490,8 @@ "bindings": { "ctrl-[": "editor::Outdent", "ctrl-]": "editor::Indent", - "ctrl-shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above - "ctrl-shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below + "ctrl-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above + "ctrl-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below "ctrl-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", @@ -512,39 +502,36 @@ "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand - "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch - "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch + "ctrl-f3": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip - "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch + "ctrl-shift-f3": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", + "ctrl-k ctrl-f": "editor::FormatSelections", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", "f12": "editor::GoToDefinition", "alt-f12": "editor::GoToDefinitionSplit", - "ctrl-shift-f10": "editor::GoToDefinitionSplit", "ctrl-f12": "editor::GoToImplementation", - "shift-f12": "editor::GoToTypeDefinition", - "ctrl-alt-f12": "editor::GoToTypeDefinitionSplit", "shift-alt-f12": "editor::FindAllReferences", - "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains "ctrl-shift-\\": "editor::MoveToEnclosingBracket", "ctrl-shift-[": "editor::Fold", "ctrl-shift-]": "editor::UnfoldLines", "ctrl-k ctrl-l": "editor::ToggleFold", "ctrl-k ctrl-[": "editor::FoldRecursive", "ctrl-k ctrl-]": "editor::UnfoldRecursive", - "ctrl-k ctrl-1": ["editor::FoldAtLevel", 1], - "ctrl-k ctrl-2": ["editor::FoldAtLevel", 2], - "ctrl-k ctrl-3": ["editor::FoldAtLevel", 3], - "ctrl-k ctrl-4": ["editor::FoldAtLevel", 4], - "ctrl-k ctrl-5": ["editor::FoldAtLevel", 5], - "ctrl-k ctrl-6": ["editor::FoldAtLevel", 6], - "ctrl-k ctrl-7": ["editor::FoldAtLevel", 7], - "ctrl-k ctrl-8": ["editor::FoldAtLevel", 8], - "ctrl-k ctrl-9": ["editor::FoldAtLevel", 9], + "ctrl-k ctrl-1": "editor::FoldAtLevel_1", + "ctrl-k ctrl-2": "editor::FoldAtLevel_2", + "ctrl-k ctrl-3": "editor::FoldAtLevel_3", + "ctrl-k ctrl-4": "editor::FoldAtLevel_4", + "ctrl-k ctrl-5": "editor::FoldAtLevel_5", + "ctrl-k ctrl-6": "editor::FoldAtLevel_6", + "ctrl-k ctrl-7": "editor::FoldAtLevel_7", + "ctrl-k ctrl-8": "editor::FoldAtLevel_8", + "ctrl-k ctrl-9": "editor::FoldAtLevel_9", "ctrl-k ctrl-0": "editor::FoldAll", "ctrl-k ctrl-j": "editor::UnfoldAll", "ctrl-space": "editor::ShowCompletions", @@ -553,34 +540,33 @@ "ctrl-k r": "editor::RevealInFileManager", "ctrl-k p": "editor::CopyPath", "ctrl-\\": "pane::SplitRight", - "ctrl-shift-alt-c": "editor::DisplayCursorNames", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "use_key_equivalents": true, "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -614,7 +600,7 @@ "ctrl-alt-b": "workspace::ToggleRightDock", "ctrl-b": "workspace::ToggleLeftDock", "ctrl-j": "workspace::ToggleBottomDock", - "ctrl-shift-y": "workspace::CloseAllDocks", + "ctrl-shift-y": "workspace::ToggleAllDocks", "alt-r": "workspace::ResetActiveDockSize", // For 0px parameter, uses UI font size value. "shift-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }], @@ -623,13 +609,13 @@ "ctrl-shift-f": "pane::DeploySearch", "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-shift-t": "pane::ReopenClosedItem", - "ctrl-k ctrl-s": "zed::OpenKeymapEditor", + "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "ctrl-e": "file_finder::Toggle", "f1": "command_palette::Toggle", "ctrl-shift-p": "command_palette::Toggle", @@ -664,22 +650,22 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", @@ -687,8 +673,8 @@ "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -705,8 +691,8 @@ "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", "ctrl-alt-right": "editor::MoveToNextSubwordEnd", "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart", - "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd" - } + "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -716,16 +702,16 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -733,8 +719,22 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, + }, + { + "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "tab": "editor::NextSnippetTabstop", + }, + }, + { + "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -747,8 +747,9 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", @@ -756,15 +757,16 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -775,16 +777,16 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "use_key_equivalents": true, "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -792,15 +794,15 @@ "bindings": { "ctrl-shift-alt-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "shift-alt-i": "dev::ToggleInspector" - } + "shift-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -813,8 +815,8 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-shift-;": "editor::ToggleInlayHints" - } + "ctrl-shift-;": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", @@ -822,8 +824,9 @@ "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", "ctrl-]": "agent::CycleNextInlineAssist", - "shift-alt-e": "agent::RemoveAllContext" - } + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-delete": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -832,15 +835,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -855,14 +858,15 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", "use_key_equivalents": true, "bindings": { "left": "project_panel::CollapseSelectedEntry", + "ctrl-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", "ctrl-n": "project_panel::NewFile", "alt-n": "project_panel::NewDirectory", @@ -886,15 +890,15 @@ "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", @@ -915,15 +919,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -933,8 +937,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -943,6 +947,7 @@ "ctrl-g ctrl-g": "git::Fetch", "ctrl-g up": "git::Push", "ctrl-g down": "git::Pull", + "ctrl-g shift-down": "git::PullRebase", "ctrl-g shift-up": "git::ForcePush", "ctrl-g d": "git::Diff", "ctrl-g backspace": "git::RestoreTrackedFiles", @@ -950,8 +955,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -960,15 +965,15 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "use_key_equivalents": true, "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -981,8 +986,8 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", @@ -990,8 +995,8 @@ "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -1004,8 +1009,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1014,38 +1019,39 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", "use_key_equivalents": true, "bindings": { "alt-up": "collab_panel::MoveChannelUp", - "alt-down": "collab_panel::MoveChannelDown" - } + "alt-down": "collab_panel::MoveChannelDown", + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1055,22 +1061,22 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", @@ -1078,8 +1084,8 @@ "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1089,8 +1095,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1099,15 +1105,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "stash_picker::DropStashItem" - } + "ctrl-shift-backspace": "stash_picker::DropStashItem", + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1117,8 +1124,9 @@ "ctrl-insert": "terminal::Copy", "ctrl-shift-c": "terminal::Copy", "shift-insert": "terminal::Paste", + "ctrl-v": "terminal::Paste", "ctrl-shift-v": "terminal::Paste", - "ctrl-enter": "assistant::InlineAssist", + "ctrl-i": "assistant::InlineAssist", "alt-b": ["terminal::SendText", "\u001bb"], "alt-f": ["terminal::SendText", "\u001bf"], "alt-.": ["terminal::SendText", "\u001b."], @@ -1130,6 +1138,8 @@ "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-q": ["terminal::SendKeystroke", "ctrl-q"], + "ctrl-r": ["terminal::SendKeystroke", "ctrl-r"], "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], "ctrl-shift-a": "editor::SelectAll", "ctrl-shift-f": "buffer_search::Deploy", @@ -1150,15 +1160,22 @@ "ctrl-shift-space": "terminal::ToggleViMode", "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", - "alt-t": "terminal::RerunTask" - } + "alt-t": "terminal::RerunTask", + "ctrl-shift-5": "pane::SplitRight", + }, + }, + { + "context": "Terminal && selection", + "bindings": { + "ctrl-c": "terminal::Copy", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1166,53 +1183,57 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "use_key_equivalents": true, "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1225,8 +1246,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1234,43 +1255,66 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", "use_key_equivalents": true, "bindings": { - "ctrl-1": "onboarding::ActivateBasicsPage", - "ctrl-2": "onboarding::ActivateEditingPage", - "ctrl-3": "onboarding::ActivateAISetupPage", + "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], + "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], + "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "shift-alt-a": "onboarding::OpenAccount" - } + "shift-alt-a": "onboarding::OpenAccount", + }, + }, + { + "context": "Welcome", + "use_key_equivalents": true, + "bindings": { + "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], + "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], + "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + }, + }, + { + "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", "use_key_equivalents": true, "bindings": { "ctrl-w": "workspace::CloseWindow", + "escape": "workspace::CloseWindow", + "ctrl-m": "settings_editor::Minimize", "ctrl-f": "search::FocusSearch", + "ctrl-,": "settings_editor::OpenCurrentFile", + "left": "settings_editor::ToggleFocusNav", "ctrl-shift-e": "settings_editor::ToggleFocusNav", // todo(settings_ui): cut this down based on the max files and overflow UI "ctrl-1": ["settings_editor::FocusFile", 0], @@ -1284,7 +1328,47 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, + }, + { + "context": "StashDiff > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-space": "git::ApplyCurrentStash", + "ctrl-shift-space": "git::PopCurrentStash", + "ctrl-shift-backspace": "git::DropCurrentStash", + }, + }, + { + "context": "SettingsWindow > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "up": "settings_editor::FocusPreviousNavEntry", + "shift-tab": "settings_editor::FocusPreviousNavEntry", + "down": "settings_editor::FocusNextNavEntry", + "tab": "settings_editor::FocusNextNavEntry", + "right": "settings_editor::ExpandNavEntry", + "left": "settings_editor::CollapseNavEntry", + "pageup": "settings_editor::FocusPreviousRootNavEntry", + "pagedown": "settings_editor::FocusNextRootNavEntry", + "home": "settings_editor::FocusFirstNavEntry", + "end": "settings_editor::FocusLastNavEntry", + }, + }, + { + "context": "EditPredictionContext > Editor", + "bindings": { + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, + }, + { + "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-backspace": "branch_picker::DeleteBranch", + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/initial.json b/assets/keymaps/initial.json index 8e4fe59f44..3a8d7f382a 100644 --- a/assets/keymaps/initial.json +++ b/assets/keymaps/initial.json @@ -10,12 +10,12 @@ "context": "Workspace", "bindings": { // "shift shift": "file_finder::Toggle" - } + }, }, { "context": "Editor && vim_mode == insert", "bindings": { // "j k": "vim::NormalBefore" - } - } + }, + }, ] diff --git a/assets/keymaps/linux/atom.json b/assets/keymaps/linux/atom.json index 86ee068b06..a15d4877aa 100644 --- a/assets/keymaps/linux/atom.json +++ b/assets/keymaps/linux/atom.json @@ -4,15 +4,15 @@ "bindings": { "ctrl-shift-f5": "workspace::Reload", // window:reload "ctrl-k ctrl-n": "workspace::ActivatePreviousPane", // window:focus-next-pane - "ctrl-k ctrl-p": "workspace::ActivateNextPane" // window:focus-previous-pane - } + "ctrl-k ctrl-p": "workspace::ActivateNextPane", // window:focus-previous-pane + }, }, { "context": "Editor", "bindings": { "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case - "ctrl-k ctrl-l": "editor::ConvertToLowerCase" // editor:lower-case - } + "ctrl-k ctrl-l": "editor::ConvertToLowerCase", // editor:lower-case + }, }, { "context": "Editor && mode == full", @@ -24,16 +24,16 @@ "ctrl-<": "editor::ScrollCursorCenter", // editor:scroll-to-cursor "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous - "alt-shift-down": "editor::AddSelectionBelow", // editor:add-selection-below - "alt-shift-up": "editor::AddSelectionAbove", // editor:add-selection-above + "alt-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // editor:add-selection-below + "alt-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // editor:add-selection-above "ctrl-j": "editor::JoinLines", // editor:join-lines "ctrl-shift-d": "editor::DuplicateLineDown", // editor:duplicate-lines "ctrl-up": "editor::MoveLineUp", // editor:move-line-up "ctrl-down": "editor::MoveLineDown", // editor:move-line-down "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-shift-m": "markdown::OpenPreviewToTheSide", // markdown-preview:toggle - "ctrl-r": "outline::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "outline::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "BufferSearchBar", @@ -41,8 +41,8 @@ "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous "ctrl-f3": "search::SelectNextMatch", // find-and-replace:find-next-selected - "ctrl-shift-f3": "search::SelectPreviousMatch" // find-and-replace:find-previous-selected - } + "ctrl-shift-f3": "search::SelectPreviousMatch", // find-and-replace:find-previous-selected + }, }, { "context": "Workspace", @@ -50,8 +50,8 @@ "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-t": "file_finder::Toggle", // fuzzy-finder:toggle-file-finder - "ctrl-r": "project_symbols::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "project_symbols::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "Pane", @@ -65,8 +65,8 @@ "ctrl-6": ["pane::ActivateItem", 5], // tree-view:open-selected-entry-in-pane-6 "ctrl-7": ["pane::ActivateItem", 6], // tree-view:open-selected-entry-in-pane-7 "ctrl-8": ["pane::ActivateItem", 7], // tree-view:open-selected-entry-in-pane-8 - "ctrl-9": ["pane::ActivateItem", 8] // tree-view:open-selected-entry-in-pane-9 - } + "ctrl-9": ["pane::ActivateItem", 8], // tree-view:open-selected-entry-in-pane-9 + }, }, { "context": "ProjectPanel", @@ -75,8 +75,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "ctrl-x": "project_panel::Cut", // tree-view:cut "ctrl-c": "project_panel::Copy", // tree-view:copy - "ctrl-v": "project_panel::Paste" // tree-view:paste - } + "ctrl-v": "project_panel::Paste", // tree-view:paste + }, }, { "context": "ProjectPanel && not_editing", @@ -90,7 +90,7 @@ "d": "project_panel::Duplicate", // tree-view:duplicate "home": "menu::SelectFirst", // core:move-to-top "end": "menu::SelectLast", // core:move-to-bottom - "shift-a": "project_panel::NewDirectory" // tree-view:add-folder - } - } + "shift-a": "project_panel::NewDirectory", // tree-view:add-folder + }, + }, ] diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 2e27158e11..53f38234bb 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -8,8 +8,8 @@ "ctrl-shift-i": "agent::ToggleFocus", "ctrl-l": "agent::ToggleFocus", "ctrl-shift-l": "agent::ToggleFocus", - "ctrl-shift-j": "agent::OpenSettings" - } + "ctrl-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -17,21 +17,21 @@ "bindings": { "ctrl-i": "agent::ToggleFocus", "ctrl-shift-i": "agent::ToggleFocus", - "ctrl-shift-l": "agent::QuoteSelection", // In cursor uses "Ask" mode - "ctrl-l": "agent::QuoteSelection", // In cursor uses "Agent" mode + "ctrl-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode + "ctrl-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "ctrl-k": "assistant::InlineAssist", - "ctrl-shift-k": "assistant::InsertIntoEditor" - } + "ctrl-shift-k": "assistant::InsertIntoEditor", + }, }, { "context": "InlineAssistEditor", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "editor::Cancel" + "ctrl-shift-backspace": "editor::Cancel", // "alt-enter": // Quick Question // "ctrl-shift-enter": // Full File Context // "ctrl-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -47,7 +47,7 @@ "ctrl-shift-backspace": "editor::Cancel", "ctrl-r": "agent::NewThread", "ctrl-shift-v": "editor::Paste", - "ctrl-shift-k": "assistant::InsertIntoEditor" + "ctrl-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "ctrl-t": // new thread tab @@ -56,28 +56,28 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "ctrl-enter": "agent::KeepAll", - "ctrl-backspace": "agent::RejectAll" - } + "ctrl-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "ctrl-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-k": "assistant::InlineAssist" - } - } + "ctrl-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 0f936ba2f9..5b6f841de0 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -5,14 +5,26 @@ [ { "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, + }, + { + // Workaround to avoid falling back to default bindings. + // Unbind so Zed ignores these keys and lets emacs handle them. + // NOTE: must be declared before the `Editor` override. + // NOTE: in macos the 'ctrl-x' 'ctrl-p' and 'ctrl-n' rebindings are not needed, since they default to 'cmd'. + "context": "Editor", + "bindings": { + "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel + "ctrl-x": null, // currently activates `editor::Cut` if no following key is pressed for 1 second + "ctrl-p": null, // currently activates `file_finder::Toggle` when the cursor is on the first character of the buffer + "ctrl-n": null, // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer + }, }, { "context": "Editor", "bindings": { "ctrl-g": "editor::Cancel", - "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer "alt-g g": "go_to_line::Toggle", // goto-line "alt-g alt-g": "go_to_line::Toggle", // goto-line "ctrl-space": "editor::SetMark", // set-mark @@ -29,8 +41,10 @@ "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], // move-beginning-of-line "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line "alt-m": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": false, "stop_at_indent": true }], // back-to-indentation - "alt-f": "editor::MoveToNextSubwordEnd", // forward-word - "alt-b": "editor::MoveToPreviousSubwordStart", // backward-word + "alt-left": "editor::MoveToPreviousWordStart", // left-word + "alt-right": "editor::MoveToNextWordEnd", // right-word + "alt-f": "editor::MoveToNextWordEnd", // forward-word + "alt-b": "editor::MoveToPreviousWordStart", // backward-word "alt-u": "editor::ConvertToUpperCase", // upcase-word "alt-l": "editor::ConvertToLowerCase", // downcase-word "alt-c": "editor::ConvertToUpperCamelCase", // capitalize-word @@ -43,6 +57,8 @@ "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char "alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word + "alt-backspace": "editor::DeleteToPreviousWordStart", // backward-kill-word + "alt-delete": "editor::DeleteToPreviousWordStart", // backward-kill-word "ctrl-k": "editor::KillRingCut", // kill-line "ctrl-w": "editor::Cut", // kill-region "alt-w": "editor::Copy", // kill-ring-save @@ -52,17 +68,22 @@ "ctrl-x u": "editor::Undo", // undo "alt-{": "editor::MoveToStartOfParagraph", // backward-paragraph "alt-}": "editor::MoveToEndOfParagraph", // forward-paragraph + "ctrl-up": "editor::MoveToStartOfParagraph", // backward-paragraph + "ctrl-down": "editor::MoveToEndOfParagraph", // forward-paragraph "ctrl-v": "editor::MovePageDown", // scroll-up "alt-v": "editor::MovePageUp", // scroll-down "ctrl-x [": "editor::MoveToBeginning", // beginning-of-buffer "ctrl-x ]": "editor::MoveToEnd", // end-of-buffer "alt-<": "editor::MoveToBeginning", // beginning-of-buffer "alt->": "editor::MoveToEnd", // end-of-buffer + "ctrl-home": "editor::MoveToBeginning", // beginning-of-buffer + "ctrl-end": "editor::MoveToEnd", // end-of-buffer "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom "ctrl-s": "buffer_search::Deploy", // isearch-forward + "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -85,70 +106,101 @@ "end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], "ctrl-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], "ctrl-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], + "alt-m": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false, "stop_at_indent": true }], "alt-f": "editor::SelectToNextWordEnd", - "alt-b": "editor::SelectToPreviousSubwordStart", + "alt-b": "editor::SelectToPreviousWordStart", + "alt-{": "editor::SelectToStartOfParagraph", + "alt-}": "editor::SelectToEndOfParagraph", + "ctrl-up": "editor::SelectToStartOfParagraph", + "ctrl-down": "editor::SelectToEndOfParagraph", + "ctrl-x [": "editor::SelectToBeginning", + "ctrl-x ]": "editor::SelectToEnd", "alt-<": "editor::SelectToBeginning", "alt->": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-home": "editor::SelectToBeginning", + "ctrl-end": "editor::SelectToEnd", + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, + // Example setting for using emacs-style tab + // (i.e. indent the current line / selection or perform symbol completion depending on context) + // { + // "context": "Editor && !showing_code_actions && !showing_completions", + // "bindings": { + // "tab": "editor::AutoIndent" // indent-for-tab-command + // } + // }, { "context": "Workspace", "bindings": { + "alt-x": "command_palette::Toggle", // execute-extended-command + "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer + "ctrl-x ctrl-b": "tab_switcher::Toggle", // list-buffers + // "ctrl-x ctrl-c": "workspace::CloseWindow" // in case you only want to exit the current Zed instance "ctrl-x ctrl-c": "zed::Quit", // save-buffers-kill-terminal "ctrl-x 5 0": "workspace::CloseWindow", // delete-frame "ctrl-x 5 2": "workspace::NewWindow", // make-frame-command "ctrl-x o": "workspace::ActivateNextPane", // other-window "ctrl-x k": "pane::CloseActiveItem", // kill-buffer "ctrl-x 0": "pane::CloseActiveItem", // delete-window + // "ctrl-x 1": "pane::JoinAll", // in case you prefer to delete the splits but keep the buffers open "ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows "ctrl-x 2": "pane::SplitDown", // split-window-below "ctrl-x 3": "pane::SplitRight", // split-window-right "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { - // Workaround to enable using emacs in the Zed terminal. + // Workaround to enable using native emacs from the Zed terminal. // Unbind so Zed ignores these keys and lets emacs handle them. + // NOTE: + // "terminal::SendKeystroke" only works for a single key stroke (e.g. ctrl-x), + // so override with null for compound sequences (e.g. ctrl-x ctrl-c). "context": "Terminal", "bindings": { + // If you want to perfect your emacs-in-zed setup, also consider the following. + // You may need to enable "option_as_meta" from the Zed settings for "alt-x" to work. + // "alt-x": ["terminal::SendKeystroke", "alt-x"], + // "ctrl-x": ["terminal::SendKeystroke", "ctrl-x"], + // "ctrl-n": ["terminal::SendKeystroke", "ctrl-n"], + // ... "ctrl-x ctrl-c": null, // save-buffers-kill-terminal "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 59a182a968..d3bf53a0d3 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -5,14 +5,16 @@ "ctrl-{": "pane::ActivatePreviousItem", "ctrl-}": "pane::ActivateNextItem", "shift-escape": null, // Unmap workspace::zoom + "ctrl-~": "git::Branch", "ctrl-f2": "debugger::Stop", "f6": "debugger::Pause", "f7": "debugger::StepInto", "f8": "debugger::StepOver", "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", - "alt-shift-f9": "debugger::Start" - } + "shift-f9": "debugger::Start", + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -46,7 +48,7 @@ "alt-f7": "editor::FindAllReferences", "ctrl-alt-f7": "editor::FindAllReferences", "ctrl-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock - "ctrl-alt-b": "editor::GoToDefinitionSplit", // Conflicts with workspace::ToggleRightDock + "ctrl-alt-b": "editor::GoToImplementation", // Conflicts with workspace::ToggleRightDock "ctrl-shift-b": "editor::GoToTypeDefinition", "ctrl-alt-shift-b": "editor::GoToTypeDefinitionSplit", "f2": "editor::GoToDiagnostic", @@ -60,24 +62,30 @@ "ctrl-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "ctrl-shift-u": "editor::ToggleCase" - } + "ctrl-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-f12": "outline::Toggle", "ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }], + "ctrl-e": "file_finder::Toggle", "ctrl-shift-n": "file_finder::Toggle", + "ctrl-alt-n": "file_finder::Toggle", "ctrl-g": "go_to_line::Toggle", - "alt-enter": "editor::ToggleCodeActions" - } + "alt-enter": "editor::ToggleCodeActions", + "ctrl-space": "editor::ShowCompletions", + "ctrl-q": "editor::Hover", + "ctrl-p": "editor::ShowSignatureHelp", + "ctrl-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -85,18 +93,22 @@ "alt-c": "search::ToggleCaseSensitive", "alt-e": "search::ToggleSelection", "alt-x": "search::ToggleRegex", - "alt-w": "search::ToggleWholeWord" - } + "alt-w": "search::ToggleWholeWord", + }, }, { "context": "Workspace", "bindings": { - "ctrl-shift-f12": "workspace::CloseAllDocks", + "ctrl-shift-f12": "workspace::ToggleAllDocks", "ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], "alt-shift-f10": "task::Spawn", + "shift-f10": "task::Spawn", + "ctrl-f5": "task::Rerun", "ctrl-e": "file_finder::Toggle", - // "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor + "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "ctrl-shift-n": "file_finder::Toggle", + "ctrl-alt-n": "file_finder::Toggle", + "ctrl-n": "project_symbols::Toggle", "ctrl-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", "ctrl-alt-shift-n": "project_symbols::Toggle", @@ -104,8 +116,8 @@ "alt-1": "project_panel::ToggleFocus", "alt-5": "debug_panel::ToggleFocus", "alt-6": "diagnostics::Deploy", - "alt-7": "outline_panel::ToggleFocus" - } + "alt-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -119,22 +131,24 @@ "alt-7": "outline_panel::ToggleFocus", "alt-8": null, // Services (bottom dock) "alt-9": null, // Git History (bottom dock) - "alt-0": "git_panel::ToggleFocus" - } + "alt-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "ctrl-shift-k": "git::Push" - } + "ctrl-shift-k": "git::Push", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } + "ctrl-alt-right": "pane::GoForward", + "alt-left": "pane::ActivatePreviousItem", + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -144,21 +158,19 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", "bindings": { "ctrl-shift-t": "workspace::NewTerminal", "alt-f12": "workspace::CloseActiveDock", - "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem", "ctrl-up": "terminal::ScrollLineUp", "ctrl-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } }, @@ -169,7 +181,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json index f526db45ff..1d689a6f58 100644 --- a/assets/keymaps/linux/sublime_text.json +++ b/assets/keymaps/linux/sublime_text.json @@ -22,14 +22,14 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", "bindings": { - "ctrl-alt-up": "editor::AddSelectionAbove", - "ctrl-alt-down": "editor::AddSelectionBelow", + "ctrl-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }], + "ctrl-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }], "ctrl-shift-up": "editor::MoveLineUp", "ctrl-shift-down": "editor::MoveLineDown", "ctrl-shift-m": "editor::SelectLargerSyntaxNode", @@ -55,20 +55,20 @@ "alt-right": "editor::MoveToNextSubwordEnd", "alt-left": "editor::MoveToPreviousSubwordStart", "alt-shift-right": "editor::SelectToNextSubwordEnd", - "alt-shift-left": "editor::SelectToPreviousSubwordStart" - } + "alt-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "ctrl-r": "outline::Toggle" - } + "ctrl-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "ctrl-k ctrl-z": "git::Restore" - } + "ctrl-k ctrl-z": "git::Restore", + }, }, { "context": "Pane", @@ -83,15 +83,15 @@ "alt-6": ["pane::ActivateItem", 5], "alt-7": ["pane::ActivateItem", 6], "alt-8": ["pane::ActivateItem", 7], - "alt-9": "pane::ActivateLastItem" - } + "alt-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", "bindings": { "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // "ctrl-0": "project_panel::ToggleFocus", // normally resets zoom - "shift-ctrl-r": "project_symbols::Toggle" - } - } + "shift-ctrl-r": "project_symbols::Toggle", + }, + }, ] diff --git a/assets/keymaps/macos/atom.json b/assets/keymaps/macos/atom.json index df48e51767..bf049fd3cb 100644 --- a/assets/keymaps/macos/atom.json +++ b/assets/keymaps/macos/atom.json @@ -4,16 +4,16 @@ "bindings": { "ctrl-alt-cmd-l": "workspace::Reload", "cmd-k cmd-p": "workspace::ActivatePreviousPane", - "cmd-k cmd-n": "workspace::ActivateNextPane" - } + "cmd-k cmd-n": "workspace::ActivateNextPane", + }, }, { "context": "Editor", "bindings": { "cmd-shift-backspace": "editor::DeleteToBeginningOfLine", "cmd-k cmd-u": "editor::ConvertToUpperCase", - "cmd-k cmd-l": "editor::ConvertToLowerCase" - } + "cmd-k cmd-l": "editor::ConvertToLowerCase", + }, }, { "context": "Editor && mode == full", @@ -25,16 +25,16 @@ "cmd-<": "editor::ScrollCursorCenter", "cmd-g": ["editor::SelectNext", { "replace_newest": true }], "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], - "ctrl-shift-down": "editor::AddSelectionBelow", - "ctrl-shift-up": "editor::AddSelectionAbove", + "ctrl-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], + "ctrl-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], "alt-enter": "editor::Newline", "cmd-shift-d": "editor::DuplicateLineDown", "ctrl-cmd-up": "editor::MoveLineUp", "ctrl-cmd-down": "editor::MoveLineDown", "cmd-\\": "workspace::ToggleLeftDock", "ctrl-shift-m": "markdown::OpenPreviewToTheSide", - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "BufferSearchBar", @@ -42,8 +42,8 @@ "cmd-g": ["editor::SelectNext", { "replace_newest": true }], "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], "cmd-f3": "search::SelectNextMatch", - "cmd-shift-f3": "search::SelectPreviousMatch" - } + "cmd-shift-f3": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", @@ -51,8 +51,8 @@ "cmd-\\": "workspace::ToggleLeftDock", "cmd-k cmd-b": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-r": "project_symbols::Toggle" - } + "cmd-shift-r": "project_symbols::Toggle", + }, }, { "context": "Pane", @@ -67,8 +67,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "ProjectPanel", @@ -77,8 +77,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "cmd-x": "project_panel::Cut", "cmd-c": "project_panel::Copy", - "cmd-v": "project_panel::Paste" - } + "cmd-v": "project_panel::Paste", + }, }, { "context": "ProjectPanel && not_editing", @@ -92,7 +92,7 @@ "d": "project_panel::Duplicate", "home": "menu::SelectFirst", "end": "menu::SelectLast", - "shift-a": "project_panel::NewDirectory" - } - } + "shift-a": "project_panel::NewDirectory", + }, + }, ] diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index 1d723bd75b..93e259db37 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -8,8 +8,8 @@ "cmd-shift-i": "agent::ToggleFocus", "cmd-l": "agent::ToggleFocus", "cmd-shift-l": "agent::ToggleFocus", - "cmd-shift-j": "agent::OpenSettings" - } + "cmd-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -17,22 +17,22 @@ "bindings": { "cmd-i": "agent::ToggleFocus", "cmd-shift-i": "agent::ToggleFocus", - "cmd-shift-l": "agent::QuoteSelection", // In cursor uses "Ask" mode - "cmd-l": "agent::QuoteSelection", // In cursor uses "Agent" mode + "cmd-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode + "cmd-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "cmd-k": "assistant::InlineAssist", - "cmd-shift-k": "assistant::InsertIntoEditor" - } + "cmd-shift-k": "assistant::InsertIntoEditor", + }, }, { "context": "InlineAssistEditor", "use_key_equivalents": true, "bindings": { "cmd-shift-backspace": "editor::Cancel", - "cmd-enter": "menu::Confirm" + "cmd-enter": "menu::Confirm", // "alt-enter": // Quick Question // "cmd-shift-enter": // Full File Context // "cmd-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -48,7 +48,7 @@ "cmd-shift-backspace": "editor::Cancel", "cmd-r": "agent::NewThread", "cmd-shift-v": "editor::Paste", - "cmd-shift-k": "assistant::InsertIntoEditor" + "cmd-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "cmd-t": // new thread tab @@ -57,28 +57,29 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "cmd-enter": "agent::KeepAll", - "cmd-backspace": "agent::RejectAll" - } + "cmd-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "cmd-right": "editor::AcceptPartialEditPrediction" - } + "cmd-right": "editor::AcceptNextWordEditPrediction", + "cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "cmd-k": "assistant::InlineAssist" - } - } + "cmd-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 78e2235965..2f11e2ce00 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -6,14 +6,22 @@ { "context": "!GitPanel", "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, + }, + { + // Workaround to avoid falling back to default bindings. + // Unbind so Zed ignores these keys and lets emacs handle them. + // NOTE: must be declared before the `Editor` override. + "context": "Editor", + "bindings": { + "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel + }, }, { "context": "Editor", "bindings": { "ctrl-g": "editor::Cancel", - "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer "alt-g g": "go_to_line::Toggle", // goto-line "alt-g alt-g": "go_to_line::Toggle", // goto-line "ctrl-space": "editor::SetMark", // set-mark @@ -30,8 +38,10 @@ "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], // move-beginning-of-line "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line "alt-m": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": false, "stop_at_indent": true }], // back-to-indentation - "alt-f": "editor::MoveToNextSubwordEnd", // forward-word - "alt-b": "editor::MoveToPreviousSubwordStart", // backward-word + "alt-left": "editor::MoveToPreviousWordStart", // left-word + "alt-right": "editor::MoveToNextWordEnd", // right-word + "alt-f": "editor::MoveToNextWordEnd", // forward-word + "alt-b": "editor::MoveToPreviousWordStart", // backward-word "alt-u": "editor::ConvertToUpperCase", // upcase-word "alt-l": "editor::ConvertToLowerCase", // downcase-word "alt-c": "editor::ConvertToUpperCamelCase", // capitalize-word @@ -44,6 +54,8 @@ "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char "alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word + "alt-backspace": "editor::DeleteToPreviousWordStart", // backward-kill-word + "alt-delete": "editor::DeleteToPreviousWordStart", // backward-kill-word "ctrl-k": "editor::KillRingCut", // kill-line "ctrl-w": "editor::Cut", // kill-region "alt-w": "editor::Copy", // kill-ring-save @@ -53,17 +65,22 @@ "ctrl-x u": "editor::Undo", // undo "alt-{": "editor::MoveToStartOfParagraph", // backward-paragraph "alt-}": "editor::MoveToEndOfParagraph", // forward-paragraph + "ctrl-up": "editor::MoveToStartOfParagraph", // backward-paragraph + "ctrl-down": "editor::MoveToEndOfParagraph", // forward-paragraph "ctrl-v": "editor::MovePageDown", // scroll-up "alt-v": "editor::MovePageUp", // scroll-down "ctrl-x [": "editor::MoveToBeginning", // beginning-of-buffer "ctrl-x ]": "editor::MoveToEnd", // end-of-buffer "alt-<": "editor::MoveToBeginning", // beginning-of-buffer "alt->": "editor::MoveToEnd", // end-of-buffer + "ctrl-home": "editor::MoveToBeginning", // beginning-of-buffer + "ctrl-end": "editor::MoveToEnd", // end-of-buffer "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom "ctrl-s": "buffer_search::Deploy", // isearch-forward + "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -86,70 +103,101 @@ "end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], "ctrl-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], "ctrl-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], + "alt-m": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false, "stop_at_indent": true }], "alt-f": "editor::SelectToNextWordEnd", - "alt-b": "editor::SelectToPreviousSubwordStart", + "alt-b": "editor::SelectToPreviousWordStart", + "alt-{": "editor::SelectToStartOfParagraph", + "alt-}": "editor::SelectToEndOfParagraph", + "ctrl-up": "editor::SelectToStartOfParagraph", + "ctrl-down": "editor::SelectToEndOfParagraph", + "ctrl-x [": "editor::SelectToBeginning", + "ctrl-x ]": "editor::SelectToEnd", "alt-<": "editor::SelectToBeginning", "alt->": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-home": "editor::SelectToBeginning", + "ctrl-end": "editor::SelectToEnd", + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, + // Example setting for using emacs-style tab + // (i.e. indent the current line / selection or perform symbol completion depending on context) + // { + // "context": "Editor && !showing_code_actions && !showing_completions", + // "bindings": { + // "tab": "editor::AutoIndent" // indent-for-tab-command + // } + // }, { "context": "Workspace", "bindings": { + "alt-x": "command_palette::Toggle", // execute-extended-command + "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer + "ctrl-x ctrl-b": "tab_switcher::Toggle", // list-buffers + // "ctrl-x ctrl-c": "workspace::CloseWindow" // in case you only want to exit the current Zed instance "ctrl-x ctrl-c": "zed::Quit", // save-buffers-kill-terminal "ctrl-x 5 0": "workspace::CloseWindow", // delete-frame "ctrl-x 5 2": "workspace::NewWindow", // make-frame-command "ctrl-x o": "workspace::ActivateNextPane", // other-window "ctrl-x k": "pane::CloseActiveItem", // kill-buffer "ctrl-x 0": "pane::CloseActiveItem", // delete-window + // "ctrl-x 1": "pane::JoinAll", // in case you prefer to delete the splits but keep the buffers open "ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows "ctrl-x 2": "pane::SplitDown", // split-window-below "ctrl-x 3": "pane::SplitRight", // split-window-right "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { - // Workaround to enable using emacs in the Zed terminal. + // Workaround to enable using native emacs from the Zed terminal. // Unbind so Zed ignores these keys and lets emacs handle them. + // NOTE: + // "terminal::SendKeystroke" only works for a single key stroke (e.g. ctrl-x), + // so override with null for compound sequences (e.g. ctrl-x ctrl-c). "context": "Terminal", "bindings": { + // If you want to perfect your emacs-in-zed setup, also consider the following. + // You may need to enable "option_as_meta" from the Zed settings for "alt-x" to work. + // "alt-x": ["terminal::SendKeystroke", "alt-x"], + // "ctrl-x": ["terminal::SendKeystroke", "ctrl-x"], + // "ctrl-n": ["terminal::SendKeystroke", "ctrl-n"], + // ... "ctrl-x ctrl-c": null, // save-buffers-kill-terminal "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 2c757c3a30..9946d8b124 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -5,14 +5,16 @@ "cmd-}": "pane::ActivateNextItem", "cmd-0": "git_panel::ToggleFocus", // overrides `cmd-0` zoom reset "shift-escape": null, // Unmap workspace::zoom + "cmd-~": "git::Branch", "ctrl-f2": "debugger::Stop", "f6": "debugger::Pause", "f7": "debugger::StepInto", "f8": "debugger::StepOver", "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", - "alt-shift-f9": "debugger::Start" - } + "shift-f9": "debugger::Start", + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -45,7 +47,7 @@ "alt-f7": "editor::FindAllReferences", "cmd-alt-f7": "editor::FindAllReferences", "cmd-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock - "cmd-alt-b": "editor::GoToDefinitionSplit", + "cmd-alt-b": "editor::GoToImplementation", "cmd-shift-b": "editor::GoToTypeDefinition", "cmd-alt-shift-b": "editor::GoToTypeDefinitionSplit", "f2": "editor::GoToDiagnostic", @@ -58,24 +60,30 @@ "cmd-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "cmd-shift-u": "editor::ToggleCase" - } + "cmd-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", "bindings": { "cmd-f12": "outline::Toggle", "cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }], - "cmd-shift-o": "file_finder::Toggle", "cmd-l": "go_to_line::Toggle", - "alt-enter": "editor::ToggleCodeActions" - } + "cmd-e": "file_finder::Toggle", + "cmd-shift-o": "file_finder::Toggle", + "cmd-shift-n": "file_finder::Toggle", + "alt-enter": "editor::ToggleCodeActions", + "ctrl-space": "editor::ShowCompletions", + "cmd-j": "editor::Hover", + "cmd-p": "editor::ShowSignatureHelp", + "cmd-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -87,18 +95,22 @@ "ctrl-alt-c": "search::ToggleCaseSensitive", "ctrl-alt-e": "search::ToggleSelection", "ctrl-alt-w": "search::ToggleWholeWord", - "ctrl-alt-x": "search::ToggleRegex" - } + "ctrl-alt-x": "search::ToggleRegex", + }, }, { "context": "Workspace", "bindings": { - "cmd-shift-f12": "workspace::CloseAllDocks", + "cmd-shift-f12": "workspace::ToggleAllDocks", "cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-alt-r": "task::Spawn", + "shift-f10": "task::Spawn", + "cmd-f5": "task::Rerun", "cmd-e": "file_finder::Toggle", - // "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor + "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "cmd-shift-o": "file_finder::Toggle", + "cmd-shift-n": "file_finder::Toggle", + "cmd-n": "project_symbols::Toggle", "cmd-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", "cmd-alt-o": "project_symbols::Toggle", // JetBrains: Go to Symbol @@ -106,8 +118,8 @@ "cmd-1": "project_panel::ToggleFocus", "cmd-5": "debug_panel::ToggleFocus", "cmd-6": "diagnostics::Deploy", - "cmd-7": "outline_panel::ToggleFocus" - } + "cmd-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -121,22 +133,24 @@ "cmd-7": "outline_panel::ToggleFocus", "cmd-8": null, // Services (bottom dock) "cmd-9": null, // Git History (bottom dock) - "cmd-0": "git_panel::ToggleFocus" - } + "cmd-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "cmd-shift-k": "git::Push" - } + "cmd-shift-k": "git::Push", + }, }, { "context": "Pane", "bindings": { "cmd-alt-left": "pane::GoBack", - "cmd-alt-right": "pane::GoForward" - } + "cmd-alt-right": "pane::GoForward", + "alt-left": "pane::ActivatePreviousItem", + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -147,8 +161,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -158,8 +172,8 @@ "cmd-up": "terminal::ScrollLineUp", "cmd-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "cmd-1": "workspace::CloseActiveDock" } }, @@ -170,7 +184,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/macos/sublime_text.json b/assets/keymaps/macos/sublime_text.json index a1e61bf885..f4ae1ce5dd 100644 --- a/assets/keymaps/macos/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -22,14 +22,14 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", "bindings": { - "ctrl-shift-up": "editor::AddSelectionAbove", - "ctrl-shift-down": "editor::AddSelectionBelow", + "ctrl-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }], + "ctrl-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }], "cmd-ctrl-up": "editor::MoveLineUp", "cmd-ctrl-down": "editor::MoveLineDown", "cmd-shift-space": "editor::SelectAll", @@ -57,20 +57,20 @@ "ctrl-right": "editor::MoveToNextSubwordEnd", "ctrl-left": "editor::MoveToPreviousSubwordStart", "ctrl-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-shift-left": "editor::SelectToPreviousSubwordStart" - } + "ctrl-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "cmd-k cmd-z": "git::Restore" - } + "cmd-k cmd-z": "git::Restore", + }, }, { "context": "Pane", @@ -85,8 +85,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", @@ -95,7 +95,7 @@ "cmd-t": "file_finder::Toggle", "shift-cmd-r": "project_symbols::Toggle", // Currently busted: https://github.com/zed-industries/feedback/issues/898 - "ctrl-0": "project_panel::ToggleFocus" - } - } + "ctrl-0": "project_panel::ToggleFocus", + }, + }, ] diff --git a/assets/keymaps/macos/textmate.json b/assets/keymaps/macos/textmate.json index f91f39b7f5..90450e60af 100644 --- a/assets/keymaps/macos/textmate.json +++ b/assets/keymaps/macos/textmate.json @@ -2,8 +2,8 @@ { "bindings": { "cmd-shift-o": "projects::OpenRecent", - "cmd-alt-tab": "project_panel::ToggleFocus" - } + "cmd-alt-tab": "project_panel::ToggleFocus", + }, }, { "context": "Editor && mode == full", @@ -15,8 +15,8 @@ "cmd-enter": "editor::NewlineBelow", "cmd-alt-enter": "editor::NewlineAbove", "cmd-shift-l": "editor::SelectLine", - "cmd-shift-t": "outline::Toggle" - } + "cmd-shift-t": "outline::Toggle", + }, }, { "context": "Editor", @@ -41,30 +41,30 @@ "ctrl-u": "editor::ConvertToUpperCase", "ctrl-shift-u": "editor::ConvertToLowerCase", "ctrl-alt-u": "editor::ConvertToUpperCamelCase", - "ctrl-_": "editor::ConvertToSnakeCase" - } + "ctrl-_": "editor::ConvertToSnakeCase", + }, }, { "context": "BufferSearchBar", "bindings": { "ctrl-s": "search::SelectNextMatch", - "ctrl-shift-s": "search::SelectPreviousMatch" - } + "ctrl-shift-s": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", "bindings": { "cmd-alt-ctrl-d": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-t": "project_symbols::Toggle" - } + "cmd-shift-t": "project_symbols::Toggle", + }, }, { "context": "Pane", "bindings": { "alt-cmd-r": "search::ToggleRegex", - "ctrl-tab": "project_panel::ToggleFocus" - } + "ctrl-tab": "project_panel::ToggleFocus", + }, }, { "context": "ProjectPanel", @@ -75,11 +75,11 @@ "return": "project_panel::Rename", "cmd-c": "project_panel::Copy", "cmd-v": "project_panel::Paste", - "cmd-alt-c": "project_panel::CopyPath" - } + "cmd-alt-c": "project_panel::CopyPath", + }, }, { "context": "Dock", - "bindings": {} - } + "bindings": {}, + }, ] diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json index 9b92fbe1a3..432bdc7004 100644 --- a/assets/keymaps/storybook.json +++ b/assets/keymaps/storybook.json @@ -27,7 +27,7 @@ "backspace": "editor::Backspace", "delete": "editor::Delete", "left": "editor::MoveLeft", - "right": "editor::MoveRight" - } - } + "right": "editor::MoveRight", + }, + }, ] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 4d296667ff..0097480e27 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -95,8 +95,6 @@ "g g": "vim::StartOfDocument", "g h": "editor::Hover", "g B": "editor::BlameHover", - "g t": "vim::GoToTab", - "g shift-t": "vim::GoToPreviousTab", "g d": "editor::GoToDefinition", "g shift-d": "editor::GoToDeclaration", "g y": "editor::GoToTypeDefinition", @@ -182,10 +180,9 @@ "ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit", "ctrl-w space": "editor::OpenExcerptsSplit", "ctrl-w g space": "editor::OpenExcerptsSplit", - "ctrl-6": "pane::AlternateFile", "ctrl-^": "pane::AlternateFile", - ".": "vim::Repeat" - } + ".": "vim::Repeat", + }, }, { "context": "vim_mode == normal || vim_mode == visual || vim_mode == operator", @@ -222,10 +219,12 @@ "[ {": ["vim::UnmatchedBackward", { "char": "{" }], "] )": ["vim::UnmatchedForward", { "char": ")" }], "[ (": ["vim::UnmatchedBackward", { "char": "(" }], + "[ r": "vim::GoToPreviousReference", + "] r": "vim::GoToNextReference", // tree-sitter related commands "[ x": "vim::SelectLargerSyntaxNode", - "] x": "vim::SelectSmallerSyntaxNode" - } + "] x": "vim::SelectSmallerSyntaxNode", + }, }, { "context": "vim_mode == normal", @@ -262,16 +261,16 @@ "[ d": "editor::GoToPreviousDiagnostic", "] c": "editor::GoToHunk", "[ c": "editor::GoToPreviousHunk", - "g c": "vim::PushToggleComments" - } + "g c": "vim::PushToggleComments", + }, }, { "context": "VimControl && VimCount", "bindings": { "0": ["vim::Number", 0], ":": "vim::CountCommand", - "%": "vim::GoToPercentage" - } + "%": "vim::GoToPercentage", + }, }, { "context": "vim_mode == visual", @@ -323,8 +322,8 @@ "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", - "\"": "vim::PushRegister" - } + "\"": "vim::PushRegister", + }, }, { "context": "vim_mode == helix_select", @@ -344,8 +343,8 @@ "ctrl-pageup": "pane::ActivatePreviousItem", "ctrl-pagedown": "pane::ActivateNextItem", ".": "vim::Repeat", - "alt-.": "vim::RepeatFind" - } + "alt-.": "vim::RepeatFind", + }, }, { "context": "vim_mode == insert", @@ -375,8 +374,8 @@ "ctrl-r": "vim::PushRegister", "insert": "vim::ToggleReplace", "ctrl-o": "vim::TemporaryNormal", - "ctrl-s": "editor::ShowSignatureHelp" - } + "ctrl-s": "editor::ShowSignatureHelp", + }, }, { "context": "showing_completions", @@ -384,8 +383,8 @@ "ctrl-d": "vim::ScrollDown", "ctrl-u": "vim::ScrollUp", "ctrl-e": "vim::LineDown", - "ctrl-y": "vim::LineUp" - } + "ctrl-y": "vim::LineUp", + }, }, { "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu", @@ -410,80 +409,111 @@ "shift-s": "vim::SubstituteLine", "\"": "vim::PushRegister", "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem" - } + "ctrl-pageup": "pane::ActivatePreviousItem", + }, }, { - "context": "vim_mode == helix_normal && !menu", + "context": "VimControl && vim_mode == helix_normal && !menu", "bindings": { + "j": ["vim::Down", { "display_lines": true }], + "down": ["vim::Down", { "display_lines": true }], + "k": ["vim::Up", { "display_lines": true }], + "up": ["vim::Up", { "display_lines": true }], + "g j": "vim::Down", + "g down": "vim::Down", + "g k": "vim::Up", + "g up": "vim::Up", + "escape": "vim::SwitchToHelixNormalMode", "i": "vim::HelixInsert", "a": "vim::HelixAppend", - "ctrl-[": "editor::Cancel" - } + "ctrl-[": "editor::Cancel", + }, + }, + { + "context": "vim_mode == helix_select && !menu", + "bindings": { + "escape": "vim::SwitchToHelixNormalMode", + }, }, { "context": "(vim_mode == helix_normal || vim_mode == helix_select) && !menu", "bindings": { - ";": "vim::HelixCollapseSelection", - ":": "command_palette::Toggle", - "m": "vim::PushHelixMatch", - "s": "vim::HelixSelectRegex", - "]": ["vim::PushHelixNext", { "around": true }], - "[": ["vim::PushHelixPrevious", { "around": true }], - "left": "vim::WrappingLeft", - "right": "vim::WrappingRight", + // Movement "h": "vim::WrappingLeft", + "left": "vim::WrappingLeft", "l": "vim::WrappingRight", + "right": "vim::WrappingRight", + "t": ["vim::PushFindForward", { "before": true, "multiline": true }], + "f": ["vim::PushFindForward", { "before": false, "multiline": true }], + "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }], + "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }], + "alt-.": "vim::RepeatFind", + + // Changes + "shift-r": "editor::Paste", + "`": "vim::ConvertToLowerCase", + "alt-`": "vim::ConvertToUpperCase", + "insert": "vim::InsertBefore", // not a helix default + "shift-u": "editor::Redo", + "ctrl-r": "vim::Redo", // not a helix default "y": "vim::HelixYank", "p": "vim::HelixPaste", "shift-p": ["vim::HelixPaste", { "before": true }], - "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 }], ">": "vim::Indent", "<": "vim::Outdent", "=": "vim::AutoIndent", - "`": "vim::ConvertToLowerCase", - "alt-`": "vim::ConvertToUpperCase", - "g q": "vim::PushRewrap", - "g w": "vim::PushRewrap", - "insert": "vim::InsertBefore", - "alt-.": "vim::RepeatFind", + "d": "vim::HelixDelete", + "alt-d": "editor::Delete", // Delete selection, without yanking + "c": "vim::HelixSubstitute", + "alt-c": "vim::HelixSubstituteNoYank", + + // Selection manipulation + "s": "vim::HelixSelectRegex", "alt-s": ["editor::SplitSelectionIntoLines", { "keep_selections": true }], + ";": "vim::HelixCollapseSelection", + "alt-;": "vim::OtherEnd", + ",": "vim::HelixKeepNewestSelection", + "shift-c": "vim::HelixDuplicateBelow", + "alt-shift-c": "vim::HelixDuplicateAbove", + "%": "editor::SelectAll", + "x": "vim::HelixSelectLine", + "shift-x": "editor::SelectLine", + "ctrl-c": "editor::ToggleComments", + "alt-o": "editor::SelectLargerSyntaxNode", + "alt-i": "editor::SelectSmallerSyntaxNode", + "alt-p": "editor::SelectPreviousSyntaxNode", + "alt-n": "editor::SelectNextSyntaxNode", + + // Search + "n": "vim::HelixSelectNext", + "shift-n": "vim::HelixSelectPrevious", + // Goto mode - "g n": "pane::ActivateNextItem", - "g p": "pane::ActivatePreviousItem", - // "tab": "pane::ActivateNextItem", - // "shift-tab": "pane::ActivatePrevItem", - "shift-h": "pane::ActivatePreviousItem", - "shift-l": "pane::ActivateNextItem", - "g l": "vim::EndOfLine", - "g h": "vim::StartOfLine", - "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" "g e": "vim::EndOfDocument", - "g .": "vim::HelixGotoLastModification", // go to last modification - "g r": "editor::FindAllReferences", // zed specific + "g h": "vim::StartOfLine", + "g l": "vim::EndOfLine", + "g s": "vim::FirstNonWhitespace", "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", + "g r": "editor::FindAllReferences", + "g n": "pane::ActivateNextItem", + "shift-l": "pane::ActivateNextItem", // not a helix default + "g p": "pane::ActivatePreviousItem", + "shift-h": "pane::ActivatePreviousItem", // not a helix default + "g .": "vim::HelixGotoLastModification", - "shift-r": "editor::Paste", - "x": "vim::HelixSelectLine", - "shift-x": "editor::SelectLine", - "%": "editor::SelectAll", // Window mode - "space w h": "workspace::ActivatePaneLeft", - "space w l": "workspace::ActivatePaneRight", - "space w k": "workspace::ActivatePaneUp", - "space w j": "workspace::ActivatePaneDown", - "space w q": "pane::CloseActiveItem", - "space w s": "pane::SplitRight", - "space w r": "pane::SplitRight", "space w v": "pane::SplitDown", - "space w d": "pane::SplitDown", + "space w s": "pane::SplitRight", + "space w h": "workspace::ActivatePaneLeft", + "space w j": "workspace::ActivatePaneDown", + "space w k": "workspace::ActivatePaneUp", + "space w l": "workspace::ActivatePaneRight", + "space w q": "pane::CloseActiveItem", + "space w r": "pane::SplitRight", // not a helix default + "space w d": "pane::SplitDown", // not a helix default + // Space mode "space f": "file_finder::Toggle", "space k": "editor::Hover", @@ -494,29 +524,32 @@ "space a": "editor::ToggleCodeActions", "space h": "editor::SelectAllMatches", "space c": "editor::ToggleComments", - "space y": "editor::Copy", "space p": "editor::Paste", - "shift-u": "editor::Redo", - "ctrl-c": "editor::ToggleComments", - "d": "vim::HelixDelete", - "c": "vim::Substitute", - "shift-c": "editor::AddSelectionBelow", - "alt-shift-c": "editor::AddSelectionAbove" - } + "space y": "editor::Copy", + "space /": "pane::DeploySearch", + + // Other + ":": "command_palette::Toggle", + "m": "vim::PushHelixMatch", + "]": ["vim::PushHelixNext", { "around": true }], + "[": ["vim::PushHelixPrevious", { "around": true }], + "g q": "vim::PushRewrap", + "g w": "vim::PushRewrap", // not a helix default & clashes with helix `goto_word` + }, }, { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ShowWordCompletions", - "ctrl-n": "editor::ShowWordCompletions" - } + "ctrl-n": "editor::ShowWordCompletions", + }, }, { "context": "(vim_mode == insert || vim_mode == normal) && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, { "context": "vim_mode == replace", @@ -532,8 +565,8 @@ "backspace": "vim::UndoReplace", "tab": "vim::Tab", "enter": "vim::Enter", - "insert": "vim::InsertBefore" - } + "insert": "vim::InsertBefore", + }, }, { "context": "vim_mode == waiting", @@ -545,14 +578,14 @@ "escape": "vim::ClearOperators", "ctrl-k": ["vim::PushDigraph", {}], "ctrl-v": ["vim::PushLiteral", {}], - "ctrl-q": ["vim::PushLiteral", {}] - } + "ctrl-q": ["vim::PushLiteral", {}], + }, }, { "context": "Editor && vim_mode == waiting && (vim_operator == ys || vim_operator == cs)", "bindings": { - "escape": "vim::SwitchToNormalMode" - } + "escape": "vim::SwitchToNormalMode", + }, }, { "context": "vim_mode == operator", @@ -560,8 +593,8 @@ "ctrl-c": "vim::ClearOperators", "ctrl-[": "vim::ClearOperators", "escape": "vim::ClearOperators", - "g c": "vim::Comment" - } + "g c": "vim::Comment", + }, }, { "context": "vim_operator == a || vim_operator == i || vim_operator == cs || vim_operator == helix_next || vim_operator == helix_previous", @@ -580,32 +613,32 @@ // "q": "vim::AnyQuotes", "q": "vim::MiniQuotes", "|": "vim::VerticalBars", - "(": "vim::Parentheses", + "(": ["vim::Parentheses", { "opening": true }], ")": "vim::Parentheses", "b": "vim::Parentheses", // "b": "vim::AnyBrackets", // "b": "vim::MiniBrackets", - "[": "vim::SquareBrackets", + "[": ["vim::SquareBrackets", { "opening": true }], "]": "vim::SquareBrackets", "r": "vim::SquareBrackets", - "{": "vim::CurlyBrackets", + "{": ["vim::CurlyBrackets", { "opening": true }], "}": "vim::CurlyBrackets", "shift-b": "vim::CurlyBrackets", - "<": "vim::AngleBrackets", + "<": ["vim::AngleBrackets", { "opening": true }], ">": "vim::AngleBrackets", "a": "vim::Argument", "i": "vim::IndentObj", "shift-i": ["vim::IndentObj", { "include_below": true }], "f": "vim::Method", "c": "vim::Class", - "e": "vim::EntireFile" - } + "e": "vim::EntireFile", + }, }, { "context": "vim_operator == helix_m", "bindings": { - "m": "vim::Matching" - } + "m": "vim::Matching", + }, }, { "context": "vim_operator == helix_next", @@ -622,8 +655,8 @@ "x": "editor::SelectSmallerSyntaxNode", "d": "editor::GoToDiagnostic", "c": "editor::GoToHunk", - "space": "vim::InsertEmptyLineBelow" - } + "space": "vim::InsertEmptyLineBelow", + }, }, { "context": "vim_operator == helix_previous", @@ -640,8 +673,8 @@ "x": "editor::SelectLargerSyntaxNode", "d": "editor::GoToPreviousDiagnostic", "c": "editor::GoToPreviousHunk", - "space": "vim::InsertEmptyLineAbove" - } + "space": "vim::InsertEmptyLineAbove", + }, }, { "context": "vim_operator == c", @@ -649,8 +682,8 @@ "c": "vim::CurrentLine", "x": "vim::Exchange", "d": "editor::Rename", // zed specific - "s": ["vim::PushChangeSurrounds", {}] - } + "s": ["vim::PushChangeSurrounds", {}], + }, }, { "context": "vim_operator == d", @@ -662,36 +695,36 @@ "shift-o": "git::ToggleStaged", "p": "git::Restore", // "d p" "u": "git::StageAndNext", // "d u" - "shift-u": "git::UnstageAndNext" // "d shift-u" - } + "shift-u": "git::UnstageAndNext", // "d shift-u" + }, }, { "context": "vim_operator == gu", "bindings": { "g u": "vim::CurrentLine", - "u": "vim::CurrentLine" - } + "u": "vim::CurrentLine", + }, }, { "context": "vim_operator == gU", "bindings": { "g shift-u": "vim::CurrentLine", - "shift-u": "vim::CurrentLine" - } + "shift-u": "vim::CurrentLine", + }, }, { "context": "vim_operator == g~", "bindings": { "g ~": "vim::CurrentLine", - "~": "vim::CurrentLine" - } + "~": "vim::CurrentLine", + }, }, { "context": "vim_operator == g?", "bindings": { "g ?": "vim::CurrentLine", - "?": "vim::CurrentLine" - } + "?": "vim::CurrentLine", + }, }, { "context": "vim_operator == gq", @@ -699,66 +732,66 @@ "g q": "vim::CurrentLine", "q": "vim::CurrentLine", "g w": "vim::CurrentLine", - "w": "vim::CurrentLine" - } + "w": "vim::CurrentLine", + }, }, { "context": "vim_operator == y", "bindings": { "y": "vim::CurrentLine", "v": "vim::PushForcedMotion", - "s": ["vim::PushAddSurrounds", {}] - } + "s": ["vim::PushAddSurrounds", {}], + }, }, { "context": "vim_operator == ys", "bindings": { - "s": "vim::CurrentLine" - } + "s": "vim::CurrentLine", + }, }, { "context": "vim_operator == >", "bindings": { - ">": "vim::CurrentLine" - } + ">": "vim::CurrentLine", + }, }, { "context": "vim_operator == <", "bindings": { - "<": "vim::CurrentLine" - } + "<": "vim::CurrentLine", + }, }, { "context": "vim_operator == eq", "bindings": { - "=": "vim::CurrentLine" - } + "=": "vim::CurrentLine", + }, }, { "context": "vim_operator == sh", "bindings": { - "!": "vim::CurrentLine" - } + "!": "vim::CurrentLine", + }, }, { "context": "vim_operator == gc", "bindings": { - "c": "vim::CurrentLine" - } + "c": "vim::CurrentLine", + }, }, { "context": "vim_operator == gR", "bindings": { "r": "vim::CurrentLine", - "shift-r": "vim::CurrentLine" - } + "shift-r": "vim::CurrentLine", + }, }, { "context": "vim_operator == cx", "bindings": { "x": "vim::CurrentLine", - "c": "vim::ClearExchange" - } + "c": "vim::ClearExchange", + }, }, { "context": "vim_mode == literal", @@ -800,18 +833,18 @@ "tab": ["vim::Literal", ["tab", "\u0009"]], // zed extensions: "backspace": ["vim::Literal", ["backspace", "\u0008"]], - "delete": ["vim::Literal", ["delete", "\u007F"]] - } + "delete": ["vim::Literal", ["delete", "\u007F"]], + }, }, { "context": "BufferSearchBar && !in_replace", "bindings": { "enter": "vim::SearchSubmit", - "escape": "buffer_search::Dismiss" - } + "escape": "buffer_search::Dismiss", + }, }, { - "context": "VimControl || !Editor && !Terminal", + "context": "VimControl && !menu || !Editor && !Terminal", "bindings": { // window related commands (ctrl-w X) "ctrl-w": null, @@ -831,10 +864,12 @@ "ctrl-w shift-right": "workspace::SwapPaneRight", "ctrl-w shift-up": "workspace::SwapPaneUp", "ctrl-w shift-down": "workspace::SwapPaneDown", - "ctrl-w shift-h": "workspace::SwapPaneLeft", - "ctrl-w shift-l": "workspace::SwapPaneRight", - "ctrl-w shift-k": "workspace::SwapPaneUp", - "ctrl-w shift-j": "workspace::SwapPaneDown", + "ctrl-w x": "workspace::SwapPaneAdjacent", + "ctrl-w ctrl-x": "workspace::SwapPaneAdjacent", + "ctrl-w shift-h": "workspace::MovePaneLeft", + "ctrl-w shift-l": "workspace::MovePaneRight", + "ctrl-w shift-k": "workspace::MovePaneUp", + "ctrl-w shift-j": "workspace::MovePaneDown", "ctrl-w >": "vim::ResizePaneRight", "ctrl-w <": "vim::ResizePaneLeft", "ctrl-w -": "vim::ResizePaneDown", @@ -865,15 +900,21 @@ "ctrl-w ctrl-o": "workspace::CloseInactiveTabsAndPanes", "ctrl-w o": "workspace::CloseInactiveTabsAndPanes", "ctrl-w ctrl-n": "workspace::NewFileSplitHorizontal", - "ctrl-w n": "workspace::NewFileSplitHorizontal" - } + "ctrl-w n": "workspace::NewFileSplitHorizontal", + "g t": "vim::GoToTab", + "g shift-t": "vim::GoToPreviousTab", + }, }, { "context": "!Editor && !Terminal", "bindings": { ":": "command_palette::Toggle", - "g /": "pane::DeploySearch" - } + "g /": "pane::DeploySearch", + "] b": "pane::ActivateNextItem", + "[ b": "pane::ActivatePreviousItem", + "] shift-b": "pane::ActivateLastItem", + "[ shift-b": ["pane::ActivateItem", 0], + }, }, { // netrw compatibility @@ -923,17 +964,45 @@ "6": ["vim::Number", 6], "7": ["vim::Number", 7], "8": ["vim::Number", 8], - "9": ["vim::Number", 9] - } + "9": ["vim::Number", 9], + }, }, { "context": "OutlinePanel && not_editing", "bindings": { - "j": "menu::SelectNext", - "k": "menu::SelectPrevious", + "h": "outline_panel::CollapseSelectedEntry", + "j": "vim::MenuSelectNext", + "k": "vim::MenuSelectPrevious", + "down": "vim::MenuSelectNext", + "up": "vim::MenuSelectPrevious", + "l": "outline_panel::ExpandSelectedEntry", "shift-g": "menu::SelectLast", - "g g": "menu::SelectFirst" - } + "g g": "menu::SelectFirst", + "-": "outline_panel::SelectParent", + "enter": "editor::ToggleFocus", + "/": "menu::Cancel", + "ctrl-u": "outline_panel::ScrollUp", + "ctrl-d": "outline_panel::ScrollDown", + "z t": "outline_panel::ScrollCursorTop", + "z z": "outline_panel::ScrollCursorCenter", + "z b": "outline_panel::ScrollCursorBottom", + "0": ["vim::Number", 0], + "1": ["vim::Number", 1], + "2": ["vim::Number", 2], + "3": ["vim::Number", 3], + "4": ["vim::Number", 4], + "5": ["vim::Number", 5], + "6": ["vim::Number", 6], + "7": ["vim::Number", 7], + "8": ["vim::Number", 8], + "9": ["vim::Number", 9], + }, + }, + { + "context": "OutlinePanel && editing", + "bindings": { + "enter": "menu::Cancel", + }, }, { "context": "GitPanel && ChangesList", @@ -948,8 +1017,8 @@ "x": "git::ToggleStaged", "shift-x": "git::StageAll", "g x": "git::StageRange", - "shift-u": "git::UnstageAll" - } + "shift-u": "git::UnstageAll", + }, }, { "context": "Editor && mode == auto_height && VimControl", @@ -960,37 +1029,39 @@ "#": null, "*": null, "n": null, - "shift-n": null - } + "shift-n": null, + }, }, { "context": "Picker > Editor", "bindings": { "ctrl-h": "editor::Backspace", "ctrl-u": "editor::DeleteToBeginningOfLine", - "ctrl-w": "editor::DeleteToPreviousWordStart" - } + "ctrl-w": "editor::DeleteToPreviousWordStart", + "ctrl-p": "menu::SelectPrevious", + "ctrl-n": "menu::SelectNext", + }, }, { "context": "GitCommit > Editor && VimControl && vim_mode == normal", "bindings": { "ctrl-c": "menu::Cancel", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Editor && edit_prediction", "bindings": { // This is identical to the binding in the base keymap, but the vim bindings above to // "vim::Tab" shadow it, so it needs to be bound again. - "tab": "editor::AcceptEditPrediction" - } + "tab": "editor::AcceptEditPrediction", + }, }, { "context": "MessageEditor > Editor && VimControl", "bindings": { - "enter": "agent::Chat" - } + "enter": "agent::Chat", + }, }, { "context": "os != macos && Editor && edit_prediction_conflict", @@ -998,7 +1069,27 @@ // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This // is because alt-tab may not be available, as it is often used for window switching on Linux // and Windows. - "alt-l": "editor::AcceptEditPrediction" - } - } + "alt-l": "editor::AcceptEditPrediction", + }, + }, + { + "context": "SettingsWindow > NavigationMenu && !search", + "bindings": { + "l": "settings_editor::ExpandNavEntry", + "h": "settings_editor::CollapseNavEntry", + "k": "settings_editor::FocusPreviousNavEntry", + "j": "settings_editor::FocusNextNavEntry", + "g g": "settings_editor::FocusFirstNavEntry", + "shift-g": "settings_editor::FocusLastNavEntry", + }, + }, + { + "context": "MarkdownPreview", + "bindings": { + "ctrl-u": "markdown::ScrollPageUp", + "ctrl-d": "markdown::ScrollPageDown", + "ctrl-y": "markdown::ScrollUp", + "ctrl-e": "markdown::ScrollDown", + }, + }, ] diff --git a/assets/prompts/assistant_system_prompt.hbs b/assets/prompts/assistant_system_prompt.hbs deleted file mode 100644 index f47c1ffa90..0000000000 --- a/assets/prompts/assistant_system_prompt.hbs +++ /dev/null @@ -1,179 +0,0 @@ -You are a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. - -## Communication - -1. Be conversational but professional. -2. Refer to the user in the second person and yourself in the first person. -3. Format your responses in markdown. Use backticks to format file, directory, function, and class names. -4. NEVER lie or make things up. -5. Refrain from apologizing all the time when results are unexpected. Instead, just try your best to proceed or explain the circumstances to the user without apologizing. - -{{#if has_tools}} -## Tool Use - -1. Make sure to adhere to the tools schema. -2. Provide every required argument. -3. DO NOT use tools to access items that are already available in the context section. -4. Use only the tools that are currently available. -5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off. -6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers. -7. Avoid HTML entity escaping - use plain characters instead. - -## Searching and Reading - -If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions. - -{{! TODO: If there are files, we should mention it but otherwise omit that fact }} -If appropriate, use tool calls to explore the current project, which contains the following root directories: - -{{#each worktrees}} -- `{{abs_path}}` -{{/each}} - -- Bias towards not asking the user for help if you can find the answer yourself. -- When providing paths to tools, the path should always start with the name of a project root directory listed above. -- Before you read or edit a file, you must first find the full path. DO NOT ever guess a file path! -{{# if (has_tool 'grep') }} -- When looking for symbols in the project, prefer the `grep` tool. -- As you learn about the structure of the project, use that information to scope `grep` searches to targeted subtrees of the project. -- The user might specify a partial file path. If you don't know the full path, use `find_path` (not `grep`) before you read the file. -{{/if}} -{{else}} -You are being tasked with providing a response, but you have no ability to use tools or to read or write any aspect of the user's system (other than any context the user might have provided to you). - -As such, if you need the user to perform any actions for you, you must request them explicitly. Bias towards giving a response to the best of your ability, and then making requests for the user to take action (e.g. to give you more context) only optionally. - -The one exception to this is if the user references something you don't know about - for example, the name of a source code file, function, type, or other piece of code that you have no awareness of. In this case, you MUST NOT MAKE SOMETHING UP, or assume you know what that thing is or how it works. Instead, you must ask the user for clarification rather than giving a response. -{{/if}} - -## Code Block Formatting - -Whenever you mention a code block, you MUST use ONLY use the following format: -```path/to/Something.blah#L123-456 -(code goes here) -``` -The `#L123-456` means the line number range 123 through 456, and the path/to/Something.blah -is a path in the project. (If there is no valid path in the project, then you can use -/dev/null/path.extension for its path.) This is the ONLY valid way to format code blocks, because the Markdown parser -does not understand the more common ```language syntax, or bare ``` blocks. It only -understands this path-based syntax, and if the path is missing, then it will error and you will have to do it over again. -Just to be really clear about this, if you ever find yourself writing three backticks followed by a language name, STOP! -You have made a mistake. You can only ever put paths after triple backticks! - -Based on all the information I've gathered, here's a summary of how this system works: -1. The README file is loaded into the system. -2. The system finds the first two headers, including everything in between. In this case, that would be: -```path/to/README.md#L8-12 -# First Header -This is the info under the first header. -## Sub-header -``` -3. Then the system finds the last header in the README: -```path/to/README.md#L27-29 -## Last Header -This is the last header in the README. -``` -4. Finally, it passes this information on to the next process. - - -In Markdown, hash marks signify headings. For example: -```/dev/null/example.md#L1-3 -# Level 1 heading -## Level 2 heading -### Level 3 heading -``` - -Here are examples of ways you must never render code blocks: - -In Markdown, hash marks signify headings. For example: -``` -# Level 1 heading -## Level 2 heading -### Level 3 heading -``` - -This example is unacceptable because it does not include the path. - -In Markdown, hash marks signify headings. For example: -```markdown -# Level 1 heading -## Level 2 heading -### Level 3 heading -``` - -This example is unacceptable because it has the language instead of the path. - -In Markdown, hash marks signify headings. For example: - # Level 1 heading - ## Level 2 heading - ### Level 3 heading - -This example is unacceptable because it uses indentation to mark the code block -instead of backticks with a path. - -In Markdown, hash marks signify headings. For example: -```markdown -/dev/null/example.md#L1-3 -# Level 1 heading -## Level 2 heading -### Level 3 heading -``` - -This example is unacceptable because the path is in the wrong place. The path must be directly after the opening backticks. - -{{#if has_tools}} -## Fixing Diagnostics - -1. Make 1-2 attempts at fixing diagnostics, then defer to the user. -2. Never simplify code you've written just to solve diagnostics. Complete, mostly correct code is more valuable than perfect code that doesn't solve the problem. - -## Debugging - -When debugging, only make code changes if you are certain that you can solve the problem. -Otherwise, follow debugging best practices: -1. Address the root cause instead of the symptoms. -2. Add descriptive logging statements and error messages to track variable and code state. -3. Add test functions and statements to isolate the problem. - -{{/if}} -## Calling External APIs - -1. Unless explicitly requested by the user, use the best suited external APIs and packages to solve the task. There is no need to ask the user for permission. -2. When selecting which version of an API or package to use, choose one that is compatible with the user's dependency management file(s). If no such file exists or if the package is not present, use the latest version that is in your training data. -3. If an external API requires an API Key, be sure to point this out to the user. Adhere to best security practices (e.g. DO NOT hardcode an API key in a place where it can be exposed) - -## System Information - -Operating System: {{os}} -Default Shell: {{shell}} - -{{#if (or has_rules has_user_rules)}} -## User's Custom Instructions - -The following additional instructions are provided by the user, and should be followed to the best of your ability{{#if has_tools}} without interfering with the tool use guidelines{{/if}}. - -{{#if has_rules}} -There are project rules that apply to these root directories: -{{#each worktrees}} -{{#if rules_file}} -`{{root_name}}/{{rules_file.path_in_worktree}}`: -`````` -{{{rules_file.text}}} -`````` -{{/if}} -{{/each}} -{{/if}} - -{{#if has_user_rules}} -The user has specified the following rules that should be applied: -{{#each user_rules}} - -{{#if title}} -Rules title: {{title}} -{{/if}} -`````` -{{contents}} -`````` -{{/each}} -{{/if}} -{{/if}} diff --git a/assets/prompts/content_prompt_v2.hbs b/assets/prompts/content_prompt_v2.hbs new file mode 100644 index 0000000000..87376f49f1 --- /dev/null +++ b/assets/prompts/content_prompt_v2.hbs @@ -0,0 +1,43 @@ +{{#if language_name}} +Here's a file of {{language_name}} that the user is going to ask you to make an edit to. +{{else}} +Here's a file of text that the user is going to ask you to make an edit to. +{{/if}} + +The section you'll need to rewrite is marked with tags. + + +{{{document_content}}} + + +{{#if is_truncated}} +The context around the relevant section has been truncated (possibly in the middle of a line) for brevity. +{{/if}} + +{{#if rewrite_section}} +And here's the section to rewrite based on that prompt again for reference: + + +{{{rewrite_section}}} + + +{{#if diagnostic_errors}} +Below are the diagnostic errors visible to the user. If the user requests problems to be fixed, use this information, but do not try to fix these errors if the user hasn't asked you to. + +{{#each diagnostic_errors}} + + {{line_number}} + {{error_message}} + {{code_content}} + +{{/each}} +{{/if}} + +{{/if}} + +Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding {{content_type}} will be preserved. + +Start at the indentation level in the original file in the rewritten {{content_type}}. + +IMPORTANT: You MUST use one of the provided tools to make the rewrite or to provide an explanation as to why the user's request cannot be fulfilled. You MUST NOT send back unstructured text. If you need to make a statement or ask a question you MUST use one of the tools to do so. +It is an error if you try to make a change that cannot be made simply by editing the rewrite_section. diff --git a/assets/settings/default.json b/assets/settings/default.json index 5d195f4bcd..146915dd1a 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1,7 +1,8 @@ { - /// The displayed name of this project. If not set or empty, the root directory name + "$schema": "zed://schemas/settings", + /// The displayed name of this project. If not set or null, the root directory name /// will be displayed. - "project_name": "", + "project_name": null, // The name of the Zed theme to use for the UI. // // `mode` is one of: @@ -11,7 +12,7 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" + "dark": "One Dark", }, "icon_theme": "Zed (Default)", // The name of a base set of key bindings to use. @@ -28,7 +29,7 @@ // Features that can be globally enabled or disabled "features": { // Which edit prediction provider to use. - "edit_prediction_provider": "zed" + "edit_prediction_provider": "zed", }, // The name of a font to use for rendering text in the editor // ".ZedMono" currently aliases to Lilex @@ -68,7 +69,7 @@ // The OpenType features to enable for text in the UI "ui_font_features": { // Disable ligatures: - "calt": false + "calt": false, }, // The weight of the UI font in standard CSS units from 100 to 900. "ui_font_weight": 400, @@ -76,7 +77,7 @@ "ui_font_size": 16, // The default font size for agent responses in the agent panel. Falls back to the UI font size if unset. "agent_ui_font_size": null, - // The default font size for user messages in the agent panel. Falls back to the buffer font size if unset. + // The default font size for user messages in the agent panel. "agent_buffer_font_size": 12, // How much to fade out unused code. "unnecessary_code_fade": 0.3, @@ -86,7 +87,7 @@ "border_size": 0.0, // Opacity of the inactive panes. 0 means transparent, 1 means opaque. // Values are clamped to the [0.0, 1.0] range. - "inactive_opacity": 1.0 + "inactive_opacity": 1.0, }, // Layout mode of the bottom dock. Defaults to "contained" // choices: contained, full, left_aligned, right_aligned @@ -102,12 +103,12 @@ "left_padding": 0.2, // The relative width of the right padding of the central pane from the // workspace when the centered layout is used. - "right_padding": 0.2 + "right_padding": 0.2, }, // Image viewer settings "image_viewer": { // The unit for image file sizes: "binary" (KiB, MiB) or decimal (KB, MB) - "unit": "binary" + "unit": "binary", }, // Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. // @@ -174,6 +175,16 @@ // // Default: true "zoomed_padding": true, + // What draws Zed's window decorations (titlebar): + // 1. Client application (Zed) draws its own window decorations + // "client" + // 2. Display server draws the window decorations. Not supported by GNOME Wayland. + // "server" + // + // This requires restarting Zed for changes to take effect. + // + // Default: "client" + "window_decorations": "client", // Whether to use the system provided dialogs for Open and Save As. // When set to false, Zed will use the built-in keyboard-first pickers. "use_system_path_prompts": true, @@ -254,6 +265,25 @@ // Whether to display inline and alongside documentation for items in the // completions menu "show_completion_documentation": true, + // Whether to colorize brackets in the editor. + // (also known as "rainbow brackets") + // + // The colors that are used for different indentation levels are defined in the theme (theme key: `accents`). + // They can be customized by using theme overrides. + "colorize_brackets": false, + // When to show the scrollbar in the completion menu. + // This setting can take four values: + // + // 1. Show the scrollbar if there's important information or + // follow the system's configured behavior + // "auto" + // 2. Match the system's configured behavior: + // "system" + // 3. Always show the scrollbar: + // "always" + // 4. Never show the scrollbar: + // "never" (default) + "completion_menu_scrollbar": "never", // Show method signatures in the editor, when inside parentheses. "auto_signature_help": false, // Whether to show the signature help after completion or a bracket pair inserted. @@ -266,7 +296,7 @@ // 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 + "delay": 300, }, // What to do when go to definition yields no results. // @@ -310,11 +340,11 @@ "use_on_type_format": true, // Whether to automatically add matching closing characters when typing // opening parenthesis, bracket, brace, single or double quote characters. - // For example, when you type (, Zed will add a closing ) at the correct position. + // For example, when you type '(', Zed will add a closing ) at the correct position. "use_autoclose": true, // Whether to automatically surround selected text when typing opening parenthesis, // bracket, brace, single or double quote characters. - // For example, when you select text and type (, Zed will surround the text with (). + // For example, when you select text and type '(', Zed will surround the text with (). "use_auto_surround": true, // Whether indentation should be adjusted based on the context whilst typing. "auto_indent": true, @@ -370,14 +400,14 @@ // Visible characters used to render whitespace when show_whitespaces is enabled. "whitespace_map": { "space": "•", - "tab": "→" + "tab": "→", }, // Settings related to calls in Zed "calls": { // Join calls with the microphone live by default "mute_on_join": false, // Share your project when you are the first to join a channel - "share_on_join": false + "share_on_join": false, }, // Toolbar related settings "toolbar": { @@ -390,7 +420,7 @@ // Whether to show agent review buttons in the editor toolbar. "agent_review": true, // Whether to show code action buttons in the editor toolbar. - "code_actions": false + "code_actions": false, }, // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). "use_system_window_tabs": false, @@ -406,10 +436,12 @@ "show_onboarding_banner": true, // Whether to show user picture in the titlebar. "show_user_picture": true, + // Whether to show the user menu in the titlebar. + "show_user_menu": true, // Whether to show the sign in button in the titlebar. "show_sign_in": true, // Whether to show the menus in the titlebar. - "show_menus": false + "show_menus": false, }, "audio": { // Opt into the new audio system. @@ -442,7 +474,7 @@ // the future we will migrate by setting this to false // // You need to rejoin a call for this setting to apply - "experimental.legacy_audio_compatible": true + "experimental.legacy_audio_compatible": true, }, // Scrollbar related settings "scrollbar": { @@ -481,8 +513,8 @@ // When false, forcefully disables the horizontal scrollbar. Otherwise, obey other settings. "horizontal": true, // When false, forcefully disables the vertical scrollbar. Otherwise, obey other settings. - "vertical": true - } + "vertical": true, + }, }, // Minimap related settings "minimap": { @@ -530,7 +562,7 @@ // 3. "gutter" or "none" to not highlight the current line in the minimap. "current_line_highlight": null, // Maximum number of columns to display in the minimap. - "max_width_columns": 80 + "max_width_columns": 80, }, // Enable middle-click paste on Linux. "middle_click_paste": true, @@ -553,7 +585,7 @@ // Whether to show fold buttons in the gutter. "folds": true, // Minimum number of characters to reserve space for in the gutter. - "min_line_number_digits": 4 + "min_line_number_digits": 4, }, "indent_guides": { // Whether to show indent guides in the editor. @@ -574,7 +606,7 @@ // // 1. "disabled" // 2. "indent_aware" - "background_coloring": "disabled" + "background_coloring": "disabled", }, // Whether the editor will scroll beyond the last line. "scroll_beyond_last_line": "one_page", @@ -591,17 +623,27 @@ // to both the horizontal and vertical delta values while scrolling. Fast scrolling // happens when a user holds the alt or option key while scrolling. "fast_scroll_sensitivity": 4.0, - "relative_line_numbers": false, + "sticky_scroll": { + // Whether to stick scopes to the top of the editor. + "enabled": false, + }, + "relative_line_numbers": "disabled", // If 'search_wrap' is disabled, search result do not wrap around the end of the file. "search_wrap": true, // Search options to enable by default when opening new project and buffer searches. "search": { // Whether to show the project search button in the status bar. "button": true, + // Whether to only match on whole words. "whole_word": false, + // Whether to match case sensitively. "case_sensitive": false, + // Whether to include gitignored files in search results. "include_ignored": false, - "regex": false + // Whether to interpret the search query as a regular expression. + "regex": false, + // Whether to center the cursor on each search match when navigating. + "center_on_match": false, }, // When to populate a new search's query based on the text under the cursor. // This setting can take the following three values: @@ -644,8 +686,8 @@ "shift": false, "alt": false, "platform": false, - "function": false - } + "function": false, + }, }, // Whether to resize all the panels in a dock when resizing the dock. // Can be a combination of "left", "right" and "bottom". @@ -693,7 +735,7 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Which files containing diagnostic errors/warnings to mark in the project panel. // This setting can take the following three values: @@ -716,12 +758,33 @@ // "always" // 2. Never show indent guides: // "never" - "show": "always" + "show": "always", }, + // Sort order for entries in the project panel. + // This setting can take three values: + // + // 1. Show directories first, then files: + // "directories_first" + // 2. Mix directories and files together: + // "mixed" + // 3. Show files first, then directories: + // "files_first" + "sort_mode": "directories_first", // Whether to enable drag-and-drop operations in the project panel. "drag_and_drop": true, // Whether to hide the root entry when only one folder is open in the window. - "hide_root": false + "hide_root": false, + // Whether to hide the hidden entries in the project panel. + "hide_hidden": false, + // Settings for automatically opening files. + "auto_open": { + // Whether to automatically open newly created files in the editor. + "on_create": true, + // Whether to automatically open files after pasting or duplicating them. + "on_paste": true, + // Whether to automatically open files dropped from external sources. + "on_drop": true, + }, }, "outline_panel": { // Whether to show the outline panel button in the status bar @@ -754,7 +817,7 @@ // "always" // 2. Never show indent guides: // "never" - "show": "always" + "show": "always", }, // Scrollbar-related settings "scrollbar": { @@ -771,11 +834,11 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Default depth to expand outline items in the current file. // Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper. - "expand_outlines_with_depth": 100 + "expand_outlines_with_depth": 100, }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. @@ -783,7 +846,7 @@ // Where to dock the collaboration panel. Can be 'left' or 'right'. "dock": "left", // Default width of the collaboration panel. - "default_width": 240 + "default_width": 240, }, "git_panel": { // Whether to show the git panel button in the status bar. @@ -809,18 +872,22 @@ // // Default: false "collapse_untracked_diff": false, + /// Whether to show entries with tree or flat view in the panel + /// + /// Default: false + "tree_view": false, "scrollbar": { // When to show the scrollbar in the git panel. // // Choices: always, auto, never, system // Default: inherits editor scrollbar settings // "show": null - } + }, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. // For example: typing `:wave:` gets replaced with `👋`. - "auto_replace_emoji_shortcode": true + "auto_replace_emoji_shortcode": true, }, "notification_panel": { // Whether to show the notification panel button in the status bar. @@ -828,9 +895,11 @@ // Where to dock the notification panel. Can be 'left' or 'right'. "dock": "right", // Default width of the notification panel. - "default_width": 380 + "default_width": 380, }, "agent": { + // Whether the inline assistant should use streaming tools, when available + "inline_assistant_use_streaming_tools": true, // Whether the agent is enabled. "enabled": true, // What completion mode to start new threads in, if available. Can be 'normal' or 'burn'. @@ -839,6 +908,8 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "right", + // Where to dock the agents panel. Can be 'left' or 'right'. + "agents_panel_dock": "left", // Default width when the agent panel is docked to the left or right. "default_width": 640, // Default height when the agent panel is docked to the bottom. @@ -850,7 +921,7 @@ // The provider to use. "provider": "zed.dev", // The model to use. - "model": "claude-sonnet-4" + "model": "claude-sonnet-4", }, // Additional parameters for language model requests. When making a request to a model, parameters will be taken // from the last entry in this list that matches the model's provider and name. In each entry, both provider @@ -879,8 +950,6 @@ // Note: This setting has no effect on external agents that support permission modes, such as Claude Code. // You can set `agent_servers.claude.default_mode` to `bypassPermissions` to skip all permission requests. "always_allow_tool_actions": false, - // When enabled, the agent will stream edits. - "stream_edits": false, // When enabled, agent edits will be displayed in single-file editors for review "single_file_review": true, // When enabled, show voting thumbs for feedback on agent edits. @@ -903,18 +972,18 @@ "now": true, "find_path": true, "read_file": true, + "open": true, "grep": true, "terminal": true, "thinking": true, - "web_search": true - } + "web_search": true, + }, }, "ask": { "name": "Ask", // We don't know which of the context server tools are safe for the "Ask" profile, so we don't enable them by default. // "enable_all_context_servers": true, "tools": { - "contents": true, "diagnostics": true, "fetch": true, "list_directory": true, @@ -925,14 +994,14 @@ "open": true, "grep": true, "thinking": true, - "web_search": true - } + "web_search": true, + }, }, "minimal": { "name": "Minimal", "enable_all_context_servers": false, - "tools": {} - } + "tools": {}, + }, }, // Where to show notifications when the agent has either completed // its response, or else needs confirmation before it can run a @@ -961,7 +1030,7 @@ // Minimum number of lines to display in the agent message editor. // // Default: 4 - "message_editor_min_lines": 4 + "message_editor_min_lines": 4, }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, @@ -996,7 +1065,7 @@ // Whether or not to show the navigation history buttons. "show_nav_history_buttons": true, // Whether or not to show the tab bar buttons. - "show_tab_bar_buttons": true + "show_tab_bar_buttons": true, }, // Settings related to the editor's tabs "tabs": { @@ -1035,19 +1104,28 @@ // "errors" // 3. Mark files with errors and warnings: // "all" - "show_diagnostics": "off" + "show_diagnostics": "off", }, // Settings related to preview tabs. "preview_tabs": { // Whether preview tabs should be enabled. // Preview tabs allow you to open files in preview mode, where they close automatically - // when you switch to another file unless you explicitly pin them. + // when you open another preview tab. // This is useful for quickly viewing files without cluttering your workspace. "enabled": true, + // Whether to open tabs in preview mode when opened from the project panel with a single click. + "enable_preview_from_project_panel": true, // Whether to open tabs in preview mode when selected from the file finder. "enable_preview_from_file_finder": false, - // Whether a preview tab gets replaced when code navigation is used to navigate away from the tab. - "enable_preview_from_code_navigation": false + // Whether to open tabs in preview mode when opened from a multibuffer. + "enable_preview_from_multibuffer": true, + // Whether to open tabs in preview mode when code navigation is used to open a multibuffer. + "enable_preview_multibuffer_from_code_navigation": false, + // Whether to open tabs in preview mode when code navigation is used to open a single file. + "enable_preview_file_from_code_navigation": true, + // Whether to keep tabs in preview mode when code navigation is used to navigate away from them. + // If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. + "enable_keep_preview_on_code_navigation": false, }, // Settings related to the file finder. "file_finder": { @@ -1088,10 +1166,10 @@ // Only the file Zed had indexed will be used, not necessary all the gitignored files. // // Can accept 3 values: - // * `true`: Use all gitignored files - // * `false`: Use only the files Zed had indexed - // * `null`: Be smart and search for ignored when called from a gitignored worktree - "include_ignored": null + // * "all": Use all gitignored files + // * "indexed": Use only the files Zed had indexed + // * "smart": Be smart and search for ignored when called from a gitignored worktree + "include_ignored": "smart", }, // Whether or not to remove any trailing whitespace from lines of a buffer // before saving it. @@ -1101,25 +1179,31 @@ // Removes any lines containing only whitespace at the end of the file and // ensures just one newline at the end. "ensure_final_newline_on_save": true, - // Whether or not to perform a buffer format before saving: [on, off, prettier, language_server] + // Whether or not to perform a buffer format before saving: [on, off] // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored "format_on_save": "on", - // How to perform a buffer format. This setting can take 4 values: + // How to perform a buffer format. This setting can take multiple values: // - // 1. Format code using the current language server: + // 1. Default. Format files using Zed's Prettier integration (if applicable), + // or falling back to formatting via language server: + // "formatter": "auto" + // 2. Format code using the current language server: // "formatter": "language_server" - // 2. Format code using an external command: + // 3. Format code using a specific language server: + // "formatter": {"language_server": {"name": "ruff"}} + // 4. Format code using an external command: // "formatter": { // "external": { // "command": "prettier", // "arguments": ["--stdin-filepath", "{buffer_path}"] // } // } - // 3. Format code using Zed's Prettier integration: + // 5. Format code using Zed's Prettier integration: // "formatter": "prettier" - // 4. Default. Format files using Zed's Prettier integration (if applicable), - // or falling back to formatting via language server: - // "formatter": "auto" + // 6. Format code using a code action + // "formatter": {"code_action": "source.fixAll.eslint"} + // 7. An array of any format step specified above to apply in order + // "formatter": [{"code_action": "source.fixAll.eslint"}, "prettier"] "formatter": "auto", // How to soft-wrap long lines of text. // Possible values: @@ -1144,12 +1228,19 @@ "tab_size": 4, // What debuggers are preferred by default for all languages. "debuggers": [], + // Whether to enable word diff highlighting in the editor. + // + // When enabled, changed words within modified lines are highlighted + // to show exactly what changed. + // + // Default: true + "word_diff_enabled": true, // Control what info is collected by Zed. "telemetry": { // Send debug info like crash reports. "diagnostics": true, // Send anonymized usage data like what languages you're using Zed with. - "metrics": true + "metrics": true, }, // Whether to disable all AI features in Zed. // @@ -1183,7 +1274,7 @@ "enabled": true, // Minimum time to wait before pulling diagnostics from the language server(s). // 0 turns the debounce off. - "debounce_ms": 50 + "debounce_ms": 50, }, // Settings for inline diagnostics "inline": { @@ -1201,8 +1292,8 @@ "min_column": 0, // The minimum severity of the diagnostics to show inline. // Inherits editor's diagnostics' max severity settings when `null`. - "max_severity": null - } + "max_severity": null, + }, }, // Files or globs of files that will be excluded by Zed entirely. They will be skipped during file // scans, file searches, and not be displayed in the project file tree. Takes precedence over `file_scan_inclusions`. @@ -1216,13 +1307,16 @@ "**/.DS_Store", "**/Thumbs.db", "**/.classpath", - "**/.settings" + "**/.settings", ], // Files or globs of files that will be included by Zed, even when ignored by git. This is useful // for files that are not tracked by git, but are still important to your project. Note that globs // that are overly broad can slow down Zed's file scanning. `file_scan_exclusions` takes // precedence over these inclusions. "file_scan_inclusions": [".env*"], + // Globs to match files that will be considered "hidden". These files can be hidden from the + // project panel by toggling the "hide_hidden" setting. + "hidden_files": ["**/.*"], // Git gutter behavior configuration. "git": { // Control whether the git gutter is shown. May take 2 values: @@ -1248,14 +1342,14 @@ // Whether or not to display the git commit summary on the same line. "show_commit_summary": false, // The minimum column number to show the inline blame information at - "min_column": 0 + "min_column": 0, }, "blame": { - "show_avatar": true + "show_avatar": true, }, // Control which information is shown in the branch picker. "branch_picker": { - "show_author_name": true + "show_author_name": true, }, // How git hunks are displayed visually in the editor. // This setting can take two values: @@ -1264,7 +1358,10 @@ // "hunk_style": "staged_hollow" // 2. Show unstaged hunks hollow and staged hunks filled: // "hunk_style": "unstaged_hollow" - "hunk_style": "staged_hollow" + "hunk_style": "staged_hollow", + // Should the name or path be displayed first in the git view. + // "path_style": "file_name_first" or "file_path_first" + "path_style": "file_name_first", }, // The list of custom Git hosting providers. "git_hosting_providers": [ @@ -1279,6 +1376,8 @@ // "load_direnv": "direct" // 2. Load direnv configuration through the shell hook, works for POSIX shells and fish. // "load_direnv": "shell_hook" + // 3. Don't load direnv configuration at all. + // "load_direnv": "disabled" "load_direnv": "direct", "edit_predictions": { // A list of globs representing files that edit predictions should be disabled for. @@ -1296,7 +1395,7 @@ "**/secrets.yml", "**/.zed/settings.json", // zed project settings "/**/zed/settings.json", // zed user settings - "/**/zed/keymap.json" + "/**/zed/keymap.json", ], // When to show edit predictions previews in buffer. // This setting takes two possible values: @@ -1311,15 +1410,19 @@ // "proxy": "", // "proxy_no_verify": false // }, - // Whether edit predictions are enabled when editing text threads. - // This setting has no effect if globally disabled. - "enabled_in_text_threads": true, - "copilot": { "enterprise_uri": null, "proxy": null, - "proxy_no_verify": null - } + "proxy_no_verify": null, + }, + "codestral": { + "api_url": "https://codestral.mistral.ai", + "model": "codestral-latest", + "max_tokens": 150, + }, + // Whether edit predictions are enabled when editing text threads in the agent panel. + // This setting has no effect if globally disabled. + "enabled_in_text_threads": true, }, // Settings specific to journaling "journal": { @@ -1329,7 +1432,7 @@ // May take 2 values: // 1. hour12 // 2. hour24 - "hour_format": "hour12" + "hour_format": "hour12", }, // Status bar-related settings. "status_bar": { @@ -1338,7 +1441,9 @@ // Whether to show the active language button in the status bar. "active_language_button": true, // Whether to show the cursor position button in the status bar. - "cursor_position_button": true + "cursor_position_button": true, + // Whether to show active line endings button in the status bar. + "line_endings_button": false, }, // Settings specific to the terminal "terminal": { @@ -1365,7 +1470,7 @@ "default_height": 320, // What working directory to use when launching the terminal. // May take 4 values: - // 1. Use the current file's project directory. Will Fallback to the + // 1. Use the current file's project directory. Fallback to the // first project directory strategy if unsuccessful // "working_directory": "current_project_directory" // 2. Use the first project in this workspace's directory @@ -1401,8 +1506,8 @@ // 4. A box drawn around the following character // "hollow" // - // Default: not set, defaults to "block" - "cursor_shape": null, + // Default: "block" + "cursor_shape": "block", // Set whether Alternate Scroll mode (code: ?1007) is active by default. // Alternate Scroll mode converts mouse scroll events into up / down key // presses when in the alternate screen (e.g. when running applications @@ -1424,8 +1529,8 @@ // Whether or not selecting text in the terminal will automatically // copy to the system clipboard. "copy_on_select": false, - // Whether to keep the text selection after copying it to the clipboard - "keep_selection_on_copy": false, + // Whether to keep the text selection after copying it to the clipboard. + "keep_selection_on_copy": true, // Whether to show the terminal button in the status bar "button": true, // Any key-value pairs added to this list will be added to the terminal's @@ -1455,8 +1560,12 @@ // in your project's settings, rather than globally. "directories": [".env", "env", ".venv", "venv"], // Can also be `csh`, `fish`, `nushell` and `power_shell` - "activate_script": "default" - } + "activate_script": "default", + // Preferred Conda manager to use when activating Conda environments. + // Values: "auto", "conda", "mamba", "micromamba" + // Default: "auto" + "conda_manager": "auto", + }, }, "toolbar": { // Whether to display the terminal title in its toolbar's breadcrumbs. @@ -1464,7 +1573,7 @@ // // The shell running in the terminal needs to be configured to emit the title. // Example: `echo -e "\e]2;New Title\007";` - "breadcrumbs": false + "breadcrumbs": false, }, // Scrollbar-related settings "scrollbar": { @@ -1481,7 +1590,7 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "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. @@ -1499,6 +1608,8 @@ // 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, + // The multiplier for scrolling speed in the terminal. + "scroll_multiplier": 1.0, // 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. @@ -1513,7 +1624,55 @@ // // Most terminal themes have APCA values of 40-70. // A value of 45 preserves colorful themes while ensuring legibility. - "minimum_contrast": 45 + "minimum_contrast": 45, + // Regexes used to identify paths for hyperlink navigation. Supports optional named capture + // groups `path`, `line`, `column`, and `link`. If none of these are present, the entire match + // is the hyperlink target. If `path` is present, it is the hyperlink target, along with `line` + // and `column` if present. `link` may be used to customize what text in terminal is part of the + // hyperlink. If `link` is not present, the text of the entire match is used. If `line` and + // `column` are not present, the default built-in line and column suffix processing is used + // which parses `line:column` and `(line,column)` variants. The default value handles Python + // diagnostics and common path, line, column syntaxes. This can be extended or replaced to + // handle specific scenarios. For example, to enable support for hyperlinking paths which + // contain spaces in rust output, + // + // [ + // "\\s+(-->|:::|at) (?(?.+?))(:$|$)", + // "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)" + // ], + // + // could be used. Processing stops at the first regex with a match, even if no link is + // produced which is the case when the cursor is not over the hyperlinked text. For best + // performance it is recommended to order regexes from most common to least common. For + // readability and documentation, each regex may be an array of strings which are collected + // into one multi-line regex string for use in terminal path hyperlink detection. + "path_hyperlink_regexes": [ + // Python-style diagnostics + "File \"(?[^\"]+)\", line (?[0-9]+)", + // Common path syntax with optional line, column, description, trailing punctuation, or + // surrounding symbols or quotes + [ + "(?x)", + "(?", + " (", + " # multi-char path: first char (not opening delimiter or space)", + " [^({\\[<\"'`\\ ]", + " # middle chars: non-space, and colon/paren only if not followed by digit/paren", + " ([^\\ :(]|[:(][^0-9()])*", + " # last char: not closing delimiter or colon", + " [^()}\\]>\"'`.,;:\\ ]", + " |", + " # single-char path: not delimiter, punctuation, or space", + " [^(){}\\[\\]<>\"'`.,;:\\ ]", + " )", + " # optional line/column suffix (included in path for PathWithPosition::parse_str)", + " (:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:]?[0-9]+)?\\))?", + ")", + ], + ], + // Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. Specifying a + // timeout of `0` will disable path hyperlinking in terminal. + "path_hyperlink_timeout_ms": 1, }, "code_actions_on_format": {}, // Settings related to running tasks. @@ -1529,7 +1688,7 @@ // * Zed task from history (e.g. one-off task was spawned before) // // Default: true - "prefer_lsp": true + "prefer_lsp": true, }, // An object whose keys are language names, and whose values // are arrays of filenames or extensions of files that should @@ -1545,7 +1704,8 @@ // "file_types": { "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"], - "Shell Script": [".env.*"] + "Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"], + "Shell Script": [".env.*"], }, // Settings for which version of Node.js and NPM to use when installing // language servers and Copilot. @@ -1561,14 +1721,14 @@ // `path`, but not `npm_path`, Zed will assume that `npm` is located at // `${path}/../npm`. "path": null, - "npm_path": null + "npm_path": null, }, // The extensions that Zed should automatically install on startup. // // If you don't want any of these extensions, add this field to your settings // and change the value to `false`. "auto_install_extensions": { - "html": true + "html": true, }, // The capabilities granted to extensions. // @@ -1576,7 +1736,7 @@ "granted_extension_capabilities": [ { "kind": "process:exec", "command": "*", "args": ["**"] }, { "kind": "download_file", "host": "*", "path": ["**"] }, - { "kind": "npm:install", "package": "*" } + { "kind": "npm:install", "package": "*" }, ], // Controls how completions are processed for this language. "completions": { @@ -1627,7 +1787,7 @@ // 4. "replace_suffix" // Behaves like `"replace"` if the text after the cursor is a suffix of the completion, and like // `"insert"` otherwise. - "lsp_insert_mode": "replace_suffix" + "lsp_insert_mode": "replace_suffix", }, // Different settings for specific languages. "languages": { @@ -1635,233 +1795,262 @@ "language_servers": ["astro-language-server", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-astro"] - } + "plugins": ["prettier-plugin-astro"], + }, }, "Blade": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "C": { "format_on_save": "off", "use_on_type_format": false, "prettier": { - "allowed": false - } + "allowed": false, + }, }, "C++": { "format_on_save": "off", "use_on_type_format": false, "prettier": { - "allowed": false - } + "allowed": false, + }, + }, + "CSharp": { + "language_servers": ["roslyn", "!omnisharp", "..."], }, "CSS": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Dart": { - "tab_size": 2 + "tab_size": 2, }, "Diff": { "show_edit_predictions": false, "remove_trailing_whitespace_on_save": false, - "ensure_final_newline_on_save": false + "ensure_final_newline_on_save": false, }, "Elixir": { - "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "Elm": { - "tab_size": 4 + "tab_size": 4, }, "Erlang": { - "language_servers": ["erlang-ls", "!elp", "..."] + "language_servers": ["erlang-ls", "!elp", "..."], }, "Git Commit": { "allow_rewrap": "anywhere", "soft_wrap": "editor_width", - "preferred_line_length": 72 + "preferred_line_length": 72, }, "Go": { + "hard_tabs": true, "code_actions_on_format": { - "source.organizeImports": true + "source.organizeImports": true, }, - "debuggers": ["Delve"] + "debuggers": ["Delve"], }, "GraphQL": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "HEEX": { - "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "HTML": { "prettier": { - "allowed": true - } + "allowed": true, + }, + }, + "HTML+ERB": { + "language_servers": ["herb", "!ruby-lsp", "..."], }, "Java": { "prettier": { "allowed": true, - "plugins": ["prettier-plugin-java"] - } + "plugins": ["prettier-plugin-java"], + }, }, "JavaScript": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JSON": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JSONC": { "prettier": { - "allowed": true - } + "allowed": true, + }, + }, + "JS+ERB": { + "language_servers": ["!ruby-lsp", "..."], }, "Kotlin": { - "language_servers": ["kotlin-language-server", "!kotlin-lsp", "..."] + "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."], }, "LaTeX": { "formatter": "language_server", "language_servers": ["texlab", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-latex"] - } + "plugins": ["prettier-plugin-latex"], + }, }, "Markdown": { "format_on_save": "off", "use_on_type_format": false, + "remove_trailing_whitespace_on_save": false, "allow_rewrap": "anywhere", "soft_wrap": "editor_width", + "completions": { + "words": "disabled", + }, "prettier": { - "allowed": true - } + "allowed": true, + }, }, "PHP": { - "language_servers": ["phpactor", "!intelephense", "..."], + "language_servers": ["phpactor", "!intelephense", "!phptools", "..."], "prettier": { "allowed": true, "plugins": ["@prettier/plugin-php"], - "parser": "php" - } + "parser": "php", + }, }, "Plain Text": { - "allow_rewrap": "anywhere" + "allow_rewrap": "anywhere", + "soft_wrap": "editor_width", + "completions": { + "words": "disabled", + }, + }, + "Proto": { + "language_servers": ["buf", "!protols", "!protobuf-language-server", "..."], }, "Python": { + "code_actions_on_format": { + "source.organizeImports.ruff": true, + }, "formatter": { "language_server": { - "name": "ruff" - } + "name": "ruff", + }, }, - "debuggers": ["Debugpy"] + "debuggers": ["Debugpy"], + "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."], }, "Ruby": { - "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."] + "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."], }, "Rust": { - "debuggers": ["CodeLLDB"] + "debuggers": ["CodeLLDB"], }, "SCSS": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Starlark": { - "language_servers": ["starpls", "!buck2-lsp", "..."] + "language_servers": ["starpls", "!buck2-lsp", "..."], }, "Svelte": { "language_servers": ["svelte-language-server", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-svelte"] - } + "plugins": ["prettier-plugin-svelte"], + }, }, "TSX": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Twig": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "TypeScript": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "SystemVerilog": { "format_on_save": "off", - "use_on_type_format": false + "language_servers": ["!slang", "..."], + "use_on_type_format": false, }, "Vue.js": { - "language_servers": ["vue-language-server", "..."], + "language_servers": ["vue-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "XML": { "prettier": { "allowed": true, - "plugins": ["@prettier/plugin-xml"] - } + "plugins": ["@prettier/plugin-xml"], + }, }, "YAML": { "prettier": { - "allowed": true - } + "allowed": true, + }, + }, + "YAML+ERB": { + "language_servers": ["!ruby-lsp", "..."], }, "Zig": { - "language_servers": ["zls", "..."] - } + "language_servers": ["zls", "..."], + }, }, // Different settings for specific language models. "language_models": { "anthropic": { - "api_url": "https://api.anthropic.com" + "api_url": "https://api.anthropic.com", }, "bedrock": {}, "google": { - "api_url": "https://generativelanguage.googleapis.com" + "api_url": "https://generativelanguage.googleapis.com", }, "ollama": { - "api_url": "http://localhost:11434" + "api_url": "http://localhost:11434", }, "openai": { - "api_url": "https://api.openai.com/v1" + "api_url": "https://api.openai.com/v1", }, "openai_compatible": {}, "open_router": { - "api_url": "https://openrouter.ai/api/v1" + "api_url": "https://openrouter.ai/api/v1", }, "lmstudio": { - "api_url": "http://localhost:1234/api/v0" + "api_url": "http://localhost:1234/api/v0", }, "deepseek": { - "api_url": "https://api.deepseek.com/v1" + "api_url": "https://api.deepseek.com/v1", }, "mistral": { - "api_url": "https://api.mistral.ai/v1" + "api_url": "https://api.mistral.ai/v1", }, "vercel": { - "api_url": "https://api.v0.dev/v1" + "api_url": "https://api.v0.dev/v1", }, "x_ai": { - "api_url": "https://api.x.ai/v1" + "api_url": "https://api.x.ai/v1", }, - "zed.dev": {} + "zed.dev": {}, }, "session": { // Whether or not to restore unsaved buffers on restart. @@ -1870,7 +2059,7 @@ // dirty files when closing the application. // // Default: true - "restore_unsaved_buffers": true + "restore_unsaved_buffers": true, }, // Zed's Prettier integration settings. // Allows to enable/disable formatting with Prettier @@ -1888,11 +2077,11 @@ // "singleQuote": true // Forces Prettier integration to use a specific parser name when formatting files with the language // when set to a non-empty string. - "parser": "" + "parser": "", }, // Settings for auto-closing of JSX tags. "jsx_tag_auto_close": { - "enabled": true + "enabled": true, }, // LSP Specific settings. "lsp": { @@ -1911,16 +2100,21 @@ // DAP Specific settings. "dap": { // Specify the DAP name as a key here. + "CodeLLDB": { + "env": { + "RUST_LOG": "info", + }, + }, }, // Common language server settings. "global_lsp_settings": { // Whether to show the LSP servers button in the status bar. - "button": true + "button": true, }, // Jupyter settings "jupyter": { "enabled": true, - "kernel_selections": {} + "kernel_selections": {}, // Specify the language name as the key and the kernel name as the value. // "kernel_selections": { // "python": "conda-base" @@ -1934,7 +2128,7 @@ "max_columns": 128, // Maximum number of lines to keep in REPL's scrollback buffer. // Clamped with [4, 256] range. - "max_lines": 32 + "max_lines": 32, }, // Vim settings "vim": { @@ -1948,7 +2142,7 @@ // Specify the mode as the key and the shape as the value. // The mode can be one of the following: "normal", "replace", "insert", "visual". // The shape can be one of the following: "block", "bar", "underline", "hollow". - "cursor_shape": {} + "cursor_shape": {}, }, // The server to connect to. If the environment variable // ZED_SERVER_URL is set, it will override this setting. @@ -1973,6 +2167,18 @@ "dev": { // "theme": "Andromeda" }, + // Settings overrides to use when using Linux. + "linux": {}, + // Settings overrides to use when using macOS. + "macos": {}, + // Settings overrides to use when using Windows. + "windows": { + "languages": { + "PHP": { + "language_servers": ["intelephense", "!phpactor", "!phptools", "..."], + }, + }, + }, // Whether to show full labels in line indicator or short ones // // Values: @@ -2030,7 +2236,7 @@ "dock": "bottom", "log_dap_communications": true, "format_dap_log_messages": true, - "button": true + "button": true, }, // Configures any number of settings profiles that are temporarily applied on // top of your existing user settings when selected from @@ -2051,11 +2257,11 @@ // } // } // } - "profiles": [], + "profiles": {}, // A map of log scopes to the desired log level. // Useful for filtering out noisy logs or enabling more verbose logging. // // Example: {"log": {"client": "warn"}} - "log": {} + "log": {}, } diff --git a/assets/settings/initial_debug_tasks.json b/assets/settings/initial_debug_tasks.json index af4512bd51..851289392a 100644 --- a/assets/settings/initial_debug_tasks.json +++ b/assets/settings/initial_debug_tasks.json @@ -8,7 +8,7 @@ "adapter": "Debugpy", "program": "$ZED_FILE", "request": "launch", - "cwd": "$ZED_WORKTREE_ROOT" + "cwd": "$ZED_WORKTREE_ROOT", }, { "label": "Debug active JavaScript file", @@ -16,7 +16,7 @@ "program": "$ZED_FILE", "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", - "type": "pwa-node" + "type": "pwa-node", }, { "label": "JavaScript debug terminal", @@ -24,6 +24,6 @@ "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", "console": "integratedTerminal", - "type": "pwa-node" - } + "type": "pwa-node", + }, ] diff --git a/assets/settings/initial_server_settings.json b/assets/settings/initial_server_settings.json index d6ec33e601..29aa569b10 100644 --- a/assets/settings/initial_server_settings.json +++ b/assets/settings/initial_server_settings.json @@ -3,5 +3,5 @@ // For a full list of overridable settings, and general information on settings, // see the documentation: https://zed.dev/docs/configuring-zed#settings-files { - "lsp": {} + "lsp": {}, } diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a79e980632..5bedafbd3a 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -47,8 +47,8 @@ // Whether to show the task line in the output of the spawned task, defaults to `true`. "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. - "show_command": true + "show_command": true, // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] - } + }, ] diff --git a/assets/settings/initial_user_settings.json b/assets/settings/initial_user_settings.json index 5ac2063bdb..8b57385489 100644 --- a/assets/settings/initial_user_settings.json +++ b/assets/settings/initial_user_settings.json @@ -12,6 +12,6 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" - } + "dark": "One Dark", + }, } diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index 7c84c603bd..e2b7c3c91f 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -45,6 +45,7 @@ "tab.inactive_background": "#1f2127ff", "tab.active_background": "#0d1016ff", "search.match_background": "#5ac2fe66", + "search.active_match_background": "#ea570166", "panel.background": "#1f2127ff", "panel.focused_border": "#5ac1feff", "pane.focused_border": null, @@ -436,6 +437,7 @@ "tab.inactive_background": "#ececedff", "tab.active_background": "#fcfcfcff", "search.match_background": "#3b9ee566", + "search.active_match_background": "#f88b3666", "panel.background": "#ececedff", "panel.focused_border": "#3b9ee5ff", "pane.focused_border": null, @@ -827,6 +829,7 @@ "tab.inactive_background": "#353944ff", "tab.active_background": "#242835ff", "search.match_background": "#73cffe66", + "search.active_match_background": "#fd722b66", "panel.background": "#353944ff", "panel.focused_border": null, "pane.focused_border": null, diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 4e6f8334b2..16ae188712 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -6,8 +6,8 @@ { "name": "Gruvbox Dark", "appearance": "dark", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#5b534dff", "border.variant": "#494340ff", "border.focused": "#303a36ff", @@ -46,11 +46,13 @@ "tab.inactive_background": "#3a3735ff", "tab.active_background": "#282828ff", "search.match_background": "#83a59866", + "search.active_match_background": "#c09f3f66", "panel.background": "#3a3735ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, - "scrollbar.thumb.background": "#fbf1c74c", - "scrollbar.thumb.hover_background": "#494340ff", + "scrollbar.thumb.active_background": "#83a598ac", + "scrollbar.thumb.hover_background": "#fbf1c74c", + "scrollbar.thumb.background": "#a899844c", "scrollbar.thumb.border": "#494340ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#373432ff", @@ -69,33 +71,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#282828ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#282828ff", + "terminal.dim_foreground": "#766b5dff", "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -411,8 +413,8 @@ { "name": "Gruvbox Dark Hard", "appearance": "dark", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#5b534dff", "border.variant": "#494340ff", "border.focused": "#303a36ff", @@ -451,11 +453,13 @@ "tab.inactive_background": "#393634ff", "tab.active_background": "#1d2021ff", "search.match_background": "#83a59866", + "search.active_match_background": "#c9653666", "panel.background": "#393634ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, - "scrollbar.thumb.background": "#fbf1c74c", - "scrollbar.thumb.hover_background": "#494340ff", + "scrollbar.thumb.active_background": "#83a598ac", + "scrollbar.thumb.hover_background": "#fbf1c74c", + "scrollbar.thumb.background": "#a899844c", "scrollbar.thumb.border": "#494340ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#343130ff", @@ -474,33 +478,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#1d2021ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#1d2021ff", - "terminal.ansi.black": "#1d2021ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -816,8 +820,8 @@ { "name": "Gruvbox Dark Soft", "appearance": "dark", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#5b534dff", "border.variant": "#494340ff", "border.focused": "#303a36ff", @@ -856,11 +860,13 @@ "tab.inactive_background": "#3b3735ff", "tab.active_background": "#32302fff", "search.match_background": "#83a59866", + "search.active_match_background": "#aea85166", "panel.background": "#3b3735ff", "panel.focused_border": null, "pane.focused_border": null, - "scrollbar.thumb.background": "#fbf1c74c", - "scrollbar.thumb.hover_background": "#494340ff", + "scrollbar.thumb.active_background": "#83a598ac", + "scrollbar.thumb.hover_background": "#fbf1c74c", + "scrollbar.thumb.background": "#a899844c", "scrollbar.thumb.border": "#494340ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#393634ff", @@ -879,33 +885,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#32302fff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#32302fff", - "terminal.ansi.black": "#32302fff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -1221,8 +1227,8 @@ { "name": "Gruvbox Light", "appearance": "light", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#c8b899ff", "border.variant": "#ddcca7ff", "border.focused": "#adc5ccff", @@ -1261,11 +1267,13 @@ "tab.inactive_background": "#ecddb4ff", "tab.active_background": "#fbf1c7ff", "search.match_background": "#0b667866", + "search.active_match_background": "#ba2d1166", "panel.background": "#ecddb4ff", "panel.focused_border": null, "pane.focused_border": null, - "scrollbar.thumb.background": "#2828284c", - "scrollbar.thumb.hover_background": "#ddcca7ff", + "scrollbar.thumb.active_background": "#458588ac", + "scrollbar.thumb.hover_background": "#2828284c", + "scrollbar.thumb.background": "#7c6f644c", "scrollbar.thumb.border": "#ddcca7ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#eee0b7ff", @@ -1287,30 +1295,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#fbf1c7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#0b6678ff", - "terminal.ansi.dim_black": "#5f5650ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -1626,8 +1634,8 @@ { "name": "Gruvbox Light Hard", "appearance": "light", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#c8b899ff", "border.variant": "#ddcca7ff", "border.focused": "#adc5ccff", @@ -1666,11 +1674,13 @@ "tab.inactive_background": "#ecddb5ff", "tab.active_background": "#f9f5d7ff", "search.match_background": "#0b667866", + "search.active_match_background": "#dc351466", "panel.background": "#ecddb5ff", "panel.focused_border": null, "pane.focused_border": null, - "scrollbar.thumb.background": "#2828284c", - "scrollbar.thumb.hover_background": "#ddcca7ff", + "scrollbar.thumb.active_background": "#458588ac", + "scrollbar.thumb.hover_background": "#2828284c", + "scrollbar.thumb.background": "#7c6f644c", "scrollbar.thumb.border": "#ddcca7ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#eee1bbff", @@ -1692,30 +1702,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f9f5d7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f9f5d7ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f9f5d7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -2031,8 +2041,8 @@ { "name": "Gruvbox Light Soft", "appearance": "light", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#c8b899ff", "border.variant": "#ddcca7ff", "border.focused": "#adc5ccff", @@ -2071,11 +2081,13 @@ "tab.inactive_background": "#ecdcb3ff", "tab.active_background": "#f2e5bcff", "search.match_background": "#0b667866", + "search.active_match_background": "#d7331466", "panel.background": "#ecdcb3ff", "panel.focused_border": null, "pane.focused_border": null, - "scrollbar.thumb.background": "#2828284c", - "scrollbar.thumb.hover_background": "#ddcca7ff", + "scrollbar.thumb.active_background": "#458588ac", + "scrollbar.thumb.hover_background": "#2828284c", + "scrollbar.thumb.background": "#7c6f644c", "scrollbar.thumb.border": "#ddcca7ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#eddeb5ff", @@ -2097,30 +2109,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f2e5bcff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f2e5bcff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f2e5bcff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 6849cd05dc..13f94991ad 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -45,6 +45,7 @@ "tab.inactive_background": "#2f343eff", "tab.active_background": "#282c33ff", "search.match_background": "#74ade866", + "search.active_match_background": "#e8af7466", "panel.background": "#2f343eff", "panel.focused_border": null, "pane.focused_border": null, @@ -67,37 +68,39 @@ "editor.active_wrap_guide": "#c8ccd41a", "editor.document_highlight.read_background": "#74ade81a", "editor.document_highlight.write_background": "#555a6366", - "terminal.background": "#282c33ff", - "terminal.foreground": "#dce0e5ff", + "terminal.background": "#282c34ff", + "terminal.foreground": "#abb2bfff", "terminal.bright_foreground": "#dce0e5ff", - "terminal.dim_foreground": "#282c33ff", - "terminal.ansi.black": "#282c33ff", - "terminal.ansi.bright_black": "#525561ff", - "terminal.ansi.dim_black": "#dce0e5ff", - "terminal.ansi.red": "#d07277ff", - "terminal.ansi.bright_red": "#673a3cff", - "terminal.ansi.dim_red": "#eab7b9ff", - "terminal.ansi.green": "#a1c181ff", - "terminal.ansi.bright_green": "#4d6140ff", - "terminal.ansi.dim_green": "#d1e0bfff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#e5c07bff", - "terminal.ansi.dim_yellow": "#f1dfc1ff", - "terminal.ansi.blue": "#74ade8ff", - "terminal.ansi.bright_blue": "#385378ff", - "terminal.ansi.dim_blue": "#bed5f4ff", - "terminal.ansi.magenta": "#b477cfff", - "terminal.ansi.bright_magenta": "#d6b4e4ff", - "terminal.ansi.dim_magenta": "#612a79ff", - "terminal.ansi.cyan": "#6eb4bfff", - "terminal.ansi.bright_cyan": "#3a565bff", - "terminal.ansi.dim_cyan": "#b9d9dfff", - "terminal.ansi.white": "#dce0e5ff", + "terminal.dim_foreground": "#636d83ff", + "terminal.ansi.black": "#282c34ff", + "terminal.ansi.bright_black": "#636d83ff", + "terminal.ansi.dim_black": "#3b3f4aff", + "terminal.ansi.red": "#e06c75ff", + "terminal.ansi.bright_red": "#EA858Bff", + "terminal.ansi.dim_red": "#a7545aff", + "terminal.ansi.green": "#98c379ff", + "terminal.ansi.bright_green": "#AAD581ff", + "terminal.ansi.dim_green": "#6d8f59ff", + "terminal.ansi.yellow": "#e5c07bff", + "terminal.ansi.bright_yellow": "#FFD885ff", + "terminal.ansi.dim_yellow": "#b8985bff", + "terminal.ansi.blue": "#61afefff", + "terminal.ansi.bright_blue": "#85C1FFff", + "terminal.ansi.dim_blue": "#457cadff", + "terminal.ansi.magenta": "#c678ddff", + "terminal.ansi.bright_magenta": "#D398EBff", + "terminal.ansi.dim_magenta": "#8d54a0ff", + "terminal.ansi.cyan": "#56b6c2ff", + "terminal.ansi.bright_cyan": "#6ED5DEff", + "terminal.ansi.dim_cyan": "#3c818aff", + "terminal.ansi.white": "#abb2bfff", "terminal.ansi.bright_white": "#fafafaff", - "terminal.ansi.dim_white": "#575d65ff", + "terminal.ansi.dim_white": "#8f969bff", "link_text.hover": "#74ade8ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", + "version_control.word_added": "#2EA04859", + "version_control.word_deleted": "#78081BCC", "version_control.deleted": "#e06c76ff", "version_control.conflict_marker.ours": "#a1c1811a", "version_control.conflict_marker.theirs": "#74ade81a", @@ -446,6 +449,7 @@ "tab.inactive_background": "#ebebecff", "tab.active_background": "#fafafaff", "search.match_background": "#5c79e266", + "search.active_match_background": "#d0a92366", "panel.background": "#ebebecff", "panel.focused_border": null, "pane.focused_border": null, @@ -469,36 +473,38 @@ "editor.document_highlight.read_background": "#5c78e225", "editor.document_highlight.write_background": "#a3a3a466", "terminal.background": "#fafafaff", - "terminal.foreground": "#242529ff", - "terminal.bright_foreground": "#242529ff", - "terminal.dim_foreground": "#fafafaff", - "terminal.ansi.black": "#242529ff", - "terminal.ansi.bright_black": "#747579ff", - "terminal.ansi.dim_black": "#97979aff", - "terminal.ansi.red": "#d36151ff", - "terminal.ansi.bright_red": "#f0b0a4ff", - "terminal.ansi.dim_red": "#6f312aff", - "terminal.ansi.green": "#669f59ff", - "terminal.ansi.bright_green": "#b2cfa9ff", - "terminal.ansi.dim_green": "#354d2eff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#826221ff", - "terminal.ansi.dim_yellow": "#786441ff", - "terminal.ansi.blue": "#5c78e2ff", - "terminal.ansi.bright_blue": "#b5baf2ff", - "terminal.ansi.dim_blue": "#2d3d75ff", - "terminal.ansi.magenta": "#984ea5ff", - "terminal.ansi.bright_magenta": "#cea6d3ff", - "terminal.ansi.dim_magenta": "#4b2a50ff", - "terminal.ansi.cyan": "#3a82b7ff", - "terminal.ansi.bright_cyan": "#a3bedaff", - "terminal.ansi.dim_cyan": "#254058ff", - "terminal.ansi.white": "#fafafaff", + "terminal.foreground": "#2a2c33ff", + "terminal.bright_foreground": "#2a2c33ff", + "terminal.dim_foreground": "#bbbbbbff", + "terminal.ansi.black": "#000000ff", + "terminal.ansi.bright_black": "#000000ff", + "terminal.ansi.dim_black": "#555555ff", + "terminal.ansi.red": "#de3e35ff", + "terminal.ansi.bright_red": "#de3e35ff", + "terminal.ansi.dim_red": "#9c2b26ff", + "terminal.ansi.green": "#3f953aff", + "terminal.ansi.bright_green": "#3f953aff", + "terminal.ansi.dim_green": "#2b6927ff", + "terminal.ansi.yellow": "#d2b67cff", + "terminal.ansi.bright_yellow": "#d2b67cff", + "terminal.ansi.dim_yellow": "#a48c5aff", + "terminal.ansi.blue": "#2f5af3ff", + "terminal.ansi.bright_blue": "#2f5af3ff", + "terminal.ansi.dim_blue": "#2140abff", + "terminal.ansi.magenta": "#950095ff", + "terminal.ansi.bright_magenta": "#a00095ff", + "terminal.ansi.dim_magenta": "#6a006aff", + "terminal.ansi.cyan": "#3f953aff", + "terminal.ansi.bright_cyan": "#3f953aff", + "terminal.ansi.dim_cyan": "#2b6927ff", + "terminal.ansi.white": "#bbbbbbff", "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#aaaaaaff", + "terminal.ansi.dim_white": "#888888ff", "link_text.hover": "#5c78e2ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", + "version_control.word_added": "#2EA04859", + "version_control.word_deleted": "#F85149CC", "version_control.deleted": "#e06c76ff", "conflict": "#a48819ff", "conflict.background": "#faf2e6ff", diff --git a/ci/Dockerfile.namespace b/ci/Dockerfile.namespace new file mode 100644 index 0000000000..f370dae194 --- /dev/null +++ b/ci/Dockerfile.namespace @@ -0,0 +1,21 @@ +ARG NAMESPACE_BASE_IMAGE_REF="" + +# Your image must build FROM NAMESPACE_BASE_IMAGE_REF +FROM ${NAMESPACE_BASE_IMAGE_REF} AS base + +# Remove problematic git-lfs packagecloud source +RUN sudo rm -f /etc/apt/sources.list.d/*git-lfs*.list +# Install git and SSH for cloning private repositories +RUN sudo apt-get update && \ + sudo apt-get install -y git openssh-client + +# Clone the Zed repository +RUN git clone https://github.com/zed-industries/zed.git ~/zed + +# Run the Linux installation script +WORKDIR /home/runner/zed +RUN ./script/linux + +# Clean up unnecessary files to reduce image size +RUN sudo apt-get clean && sudo rm -rf \ + /home/runner/zed diff --git a/clippy.toml b/clippy.toml index 57f6f59385..9dd246074a 100644 --- a/clippy.toml +++ b/clippy.toml @@ -3,12 +3,18 @@ avoid-breaking-exported-api = false ignore-interior-mutability = [ # Suppresses clippy::mutable_key_type, which is a false positive as the Eq # and Hash impls do not use fields with interior mutability. - "agent::context::AgentContextKey" + "agent_ui::context::AgentContextKey" ] disallowed-methods = [ { path = "std::process::Command::spawn", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::spawn" }, { path = "std::process::Command::output", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::output" }, { path = "std::process::Command::status", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::status" }, + { path = "std::process::Command::stdin", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stdin" }, + { path = "std::process::Command::stdout", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stdout" }, + { path = "std::process::Command::stderr", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stderr" }, + { path = "serde_json::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892. Use `serde_json::from_slice` instead." }, + { path = "serde_json_lenient::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892, Use `serde_json_lenient::from_slice` instead." }, + { path = "cocoa::foundation::NSString::alloc", reason = "NSString must be autoreleased to avoid memory leaks. Use `ns_string()` helper instead." }, ] disallowed-types = [ # { path = "std::collections::HashMap", replacement = "collections::HashMap" }, diff --git a/compose.yml b/compose.yml index 00a5780b59..cee63e968b 100644 --- a/compose.yml +++ b/compose.yml @@ -33,32 +33,6 @@ services: volumes: - ./livekit.yaml:/livekit.yaml - postgrest_app: - image: docker.io/postgrest/postgrest - container_name: postgrest_app - ports: - - 8081:8081 - environment: - PGRST_DB_URI: postgres://postgres@postgres:5432/zed - volumes: - - ./crates/collab/postgrest_app.conf:/etc/postgrest.conf - command: postgrest /etc/postgrest.conf - depends_on: - - postgres - - postgrest_llm: - image: docker.io/postgrest/postgrest - container_name: postgrest_llm - ports: - - 8082:8082 - environment: - PGRST_DB_URI: postgres://postgres@postgres:5432/zed_llm - volumes: - - ./crates/collab/postgrest_llm.conf:/etc/postgrest.conf - command: postgrest /etc/postgrest.conf - depends_on: - - postgres - stripe-mock: image: docker.io/stripe/stripe-mock:v0.178.0 ports: diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index ac24a6ed0f..8ef6f1a52c 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -39,13 +39,13 @@ serde_json.workspace = true settings.workspace = true smol.workspace = true task.workspace = true +telemetry.workspace = true terminal.workspace = true ui.workspace = true url.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true -workspace-hack.workspace = true [dev-dependencies] env_logger.workspace = true @@ -57,3 +57,4 @@ rand.workspace = true tempfile.workspace = true util.workspace = true settings.workspace = true +zlog.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 61486a475c..53294a963d 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3,7 +3,6 @@ mod diff; mod mention; mod terminal; -use ::terminal::terminal_settings::TerminalSettings; use agent_settings::AgentSettings; use collections::HashSet; pub use connection::*; @@ -12,11 +11,11 @@ use language::language_settings::FormatOnSave; pub use mention::*; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use serde::{Deserialize, Serialize}; -use settings::{Settings as _, SettingsLocation}; +use settings::Settings as _; use task::{Shell, ShellBuilder}; pub use terminal::*; -use action_log::ActionLog; +use action_log::{ActionLog, ActionLogTelemetry}; use agent_client_protocol::{self as acp}; use anyhow::{Context as _, Result, anyhow}; use editor::Bias; @@ -35,7 +34,7 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use std::{fmt::Display, mem, path::PathBuf, sync::Arc}; use ui::App; -use util::{ResultExt, get_default_system_shell_preferring_bash}; +use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle}; use uuid::Uuid; #[derive(Debug)] @@ -95,9 +94,14 @@ pub enum AssistantMessageChunk { } impl AssistantMessageChunk { - pub fn from_str(chunk: &str, language_registry: &Arc, cx: &mut App) -> Self { + pub fn from_str( + chunk: &str, + language_registry: &Arc, + path_style: PathStyle, + cx: &mut App, + ) -> Self { Self::Message { - block: ContentBlock::new(chunk.into(), language_registry, cx), + block: ContentBlock::new(chunk.into(), language_registry, path_style, cx), } } @@ -186,6 +190,7 @@ impl ToolCall { tool_call: acp::ToolCall, status: ToolCallStatus, language_registry: Arc, + path_style: PathStyle, terminals: &HashMap>, cx: &mut App, ) -> Result { @@ -196,16 +201,19 @@ impl ToolCall { }; let mut content = Vec::with_capacity(tool_call.content.len()); for item in tool_call.content { - content.push(ToolCallContent::from_acp( + if let Some(item) = ToolCallContent::from_acp( item, language_registry.clone(), + path_style, terminals, cx, - )?); + )? { + content.push(item); + } } let result = Self { - id: tool_call.id, + id: tool_call.tool_call_id, label: cx .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)), kind: tool_call.kind, @@ -223,6 +231,7 @@ impl ToolCall { &mut self, fields: acp::ToolCallUpdateFields, language_registry: Arc, + path_style: PathStyle, terminals: &HashMap>, cx: &mut App, ) -> Result<()> { @@ -234,6 +243,7 @@ impl ToolCall { locations, raw_input, raw_output, + .. } = fields; if let Some(kind) = kind { @@ -255,20 +265,29 @@ impl ToolCall { } if let Some(content) = content { - let new_content_len = content.len(); + let mut new_content_len = content.len(); let mut content = content.into_iter(); // Reuse existing content if we can for (old, new) in self.content.iter_mut().zip(content.by_ref()) { - old.update_from_acp(new, language_registry.clone(), terminals, cx)?; + let valid_content = + old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + if !valid_content { + new_content_len -= 1; + } } for new in content { - self.content.push(ToolCallContent::from_acp( + if let Some(new) = ToolCallContent::from_acp( new, language_registry.clone(), + path_style, terminals, cx, - )?) + )? { + self.content.push(new); + } else { + new_content_len -= 1; + } } self.content.truncate(new_content_len); } @@ -328,7 +347,7 @@ impl ToolCall { location: acp::ToolCallLocation, project: WeakEntity, cx: &mut AsyncApp, - ) -> Option { + ) -> Option { let buffer = project .update(cx, |project, cx| { project @@ -339,28 +358,25 @@ impl ToolCall { let buffer = buffer.await.log_err()?; let position = buffer .update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); if let Some(row) = location.line { - let snapshot = buffer.snapshot(); let column = snapshot.indent_size_for_line(row).len; let point = snapshot.clip_point(Point::new(row, column), Bias::Left); snapshot.anchor_before(point) } else { - Anchor::MIN + Anchor::min_for_buffer(snapshot.remote_id()) } }) .ok()?; - Some(AgentLocation { - buffer: buffer.downgrade(), - position, - }) + Some(ResolvedLocation { buffer, position }) } fn resolve_locations( &self, project: Entity, cx: &mut App, - ) -> Task>> { + ) -> Task>> { let locations = self.locations.clone(); project.update(cx, |_, cx| { cx.spawn(async move |project, cx| { @@ -374,6 +390,23 @@ impl ToolCall { } } +// Separate so we can hold a strong reference to the buffer +// for saving on the thread +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResolvedLocation { + buffer: Entity, + position: Anchor, +} + +impl From<&ResolvedLocation> for AgentLocation { + fn from(value: &ResolvedLocation) -> Self { + Self { + buffer: value.buffer.downgrade(), + position: value.position, + } + } +} + #[derive(Debug)] pub enum ToolCallStatus { /// The tool call hasn't started running yet, but we start showing it to @@ -403,6 +436,7 @@ impl From for ToolCallStatus { acp::ToolCallStatus::InProgress => Self::InProgress, acp::ToolCallStatus::Completed => Self::Completed, acp::ToolCallStatus::Failed => Self::Failed, + _ => Self::Pending, } } } @@ -436,21 +470,23 @@ impl ContentBlock { pub fn new( block: acp::ContentBlock, language_registry: &Arc, + path_style: PathStyle, cx: &mut App, ) -> Self { let mut this = Self::Empty; - this.append(block, language_registry, cx); + this.append(block, language_registry, path_style, cx); this } pub fn new_combined( blocks: impl IntoIterator, language_registry: Arc, + path_style: PathStyle, cx: &mut App, ) -> Self { let mut this = Self::Empty; for block in blocks { - this.append(block, &language_registry, cx); + this.append(block, &language_registry, path_style, cx); } this } @@ -459,6 +495,7 @@ impl ContentBlock { &mut self, block: acp::ContentBlock, language_registry: &Arc, + path_style: PathStyle, cx: &mut App, ) { if matches!(self, ContentBlock::Empty) @@ -468,7 +505,7 @@ impl ContentBlock { return; } - let new_content = self.block_string_contents(block); + let new_content = self.block_string_contents(block, path_style); match self { ContentBlock::Empty => { @@ -478,7 +515,7 @@ impl ContentBlock { markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx)); } ContentBlock::ResourceLink { resource_link } => { - let existing_content = Self::resource_link_md(&resource_link.uri); + let existing_content = Self::resource_link_md(&resource_link.uri, path_style); let combined = format!("{}\n{}", existing_content, new_content); *self = Self::create_markdown_block(combined, language_registry, cx); @@ -497,11 +534,11 @@ impl ContentBlock { } } - fn block_string_contents(&self, block: acp::ContentBlock) -> String { + fn block_string_contents(&self, block: acp::ContentBlock, path_style: PathStyle) -> String { match block { acp::ContentBlock::Text(text_content) => text_content.text, acp::ContentBlock::ResourceLink(resource_link) => { - Self::resource_link_md(&resource_link.uri) + Self::resource_link_md(&resource_link.uri, path_style) } acp::ContentBlock::Resource(acp::EmbeddedResource { resource: @@ -510,14 +547,14 @@ impl ContentBlock { .. }), .. - }) => Self::resource_link_md(&uri), + }) => Self::resource_link_md(&uri, path_style), acp::ContentBlock::Image(image) => Self::image_md(&image), - acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(), + _ => String::new(), } } - fn resource_link_md(uri: &str) -> String { - if let Some(uri) = MentionUri::parse(uri).log_err() { + fn resource_link_md(uri: &str, path_style: PathStyle) -> String { + if let Some(uri) = MentionUri::parse(uri, path_style).log_err() { uri.as_link().to_string() } else { uri.to_string() @@ -563,16 +600,20 @@ impl ToolCallContent { pub fn from_acp( content: acp::ToolCallContent, language_registry: Arc, + path_style: PathStyle, terminals: &HashMap>, cx: &mut App, - ) -> Result { + ) -> Result> { match content { - acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new( - content, - &language_registry, - cx, - ))), - acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| { + acp::ToolCallContent::Content(acp::Content { content, .. }) => { + Ok(Some(Self::ContentBlock(ContentBlock::new( + content, + &language_registry, + path_style, + cx, + )))) + } + acp::ToolCallContent::Diff(diff) => Ok(Some(Self::Diff(cx.new(|cx| { Diff::finalized( diff.path.to_string_lossy().into_owned(), diff.old_text, @@ -580,12 +621,13 @@ impl ToolCallContent { language_registry, cx, ) - }))), - acp::ToolCallContent::Terminal { terminal_id } => terminals + })))), + acp::ToolCallContent::Terminal(acp::Terminal { terminal_id, .. }) => terminals .get(&terminal_id) .cloned() - .map(Self::Terminal) + .map(|terminal| Some(Self::Terminal(terminal))) .ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)), + _ => Ok(None), } } @@ -593,11 +635,12 @@ impl ToolCallContent { &mut self, new: acp::ToolCallContent, language_registry: Arc, + path_style: PathStyle, terminals: &HashMap>, cx: &mut App, - ) -> Result<()> { + ) -> Result { let needs_update = match (&self, &new) { - (Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => { + (Self::Diff(old_diff), acp::ToolCallContent::Diff(new_diff)) => { old_diff.read(cx).needs_update( new_diff.old_text.as_deref().unwrap_or(""), &new_diff.new_text, @@ -607,10 +650,14 @@ impl ToolCallContent { _ => true, }; - if needs_update { - *self = Self::from_acp(new, language_registry, terminals, cx)?; + if let Some(update) = Self::from_acp(new, language_registry, path_style, terminals, cx)? { + if needs_update { + *self = update; + } + Ok(true) + } else { + Ok(false) } - Ok(()) } pub fn to_markdown(&self, cx: &App) -> String { @@ -632,7 +679,7 @@ pub enum ToolCallUpdate { impl ToolCallUpdate { fn id(&self) -> &acp::ToolCallId { match self { - Self::UpdateFields(update) => &update.id, + Self::UpdateFields(update) => &update.tool_call_id, Self::UpdateDiff(diff) => &diff.id, Self::UpdateTerminal(terminal) => &terminal.id, } @@ -704,6 +751,7 @@ impl Plan { acp::PlanEntryStatus::Completed => { stats.completed += 1; } + _ => {} } } @@ -792,6 +840,15 @@ pub struct AcpThread { pending_terminal_exit: HashMap, } +impl From<&AcpThread> for ActionLogTelemetry { + fn from(value: &AcpThread) -> Self { + Self { + agent_telemetry_id: value.connection().telemetry_id(), + session_id: value.session_id.0.clone(), + } + } +} + #[derive(Debug)] pub enum AcpThreadEvent { NewEntry, @@ -1091,13 +1148,13 @@ impl AcpThread { cx: &mut Context, ) -> Result<(), acp::Error> { match update { - acp::SessionUpdate::UserMessageChunk { content } => { + acp::SessionUpdate::UserMessageChunk(acp::ContentChunk { content, .. }) => { self.push_user_content_block(None, content, cx); } - acp::SessionUpdate::AgentMessageChunk { content } => { + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content, .. }) => { self.push_assistant_content_block(content, false, cx); } - acp::SessionUpdate::AgentThoughtChunk { content } => { + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { content, .. }) => { self.push_assistant_content_block(content, true, cx); } acp::SessionUpdate::ToolCall(tool_call) => { @@ -1109,12 +1166,15 @@ impl AcpThread { acp::SessionUpdate::Plan(plan) => { self.update_plan(plan, cx); } - acp::SessionUpdate::AvailableCommandsUpdate { available_commands } => { - cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands)) - } - acp::SessionUpdate::CurrentModeUpdate { current_mode_id } => { - cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)) - } + acp::SessionUpdate::AvailableCommandsUpdate(acp::AvailableCommandsUpdate { + available_commands, + .. + }) => cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands)), + acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate { + current_mode_id, + .. + }) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)), + _ => {} } Ok(()) } @@ -1126,6 +1186,7 @@ impl AcpThread { cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() @@ -1137,12 +1198,12 @@ impl AcpThread { }) = last_entry { *id = message_id.or(id.take()); - content.append(chunk.clone(), &language_registry, cx); + content.append(chunk.clone(), &language_registry, path_style, cx); chunks.push(chunk); let idx = entries_len - 1; cx.emit(AcpThreadEvent::EntryUpdated(idx)); } else { - let content = ContentBlock::new(chunk.clone(), &language_registry, cx); + let content = ContentBlock::new(chunk.clone(), &language_registry, path_style, cx); self.push_entry( AgentThreadEntry::UserMessage(UserMessage { id: message_id, @@ -1162,6 +1223,7 @@ impl AcpThread { cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry @@ -1171,10 +1233,10 @@ impl AcpThread { match (chunks.last_mut(), is_thought) { (Some(AssistantMessageChunk::Message { block }), false) | (Some(AssistantMessageChunk::Thought { block }), true) => { - block.append(chunk, &language_registry, cx) + block.append(chunk, &language_registry, path_style, cx) } _ => { - let block = ContentBlock::new(chunk, &language_registry, cx); + let block = ContentBlock::new(chunk, &language_registry, path_style, cx); if is_thought { chunks.push(AssistantMessageChunk::Thought { block }) } else { @@ -1183,7 +1245,7 @@ impl AcpThread { } } } else { - let block = ContentBlock::new(chunk, &language_registry, cx); + let block = ContentBlock::new(chunk, &language_registry, path_style, cx); let chunk = if is_thought { AssistantMessageChunk::Thought { block } } else { @@ -1235,6 +1297,7 @@ impl AcpThread { ) -> Result<()> { let update = update.into(); let languages = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); let ix = match self.index_for_tool_call(update.id()) { Some(ix) => ix, @@ -1245,12 +1308,9 @@ impl AcpThread { label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)), kind: acp::ToolKind::Fetch, content: vec![ToolCallContent::ContentBlock(ContentBlock::new( - acp::ContentBlock::Text(acp::TextContent { - text: "Tool call not found".to_string(), - annotations: None, - meta: None, - }), + "Tool call not found".into(), &languages, + path_style, cx, ))], status: ToolCallStatus::Failed, @@ -1270,9 +1330,9 @@ impl AcpThread { match update { ToolCallUpdate::UpdateFields(update) => { let location_updated = update.fields.locations.is_some(); - call.update_fields(update.fields, languages, &self.terminals, cx)?; + call.update_fields(update.fields, languages, path_style, &self.terminals, cx)?; if location_updated { - self.resolve_locations(update.id, cx); + self.resolve_locations(update.tool_call_id, cx); } } ToolCallUpdate::UpdateDiff(update) => { @@ -1309,14 +1369,37 @@ impl AcpThread { cx: &mut Context, ) -> Result<(), acp::Error> { let language_registry = self.project.read(cx).languages().clone(); - let id = update.id.clone(); + let path_style = self.project.read(cx).path_style(cx); + let id = update.tool_call_id.clone(); + + let agent_telemetry_id = self.connection().telemetry_id(); + let session = self.session_id(); + if let ToolCallStatus::Completed | ToolCallStatus::Failed = status { + let status = if matches!(status, ToolCallStatus::Completed) { + "completed" + } else { + "failed" + }; + telemetry::event!( + "Agent Tool Call Completed", + agent_telemetry_id, + session, + status + ); + } if let Some(ix) = self.index_for_tool_call(&id) { let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else { unreachable!() }; - call.update_fields(update.fields, language_registry, &self.terminals, cx)?; + call.update_fields( + update.fields, + language_registry, + path_style, + &self.terminals, + cx, + )?; call.status = status; cx.emit(AcpThreadEvent::EntryUpdated(ix)); @@ -1325,6 +1408,7 @@ impl AcpThread { update.try_into()?, status, language_registry, + self.project.read(cx).path_style(cx), &self.terminals, cx, )?; @@ -1393,35 +1477,46 @@ impl AcpThread { let task = tool_call.resolve_locations(project, cx); cx.spawn(async move |this, cx| { let resolved_locations = task.await; + this.update(cx, |this, cx| { let project = this.project.clone(); + + for location in resolved_locations.iter().flatten() { + this.shared_buffers + .insert(location.buffer.clone(), location.buffer.read(cx).snapshot()); + } let Some((ix, tool_call)) = this.tool_call_mut(&id) else { return; }; + if let Some(Some(location)) = resolved_locations.last() { project.update(cx, |project, cx| { - if let Some(agent_location) = project.agent_location() { - let should_ignore = agent_location.buffer == location.buffer - && location - .buffer - .update(cx, |buffer, _| { - let snapshot = buffer.snapshot(); - let old_position = - agent_location.position.to_point(&snapshot); - let new_position = location.position.to_point(&snapshot); - // ignore this so that when we get updates from the edit tool - // the position doesn't reset to the startof line - old_position.row == new_position.row - && old_position.column > new_position.column - }) - .ok() - .unwrap_or_default(); - if !should_ignore { - project.set_agent_location(Some(location.clone()), cx); - } + let should_ignore = if let Some(agent_location) = project + .agent_location() + .filter(|agent_location| agent_location.buffer == location.buffer) + { + let snapshot = location.buffer.read(cx).snapshot(); + let old_position = agent_location.position.to_point(&snapshot); + let new_position = location.position.to_point(&snapshot); + + // ignore this so that when we get updates from the edit tool + // the position doesn't reset to the startof line + old_position.row == new_position.row + && old_position.column > new_position.column + } else { + false + }; + if !should_ignore { + project.set_agent_location(Some(location.into()), cx); } }); } + + let resolved_locations = resolved_locations + .iter() + .map(|l| l.as_ref().map(|l| AgentLocation::from(l))) + .collect::>(); + if tool_call.resolved_locations != resolved_locations { tool_call.resolved_locations = resolved_locations; cx.emit(AcpThreadEvent::EntryUpdated(ix)); @@ -1445,16 +1540,16 @@ impl AcpThread { // some tools would (incorrectly) continue to auto-accept. if let Some(allow_once_option) = options.iter().find_map(|option| { if matches!(option.kind, acp::PermissionOptionKind::AllowOnce) { - Some(option.id.clone()) + Some(option.option_id.clone()) } else { None } }) { self.upsert_tool_call_inner(tool_call, ToolCallStatus::Pending, cx)?; return Ok(async { - acp::RequestPermissionOutcome::Selected { - option_id: allow_once_option, - } + acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( + allow_once_option, + )) } .boxed()); } @@ -1470,7 +1565,9 @@ impl AcpThread { let fut = async { match rx.await { - Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, + Ok(option) => acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(option), + ), Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, } } @@ -1497,6 +1594,7 @@ impl AcpThread { acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => { ToolCallStatus::InProgress } + _ => ToolCallStatus::InProgress, }; let curr_status = mem::replace(&mut call.status, new_status); @@ -1575,14 +1673,7 @@ impl AcpThread { message: &str, cx: &mut Context, ) -> BoxFuture<'static, Result<()>> { - self.send( - vec![acp::ContentBlock::Text(acp::TextContent { - text: message.to_string(), - annotations: None, - meta: None, - })], - cx, - ) + self.send(vec![message.into()], cx) } pub fn send( @@ -1593,13 +1684,10 @@ impl AcpThread { let block = ContentBlock::new_combined( message.clone(), self.project.read(cx).languages().clone(), + self.project.read(cx).path_style(cx), cx, ); - let request = acp::PromptRequest { - prompt: message.clone(), - session_id: self.session_id.clone(), - meta: None, - }; + let request = acp::PromptRequest::new(self.session_id.clone(), message.clone()); let git_store = self.project.read(cx).git_store().clone(); let message_id = if self.connection.truncate(&self.session_id, cx).is_some() { @@ -1691,7 +1779,7 @@ impl AcpThread { result, Ok(Ok(acp::PromptResponse { stop_reason: acp::StopReason::Cancelled, - meta: None, + .. })) ); @@ -1707,7 +1795,7 @@ impl AcpThread { // Handle refusal - distinguish between user prompt and tool call refusals if let Ok(Ok(acp::PromptResponse { stop_reason: acp::StopReason::Refusal, - meta: _, + .. })) = result { if let Some((user_msg_ix, _)) = this.last_user_message() { @@ -1792,10 +1880,14 @@ impl AcpThread { .checkpoint .as_ref() .map(|c| c.git_checkpoint.clone()); + + // Cancel any in-progress generation before restoring + let cancel_task = self.cancel(cx); let rewind = self.rewind(id.clone(), cx); let git_store = self.project.read(cx).git_store().clone(); cx.spawn(async move |_, cx| { + cancel_task.await; rewind.await?; if let Some(checkpoint) = checkpoint { git_store @@ -1815,16 +1907,34 @@ impl AcpThread { return Task::ready(Err(anyhow!("not supported"))); }; + let telemetry = ActionLogTelemetry::from(&*self); cx.spawn(async move |this, cx| { cx.update(|cx| truncate.run(id.clone(), cx))?.await?; this.update(cx, |this, cx| { if let Some((ix, _)) = this.user_message_mut(&id) { + // Collect all terminals from entries that will be removed + let terminals_to_remove: Vec = this.entries[ix..] + .iter() + .flat_map(|entry| entry.terminals()) + .filter_map(|terminal| terminal.read(cx).id().clone().into()) + .collect(); + let range = ix..this.entries.len(); this.entries.truncate(ix); cx.emit(AcpThreadEvent::EntriesRemoved(range)); + + // Kill and remove the terminals + for terminal_id in terminals_to_remove { + if let Some(terminal) = this.terminals.remove(&terminal_id) { + terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + } + } } - this.action_log() - .update(cx, |action_log, cx| action_log.reject_all_edits(cx)) + this.action_log().update(cx, |action_log, cx| { + action_log.reject_all_edits(Some(telemetry), cx) + }) })? .await; Ok(()) @@ -1921,7 +2031,7 @@ impl AcpThread { })?; Ok(project.open_buffer(path, cx)) }) - .map_err(|e| acp::Error::internal_error().with_data(e.to_string())) + .map_err(|e| acp::Error::internal_error().data(e.to_string())) .flatten()?; let buffer = load.await?; @@ -1954,7 +2064,7 @@ impl AcpThread { let start_position = Point::new(line, 0); if start_position > max_point { - return Err(acp::Error::invalid_params().with_data(format!( + return Err(acp::Error::invalid_params().data(format!( "Attempting to read beyond the end of the file, line {}:{}", max_point.row + 1, max_point.column @@ -2024,7 +2134,7 @@ impl AcpThread { position: edits .last() .map(|(range, _)| range.end) - .unwrap_or(Anchor::MIN), + .unwrap_or(Anchor::min_for_buffer(buffer.read(cx).remote_id())), }), cx, ); @@ -2086,17 +2196,9 @@ impl AcpThread { ) -> Task>> { let env = match &cwd { Some(dir) => self.project.update(cx, |project, cx| { - let worktree = project.find_worktree(dir.as_path(), cx); - let shell = TerminalSettings::get( - worktree.as_ref().map(|(worktree, path)| SettingsLocation { - worktree_id: worktree.read(cx).id(), - path: &path, - }), - cx, - ) - .shell - .clone(); - project.directory_environment(&shell, dir.as_path().into(), cx) + project.environment().update(cx, |env, cx| { + env.directory_environment(dir.as_path().into(), cx) + }) }), None => Task::ready(None).shared(), }; @@ -2112,8 +2214,9 @@ impl AcpThread { let project = self.project.clone(); let language_registry = project.read(cx).languages().clone(); + let is_windows = project.read(cx).path_style(cx).is_windows(); - let terminal_id = acp::TerminalId(Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(Uuid::new_v4().to_string()); let terminal_task = cx.spawn({ let terminal_id = terminal_id.clone(); async move |_this, cx| { @@ -2125,9 +2228,10 @@ impl AcpThread { .and_then(|r| r.read(cx).default_system_shell()) })? .unwrap_or_else(|| get_default_system_shell_preferring_bash()); - let (task_command, task_args) = ShellBuilder::new(&Shell::Program(shell)) - .redirect_stdin_to_dev_null() - .build(Some(command.clone()), &args); + let (task_command, task_args) = + ShellBuilder::new(&Shell::Program(shell), is_windows) + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); let terminal = project .update(cx, |project, cx| { project.create_terminal_task( @@ -2307,8 +2411,6 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - Project::init_settings(cx); - language::init(cx); }); } @@ -2324,7 +2426,7 @@ mod tests { .await .unwrap(); - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); // Send Output BEFORE Created - should be buffered by acp_thread thread.update(cx, |thread, cx| { @@ -2386,7 +2488,7 @@ mod tests { .await .unwrap(); - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); // Send Output BEFORE Created thread.update(cx, |thread, cx| { @@ -2404,11 +2506,7 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); @@ -2465,15 +2563,7 @@ mod tests { // Test creating a new user message thread.update(cx, |thread, cx| { - thread.push_user_content_block( - None, - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "Hello, ".to_string(), - meta: None, - }), - cx, - ); + thread.push_user_content_block(None, "Hello, ".into(), cx); }); thread.update(cx, |thread, cx| { @@ -2489,15 +2579,7 @@ mod tests { // Test appending to existing user message let message_1_id = UserMessageId::new(); thread.update(cx, |thread, cx| { - thread.push_user_content_block( - Some(message_1_id.clone()), - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "world!".to_string(), - meta: None, - }), - cx, - ); + thread.push_user_content_block(Some(message_1_id.clone()), "world!".into(), cx); }); thread.update(cx, |thread, cx| { @@ -2512,26 +2594,14 @@ mod tests { // Test creating new user message after assistant message thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "Assistant response".to_string(), - meta: None, - }), - false, - cx, - ); + thread.push_assistant_content_block("Assistant response".into(), false, cx); }); let message_2_id = UserMessageId::new(); thread.update(cx, |thread, cx| { thread.push_user_content_block( Some(message_2_id.clone()), - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "New user message".to_string(), - meta: None, - }), + "New user message".into(), cx, ); }); @@ -2559,25 +2629,22 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk { - content: "Thinking ".into(), - }, + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "Thinking ".into(), + )), cx, ) .unwrap(); thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk { - content: "hard!".into(), - }, + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "hard!".into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() }, @@ -2645,10 +2712,7 @@ mod tests { .unwrap() .await .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() }, @@ -2870,7 +2934,7 @@ mod tests { .await .unwrap_err(); - assert_eq!(err.code, acp::ErrorCode::RESOURCE_NOT_FOUND.code); + assert_eq!(err.code, acp::ErrorCode::ResourceNotFound); } #[gpui::test] @@ -2879,7 +2943,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let id = acp::ToolCallId("test".into()); + let id = acp::ToolCallId::new("test"); let connection = Rc::new(FakeAgentConnection::new().on_user_message({ let id = id.clone(); @@ -2889,26 +2953,17 @@ mod tests { thread .update(&mut cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: id.clone(), - title: "Label".into(), - kind: acp::ToolKind::Fetch, - status: acp::ToolCallStatus::InProgress, - content: vec![], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(id.clone(), "Label") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::InProgress), + ), cx, ) }) .unwrap() .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -2950,14 +3005,10 @@ mod tests { thread .update(cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( id, - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - ..Default::default() - }, - meta: None, - }), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), cx, ) }) @@ -2989,33 +3040,21 @@ mod tests { thread .update(&mut cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("test".into()), - title: "Label".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/test/test.txt".into(), - old_text: None, - new_text: "foo".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("test", "Label") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff(acp::Diff::new( + "/test/test.txt", + "foo", + ))]), + ), cx, ) }) .unwrap() .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3068,17 +3107,14 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk { - content: content.text.to_uppercase().into(), - }, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3234,34 +3270,22 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool1".into()), - title: "Test Tool".into(), - kind: acp::ToolKind::Fetch, - status: acp::ToolCallStatus::Completed, - content: vec![], - locations: vec![], - raw_input: Some(serde_json::json!({"query": "test"})), - raw_output: Some( - serde_json::json!({"result": "inappropriate content"}), - ), - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool1", "Test Tool") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::Completed) + .raw_input(serde_json::json!({"query": "test"})) + .raw_output(serde_json::json!({"result": "inappropriate content"})), + ), cx, ) .unwrap(); })?; // Now return refusal because of the tool result - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } else { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } } .boxed_local() @@ -3289,16 +3313,7 @@ mod tests { }); // Send a user message - this will trigger tool call and then refusal - let send_task = thread.update(cx, |thread, cx| { - thread.send( - vec![acp::ContentBlock::Text(acp::TextContent { - text: "Hello".into(), - annotations: None, - meta: None, - })], - cx, - ) - }); + let send_task = thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx)); cx.background_executor.spawn(send_task).detach(); cx.run_until_parked(); @@ -3344,21 +3359,11 @@ mod tests { let refuse_next = refuse_next.clone(); move |_request, _thread, _cx| { if refuse_next.load(SeqCst) { - async move { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }) - } - .boxed_local() + async move { Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } + .boxed_local() } else { - async move { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) - } - .boxed_local() + async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } + .boxed_local() } } })); @@ -3415,10 +3420,7 @@ mod tests { let refuse_next = refuse_next.clone(); async move { if refuse_next.load(SeqCst) { - return Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }); + return Ok(acp::PromptResponse::new(acp::StopReason::Refusal)); } let acp::ContentBlock::Text(content) = &request.prompt[0] else { @@ -3427,17 +3429,14 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk { - content: content.text.to_uppercase().into(), - }, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3562,6 +3561,10 @@ mod tests { } impl AgentConnection for FakeAgentConnection { + fn telemetry_id(&self) -> SharedString { + "fake".into() + } + fn auth_methods(&self) -> &[acp::AuthMethod] { &self.auth_methods } @@ -3572,13 +3575,12 @@ mod tests { _cwd: &Path, cx: &mut App, ) -> Task>> { - let session_id = acp::SessionId( + let session_id = acp::SessionId::new( rand::rng() .sample_iter(&distr::Alphanumeric) .take(7) .map(char::from) - .collect::() - .into(), + .collect::(), ); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { @@ -3588,12 +3590,12 @@ mod tests { project, action_log, session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }); @@ -3622,10 +3624,7 @@ mod tests { let thread = thread.clone(); cx.spawn(async move |cx| handler(params, thread, cx.clone()).await) } else { - Task::ready(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - })) + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) } } @@ -3680,17 +3679,13 @@ mod tests { .unwrap(); // Try to update a tool call that doesn't exist - let nonexistent_id = acp::ToolCallId("nonexistent-tool-call".into()); + let nonexistent_id = acp::ToolCallId::new("nonexistent-tool-call"); thread.update(cx, |thread, cx| { let result = thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { - id: nonexistent_id.clone(), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - ..Default::default() - }, - meta: None, - }), + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + nonexistent_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), cx, ); @@ -3727,4 +3722,300 @@ mod tests { } }); } + + /// Tests that restoring a checkpoint properly cleans up terminals that were + /// created after that checkpoint, and cancels any in-progress generation. + /// + /// Reproduces issue #35142: When a checkpoint is restored, any terminal processes + /// that were started after that checkpoint should be terminated, and any in-progress + /// AI generation should be canceled. + #[gpui::test] + async fn test_restore_checkpoint_kills_terminal(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx)) + .await + .unwrap(); + + // Send first user message to create a checkpoint + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.send(vec!["first message".into()], cx) + }) + }) + .await + .unwrap(); + + // Send second message (creates another checkpoint) - we'll restore to this one + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.send(vec!["second message".into()], cx) + }) + }) + .await + .unwrap(); + + // Create 2 terminals BEFORE the checkpoint that have completed running + let terminal_id_1 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); + let mock_terminal_1 = cx.new(|cx| { + let builder = ::terminal::TerminalBuilder::new_display_only( + ::terminal::terminal_settings::CursorShape::default(), + ::terminal::terminal_settings::AlternateScroll::On, + None, + 0, + ) + .unwrap(); + builder.subscribe(cx) + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id_1.clone(), + label: "echo 'first'".to_string(), + cwd: Some(PathBuf::from("/test")), + output_byte_limit: None, + terminal: mock_terminal_1.clone(), + }, + cx, + ); + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id_1.clone(), + data: b"first\n".to_vec(), + }, + cx, + ); + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id_1.clone(), + status: acp::TerminalExitStatus::new().exit_code(0), + }, + cx, + ); + }); + + let terminal_id_2 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); + let mock_terminal_2 = cx.new(|cx| { + let builder = ::terminal::TerminalBuilder::new_display_only( + ::terminal::terminal_settings::CursorShape::default(), + ::terminal::terminal_settings::AlternateScroll::On, + None, + 0, + ) + .unwrap(); + builder.subscribe(cx) + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id_2.clone(), + label: "echo 'second'".to_string(), + cwd: Some(PathBuf::from("/test")), + output_byte_limit: None, + terminal: mock_terminal_2.clone(), + }, + cx, + ); + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id_2.clone(), + data: b"second\n".to_vec(), + }, + cx, + ); + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id_2.clone(), + status: acp::TerminalExitStatus::new().exit_code(0), + }, + cx, + ); + }); + + // Get the second message ID to restore to + let second_message_id = thread.read_with(cx, |thread, _| { + // At this point we have: + // - Index 0: First user message (with checkpoint) + // - Index 1: Second user message (with checkpoint) + // No assistant responses because FakeAgentConnection just returns EndTurn + let AgentThreadEntry::UserMessage(message) = &thread.entries[1] else { + panic!("expected user message at index 1"); + }; + message.id.clone().unwrap() + }); + + // Create a terminal AFTER the checkpoint we'll restore to. + // This simulates the AI agent starting a long-running terminal command. + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); + let mock_terminal = cx.new(|cx| { + let builder = ::terminal::TerminalBuilder::new_display_only( + ::terminal::terminal_settings::CursorShape::default(), + ::terminal::terminal_settings::AlternateScroll::On, + None, + 0, + ) + .unwrap(); + builder.subscribe(cx) + }); + + // Register the terminal as created + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: "sleep 1000".to_string(), + cwd: Some(PathBuf::from("/test")), + output_byte_limit: None, + terminal: mock_terminal.clone(), + }, + cx, + ); + }); + + // Simulate the terminal producing output (still running) + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data: b"terminal is running...\n".to_vec(), + }, + cx, + ); + }); + + // Create a tool call entry that references this terminal + // This represents the agent requesting a terminal command + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("terminal-tool-1", "Running command") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new( + terminal_id.clone(), + ))]) + .raw_input(serde_json::json!({"command": "sleep 1000", "cd": "/test"})), + ), + cx, + ) + .unwrap(); + }); + + // Verify terminal exists and is in the thread + let terminal_exists_before = + thread.read_with(cx, |thread, _| thread.terminals.contains_key(&terminal_id)); + assert!( + terminal_exists_before, + "Terminal should exist before checkpoint restore" + ); + + // Verify the terminal's underlying task is still running (not completed) + let terminal_running_before = thread.read_with(cx, |thread, _cx| { + let terminal_entity = thread.terminals.get(&terminal_id).unwrap(); + terminal_entity.read_with(cx, |term, _cx| { + term.output().is_none() // output is None means it's still running + }) + }); + assert!( + terminal_running_before, + "Terminal should be running before checkpoint restore" + ); + + // Verify we have the expected entries before restore + let entry_count_before = thread.read_with(cx, |thread, _| thread.entries.len()); + assert!( + entry_count_before > 1, + "Should have multiple entries before restore" + ); + + // Restore the checkpoint to the second message. + // This should: + // 1. Cancel any in-progress generation (via the cancel() call) + // 2. Remove the terminal that was created after that point + thread + .update(cx, |thread, cx| { + thread.restore_checkpoint(second_message_id, cx) + }) + .await + .unwrap(); + + // Verify that no send_task is in progress after restore + // (cancel() clears the send_task) + let has_send_task_after = thread.read_with(cx, |thread, _| thread.send_task.is_some()); + assert!( + !has_send_task_after, + "Should not have a send_task after restore (cancel should have cleared it)" + ); + + // Verify the entries were truncated (restoring to index 1 truncates at 1, keeping only index 0) + let entry_count = thread.read_with(cx, |thread, _| thread.entries.len()); + assert_eq!( + entry_count, 1, + "Should have 1 entry after restore (only the first user message)" + ); + + // Verify the 2 completed terminals from before the checkpoint still exist + let terminal_1_exists = thread.read_with(cx, |thread, _| { + thread.terminals.contains_key(&terminal_id_1) + }); + assert!( + terminal_1_exists, + "Terminal 1 (from before checkpoint) should still exist" + ); + + let terminal_2_exists = thread.read_with(cx, |thread, _| { + thread.terminals.contains_key(&terminal_id_2) + }); + assert!( + terminal_2_exists, + "Terminal 2 (from before checkpoint) should still exist" + ); + + // Verify they're still in completed state + let terminal_1_completed = thread.read_with(cx, |thread, _cx| { + let terminal_entity = thread.terminals.get(&terminal_id_1).unwrap(); + terminal_entity.read_with(cx, |term, _cx| term.output().is_some()) + }); + assert!(terminal_1_completed, "Terminal 1 should still be completed"); + + let terminal_2_completed = thread.read_with(cx, |thread, _cx| { + let terminal_entity = thread.terminals.get(&terminal_id_2).unwrap(); + terminal_entity.read_with(cx, |term, _cx| term.output().is_some()) + }); + assert!(terminal_2_completed, "Terminal 2 should still be completed"); + + // Verify the running terminal (created after checkpoint) was removed + let terminal_3_exists = + thread.read_with(cx, |thread, _| thread.terminals.contains_key(&terminal_id)); + assert!( + !terminal_3_exists, + "Terminal 3 (created after checkpoint) should have been removed" + ); + + // Verify total count is 2 (the two from before the checkpoint) + let terminal_count = thread.read_with(cx, |thread, _| thread.terminals.len()); + assert_eq!( + terminal_count, 2, + "Should have exactly 2 terminals (the completed ones from before checkpoint)" + ); + } } diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index fe66f95437..3c8c56b2c0 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -20,6 +20,8 @@ impl UserMessageId { } pub trait AgentConnection { + fn telemetry_id(&self) -> SharedString; + fn new_thread( self: Rc, project: Entity, @@ -106,9 +108,6 @@ pub trait AgentSessionSetTitle { } pub trait AgentTelemetry { - /// The name of the agent used for telemetry. - fn agent_name(&self) -> String; - /// A representation of the current thread state that can be serialized for /// storage with telemetry events. fn thread_data( @@ -198,6 +197,11 @@ pub trait AgentModelSelector: 'static { fn watch(&self, _cx: &mut App) -> Option> { None } + + /// Returns whether the model picker should render a footer. + fn should_render_footer(&self) -> bool { + false + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -318,6 +322,10 @@ mod test_support { } impl AgentConnection for StubAgentConnection { + fn telemetry_id(&self) -> SharedString { + "stub".into() + } + fn auth_methods(&self) -> &[acp::AuthMethod] { &[] } @@ -328,7 +336,7 @@ mod test_support { _cwd: &Path, cx: &mut gpui::App, ) -> Task>> { - let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); + let session_id = acp::SessionId::new(self.sessions.lock().len().to_string()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( @@ -337,12 +345,12 @@ mod test_support { project, action_log, session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }); @@ -381,10 +389,7 @@ mod test_support { response_tx.replace(tx); cx.spawn(async move |_| { let stop_reason = rx.await?; - Ok(acp::PromptResponse { - stop_reason, - meta: None, - }) + Ok(acp::PromptResponse::new(stop_reason)) }) } else { for update in self.next_prompt_updates.lock().drain(..) { @@ -392,7 +397,7 @@ mod test_support { let update = update.clone(); let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update - && let Some(options) = self.permission_requests.get(&tool_call.id) + && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id) { Some((tool_call.clone(), options.clone())) } else { @@ -421,10 +426,7 @@ mod test_support { cx.spawn(async move |_| { try_join_all(tasks).await?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }) } } diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 15de12af27..cae1aad908 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -50,9 +50,14 @@ impl Diff { let hunk_ranges = { let buffer = buffer.read(cx); let diff = diff.read(cx); - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) - .collect::>() + diff.hunks_intersecting_range( + Anchor::min_for_buffer(buffer.remote_id()) + ..Anchor::max_for_buffer(buffer.remote_id()), + buffer, + cx, + ) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) + .collect::>() }; multibuffer.set_excerpts_for_path( @@ -161,7 +166,7 @@ impl Diff { } pub fn has_revealed_range(&self, cx: &App) -> bool { - self.multibuffer().read(cx).excerpt_paths().next().is_some() + self.multibuffer().read(cx).paths().next().is_some() } pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool { @@ -236,21 +241,21 @@ impl PendingDiff { fn finalize(&self, cx: &mut Context) -> FinalizedDiff { let ranges = self.excerpt_ranges(cx); let base_text = self.base_text.clone(); - let language_registry = self.new_buffer.read(cx).language_registry(); + let new_buffer = self.new_buffer.read(cx); + let language_registry = new_buffer.language_registry(); - let path = self - .new_buffer - .read(cx) + let path = new_buffer .file() .map(|file| file.path().display(file.path_style(cx))) .unwrap_or("untitled".into()) .into(); + let replica_id = new_buffer.replica_id(); // Replace the buffer in the multibuffer with the snapshot let buffer = cx.new(|cx| { let language = self.new_buffer.read(cx).language().cloned(); let buffer = TextBuffer::new_normalized( - 0, + replica_id, cx.entity_id().as_non_zero_u64().into(), self.new_buffer.read(cx).line_ending(), self.new_buffer.read(cx).as_rope().clone(), @@ -316,7 +321,12 @@ impl PendingDiff { let buffer = self.new_buffer.read(cx); let diff = self.diff.read(cx); let mut ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) + .hunks_intersecting_range( + Anchor::min_for_buffer(buffer.remote_id()) + ..Anchor::max_for_buffer(buffer.remote_id()), + buffer, + cx, + ) .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) .collect::>(); ranges.extend( diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index bbd13da5fa..c1b7032cfa 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -7,10 +7,10 @@ use std::{ fmt, ops::RangeInclusive, path::{Path, PathBuf}, - str::FromStr, }; use ui::{App, IconName, SharedString}; use url::Url; +use util::paths::PathStyle; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum MentionUri { @@ -49,7 +49,7 @@ pub enum MentionUri { } impl MentionUri { - pub fn parse(input: &str) -> Result { + pub fn parse(input: &str, path_style: PathStyle) -> Result { fn parse_line_range(fragment: &str) -> Result> { let range = fragment .strip_prefix("L") @@ -74,32 +74,41 @@ impl MentionUri { let path = url.path(); match url.scheme() { "file" => { - let path = url.to_file_path().ok().context("Extracting file path")?; + let path = if path_style.is_windows() { + path.trim_start_matches("/") + } else { + path + }; + if let Some(fragment) = url.fragment() { let line_range = parse_line_range(fragment)?; if let Some(name) = single_query_param(&url, "symbol")? { Ok(Self::Symbol { name, - abs_path: path, + abs_path: path.into(), line_range, }) } else { Ok(Self::Selection { - abs_path: Some(path), + abs_path: Some(path.into()), line_range, }) } } else if input.ends_with("/") { - Ok(Self::Directory { abs_path: path }) + Ok(Self::Directory { + abs_path: path.into(), + }) } else { - Ok(Self::File { abs_path: path }) + Ok(Self::File { + abs_path: path.into(), + }) } } "zed" => { if let Some(thread_id) = path.strip_prefix("/agent/thread/") { let name = single_query_param(&url, "name")?.context("Missing thread name")?; Ok(Self::Thread { - id: acp::SessionId(thread_id.into()), + id: acp::SessionId::new(thread_id), name, }) } else if let Some(path) = path.strip_prefix("/agent/text-thread/") { @@ -213,18 +222,14 @@ impl MentionUri { pub fn to_uri(&self) -> Url { match self { MentionUri::File { abs_path } => { - let mut url = Url::parse("zed:///").unwrap(); - url.set_path("/agent/file"); - url.query_pairs_mut() - .append_pair("path", &abs_path.to_string_lossy()); + let mut url = Url::parse("file:///").unwrap(); + url.set_path(&abs_path.to_string_lossy()); url } MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(), MentionUri::Directory { abs_path } => { - let mut url = Url::parse("zed:///").unwrap(); - url.set_path("/agent/directory"); - url.query_pairs_mut() - .append_pair("path", &abs_path.to_string_lossy()); + let mut url = Url::parse("file:///").unwrap(); + url.set_path(&abs_path.to_string_lossy()); url } MentionUri::Symbol { @@ -232,10 +237,9 @@ impl MentionUri { name, line_range, } => { - let mut url = Url::parse("zed:///").unwrap(); - url.set_path(&format!("/agent/symbol/{name}")); - url.query_pairs_mut() - .append_pair("path", &abs_path.to_string_lossy()); + let mut url = Url::parse("file:///").unwrap(); + url.set_path(&abs_path.to_string_lossy()); + url.query_pairs_mut().append_pair("symbol", name); url.set_fragment(Some(&format!( "L{}:{}", line_range.start() + 1, @@ -247,13 +251,14 @@ impl MentionUri { abs_path, line_range, } => { - let mut url = Url::parse("zed:///").unwrap(); - if let Some(abs_path) = abs_path { - url.set_path("/agent/selection"); - url.query_pairs_mut() - .append_pair("path", &abs_path.to_string_lossy()); + let mut url = if let Some(path) = abs_path { + let mut url = Url::parse("file:///").unwrap(); + url.set_path(&path.to_string_lossy()); + url } else { + let mut url = Url::parse("zed:///").unwrap(); url.set_path("/agent/untitled-buffer"); + url }; url.set_fragment(Some(&format!( "L{}:{}", @@ -288,14 +293,6 @@ impl MentionUri { } } -impl FromStr for MentionUri { - type Err = anyhow::Error; - - fn from_str(s: &str) -> anyhow::Result { - Self::parse(s) - } -} - pub struct MentionLink<'a>(&'a MentionUri); impl fmt::Display for MentionLink<'_> { @@ -338,93 +335,81 @@ mod tests { #[test] fn test_parse_file_uri() { - let old_uri = uri!("file:///path/to/file.rs"); - let parsed = MentionUri::parse(old_uri).unwrap(); + let file_uri = uri!("file:///path/to/file.rs"); + let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::File { abs_path } => { - assert_eq!(abs_path.to_str().unwrap(), path!("/path/to/file.rs")); + assert_eq!(abs_path, Path::new(path!("/path/to/file.rs"))); } _ => panic!("Expected File variant"), } - let new_uri = parsed.to_uri().to_string(); - assert!(new_uri.starts_with("zed:///agent/file")); - assert_eq!(MentionUri::parse(&new_uri).unwrap(), parsed); + assert_eq!(parsed.to_uri().to_string(), file_uri); } #[test] fn test_parse_directory_uri() { - let old_uri = uri!("file:///path/to/dir/"); - let parsed = MentionUri::parse(old_uri).unwrap(); + let file_uri = uri!("file:///path/to/dir/"); + let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Directory { abs_path } => { - assert_eq!(abs_path.to_str().unwrap(), path!("/path/to/dir/")); + assert_eq!(abs_path, Path::new(path!("/path/to/dir/"))); } _ => panic!("Expected Directory variant"), } - let new_uri = parsed.to_uri().to_string(); - assert!(new_uri.starts_with("zed:///agent/directory")); - assert_eq!(MentionUri::parse(&new_uri).unwrap(), parsed); + assert_eq!(parsed.to_uri().to_string(), file_uri); } #[test] fn test_to_directory_uri_without_slash() { let uri = MentionUri::Directory { - abs_path: PathBuf::from(path!("/path/to/dir")), + abs_path: PathBuf::from(path!("/path/to/dir/")), }; - let uri_string = uri.to_uri().to_string(); - assert!(uri_string.starts_with("zed:///agent/directory")); - assert_eq!(MentionUri::parse(&uri_string).unwrap(), uri); + let expected = uri!("file:///path/to/dir/"); + assert_eq!(uri.to_uri().to_string(), expected); } #[test] fn test_parse_symbol_uri() { - let old_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20"); - let parsed = MentionUri::parse(old_uri).unwrap(); + let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20"); + let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Symbol { abs_path: path, name, line_range, } => { - assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs")); + assert_eq!(path, Path::new(path!("/path/to/file.rs"))); assert_eq!(name, "MySymbol"); assert_eq!(line_range.start(), &9); assert_eq!(line_range.end(), &19); } _ => panic!("Expected Symbol variant"), } - let new_uri = parsed.to_uri().to_string(); - assert!(new_uri.starts_with("zed:///agent/symbol/MySymbol")); - assert_eq!(MentionUri::parse(&new_uri).unwrap(), parsed); + assert_eq!(parsed.to_uri().to_string(), symbol_uri); } #[test] fn test_parse_selection_uri() { - let old_uri = uri!("file:///path/to/file.rs#L5:15"); - let parsed = MentionUri::parse(old_uri).unwrap(); + let selection_uri = uri!("file:///path/to/file.rs#L5:15"); + let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, line_range, } => { - assert_eq!( - path.as_ref().unwrap().to_str().unwrap(), - path!("/path/to/file.rs") - ); + assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &4); assert_eq!(line_range.end(), &14); } _ => panic!("Expected Selection variant"), } - let new_uri = parsed.to_uri().to_string(); - assert!(new_uri.starts_with("zed:///agent/selection")); - assert_eq!(MentionUri::parse(&new_uri).unwrap(), parsed); + assert_eq!(parsed.to_uri().to_string(), selection_uri); } #[test] fn test_parse_untitled_selection_uri() { let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10"); - let parsed = MentionUri::parse(selection_uri).unwrap(); + let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Selection { abs_path: None, @@ -441,7 +426,7 @@ mod tests { #[test] fn test_parse_thread_uri() { let thread_uri = "zed:///agent/thread/session123?name=Thread+name"; - let parsed = MentionUri::parse(thread_uri).unwrap(); + let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Thread { id: thread_id, @@ -458,7 +443,7 @@ mod tests { #[test] fn test_parse_rule_uri() { let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule"; - let parsed = MentionUri::parse(rule_uri).unwrap(); + let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Rule { id, name } => { assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52"); @@ -472,7 +457,7 @@ mod tests { #[test] fn test_parse_fetch_http_uri() { let http_uri = "http://example.com/path?query=value#fragment"; - let parsed = MentionUri::parse(http_uri).unwrap(); + let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Fetch { url } => { assert_eq!(url.to_string(), http_uri); @@ -485,7 +470,7 @@ mod tests { #[test] fn test_parse_fetch_https_uri() { let https_uri = "https://example.com/api/endpoint"; - let parsed = MentionUri::parse(https_uri).unwrap(); + let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Fetch { url } => { assert_eq!(url.to_string(), https_uri); @@ -497,40 +482,55 @@ mod tests { #[test] fn test_invalid_scheme() { - assert!(MentionUri::parse("ftp://example.com").is_err()); - assert!(MentionUri::parse("ssh://example.com").is_err()); - assert!(MentionUri::parse("unknown://example.com").is_err()); + assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err()); + assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err()); + assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err()); } #[test] fn test_invalid_zed_path() { - assert!(MentionUri::parse("zed:///invalid/path").is_err()); - assert!(MentionUri::parse("zed:///agent/unknown/test").is_err()); + assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err()); + assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err()); } #[test] fn test_invalid_line_range_format() { // Missing L prefix - assert!(MentionUri::parse(uri!("file:///path/to/file.rs#10:20")).is_err()); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#10:20"), PathStyle::local()).is_err() + ); // Missing colon separator - assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L1020")).is_err()); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L1020"), PathStyle::local()).is_err() + ); // Invalid numbers - assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L10:abc")).is_err()); - assert!(MentionUri::parse(uri!("file:///path/to/file.rs#Labc:20")).is_err()); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L10:abc"), PathStyle::local()).is_err() + ); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#Labc:20"), PathStyle::local()).is_err() + ); } #[test] fn test_invalid_query_parameters() { // Invalid query parameter name - assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L10:20?invalid=test")).is_err()); + assert!( + MentionUri::parse( + uri!("file:///path/to/file.rs#L10:20?invalid=test"), + PathStyle::local() + ) + .is_err() + ); // Too many query parameters assert!( - MentionUri::parse(uri!( - "file:///path/to/file.rs#L10:20?symbol=test&another=param" - )) + MentionUri::parse( + uri!("file:///path/to/file.rs#L10:20?symbol=test&another=param"), + PathStyle::local() + ) .is_err() ); } @@ -538,8 +538,14 @@ mod tests { #[test] fn test_zero_based_line_numbers() { // Test that 0-based line numbers are rejected (should be 1-based) - assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L0:10")).is_err()); - assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L1:0")).is_err()); - assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L0:0")).is_err()); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L0:10"), PathStyle::local()).is_err() + ); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L1:0"), PathStyle::local()).is_err() + ); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L0:0"), PathStyle::local()).is_err() + ); } } diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 888c7698c3..f70e044fbc 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -1,10 +1,13 @@ use agent_client_protocol as acp; - +use anyhow::Result; use futures::{FutureExt as _, future::Shared}; -use gpui::{App, AppContext, Context, Entity, Task}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; use language::LanguageRegistry; use markdown::Markdown; +use project::Project; use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant}; +use task::Shell; +use util::get_default_system_shell_preferring_bash; pub struct Terminal { id: acp::TerminalId, @@ -72,11 +75,9 @@ impl Terminal { let exit_status = exit_status.map(portable_pty::ExitStatus::from); - acp::TerminalExitStatus { - exit_code: exit_status.as_ref().map(|e| e.exit_code()), - signal: exit_status.and_then(|e| e.signal().map(Into::into)), - meta: None, - } + acp::TerminalExitStatus::new() + .exit_code(exit_status.as_ref().map(|e| e.exit_code())) + .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))) }) .shared(), } @@ -100,25 +101,19 @@ impl Terminal { if let Some(output) = self.output.as_ref() { let exit_status = output.exit_status.map(portable_pty::ExitStatus::from); - acp::TerminalOutputResponse { - output: output.content.clone(), - truncated: output.original_content_len > output.content.len(), - exit_status: Some(acp::TerminalExitStatus { - exit_code: exit_status.as_ref().map(|e| e.exit_code()), - signal: exit_status.and_then(|e| e.signal().map(Into::into)), - meta: None, - }), - meta: None, - } + acp::TerminalOutputResponse::new( + output.content.clone(), + output.original_content_len > output.content.len(), + ) + .exit_status( + acp::TerminalExitStatus::new() + .exit_code(exit_status.as_ref().map(|e| e.exit_code())) + .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))), + ) } else { let (current_content, original_len) = self.truncated_output(cx); - - acp::TerminalOutputResponse { - truncated: current_content.len() < original_len, - output: current_content, - exit_status: None, - meta: None, - } + let truncated = current_content.len() < original_len; + acp::TerminalOutputResponse::new(current_content, truncated) } } @@ -170,3 +165,62 @@ impl Terminal { ) } } + +pub async fn create_terminal_entity( + command: String, + args: &[String], + env_vars: Vec<(String, String)>, + cwd: Option, + project: &Entity, + cx: &mut AsyncApp, +) -> Result> { + let mut env = if let Some(dir) = &cwd { + project + .update(cx, |project, cx| { + project.environment().update(cx, |env, cx| { + env.directory_environment(dir.clone().into(), cx) + }) + })? + .await + .unwrap_or_default() + } else { + Default::default() + }; + + // Disable pagers so agent/terminal commands don't hang behind interactive UIs + env.insert("PAGER".into(), "".into()); + // Override user core.pager (e.g. delta) which Git prefers over PAGER + env.insert("GIT_PAGER".into(), "cat".into()); + env.extend(env_vars); + + // Use remote shell or default system shell, as appropriate + let shell = project + .update(cx, |project, cx| { + project + .remote_client() + .and_then(|r| r.read(cx).default_system_shell()) + .map(Shell::Program) + })? + .unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash())); + let is_windows = project + .read_with(cx, |project, cx| project.path_style(cx).is_windows()) + .unwrap_or(cfg!(windows)); + let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows) + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + + project + .update(cx, |project, cx| { + project.create_terminal_task( + task::SpawnInTerminal { + command: Some(task_command), + args: task_args, + cwd, + env, + ..Default::default() + }, + cx, + ) + })? + .await +} diff --git a/crates/acp_tools/Cargo.toml b/crates/acp_tools/Cargo.toml index 7a6d8c21a0..0720c4b668 100644 --- a/crates/acp_tools/Cargo.toml +++ b/crates/acp_tools/Cargo.toml @@ -26,5 +26,4 @@ settings.workspace = true theme.workspace = true ui.workspace = true util.workspace = true -workspace-hack.workspace = true workspace.workspace = true diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index e20a040e9d..b0d30367da 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -4,22 +4,26 @@ use std::{ fmt::Display, rc::{Rc, Weak}, sync::Arc, + time::Duration, }; use agent_client_protocol as acp; use collections::HashMap; use gpui::{ - App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState, - StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*, + App, ClipboardItem, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, + ListState, StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, + prelude::*, }; use language::LanguageRegistry; use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; use project::Project; use settings::Settings; use theme::ThemeSettings; -use ui::prelude::*; +use ui::{Tooltip, WithScrollbar, prelude::*}; use util::ResultExt as _; -use workspace::{Item, Workspace}; +use workspace::{ + Item, ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, +}; actions!(dev, [OpenAcpLogs]); @@ -89,8 +93,8 @@ struct WatchedConnection { messages: Vec, list_state: ListState, connection: Weak, - incoming_request_methods: HashMap>, - outgoing_request_methods: HashMap>, + incoming_request_methods: HashMap>, + outgoing_request_methods: HashMap>, _task: Task<()>, } @@ -171,7 +175,7 @@ impl AcpTools { } }; - method_map.insert(id, method.clone()); + method_map.insert(id.clone(), method.clone()); (Some(id), method.into(), MessageType::Request, Ok(params)) } acp::StreamMessageContent::Response { id, result } => { @@ -227,6 +231,43 @@ impl AcpTools { cx.notify(); } + fn serialize_observed_messages(&self) -> Option { + let connection = self.watched_connection.as_ref()?; + + let messages: Vec = connection + .messages + .iter() + .filter_map(|message| { + let params = match &message.params { + Ok(Some(params)) => params.clone(), + Ok(None) => serde_json::Value::Null, + Err(err) => serde_json::to_value(err).ok()?, + }; + Some(serde_json::json!({ + "_direction": match message.direction { + acp::StreamMessageDirection::Incoming => "incoming", + acp::StreamMessageDirection::Outgoing => "outgoing", + }, + "_type": message.message_type.to_string().to_lowercase(), + "id": message.request_id, + "method": message.name.to_string(), + "params": params, + })) + }) + .collect(); + + serde_json::to_string_pretty(&messages).ok() + } + + fn clear_messages(&mut self, cx: &mut Context) { + if let Some(connection) = self.watched_connection.as_mut() { + connection.messages.clear(); + connection.list_state.reset(0); + self.expanded.clear(); + cx.notify(); + } + } + fn render_message( &mut self, index: usize, @@ -250,17 +291,19 @@ impl AcpTools { let expanded = self.expanded.contains(&index); v_flex() - .w_full() - .px_4() - .py_3() - .border_color(colors.border) - .border_b_1() - .gap_2() - .items_start() - .font_buffer(cx) - .text_size(base_size) .id(index) .group("message") + .cursor_pointer() + .font_buffer(cx) + .w_full() + .py_3() + .pl_4() + .pr_5() + .gap_2() + .items_start() + .text_size(base_size) + .border_color(colors.border) + .border_b_1() .hover(|this| this.bg(colors.element_background.opacity(0.5))) .on_click(cx.listener(move |this, _, _, cx| { if this.expanded.contains(&index) { @@ -282,15 +325,14 @@ impl AcpTools { h_flex() .w_full() .gap_2() - .items_center() .flex_shrink_0() .child(match message.direction { - acp::StreamMessageDirection::Incoming => { - ui::Icon::new(ui::IconName::ArrowDown).color(Color::Error) - } - acp::StreamMessageDirection::Outgoing => { - ui::Icon::new(ui::IconName::ArrowUp).color(Color::Success) - } + acp::StreamMessageDirection::Incoming => Icon::new(IconName::ArrowDown) + .color(Color::Error) + .size(IconSize::Small), + acp::StreamMessageDirection::Outgoing => Icon::new(IconName::ArrowUp) + .color(Color::Success) + .size(IconSize::Small), }) .child( Label::new(message.name.clone()) @@ -306,6 +348,7 @@ impl AcpTools { .children( message .request_id + .as_ref() .map(|req_id| div().child(ui::Chip::new(req_id.to_string()))), ), ) @@ -328,13 +371,13 @@ impl AcpTools { syntax: cx.theme().syntax().clone(), code_block_overflow_x_scroll: true, code_block: StyleRefinement { - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some( theme_settings.buffer_font.family.clone(), ), font_size: Some((base_size * 0.8).into()), ..Default::default() - }), + }, ..Default::default() }, ..Default::default() @@ -357,7 +400,7 @@ impl AcpTools { struct WatchedConnectionMessage { name: SharedString, - request_id: Option, + request_id: Option, direction: acp::StreamMessageDirection, message_type: MessageType, params: Result, acp::Error>, @@ -459,7 +502,7 @@ impl Focusable for AcpTools { } impl Render for AcpTools { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .track_focus(&self.focus_handle) .size_full() @@ -474,13 +517,19 @@ impl Render for AcpTools { .child("No messages recorded yet") .into_any() } else { - list( - connection.list_state.clone(), - cx.processor(Self::render_message), - ) - .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_grow() - .into_any() + div() + .size_full() + .flex_grow() + .child( + list( + connection.list_state.clone(), + cx.processor(Self::render_message), + ) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .size_full(), + ) + .vertical_scrollbar_for(&connection.list_state, window, cx) + .into_any() } } None => h_flex() @@ -492,3 +541,103 @@ impl Render for AcpTools { }) } } + +pub struct AcpToolsToolbarItemView { + acp_tools: Option>, + just_copied: bool, +} + +impl AcpToolsToolbarItemView { + pub fn new() -> Self { + Self { + acp_tools: None, + just_copied: false, + } + } +} + +impl Render for AcpToolsToolbarItemView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(acp_tools) = self.acp_tools.as_ref() else { + return Empty.into_any_element(); + }; + + let acp_tools = acp_tools.clone(); + let has_messages = acp_tools + .read(cx) + .watched_connection + .as_ref() + .is_some_and(|connection| !connection.messages.is_empty()); + + h_flex() + .gap_2() + .child({ + let acp_tools = acp_tools.clone(); + IconButton::new( + "copy_all_messages", + if self.just_copied { + IconName::Check + } else { + IconName::Copy + }, + ) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text(if self.just_copied { + "Copied!" + } else { + "Copy All Messages" + })) + .disabled(!has_messages) + .on_click(cx.listener(move |this, _, _window, cx| { + if let Some(content) = acp_tools.read(cx).serialize_observed_messages() { + cx.write_to_clipboard(ClipboardItem::new_string(content)); + + this.just_copied = true; + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + this.update(cx, |this, cx| { + this.just_copied = false; + cx.notify(); + }) + }) + .detach(); + } + })) + }) + .child( + IconButton::new("clear_messages", IconName::Trash) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Messages")) + .disabled(!has_messages) + .on_click(cx.listener(move |_this, _, _window, cx| { + acp_tools.update(cx, |acp_tools, cx| { + acp_tools.clear_messages(cx); + }); + })), + ) + .into_any() + } +} + +impl EventEmitter for AcpToolsToolbarItemView {} + +impl ToolbarItemView for AcpToolsToolbarItemView { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + if let Some(item) = active_pane_item + && let Some(acp_tools) = item.downcast::() + { + self.acp_tools = Some(acp_tools); + cx.notify(); + return ToolbarItemLocation::PrimaryRight; + } + if self.acp_tools.take().is_some() { + cx.notify(); + } + ToolbarItemLocation::Hidden + } +} diff --git a/crates/action_log/Cargo.toml b/crates/action_log/Cargo.toml index 1a389e8859..699d548593 100644 --- a/crates/action_log/Cargo.toml +++ b/crates/action_log/Cargo.toml @@ -20,10 +20,10 @@ futures.workspace = true gpui.workspace = true language.workspace = true project.workspace = true +telemetry.workspace = true text.workspace = true util.workspace = true watch.workspace = true -workspace-hack.workspace = true [dev-dependencies] diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index b7722f211a..6eb18a4f12 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -3,7 +3,9 @@ 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}; +use gpui::{ + App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, +}; use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint}; use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; use std::{cmp, ops::Range, sync::Arc}; @@ -31,71 +33,6 @@ impl ActionLog { &self.project } - pub fn latest_snapshot(&self, buffer: &Entity) -> Option { - Some(self.tracked_buffers.get(buffer)?.snapshot.clone()) - } - - /// Return a unified diff patch with user edits made since last read or notification - pub fn unnotified_user_edits(&self, cx: &Context) -> Option { - let diffs = self - .tracked_buffers - .values() - .filter_map(|tracked| { - if !tracked.may_have_unnotified_user_edits { - return None; - } - - let text_with_latest_user_edits = tracked.diff_base.to_string(); - let text_with_last_seen_user_edits = tracked.last_seen_base.to_string(); - if text_with_latest_user_edits == text_with_last_seen_user_edits { - return None; - } - let patch = language::unified_diff( - &text_with_last_seen_user_edits, - &text_with_latest_user_edits, - ); - - let buffer = tracked.buffer.clone(); - let file_path = buffer - .read(cx) - .file() - .map(|file| { - let mut path = file.full_path(cx).to_string_lossy().into_owned(); - if file.path_style(cx).is_windows() { - path = path.replace('\\', "/"); - } - path - }) - .unwrap_or_else(|| format!("buffer_{}", buffer.entity_id())); - - let mut result = String::new(); - result.push_str(&format!("--- a/{}\n", file_path)); - result.push_str(&format!("+++ b/{}\n", file_path)); - result.push_str(&patch); - - Some(result) - }) - .collect::>(); - - if diffs.is_empty() { - return None; - } - - let unified_diff = diffs.join("\n\n"); - Some(unified_diff) - } - - /// Return a unified diff patch with user edits made since last read/notification - /// and mark them as notified - pub fn flush_unnotified_user_edits(&mut self, cx: &Context) -> Option { - let patch = self.unnotified_user_edits(cx); - self.tracked_buffers.values_mut().for_each(|tracked| { - tracked.may_have_unnotified_user_edits = false; - tracked.last_seen_base = tracked.diff_base.clone(); - }); - patch - } - fn track_buffer_internal( &mut self, buffer: Entity, @@ -145,31 +82,26 @@ impl ActionLog { let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx)); let (diff_update_tx, diff_update_rx) = mpsc::unbounded(); let diff_base; - let last_seen_base; let unreviewed_edits; if is_created { diff_base = Rope::default(); - last_seen_base = Rope::default(); unreviewed_edits = Patch::new(vec![Edit { old: 0..1, new: 0..text_snapshot.max_point().row + 1, }]) } else { diff_base = buffer.read(cx).as_rope().clone(); - last_seen_base = diff_base.clone(); unreviewed_edits = Patch::default(); } TrackedBuffer { buffer: buffer.clone(), diff_base, - last_seen_base, unreviewed_edits, snapshot: text_snapshot, status, version: buffer.read(cx).version(), diff, diff_update: diff_update_tx, - may_have_unnotified_user_edits: false, _open_lsp_handle: open_lsp_handle, _maintain_diff: cx.spawn({ let buffer = buffer.clone(); @@ -320,10 +252,9 @@ impl ActionLog { let new_snapshot = buffer_snapshot.clone(); let unreviewed_edits = tracked_buffer.unreviewed_edits.clone(); let edits = diff_snapshots(&old_snapshot, &new_snapshot); - let mut has_user_changes = false; async move { if let ChangeAuthor::User = author { - has_user_changes = apply_non_conflicting_edits( + apply_non_conflicting_edits( &unreviewed_edits, edits, &mut base_text, @@ -331,22 +262,13 @@ impl ActionLog { ); } - (Arc::new(base_text.to_string()), base_text, has_user_changes) + (Arc::new(base_text.to_string()), base_text) } }); anyhow::Ok(rebase) })??; - let (new_base_text, new_diff_base, has_user_changes) = rebase.await; - - this.update(cx, |this, _| { - let tracked_buffer = this - .tracked_buffers - .get_mut(buffer) - .context("buffer not tracked") - .unwrap(); - tracked_buffer.may_have_unnotified_user_edits |= has_user_changes; - })?; + let (new_base_text, new_diff_base) = rebase.await; Self::update_diff( this, @@ -487,9 +409,11 @@ impl ActionLog { let new_diff_base = new_diff_base.clone(); async move { let mut unreviewed_edits = Patch::default(); - for hunk in diff_snapshot - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer_snapshot) - { + for hunk in diff_snapshot.hunks_intersecting_range( + Anchor::min_for_buffer(buffer_snapshot.remote_id()) + ..Anchor::max_for_buffer(buffer_snapshot.remote_id()), + &buffer_snapshot, + ) { let old_range = new_diff_base .offset_to_point(hunk.diff_base_byte_range.start) ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end); @@ -565,14 +489,17 @@ impl ActionLog { &mut self, buffer: Entity, buffer_range: Range, + telemetry: Option, cx: &mut Context, ) { let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { return; }; + let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx)); match tracked_buffer.status { TrackedBufferStatus::Deleted => { + metrics.add_edits(tracked_buffer.unreviewed_edits.edits()); self.tracked_buffers.remove(&buffer); cx.notify(); } @@ -581,7 +508,6 @@ impl ActionLog { let buffer_range = buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer); let mut delta = 0i32; - tracked_buffer.unreviewed_edits.retain_mut(|edit| { edit.old.start = (edit.old.start as i32 + delta) as u32; edit.old.end = (edit.old.end as i32 + delta) as u32; @@ -613,6 +539,7 @@ impl ActionLog { .collect::(), ); delta += edit.new_len() as i32 - edit.old_len() as i32; + metrics.add_edit(edit); false } }); @@ -624,19 +551,24 @@ impl ActionLog { tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); } } + if let Some(telemetry) = telemetry { + telemetry_report_accepted_edits(&telemetry, metrics); + } } pub fn reject_edits_in_ranges( &mut self, buffer: Entity, buffer_ranges: Vec>, + telemetry: Option, cx: &mut Context, ) -> Task> { let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { return Task::ready(Ok(())); }; - match &tracked_buffer.status { + let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx)); + let task = match &tracked_buffer.status { TrackedBufferStatus::Created { existing_file_content, } => { @@ -686,6 +618,7 @@ impl ActionLog { } }; + metrics.add_edits(tracked_buffer.unreviewed_edits.edits()); self.tracked_buffers.remove(&buffer); cx.notify(); task @@ -699,6 +632,7 @@ impl ActionLog { .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); // Clear all tracked edits for this buffer and start over as if we just read it. + metrics.add_edits(tracked_buffer.unreviewed_edits.edits()); self.tracked_buffers.remove(&buffer); self.buffer_read(buffer.clone(), cx); cx.notify(); @@ -738,6 +672,7 @@ impl ActionLog { } if revert { + metrics.add_edit(edit); let old_range = tracked_buffer .diff_base .point_to_offset(Point::new(edit.old.start, 0)) @@ -758,12 +693,25 @@ impl ActionLog { self.project .update(cx, |project, cx| project.save_buffer(buffer, cx)) } + }; + if let Some(telemetry) = telemetry { + telemetry_report_rejected_edits(&telemetry, metrics); } + task } - pub fn keep_all_edits(&mut self, cx: &mut Context) { - self.tracked_buffers - .retain(|_buffer, tracked_buffer| match tracked_buffer.status { + pub fn keep_all_edits( + &mut self, + telemetry: Option, + cx: &mut Context, + ) { + self.tracked_buffers.retain(|buffer, tracked_buffer| { + let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx)); + metrics.add_edits(tracked_buffer.unreviewed_edits.edits()); + if let Some(telemetry) = telemetry.as_ref() { + telemetry_report_accepted_edits(telemetry, metrics); + } + match tracked_buffer.status { TrackedBufferStatus::Deleted => false, _ => { if let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status { @@ -774,13 +722,22 @@ impl ActionLog { tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); true } - }); + } + }); + cx.notify(); } - pub fn reject_all_edits(&mut self, cx: &mut Context) -> Task<()> { + pub fn reject_all_edits( + &mut self, + telemetry: Option, + 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); + let buffer_ranges = vec![Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + )]; + let reject = self.reject_edits_in_ranges(buffer, buffer_ranges, telemetry.clone(), cx); async move { reject.await.log_err(); @@ -788,8 +745,7 @@ impl ActionLog { }); let task = futures::future::join_all(futures); - - cx.spawn(async move |_, _| { + cx.background_spawn(async move { task.await; }) } @@ -819,6 +775,61 @@ impl ActionLog { } } +#[derive(Clone)] +pub struct ActionLogTelemetry { + pub agent_telemetry_id: SharedString, + pub session_id: Arc, +} + +struct ActionLogMetrics { + lines_removed: u32, + lines_added: u32, + language: Option, +} + +impl ActionLogMetrics { + fn for_buffer(buffer: &Buffer) -> Self { + Self { + language: buffer.language().map(|l| l.name().0), + lines_removed: 0, + lines_added: 0, + } + } + + fn add_edits(&mut self, edits: &[Edit]) { + for edit in edits { + self.add_edit(edit); + } + } + + fn add_edit(&mut self, edit: &Edit) { + self.lines_added += edit.new_len(); + self.lines_removed += edit.old_len(); + } +} + +fn telemetry_report_accepted_edits(telemetry: &ActionLogTelemetry, metrics: ActionLogMetrics) { + telemetry::event!( + "Agent Edits Accepted", + agent = telemetry.agent_telemetry_id, + session = telemetry.session_id, + language = metrics.language, + lines_added = metrics.lines_added, + lines_removed = metrics.lines_removed + ); +} + +fn telemetry_report_rejected_edits(telemetry: &ActionLogTelemetry, metrics: ActionLogMetrics) { + telemetry::event!( + "Agent Edits Rejected", + agent = telemetry.agent_telemetry_id, + session = telemetry.session_id, + language = metrics.language, + lines_added = metrics.lines_added, + lines_removed = metrics.lines_removed + ); +} + fn apply_non_conflicting_edits( patch: &Patch, edits: Vec>, @@ -949,14 +960,12 @@ enum TrackedBufferStatus { struct TrackedBuffer { buffer: Entity, diff_base: Rope, - last_seen_base: Rope, unreviewed_edits: Patch, status: TrackedBufferStatus, version: clock::Global, diff: Entity, snapshot: text::BufferSnapshot, diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>, - may_have_unnotified_user_edits: bool, _open_lsp_handle: OpenLspBufferHandle, _maintain_diff: Task<()>, _subscription: Subscription, @@ -987,7 +996,6 @@ mod tests { use super::*; use buffer_diff::DiffHunkStatusKind; use gpui::TestAppContext; - use indoc::indoc; use language::Point; use project::{FakeFs, Fs, Project, RemoveOptions}; use rand::prelude::*; @@ -1005,8 +1013,6 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); }); } @@ -1066,7 +1072,7 @@ mod tests { ); action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), cx) + log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), None, cx) }); cx.run_until_parked(); assert_eq!( @@ -1082,7 +1088,7 @@ mod tests { ); action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), cx) + log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), None, cx) }); cx.run_until_parked(); assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); @@ -1167,7 +1173,7 @@ mod tests { ); action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), cx) + log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), None, cx) }); cx.run_until_parked(); assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); @@ -1264,111 +1270,7 @@ mod tests { ); action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx) - }); - cx.run_until_parked(); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_user_edits_notifications(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/dir"), - json!({"file": indoc! {" - abc - def - ghi - jkl - mno"}}), - ) - .await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - - // Agent edits - cx.update(|cx| { - action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx) - .unwrap() - }); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - indoc! {" - abc - deF - GHI - jkl - mno"} - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(1, 0)..Point::new(3, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\nghi\n".into(), - }], - )] - ); - - // User edits - buffer.update(cx, |buffer, cx| { - buffer.edit( - [ - (Point::new(0, 2)..Point::new(0, 2), "X"), - (Point::new(3, 0)..Point::new(3, 0), "Y"), - ], - None, - cx, - ) - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - indoc! {" - abXc - deF - GHI - Yjkl - mno"} - ); - - // User edits should be stored separately from agent's - let user_edits = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx)); - assert_eq!( - user_edits.expect("should have some user edits"), - indoc! {" - --- a/dir/file - +++ b/dir/file - @@ -1,5 +1,5 @@ - -abc - +abXc - def - ghi - -jkl - +Yjkl - mno - "} - ); - - action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx) + log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), None, cx) }); cx.run_until_parked(); assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); @@ -1427,7 +1329,7 @@ mod tests { ); action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), 0..5, cx) + log.keep_edits_in_range(buffer.clone(), 0..5, None, cx) }); cx.run_until_parked(); assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); @@ -1479,7 +1381,7 @@ mod tests { action_log .update(cx, |log, cx| { - log.reject_edits_in_ranges(buffer.clone(), vec![2..5], cx) + log.reject_edits_in_ranges(buffer.clone(), vec![2..5], None, cx) }) .await .unwrap(); @@ -1559,7 +1461,7 @@ mod tests { action_log .update(cx, |log, cx| { - log.reject_edits_in_ranges(buffer.clone(), vec![2..5], cx) + log.reject_edits_in_ranges(buffer.clone(), vec![2..5], None, cx) }) .await .unwrap(); @@ -1742,6 +1644,7 @@ mod tests { log.reject_edits_in_ranges( buffer.clone(), vec![Point::new(4, 0)..Point::new(4, 0)], + None, cx, ) }) @@ -1776,6 +1679,7 @@ mod tests { log.reject_edits_in_ranges( buffer.clone(), vec![Point::new(0, 0)..Point::new(1, 0)], + None, cx, ) }) @@ -1803,6 +1707,7 @@ mod tests { log.reject_edits_in_ranges( buffer.clone(), vec![Point::new(4, 0)..Point::new(4, 0)], + None, cx, ) }) @@ -1877,7 +1782,7 @@ mod tests { let range_2 = buffer.read(cx).anchor_before(Point::new(5, 0)) ..buffer.read(cx).anchor_before(Point::new(5, 3)); - log.reject_edits_in_ranges(buffer.clone(), vec![range_1, range_2], cx) + log.reject_edits_in_ranges(buffer.clone(), vec![range_1, range_2], None, cx) .detach(); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.text()), @@ -1938,6 +1843,7 @@ mod tests { log.reject_edits_in_ranges( buffer.clone(), vec![Point::new(0, 0)..Point::new(0, 0)], + None, cx, ) }) @@ -1993,6 +1899,7 @@ mod tests { log.reject_edits_in_ranges( buffer.clone(), vec![Point::new(0, 0)..Point::new(0, 11)], + None, cx, ) }) @@ -2055,6 +1962,7 @@ mod tests { log.reject_edits_in_ranges( buffer.clone(), vec![Point::new(0, 0)..Point::new(100, 0)], + None, cx, ) }) @@ -2102,7 +2010,8 @@ mod tests { // User accepts the single hunk action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Anchor::MIN..Anchor::MAX, cx) + let buffer_range = Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()); + log.keep_edits_in_range(buffer.clone(), buffer_range, None, cx) }); cx.run_until_parked(); assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); @@ -2123,7 +2032,14 @@ mod tests { // User rejects the hunk action_log .update(cx, |log, cx| { - log.reject_edits_in_ranges(buffer.clone(), vec![Anchor::MIN..Anchor::MAX], cx) + log.reject_edits_in_ranges( + buffer.clone(), + vec![Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + )], + None, + cx, + ) }) .await .unwrap(); @@ -2167,7 +2083,7 @@ mod tests { cx.run_until_parked(); // User clicks "Accept All" - action_log.update(cx, |log, cx| log.keep_all_edits(cx)); + action_log.update(cx, |log, cx| log.keep_all_edits(None, cx)); cx.run_until_parked(); assert!(fs.is_file(path!("/dir/new_file").as_ref()).await); assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); // Hunks are cleared @@ -2186,7 +2102,7 @@ mod tests { // User clicks "Reject All" action_log - .update(cx, |log, cx| log.reject_all_edits(cx)) + .update(cx, |log, cx| log.reject_all_edits(None, cx)) .await; cx.run_until_parked(); assert!(fs.is_file(path!("/dir/new_file").as_ref()).await); @@ -2226,7 +2142,7 @@ mod tests { action_log.update(cx, |log, cx| { let range = buffer.read(cx).random_byte_range(0, &mut rng); log::info!("keeping edits in range {:?}", range); - log.keep_edits_in_range(buffer.clone(), range, cx) + log.keep_edits_in_range(buffer.clone(), range, None, cx) }); } 25..50 => { @@ -2234,7 +2150,7 @@ mod tests { .update(cx, |log, cx| { let range = buffer.read(cx).random_byte_range(0, &mut rng); log::info!("rejecting edits in range {:?}", range); - log.reject_edits_in_ranges(buffer.clone(), vec![range], cx) + log.reject_edits_in_ranges(buffer.clone(), vec![range], None, cx) }) .await .unwrap(); @@ -2488,61 +2404,4 @@ mod tests { .collect() }) } - - #[gpui::test] - async fn test_format_patch(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/dir"), - json!({"test.txt": "line 1\nline 2\nline 3\n"}), - ) - .await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - - let file_path = project - .read_with(cx, |project, cx| { - project.find_project_path("dir/test.txt", cx) - }) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - - cx.update(|cx| { - // Track the buffer and mark it as read first - action_log.update(cx, |log, cx| { - log.buffer_read(buffer.clone(), cx); - }); - - // Make some edits to create a patch - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(1, 0)..Point::new(1, 6), "CHANGED")], None, cx) - .unwrap(); // Replace "line2" with "CHANGED" - }); - }); - - cx.run_until_parked(); - - // Get the patch - let patch = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx)); - - // Verify the patch format contains expected unified diff elements - assert_eq!( - patch.unwrap(), - indoc! {" - --- a/dir/test.txt - +++ b/dir/test.txt - @@ -1,3 +1,3 @@ - line 1 - -line 2 - +CHANGED - line 3 - "} - ); - } } diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index 3a80f012f9..8587e52723 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/crates/activity_indicator/Cargo.toml @@ -17,15 +17,16 @@ anyhow.workspace = true auto_update.workspace = true editor.workspace = true extension_host.workspace = true +fs.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true project.workspace = true proto.workspace = true +semver.workspace = true smallvec.workspace = true ui.workspace = true util.workspace = true -workspace-hack.workspace = true workspace.workspace = true [dev-dependencies] diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index f35b2ad178..b537fabc9b 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -11,8 +11,7 @@ use language::{ LanguageServerStatusUpdate, ServerHealth, }; use project::{ - EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project, - ProjectEnvironmentEvent, + LanguageServerProgress, LspStoreEvent, ProgressToken, Project, ProjectEnvironmentEvent, git_store::{GitStoreEvent, Repository}, }; use smallvec::SmallVec; @@ -20,7 +19,6 @@ use std::{ cmp::Reverse, collections::HashSet, fmt::Write, - path::Path, sync::Arc, time::{Duration, Instant}, }; @@ -53,6 +51,7 @@ pub struct ActivityIndicator { project: Entity, auto_updater: Option>, context_menu_handle: PopoverMenuHandle, + fs_jobs: Vec, } #[derive(Debug)] @@ -63,7 +62,7 @@ struct ServerStatus { struct PendingWork<'a> { language_server_id: LanguageServerId, - progress_token: &'a str, + progress_token: &'a ProgressToken, progress: &'a LanguageServerProgress, } @@ -101,6 +100,27 @@ impl ActivityIndicator { }) .detach(); + let fs = project.read(cx).fs().clone(); + let mut job_events = fs.subscribe_to_jobs(); + cx.spawn(async move |this, cx| { + while let Some(job_event) = job_events.next().await { + this.update(cx, |this: &mut ActivityIndicator, cx| { + match job_event { + fs::JobEvent::Started { info } => { + this.fs_jobs.retain(|j| j.id != info.id); + this.fs_jobs.push(info); + } + fs::JobEvent::Completed { id } => { + this.fs_jobs.retain(|j| j.id != id); + } + } + cx.notify(); + })?; + } + anyhow::Ok(()) + }) + .detach(); + cx.subscribe( &project.read(cx).lsp_store(), |activity_indicator, _, event, cx| { @@ -203,7 +223,8 @@ impl ActivityIndicator { statuses: Vec::new(), project: project.clone(), auto_updater, - context_menu_handle: Default::default(), + context_menu_handle: PopoverMenuHandle::default(), + fs_jobs: Vec::new(), } }); @@ -315,9 +336,9 @@ impl ActivityIndicator { let mut pending_work = status .pending_work .iter() - .map(|(token, progress)| PendingWork { + .map(|(progress_token, progress)| PendingWork { language_server_id: server_id, - progress_token: token.as_str(), + progress_token, progress, }) .collect::>(); @@ -328,27 +349,23 @@ impl ActivityIndicator { .flatten() } - fn pending_environment_errors<'a>( - &'a self, - cx: &'a App, - ) -> impl Iterator, &'a EnvironmentErrorMessage)> { - self.project.read(cx).shell_environment_errors(cx) + fn pending_environment_error<'a>(&'a self, cx: &'a App) -> Option<&'a String> { + self.project.read(cx).peek_environment_error(cx) } fn content_to_render(&mut self, cx: &mut Context) -> Option { // Show if any direnv calls failed - if let Some((abs_path, error)) = self.pending_environment_errors(cx).next() { - let abs_path = abs_path.clone(); + if let Some(message) = self.pending_environment_error(cx) { return Some(Content { icon: Some( Icon::new(IconName::Warning) .size(IconSize::Small) .into_any_element(), ), - message: error.0.clone(), + message: message.clone(), on_click: Some(Arc::new(move |this, window, cx| { this.project.update(cx, |project, cx| { - project.remove_environment_error(&abs_path, cx); + project.pop_environment_error(cx); }); window.dispatch_action(Box::new(workspace::OpenLog), cx); })), @@ -364,11 +381,7 @@ impl ActivityIndicator { .. }) = pending_work.next() { - let mut message = progress - .title - .as_deref() - .unwrap_or(progress_token) - .to_string(); + let mut message = progress.title.clone().unwrap_or(progress_token.to_string()); if let Some(percentage) = progress.percentage { write!(&mut message, " ({}%)", percentage).unwrap(); @@ -442,6 +455,23 @@ impl ActivityIndicator { }); } + // Show any long-running fs command + for fs_job in &self.fs_jobs { + if Instant::now().duration_since(fs_job.start) >= GIT_OPERATION_DELAY { + return Some(Content { + icon: Some( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .with_rotate_animation(2) + .into_any_element(), + ), + message: fs_job.message.clone().into(), + on_click: None, + tooltip_message: None, + }); + } + } + // Show any language server installation info. let mut downloading = SmallVec::<[_; 3]>::new(); let mut checking_for_update = SmallVec::<[_; 3]>::new(); @@ -779,7 +809,7 @@ impl Render for ActivityIndicator { let Some(content) = self.content_to_render(cx) else { return result; }; - let this = cx.entity().downgrade(); + let activity_indicator = cx.entity().downgrade(); let truncate_content = content.message.len() > MAX_MESSAGE_LEN; result.gap_2().child( PopoverMenu::new("activity-indicator-popover") @@ -821,22 +851,21 @@ impl Render for ActivityIndicator { ) .anchor(gpui::Corner::BottomLeft) .menu(move |window, cx| { - let strong_this = this.upgrade()?; + let strong_this = activity_indicator.upgrade()?; let mut has_work = false; let menu = ContextMenu::build(window, cx, |mut menu, _, cx| { for work in strong_this.read(cx).pending_language_server_work(cx) { has_work = true; - let this = this.clone(); + let activity_indicator = activity_indicator.clone(); let mut title = work .progress .title - .as_deref() - .unwrap_or(work.progress_token) - .to_owned(); + .clone() + .unwrap_or(work.progress_token.to_string()); if work.progress.is_cancellable { let language_server_id = work.language_server_id; - let token = work.progress_token.to_string(); + let token = work.progress_token.clone(); let title = SharedString::from(title); menu = menu.custom_entry( move |_, _| { @@ -848,18 +877,23 @@ impl Render for ActivityIndicator { .into_any_element() }, move |_, cx| { - this.update(cx, |this, cx| { - this.project.update(cx, |project, cx| { - project.cancel_language_server_work( - language_server_id, - Some(token.clone()), + let token = token.clone(); + activity_indicator + .update(cx, |activity_indicator, cx| { + activity_indicator.project.update( cx, + |project, cx| { + project.cancel_language_server_work( + language_server_id, + Some(token), + cx, + ); + }, ); - }); - this.context_menu_handle.hide(cx); - cx.notify(); - }) - .ok(); + activity_indicator.context_menu_handle.hide(cx); + cx.notify(); + }) + .ok(); }, ); } else { @@ -891,15 +925,15 @@ impl StatusItemView for ActivityIndicator { #[cfg(test)] mod tests { - use gpui::SemanticVersion; use release_channel::AppCommitSha; + use semver::Version; use super::*; #[test] fn test_version_tooltip_message() { let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Semantic( - SemanticVersion::new(1, 0, 0), + Version::new(1, 0, 0), )); assert_eq!(message, "Version: 1.0.0"); diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 76f96647c7..667033a1bb 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -5,75 +5,101 @@ edition.workspace = true publish.workspace = true license = "GPL-3.0-or-later" +[lib] +path = "src/agent.rs" + +[features] +test-support = ["db/test-support"] +eval = [] +unit-eval = [] +e2e = [] + [lints] workspace = true -[lib] -path = "src/agent.rs" -doctest = false - -[features] -test-support = [ - "gpui/test-support", - "language/test-support", -] - [dependencies] +acp_thread.workspace = true action_log.workspace = true +agent-client-protocol.workspace = true +agent_servers.workspace = true agent_settings.workspace = true anyhow.workspace = true -assistant_context.workspace = true -assistant_tool.workspace = true +assistant_text_thread.workspace = true chrono.workspace = true client.workspace = true cloud_llm_client.workspace = true collections.workspace = true -component.workspace = true context_server.workspace = true -convert_case.workspace = true +db.workspace = true +derive_more.workspace = true fs.workspace = true futures.workspace = true git.workspace = true gpui.workspace = true -heed.workspace = true +handlebars = { workspace = true, features = ["rust-embed"] } +html_to_markdown.workspace = true http_client.workspace = true -icons.workspace = true indoc.workspace = true itertools.workspace = true language.workspace = true language_model.workspace = true +language_models.workspace = true log.workspace = true +open.workspace = true +parking_lot.workspace = true paths.workspace = true -postage.workspace = true project.workspace = true prompt_store.workspace = true -ref-cast.workspace = true -rope.workspace = true +regex.workspace = true +rust-embed.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +smallvec.workspace = true smol.workspace = true sqlez.workspace = true +streaming_diff.workspace = true +strsim.workspace = true +task.workspace = true telemetry.workspace = true text.workspace = true -theme.workspace = true thiserror.workspace = true -time.workspace = true +ui.workspace = true util.workspace = true uuid.workspace = true -workspace-hack.workspace = true +watch.workspace = true +web_search.workspace = true zed_env_vars.workspace = true zstd.workspace = true [dev-dependencies] -assistant_tools.workspace = true +agent_servers = { workspace = true, "features" = ["test-support"] } +assistant_text_thread = { workspace = true, "features" = ["test-support"] } +client = { workspace = true, "features" = ["test-support"] } +clock = { workspace = true, "features" = ["test-support"] } +context_server = { workspace = true, "features" = ["test-support"] } +ctor.workspace = true +db = { workspace = true, "features" = ["test-support"] } +editor = { workspace = true, "features" = ["test-support"] } +env_logger.workspace = true +eval_utils.workspace = true +fs = { workspace = true, "features" = ["test-support"] } +git = { workspace = true, "features" = ["test-support"] } gpui = { workspace = true, "features" = ["test-support"] } -indoc.workspace = true +gpui_tokio.workspace = true language = { workspace = true, "features" = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } -parking_lot.workspace = true +lsp = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } +project = { workspace = true, "features" = ["test-support"] } rand.workspace = true +reqwest_client.workspace = true +settings = { workspace = true, "features" = ["test-support"] } +tempfile.workspace = true +terminal = { workspace = true, "features" = ["test-support"] } +theme = { workspace = true, "features" = ["test-support"] } +tree-sitter-rust.workspace = true +unindent = { workspace = true } +worktree = { workspace = true, "features" = ["test-support"] } +zlog.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 9cd2a93d9b..715c8682ba 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1,22 +1,1606 @@ -pub mod agent_profile; -pub mod context; -pub mod context_server_tool; -pub mod context_store; -pub mod history_store; -pub mod thread; -pub mod thread_store; -pub mod tool_use; +mod db; +mod edit_agent; +mod history_store; +mod legacy_thread; +mod native_agent_server; +pub mod outline; +mod templates; +mod thread; +mod tools; -pub use context::{AgentContext, ContextId, ContextLoadResult}; -pub use context_store::ContextStore; +#[cfg(test)] +mod tests; + +pub use db::*; +pub use history_store::*; +pub use native_agent_server::NativeAgentServer; +pub use templates::*; +pub use thread::*; +pub use tools::*; + +use acp_thread::{AcpThread, AgentModelSelector}; +use agent_client_protocol as acp; +use anyhow::{Context as _, Result, anyhow}; +use chrono::{DateTime, Utc}; +use collections::{HashSet, IndexMap}; use fs::Fs; -use std::sync::Arc; -pub use thread::{ - LastRestoreCheckpoint, Message, MessageCrease, MessageId, MessageSegment, Thread, ThreadError, - ThreadEvent, ThreadFeedback, ThreadId, ThreadSummary, TokenUsageRatio, +use futures::channel::{mpsc, oneshot}; +use futures::future::Shared; +use futures::{StreamExt, future}; +use gpui::{ + App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, }; -pub use thread_store::{SerializedThread, TextThreadStore, ThreadStore}; +use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; +use project::{Project, ProjectItem, ProjectPath, Worktree}; +use prompt_store::{ + ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, + WorktreeContext, +}; +use serde::{Deserialize, Serialize}; +use settings::{LanguageModelSelection, update_settings_file}; +use std::any::Any; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; +use util::ResultExt; +use util::rel_path::RelPath; -pub fn init(fs: Arc, cx: &mut gpui::App) { - thread_store::init(fs, cx); +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectSnapshot { + pub worktree_snapshots: Vec, + pub timestamp: DateTime, +} + +pub struct RulesLoadingError { + pub message: SharedString, +} + +/// Holds both the internal Thread and the AcpThread for a session +struct Session { + /// The internal thread that processes messages + thread: Entity, + /// The ACP thread that handles protocol communication + acp_thread: WeakEntity, + pending_save: Task<()>, + _subscriptions: Vec, +} + +pub struct LanguageModels { + /// Access language model by ID + models: HashMap>, + /// Cached list for returning language model information + model_list: acp_thread::AgentModelList, + refresh_models_rx: watch::Receiver<()>, + refresh_models_tx: watch::Sender<()>, + _authenticate_all_providers_task: Task<()>, +} + +impl LanguageModels { + fn new(cx: &mut App) -> Self { + let (refresh_models_tx, refresh_models_rx) = watch::channel(()); + + let mut this = Self { + models: HashMap::default(), + model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()), + refresh_models_rx, + refresh_models_tx, + _authenticate_all_providers_task: Self::authenticate_all_language_model_providers(cx), + }; + this.refresh_list(cx); + this + } + + fn refresh_list(&mut self, cx: &App) { + let providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .into_iter() + .filter(|provider| provider.is_authenticated(cx)) + .collect::>(); + + let mut language_model_list = IndexMap::default(); + let mut recommended_models = HashSet::default(); + + let mut recommended = Vec::new(); + for provider in &providers { + for model in provider.recommended_models(cx) { + recommended_models.insert((model.provider_id(), model.id())); + recommended.push(Self::map_language_model_to_info(&model, provider)); + } + } + if !recommended.is_empty() { + language_model_list.insert( + acp_thread::AgentModelGroupName("Recommended".into()), + recommended, + ); + } + + let mut models = HashMap::default(); + for provider in providers { + let mut provider_models = Vec::new(); + for model in provider.provided_models(cx) { + let model_info = Self::map_language_model_to_info(&model, &provider); + let model_id = model_info.id.clone(); + provider_models.push(model_info); + models.insert(model_id, model); + } + if !provider_models.is_empty() { + language_model_list.insert( + acp_thread::AgentModelGroupName(provider.name().0.clone()), + provider_models, + ); + } + } + + self.models = models; + self.model_list = acp_thread::AgentModelList::Grouped(language_model_list); + self.refresh_models_tx.send(()).ok(); + } + + fn watch(&self) -> watch::Receiver<()> { + self.refresh_models_rx.clone() + } + + pub fn model_from_id(&self, model_id: &acp::ModelId) -> Option> { + self.models.get(model_id).cloned() + } + + fn map_language_model_to_info( + model: &Arc, + provider: &Arc, + ) -> acp_thread::AgentModelInfo { + acp_thread::AgentModelInfo { + id: Self::model_id(model), + name: model.name().0, + description: None, + icon: Some(provider.icon()), + } + } + + fn model_id(model: &Arc) -> acp::ModelId { + acp::ModelId::new(format!("{}/{}", model.provider_id().0, model.id().0)) + } + + fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> { + let authenticate_all_providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .iter() + .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) + .collect::>(); + + cx.background_spawn(async move { + for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { + if let Err(err) = authenticate_task.await { + match err { + language_model::AuthenticateError::CredentialsNotFound => { + // Since we're authenticating these providers in the + // background for the purposes of populating the + // language selector, we don't care about providers + // where the credentials are not found. + } + language_model::AuthenticateError::ConnectionRefused => { + // Not logging connection refused errors as they are mostly from LM Studio's noisy auth failures. + // LM Studio only has one auth method (endpoint call) which fails for users who haven't enabled it. + // TODO: Better manage LM Studio auth logic to avoid these noisy failures. + } + _ => { + // Some providers have noisy failure states that we + // don't want to spam the logs with every time the + // language model selector is initialized. + // + // Ideally these should have more clear failure modes + // that we know are safe to ignore here, like what we do + // with `CredentialsNotFound` above. + match provider_id.0.as_ref() { + "lmstudio" | "ollama" => { + // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". + // + // These fail noisily, so we don't log them. + } + "copilot_chat" => { + // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. + } + _ => { + log::error!( + "Failed to authenticate provider: {}: {err:#}", + provider_name.0 + ); + } + } + } + } + } + } + }) + } +} + +pub struct NativeAgent { + /// Session ID -> Session mapping + sessions: HashMap, + history: Entity, + /// Shared project context for all threads + project_context: Entity, + project_context_needs_refresh: watch::Sender<()>, + _maintain_project_context: Task>, + context_server_registry: Entity, + /// Shared templates for all threads + templates: Arc, + /// Cached model information + models: LanguageModels, + project: Entity, + prompt_store: Option>, + fs: Arc, + _subscriptions: Vec, +} + +impl NativeAgent { + pub async fn new( + project: Entity, + history: Entity, + templates: Arc, + prompt_store: Option>, + fs: Arc, + cx: &mut AsyncApp, + ) -> Result> { + log::debug!("Creating new NativeAgent"); + + let project_context = cx + .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))? + .await; + + cx.new(|cx| { + let mut subscriptions = vec![ + cx.subscribe(&project, Self::handle_project_event), + cx.subscribe( + &LanguageModelRegistry::global(cx), + Self::handle_models_updated_event, + ), + ]; + if let Some(prompt_store) = prompt_store.as_ref() { + subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) + } + + let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = + watch::channel(()); + Self { + sessions: HashMap::new(), + history, + project_context: cx.new(|_| project_context), + project_context_needs_refresh: project_context_needs_refresh_tx, + _maintain_project_context: cx.spawn(async move |this, cx| { + Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await + }), + context_server_registry: cx.new(|cx| { + ContextServerRegistry::new(project.read(cx).context_server_store(), cx) + }), + templates, + models: LanguageModels::new(cx), + project, + prompt_store, + fs, + _subscriptions: subscriptions, + } + }) + } + + fn register_session( + &mut self, + thread_handle: Entity, + cx: &mut Context, + ) -> Entity { + let connection = Rc::new(NativeAgentConnection(cx.entity())); + + let thread = thread_handle.read(cx); + let session_id = thread.id().clone(); + let title = thread.title(); + let project = thread.project.clone(); + let action_log = thread.action_log.clone(); + let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone(); + let acp_thread = cx.new(|cx| { + acp_thread::AcpThread::new( + title, + connection, + project.clone(), + action_log.clone(), + session_id.clone(), + prompt_capabilities_rx, + cx, + ) + }); + + let registry = LanguageModelRegistry::read_global(cx); + let summarization_model = registry.thread_summary_model().map(|c| c.model); + + thread_handle.update(cx, |thread, cx| { + thread.set_summarization_model(summarization_model, cx); + thread.add_default_tools( + Rc::new(AcpThreadEnvironment { + acp_thread: acp_thread.downgrade(), + }) as _, + cx, + ) + }); + + let subscriptions = vec![ + cx.observe_release(&acp_thread, |this, acp_thread, _cx| { + this.sessions.remove(acp_thread.session_id()); + }), + cx.subscribe(&thread_handle, Self::handle_thread_title_updated), + cx.subscribe(&thread_handle, Self::handle_thread_token_usage_updated), + cx.observe(&thread_handle, move |this, thread, cx| { + this.save_thread(thread, cx) + }), + ]; + + self.sessions.insert( + session_id, + Session { + thread: thread_handle, + acp_thread: acp_thread.downgrade(), + _subscriptions: subscriptions, + pending_save: Task::ready(()), + }, + ); + acp_thread + } + + pub fn models(&self) -> &LanguageModels { + &self.models + } + + async fn maintain_project_context( + this: WeakEntity, + mut needs_refresh: watch::Receiver<()>, + cx: &mut AsyncApp, + ) -> Result<()> { + while needs_refresh.changed().await.is_ok() { + let project_context = this + .update(cx, |this, cx| { + Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx) + })? + .await; + this.update(cx, |this, cx| { + this.project_context = cx.new(|_| project_context); + })?; + } + + Ok(()) + } + + fn build_project_context( + project: &Entity, + prompt_store: Option<&Entity>, + cx: &mut App, + ) -> Task { + let worktrees = project.read(cx).visible_worktrees(cx).collect::>(); + let worktree_tasks = worktrees + .into_iter() + .map(|worktree| { + Self::load_worktree_info_for_system_prompt(worktree, project.clone(), cx) + }) + .collect::>(); + let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() { + prompt_store.read_with(cx, |prompt_store, cx| { + let prompts = prompt_store.default_prompt_metadata(); + let load_tasks = prompts.into_iter().map(|prompt_metadata| { + let contents = prompt_store.load(prompt_metadata.id, cx); + async move { (contents.await, prompt_metadata) } + }); + cx.background_spawn(future::join_all(load_tasks)) + }) + } else { + Task::ready(vec![]) + }; + + cx.spawn(async move |_cx| { + let (worktrees, default_user_rules) = + future::join(future::join_all(worktree_tasks), default_user_rules_task).await; + + let worktrees = worktrees + .into_iter() + .map(|(worktree, _rules_error)| { + // TODO: show error message + // if let Some(rules_error) = rules_error { + // this.update(cx, |_, cx| cx.emit(rules_error)).ok(); + // } + worktree + }) + .collect::>(); + + let default_user_rules = default_user_rules + .into_iter() + .flat_map(|(contents, prompt_metadata)| match contents { + Ok(contents) => Some(UserRulesContext { + uuid: match prompt_metadata.id { + prompt_store::PromptId::User { uuid } => uuid, + prompt_store::PromptId::EditWorkflow => return None, + }, + title: prompt_metadata.title.map(|title| title.to_string()), + contents, + }), + Err(_err) => { + // TODO: show error message + // this.update(cx, |_, cx| { + // cx.emit(RulesLoadingError { + // message: format!("{err:?}").into(), + // }); + // }) + // .ok(); + None + } + }) + .collect::>(); + + ProjectContext::new(worktrees, default_user_rules) + }) + } + + fn load_worktree_info_for_system_prompt( + worktree: Entity, + project: Entity, + cx: &mut App, + ) -> Task<(WorktreeContext, Option)> { + let tree = worktree.read(cx); + let root_name = tree.root_name_str().into(); + let abs_path = tree.abs_path(); + + let mut context = WorktreeContext { + root_name, + abs_path, + rules_file: None, + }; + + let rules_task = Self::load_worktree_rules_file(worktree, project, cx); + let Some(rules_task) = rules_task else { + return Task::ready((context, None)); + }; + + cx.spawn(async move |_| { + let (rules_file, rules_file_error) = match rules_task.await { + Ok(rules_file) => (Some(rules_file), None), + Err(err) => ( + None, + Some(RulesLoadingError { + message: format!("{err}").into(), + }), + ), + }; + context.rules_file = rules_file; + (context, rules_file_error) + }) + } + + fn load_worktree_rules_file( + worktree: Entity, + project: Entity, + cx: &mut App, + ) -> Option>> { + let worktree = worktree.read(cx); + let worktree_id = worktree.id(); + let selected_rules_file = RULES_FILE_NAMES + .into_iter() + .filter_map(|name| { + worktree + .entry_for_path(RelPath::unix(name).unwrap()) + .filter(|entry| entry.is_file()) + .map(|entry| entry.path.clone()) + }) + .next(); + + // Note that Cline supports `.clinerules` being a directory, but that is not currently + // supported. This doesn't seem to occur often in GitHub repositories. + selected_rules_file.map(|path_in_worktree| { + let project_path = ProjectPath { + worktree_id, + path: path_in_worktree.clone(), + }; + let buffer_task = + project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + let rope_task = cx.spawn(async move |cx| { + buffer_task.await?.read_with(cx, |buffer, cx| { + let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?; + anyhow::Ok((project_entry_id, buffer.as_rope().clone())) + })? + }); + // Build a string from the rope on a background thread. + cx.background_spawn(async move { + let (project_entry_id, rope) = rope_task.await?; + anyhow::Ok(RulesFileContext { + path_in_worktree, + text: rope.to_string().trim().to_string(), + project_entry_id: project_entry_id.to_usize(), + }) + }) + }) + } + + fn handle_thread_title_updated( + &mut self, + thread: Entity, + _: &TitleUpdated, + cx: &mut Context, + ) { + let session_id = thread.read(cx).id(); + let Some(session) = self.sessions.get(session_id) else { + return; + }; + let thread = thread.downgrade(); + let acp_thread = session.acp_thread.clone(); + cx.spawn(async move |_, cx| { + let title = thread.read_with(cx, |thread, _| thread.title())?; + let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?; + task.await + }) + .detach_and_log_err(cx); + } + + fn handle_thread_token_usage_updated( + &mut self, + thread: Entity, + usage: &TokenUsageUpdated, + cx: &mut Context, + ) { + let Some(session) = self.sessions.get(thread.read(cx).id()) else { + return; + }; + session + .acp_thread + .update(cx, |acp_thread, cx| { + acp_thread.update_token_usage(usage.0.clone(), cx); + }) + .ok(); + } + + fn handle_project_event( + &mut self, + _project: Entity, + event: &project::Event, + _cx: &mut Context, + ) { + match event { + project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { + self.project_context_needs_refresh.send(()).ok(); + } + project::Event::WorktreeUpdatedEntries(_, items) => { + if items.iter().any(|(path, _, _)| { + RULES_FILE_NAMES + .iter() + .any(|name| path.as_ref() == RelPath::unix(name).unwrap()) + }) { + self.project_context_needs_refresh.send(()).ok(); + } + } + _ => {} + } + } + + fn handle_prompts_updated_event( + &mut self, + _prompt_store: Entity, + _event: &prompt_store::PromptsUpdatedEvent, + _cx: &mut Context, + ) { + self.project_context_needs_refresh.send(()).ok(); + } + + fn handle_models_updated_event( + &mut self, + _registry: Entity, + _event: &language_model::Event, + cx: &mut Context, + ) { + self.models.refresh_list(cx); + + let registry = LanguageModelRegistry::read_global(cx); + let default_model = registry.default_model().map(|m| m.model); + let summarization_model = registry.thread_summary_model().map(|m| m.model); + + for session in self.sessions.values_mut() { + session.thread.update(cx, |thread, cx| { + if thread.model().is_none() + && let Some(model) = default_model.clone() + { + thread.set_model(model, cx); + cx.notify(); + } + thread.set_summarization_model(summarization_model.clone(), cx); + }); + } + } + + pub fn load_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task>> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + let db_thread = database + .load_thread(id.clone()) + .await? + .with_context(|| format!("no thread found with ID: {id:?}"))?; + + this.update(cx, |this, cx| { + let summarization_model = LanguageModelRegistry::read_global(cx) + .thread_summary_model() + .map(|c| c.model); + + cx.new(|cx| { + let mut thread = Thread::from_db( + id.clone(), + db_thread, + this.project.clone(), + this.project_context.clone(), + this.context_server_registry.clone(), + this.templates.clone(), + cx, + ); + thread.set_summarization_model(summarization_model, cx); + thread + }) + }) + }) + } + + pub fn open_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task>> { + let task = self.load_thread(id, cx); + cx.spawn(async move |this, cx| { + let thread = task.await?; + let acp_thread = + this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?; + let events = thread.update(cx, |thread, cx| thread.replay(cx))?; + cx.update(|cx| { + NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) + })? + .await?; + Ok(acp_thread) + }) + } + + pub fn thread_summary( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + let thread = self.open_thread(id.clone(), cx); + cx.spawn(async move |this, cx| { + let acp_thread = thread.await?; + let result = this + .update(cx, |this, cx| { + this.sessions + .get(&id) + .unwrap() + .thread + .update(cx, |thread, cx| thread.summary(cx)) + })? + .await + .context("Failed to generate summary")?; + drop(acp_thread); + Ok(result) + }) + } + + fn save_thread(&mut self, thread: Entity, cx: &mut Context) { + if thread.read(cx).is_empty() { + return; + } + + let database_future = ThreadsDatabase::connect(cx); + let (id, db_thread) = + thread.update(cx, |thread, cx| (thread.id().clone(), thread.to_db(cx))); + let Some(session) = self.sessions.get_mut(&id) else { + return; + }; + let history = self.history.clone(); + session.pending_save = cx.spawn(async move |_, cx| { + let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else { + return; + }; + let db_thread = db_thread.await; + database.save_thread(id, db_thread).await.log_err(); + history.update(cx, |history, cx| history.reload(cx)).ok(); + }); + } +} + +/// Wrapper struct that implements the AgentConnection trait +#[derive(Clone)] +pub struct NativeAgentConnection(pub Entity); + +impl NativeAgentConnection { + pub fn thread(&self, session_id: &acp::SessionId, cx: &App) -> Option> { + self.0 + .read(cx) + .sessions + .get(session_id) + .map(|session| session.thread.clone()) + } + + pub fn load_thread(&self, id: acp::SessionId, cx: &mut App) -> Task>> { + self.0.update(cx, |this, cx| this.load_thread(id, cx)) + } + + fn run_turn( + &self, + session_id: acp::SessionId, + cx: &mut App, + f: impl 'static + + FnOnce(Entity, &mut App) -> Result>>, + ) -> Task> { + let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| { + agent + .sessions + .get_mut(&session_id) + .map(|s| (s.thread.clone(), s.acp_thread.clone())) + }) else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + log::debug!("Found session for: {}", session_id); + + let response_stream = match f(thread, cx) { + Ok(stream) => stream, + Err(err) => return Task::ready(Err(err)), + }; + Self::handle_thread_events(response_stream, acp_thread, cx) + } + + fn handle_thread_events( + mut events: mpsc::UnboundedReceiver>, + acp_thread: WeakEntity, + cx: &App, + ) -> Task> { + cx.spawn(async move |cx| { + // Handle response stream and forward to session.acp_thread + while let Some(result) = events.next().await { + match result { + Ok(event) => { + log::trace!("Received completion event: {:?}", event); + + match event { + ThreadEvent::UserMessage(message) => { + acp_thread.update(cx, |thread, cx| { + for content in message.content { + thread.push_user_content_block( + Some(message.id.clone()), + content.into(), + cx, + ); + } + })?; + } + ThreadEvent::AgentText(text) => { + acp_thread.update(cx, |thread, cx| { + thread.push_assistant_content_block(text.into(), false, cx) + })?; + } + ThreadEvent::AgentThinking(text) => { + acp_thread.update(cx, |thread, cx| { + thread.push_assistant_content_block(text.into(), true, cx) + })?; + } + ThreadEvent::ToolCallAuthorization(ToolCallAuthorization { + tool_call, + options, + response, + }) => { + let outcome_task = acp_thread.update(cx, |thread, cx| { + thread.request_tool_call_authorization( + tool_call, options, true, cx, + ) + })??; + cx.background_spawn(async move { + if let acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome { option_id, .. }, + ) = outcome_task.await + { + response + .send(option_id) + .map(|_| anyhow!("authorization receiver was dropped")) + .log_err(); + } + }) + .detach(); + } + ThreadEvent::ToolCall(tool_call) => { + acp_thread.update(cx, |thread, cx| { + thread.upsert_tool_call(tool_call, cx) + })??; + } + ThreadEvent::ToolCallUpdate(update) => { + acp_thread.update(cx, |thread, cx| { + thread.update_tool_call(update, cx) + })??; + } + ThreadEvent::Retry(status) => { + acp_thread.update(cx, |thread, cx| { + thread.update_retry_status(status, cx) + })?; + } + ThreadEvent::Stop(stop_reason) => { + log::debug!("Assistant message complete: {:?}", stop_reason); + return Ok(acp::PromptResponse::new(stop_reason)); + } + } + } + Err(e) => { + log::error!("Error in model response stream: {:?}", e); + return Err(e); + } + } + } + + log::debug!("Response stream completed"); + anyhow::Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + }) + } +} + +struct NativeAgentModelSelector { + session_id: acp::SessionId, + connection: NativeAgentConnection, +} + +impl acp_thread::AgentModelSelector for NativeAgentModelSelector { + fn list_models(&self, cx: &mut App) -> Task> { + log::debug!("NativeAgentConnection::list_models called"); + let list = self.connection.0.read(cx).models.model_list.clone(); + Task::ready(if list.is_empty() { + Err(anyhow::anyhow!("No models available")) + } else { + Ok(list) + }) + } + + fn select_model(&self, model_id: acp::ModelId, cx: &mut App) -> Task> { + log::debug!( + "Setting model for session {}: {}", + self.session_id, + model_id + ); + let Some(thread) = self + .connection + .0 + .read(cx) + .sessions + .get(&self.session_id) + .map(|session| session.thread.clone()) + else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + + let Some(model) = self.connection.0.read(cx).models.model_from_id(&model_id) else { + return Task::ready(Err(anyhow!("Invalid model ID {}", model_id))); + }; + + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + }); + + update_settings_file( + self.connection.0.read(cx).fs.clone(), + cx, + move |settings, _cx| { + let provider = model.provider_id().0.to_string(); + let model = model.id().0.to_string(); + settings + .agent + .get_or_insert_default() + .set_model(LanguageModelSelection { + provider: provider.into(), + model, + }); + }, + ); + + Task::ready(Ok(())) + } + + fn selected_model(&self, cx: &mut App) -> Task> { + let Some(thread) = self + .connection + .0 + .read(cx) + .sessions + .get(&self.session_id) + .map(|session| session.thread.clone()) + else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + let Some(model) = thread.read(cx).model() else { + return Task::ready(Err(anyhow!("Model not found"))); + }; + let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id()) + else { + return Task::ready(Err(anyhow!("Provider not found"))); + }; + Task::ready(Ok(LanguageModels::map_language_model_to_info( + model, &provider, + ))) + } + + fn watch(&self, cx: &mut App) -> Option> { + Some(self.connection.0.read(cx).models.watch()) + } + + fn should_render_footer(&self) -> bool { + true + } +} + +impl acp_thread::AgentConnection for NativeAgentConnection { + fn telemetry_id(&self) -> SharedString { + "zed".into() + } + + fn new_thread( + self: Rc, + project: Entity, + cwd: &Path, + cx: &mut App, + ) -> Task>> { + let agent = self.0.clone(); + log::debug!("Creating new thread for project at: {:?}", cwd); + + cx.spawn(async move |cx| { + log::debug!("Starting thread creation in async context"); + + // Create Thread + let thread = agent.update( + cx, + |agent, cx: &mut gpui::Context| -> Result<_> { + // Fetch default model from registry settings + let registry = LanguageModelRegistry::read_global(cx); + // Log available models for debugging + let available_count = registry.available_models(cx).count(); + log::debug!("Total available models: {}", available_count); + + let default_model = registry.default_model().and_then(|default_model| { + agent + .models + .model_from_id(&LanguageModels::model_id(&default_model.model)) + }); + Ok(cx.new(|cx| { + Thread::new( + project.clone(), + agent.project_context.clone(), + agent.context_server_registry.clone(), + agent.templates.clone(), + default_model, + cx, + ) + })) + }, + )??; + agent.update(cx, |agent, cx| agent.register_session(thread, cx)) + }) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] // No auth for in-process + } + + fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut App) -> Task> { + Task::ready(Ok(())) + } + + fn model_selector(&self, session_id: &acp::SessionId) -> Option> { + Some(Rc::new(NativeAgentModelSelector { + session_id: session_id.clone(), + connection: self.clone(), + }) as Rc) + } + + fn prompt( + &self, + id: Option, + params: acp::PromptRequest, + cx: &mut App, + ) -> Task> { + let id = id.expect("UserMessageId is required"); + let session_id = params.session_id.clone(); + log::info!("Received prompt request for session: {}", session_id); + log::debug!("Prompt blocks count: {}", params.prompt.len()); + let path_style = self.0.read(cx).project.read(cx).path_style(cx); + + self.run_turn(session_id, cx, move |thread, cx| { + let content: Vec = params + .prompt + .into_iter() + .map(|block| UserMessageContent::from_content_block(block, path_style)) + .collect::>(); + log::debug!("Converted prompt to message: {} chars", content.len()); + log::debug!("Message id: {:?}", id); + log::debug!("Message content: {:?}", content); + + thread.update(cx, |thread, cx| thread.send(id, content, cx)) + }) + } + + fn resume( + &self, + session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + Some(Rc::new(NativeAgentSessionResume { + connection: self.clone(), + session_id: session_id.clone(), + }) as _) + } + + fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { + log::info!("Cancelling on session: {}", session_id); + self.0.update(cx, |agent, cx| { + if let Some(agent) = agent.sessions.get(session_id) { + agent.thread.update(cx, |thread, cx| thread.cancel(cx)); + } + }); + } + + fn truncate( + &self, + session_id: &agent_client_protocol::SessionId, + cx: &App, + ) -> Option> { + self.0.read_with(cx, |agent, _cx| { + agent.sessions.get(session_id).map(|session| { + Rc::new(NativeAgentSessionTruncate { + thread: session.thread.clone(), + acp_thread: session.acp_thread.clone(), + }) as _ + }) + }) + } + + fn set_title( + &self, + session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + Some(Rc::new(NativeAgentSessionSetTitle { + connection: self.clone(), + session_id: session_id.clone(), + }) as _) + } + + fn telemetry(&self) -> Option> { + Some(Rc::new(self.clone()) as Rc) + } + + fn into_any(self: Rc) -> Rc { + self + } +} + +impl acp_thread::AgentTelemetry for NativeAgentConnection { + fn thread_data( + &self, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { + let Some(session) = self.0.read(cx).sessions.get(session_id) else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + + let task = session.thread.read(cx).to_db(cx); + cx.background_spawn(async move { + serde_json::to_value(task.await).context("Failed to serialize thread") + }) + } +} + +struct NativeAgentSessionTruncate { + thread: Entity, + acp_thread: WeakEntity, +} + +impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate { + fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task> { + match self.thread.update(cx, |thread, cx| { + thread.truncate(message_id.clone(), cx)?; + Ok(thread.latest_token_usage()) + }) { + Ok(usage) => { + self.acp_thread + .update(cx, |thread, cx| { + thread.update_token_usage(usage, cx); + }) + .ok(); + Task::ready(Ok(())) + } + Err(error) => Task::ready(Err(error)), + } + } +} + +struct NativeAgentSessionResume { + connection: NativeAgentConnection, + session_id: acp::SessionId, +} + +impl acp_thread::AgentSessionResume for NativeAgentSessionResume { + fn run(&self, cx: &mut App) -> Task> { + self.connection + .run_turn(self.session_id.clone(), cx, |thread, cx| { + thread.update(cx, |thread, cx| thread.resume(cx)) + }) + } +} + +struct NativeAgentSessionSetTitle { + connection: NativeAgentConnection, + session_id: acp::SessionId, +} + +impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle { + fn run(&self, title: SharedString, cx: &mut App) -> Task> { + let Some(session) = self.connection.0.read(cx).sessions.get(&self.session_id) else { + return Task::ready(Err(anyhow!("session not found"))); + }; + let thread = session.thread.clone(); + thread.update(cx, |thread, cx| thread.set_title(title, cx)); + Task::ready(Ok(())) + } +} + +pub struct AcpThreadEnvironment { + acp_thread: WeakEntity, +} + +impl ThreadEnvironment for AcpThreadEnvironment { + fn create_terminal( + &self, + command: String, + cwd: Option, + output_byte_limit: Option, + cx: &mut AsyncApp, + ) -> Task>> { + let task = self.acp_thread.update(cx, |thread, cx| { + thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx) + }); + + let acp_thread = self.acp_thread.clone(); + cx.spawn(async move |cx| { + let terminal = task?.await?; + + let (drop_tx, drop_rx) = oneshot::channel(); + let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone())?; + + cx.spawn(async move |cx| { + drop_rx.await.ok(); + acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx)) + }) + .detach(); + + let handle = AcpTerminalHandle { + terminal, + _drop_tx: Some(drop_tx), + }; + + Ok(Rc::new(handle) as _) + }) + } +} + +pub struct AcpTerminalHandle { + terminal: Entity, + _drop_tx: Option>, +} + +impl TerminalHandle for AcpTerminalHandle { + fn id(&self, cx: &AsyncApp) -> Result { + self.terminal.read_with(cx, |term, _cx| term.id().clone()) + } + + fn wait_for_exit(&self, cx: &AsyncApp) -> Result>> { + self.terminal + .read_with(cx, |term, _cx| term.wait_for_exit()) + } + + fn current_output(&self, cx: &AsyncApp) -> Result { + self.terminal + .read_with(cx, |term, cx| term.current_output(cx)) + } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } +} + +#[cfg(test)] +mod internal_tests { + use crate::HistoryEntryId; + + use super::*; + use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; + use fs::FakeFs; + use gpui::TestAppContext; + use indoc::formatdoc; + use language_model::fake_provider::FakeLanguageModel; + use serde_json::json; + use settings::SettingsStore; + use util::{path, rel_path::rel_path}; + + #[gpui::test] + async fn test_maintaining_project_context(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/", + json!({ + "a": {} + }), + ) + .await; + let project = Project::test(fs.clone(), [], cx).await; + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let agent = NativeAgent::new( + project.clone(), + history_store, + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); + agent.read_with(cx, |agent, cx| { + assert_eq!(agent.project_context.read(cx).worktrees, vec![]) + }); + + let worktree = project + .update(cx, |project, cx| project.create_worktree("/a", true, cx)) + .await + .unwrap(); + cx.run_until_parked(); + agent.read_with(cx, |agent, cx| { + assert_eq!( + agent.project_context.read(cx).worktrees, + vec![WorktreeContext { + root_name: "a".into(), + abs_path: Path::new("/a").into(), + rules_file: None + }] + ) + }); + + // Creating `/a/.rules` updates the project context. + fs.insert_file("/a/.rules", Vec::new()).await; + cx.run_until_parked(); + agent.read_with(cx, |agent, cx| { + let rules_entry = worktree + .read(cx) + .entry_for_path(rel_path(".rules")) + .unwrap(); + assert_eq!( + agent.project_context.read(cx).worktrees, + vec![WorktreeContext { + root_name: "a".into(), + abs_path: Path::new("/a").into(), + rules_file: Some(RulesFileContext { + path_in_worktree: rel_path(".rules").into(), + text: "".into(), + project_entry_id: rules_entry.id.to_usize() + }) + }] + ) + }); + } + + #[gpui::test] + async fn test_listing_models(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({ "a": {} })).await; + let project = Project::test(fs.clone(), [], cx).await; + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let connection = NativeAgentConnection( + NativeAgent::new( + project.clone(), + history_store, + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(), + ); + + // Create a thread/session + let acp_thread = cx + .update(|cx| { + Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx) + }) + .await + .unwrap(); + + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + + let models = cx + .update(|cx| { + connection + .model_selector(&session_id) + .unwrap() + .list_models(cx) + }) + .await + .unwrap(); + + let acp_thread::AgentModelList::Grouped(models) = models else { + panic!("Unexpected model group"); + }; + assert_eq!( + models, + IndexMap::from_iter([( + AgentModelGroupName("Fake".into()), + vec![AgentModelInfo { + id: acp::ModelId::new("fake/fake"), + name: "Fake".into(), + description: None, + icon: Some(ui::IconName::ZedAssistant), + }] + )]) + ); + } + + #[gpui::test] + async fn test_model_selection_persists_to_settings(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.create_dir(paths::settings_file().parent().unwrap()) + .await + .unwrap(); + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "default_model": { + "provider": "foo", + "model": "bar" + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + let project = Project::test(fs.clone(), [], cx).await; + + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Create the agent and connection + let agent = NativeAgent::new( + project.clone(), + history_store, + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); + let connection = NativeAgentConnection(agent.clone()); + + // Create a thread/session + let acp_thread = cx + .update(|cx| { + Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx) + }) + .await + .unwrap(); + + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + + // Select a model + let selector = connection.model_selector(&session_id).unwrap(); + let model_id = acp::ModelId::new("fake/fake"); + cx.update(|cx| selector.select_model(model_id.clone(), cx)) + .await + .unwrap(); + + // Verify the thread has the selected model + agent.read_with(cx, |agent, _| { + let session = agent.sessions.get(&session_id).unwrap(); + session.thread.read_with(cx, |thread, _| { + assert_eq!(thread.model().unwrap().id().0, "fake"); + }); + }); + + cx.run_until_parked(); + + // Verify settings file was updated + let settings_content = fs.load(paths::settings_file()).await.unwrap(); + let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap(); + + // Check that the agent settings contain the selected model + assert_eq!( + settings_json["agent"]["default_model"]["model"], + json!("fake") + ); + assert_eq!( + settings_json["agent"]["default_model"]["provider"], + json!("fake") + ); + } + + #[gpui::test] + async fn test_save_load_thread(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/", + json!({ + "a": { + "b.md": "Lorem" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let agent = NativeAgent::new( + project.clone(), + history_store.clone(), + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + + let acp_thread = cx + .update(|cx| { + connection + .clone() + .new_thread(project.clone(), Path::new(""), cx) + }) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + + // Ensure empty threads are not saved, even if they get mutated. + let model = Arc::new(FakeLanguageModel::default()); + let summary_model = Arc::new(FakeLanguageModel::default()); + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + cx.run_until_parked(); + assert_eq!(history_entries(&history_store, cx), vec![]); + + let send = acp_thread.update(cx, |thread, cx| { + thread.send( + vec![ + "What does ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "b.md", + MentionUri::File { + abs_path: path!("/a/b.md").into(), + } + .to_uri() + .to_string(), + )), + " mean?".into(), + ], + cx, + ) + }); + let send = cx.foreground_executor().spawn(send); + cx.run_until_parked(); + + model.send_last_completion_stream_text_chunk("Lorem."); + model.end_last_completion_stream(); + cx.run_until_parked(); + summary_model + .send_last_completion_stream_text_chunk(&format!("Explaining {}", path!("/a/b.md"))); + summary_model.end_last_completion_stream(); + + send.await.unwrap(); + let uri = MentionUri::File { + abs_path: path!("/a/b.md").into(), + } + .to_uri(); + acp_thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + formatdoc! {" + ## User + + What does [@b.md]({uri}) mean? + + ## Assistant + + Lorem. + + "} + ) + }); + + cx.run_until_parked(); + + // Drop the ACP thread, which should cause the session to be dropped as well. + cx.update(|_| { + drop(thread); + drop(acp_thread); + }); + agent.read_with(cx, |agent, _| { + assert_eq!(agent.sessions.keys().cloned().collect::>(), []); + }); + + // Ensure the thread can be reloaded from disk. + assert_eq!( + history_entries(&history_store, cx), + vec![( + HistoryEntryId::AcpThread(session_id.clone()), + format!("Explaining {}", path!("/a/b.md")) + )] + ); + let acp_thread = agent + .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .await + .unwrap(); + acp_thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + formatdoc! {" + ## User + + What does [@b.md]({uri}) mean? + + ## Assistant + + Lorem. + + "} + ) + }); + } + + fn history_entries( + history: &Entity, + cx: &mut TestAppContext, + ) -> Vec<(HistoryEntryId, String)> { + history.read_with(cx, |history, _| { + history + .entries() + .map(|e| (e.id(), e.title().to_string())) + .collect::>() + }) + } + + 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); + + LanguageModelRegistry::test(cx); + }); + } } diff --git a/crates/agent/src/agent_profile.rs b/crates/agent/src/agent_profile.rs deleted file mode 100644 index 40ba2f07db..0000000000 --- a/crates/agent/src/agent_profile.rs +++ /dev/null @@ -1,341 +0,0 @@ -use std::sync::Arc; - -use agent_settings::{AgentProfileId, AgentProfileSettings, AgentSettings}; -use assistant_tool::{Tool, ToolSource, ToolWorkingSet, UniqueToolName}; -use collections::IndexMap; -use convert_case::{Case, Casing}; -use fs::Fs; -use gpui::{App, Entity, SharedString}; -use settings::{Settings, update_settings_file}; -use util::ResultExt; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AgentProfile { - id: AgentProfileId, - tool_set: Entity, -} - -pub type AvailableProfiles = IndexMap; - -impl AgentProfile { - pub fn new(id: AgentProfileId, tool_set: Entity) -> Self { - Self { id, tool_set } - } - - /// Saves a new profile to the settings. - pub fn create( - name: String, - base_profile_id: Option, - fs: Arc, - cx: &App, - ) -> AgentProfileId { - let id = AgentProfileId(name.to_case(Case::Kebab).into()); - - let base_profile = - base_profile_id.and_then(|id| AgentSettings::get_global(cx).profiles.get(&id).cloned()); - - let profile_settings = AgentProfileSettings { - name: name.into(), - tools: base_profile - .as_ref() - .map(|profile| profile.tools.clone()) - .unwrap_or_default(), - enable_all_context_servers: base_profile - .as_ref() - .map(|profile| profile.enable_all_context_servers) - .unwrap_or_default(), - context_servers: base_profile - .map(|profile| profile.context_servers) - .unwrap_or_default(), - }; - - update_settings_file(fs, cx, { - let id = id.clone(); - move |settings, _cx| { - profile_settings.save_to_settings(id, settings).log_err(); - } - }); - - id - } - - /// Returns a map of AgentProfileIds to their names - pub fn available_profiles(cx: &App) -> AvailableProfiles { - let mut profiles = AvailableProfiles::default(); - for (id, profile) in AgentSettings::get_global(cx).profiles.iter() { - profiles.insert(id.clone(), profile.name.clone()); - } - profiles - } - - pub fn id(&self) -> &AgentProfileId { - &self.id - } - - pub fn enabled_tools(&self, cx: &App) -> Vec<(UniqueToolName, Arc)> { - let Some(settings) = AgentSettings::get_global(cx).profiles.get(&self.id) else { - return Vec::new(); - }; - - self.tool_set - .read(cx) - .tools(cx) - .into_iter() - .filter(|(_, tool)| Self::is_enabled(settings, tool.source(), tool.name())) - .collect() - } - - pub fn is_tool_enabled(&self, source: ToolSource, tool_name: String, cx: &App) -> bool { - let Some(settings) = AgentSettings::get_global(cx).profiles.get(&self.id) else { - return false; - }; - - Self::is_enabled(settings, source, tool_name) - } - - fn is_enabled(settings: &AgentProfileSettings, source: ToolSource, name: String) -> bool { - match source { - ToolSource::Native => *settings.tools.get(name.as_str()).unwrap_or(&false), - ToolSource::ContextServer { id } => settings - .context_servers - .get(id.as_ref()) - .and_then(|preset| preset.tools.get(name.as_str()).copied()) - .unwrap_or(settings.enable_all_context_servers), - } - } -} - -#[cfg(test)] -mod tests { - use agent_settings::ContextServerPreset; - use assistant_tool::ToolRegistry; - use collections::IndexMap; - use gpui::SharedString; - use gpui::{AppContext, TestAppContext}; - use http_client::FakeHttpClient; - use project::Project; - use settings::{Settings, SettingsStore}; - - use super::*; - - #[gpui::test] - async fn test_enabled_built_in_tools_for_profile(cx: &mut TestAppContext) { - init_test_settings(cx); - - let id = AgentProfileId::default(); - let profile_settings = cx.read(|cx| { - AgentSettings::get_global(cx) - .profiles - .get(&id) - .unwrap() - .clone() - }); - let tool_set = default_tool_set(cx); - - let profile = AgentProfile::new(id, tool_set); - - let mut enabled_tools = cx - .read(|cx| profile.enabled_tools(cx)) - .into_iter() - .map(|(_, tool)| tool.name()) - .collect::>(); - enabled_tools.sort(); - - let mut expected_tools = profile_settings - .tools - .into_iter() - .filter_map(|(tool, enabled)| enabled.then_some(tool.to_string())) - // Provider dependent - .filter(|tool| tool != "web_search") - .collect::>(); - // Plus all registered MCP tools - expected_tools.extend(["enabled_mcp_tool".into(), "disabled_mcp_tool".into()]); - expected_tools.sort(); - - assert_eq!(enabled_tools, expected_tools); - } - - #[gpui::test] - async fn test_custom_mcp_settings(cx: &mut TestAppContext) { - init_test_settings(cx); - - let id = AgentProfileId("custom_mcp".into()); - let profile_settings = cx.read(|cx| { - AgentSettings::get_global(cx) - .profiles - .get(&id) - .unwrap() - .clone() - }); - let tool_set = default_tool_set(cx); - - let profile = AgentProfile::new(id, tool_set); - - let mut enabled_tools = cx - .read(|cx| profile.enabled_tools(cx)) - .into_iter() - .map(|(_, tool)| tool.name()) - .collect::>(); - enabled_tools.sort(); - - let mut expected_tools = profile_settings.context_servers["mcp"] - .tools - .iter() - .filter_map(|(key, enabled)| enabled.then(|| key.to_string())) - .collect::>(); - expected_tools.sort(); - - assert_eq!(enabled_tools, expected_tools); - } - - #[gpui::test] - async fn test_only_built_in(cx: &mut TestAppContext) { - init_test_settings(cx); - - let id = AgentProfileId("write_minus_mcp".into()); - let profile_settings = cx.read(|cx| { - AgentSettings::get_global(cx) - .profiles - .get(&id) - .unwrap() - .clone() - }); - let tool_set = default_tool_set(cx); - - let profile = AgentProfile::new(id, tool_set); - - let mut enabled_tools = cx - .read(|cx| profile.enabled_tools(cx)) - .into_iter() - .map(|(_, tool)| tool.name()) - .collect::>(); - enabled_tools.sort(); - - let mut expected_tools = profile_settings - .tools - .into_iter() - .filter_map(|(tool, enabled)| enabled.then_some(tool.to_string())) - // Provider dependent - .filter(|tool| tool != "web_search") - .collect::>(); - expected_tools.sort(); - - assert_eq!(enabled_tools, expected_tools); - } - - fn init_test_settings(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - Project::init_settings(cx); - AgentSettings::register(cx); - language_model::init_settings(cx); - ToolRegistry::default_global(cx); - assistant_tools::init(FakeHttpClient::with_404_response(), cx); - }); - - cx.update(|cx| { - let mut agent_settings = AgentSettings::get_global(cx).clone(); - agent_settings.profiles.insert( - AgentProfileId("write_minus_mcp".into()), - AgentProfileSettings { - name: "write_minus_mcp".into(), - enable_all_context_servers: false, - ..agent_settings.profiles[&AgentProfileId::default()].clone() - }, - ); - agent_settings.profiles.insert( - AgentProfileId("custom_mcp".into()), - AgentProfileSettings { - name: "mcp".into(), - tools: IndexMap::default(), - enable_all_context_servers: false, - context_servers: IndexMap::from_iter([("mcp".into(), context_server_preset())]), - }, - ); - AgentSettings::override_global(agent_settings, cx); - }) - } - - fn context_server_preset() -> ContextServerPreset { - ContextServerPreset { - tools: IndexMap::from_iter([ - ("enabled_mcp_tool".into(), true), - ("disabled_mcp_tool".into(), false), - ]), - } - } - - fn default_tool_set(cx: &mut TestAppContext) -> Entity { - cx.new(|cx| { - let mut tool_set = ToolWorkingSet::default(); - tool_set.insert(Arc::new(FakeTool::new("enabled_mcp_tool", "mcp")), cx); - tool_set.insert(Arc::new(FakeTool::new("disabled_mcp_tool", "mcp")), cx); - tool_set - }) - } - - struct FakeTool { - name: String, - source: SharedString, - } - - impl FakeTool { - fn new(name: impl Into, source: impl Into) -> Self { - Self { - name: name.into(), - source: source.into(), - } - } - } - - impl Tool for FakeTool { - fn name(&self) -> String { - self.name.clone() - } - - fn source(&self) -> ToolSource { - ToolSource::ContextServer { - id: self.source.clone(), - } - } - - fn description(&self) -> String { - unimplemented!() - } - - fn icon(&self) -> icons::IconName { - unimplemented!() - } - - fn needs_confirmation( - &self, - _input: &serde_json::Value, - _project: &Entity, - _cx: &App, - ) -> bool { - unimplemented!() - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - unimplemented!() - } - - fn run( - self: Arc, - _input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - _cx: &mut App, - ) -> assistant_tool::ToolResult { - unimplemented!() - } - - fn may_perform_edits(&self) -> bool { - unimplemented!() - } - } -} diff --git a/crates/agent/src/context.rs b/crates/agent/src/context.rs deleted file mode 100644 index 3b2922087a..0000000000 --- a/crates/agent/src/context.rs +++ /dev/null @@ -1,1205 +0,0 @@ -use crate::thread::Thread; -use assistant_context::AssistantContext; -use assistant_tool::outline; -use collections::HashSet; -use futures::future; -use futures::{FutureExt, future::Shared}; -use gpui::{App, AppContext as _, ElementId, Entity, SharedString, Task}; -use icons::IconName; -use language::Buffer; -use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent}; -use project::{Project, ProjectEntryId, ProjectPath, Worktree}; -use prompt_store::{PromptStore, UserPromptId}; -use ref_cast::RefCast; -use rope::Point; -use std::fmt::{self, Display, Formatter, Write as _}; -use std::hash::{Hash, Hasher}; -use std::path::PathBuf; -use std::{ops::Range, path::Path, sync::Arc}; -use text::{Anchor, OffsetRangeExt as _}; -use util::markdown::MarkdownCodeBlock; -use util::rel_path::RelPath; -use util::{ResultExt as _, post_inc}; - -pub const RULES_ICON: IconName = IconName::Reader; - -pub enum ContextKind { - File, - Directory, - Symbol, - Selection, - FetchedUrl, - Thread, - TextThread, - Rules, - Image, -} - -impl ContextKind { - pub fn icon(&self) -> IconName { - match self { - ContextKind::File => IconName::File, - ContextKind::Directory => IconName::Folder, - ContextKind::Symbol => IconName::Code, - ContextKind::Selection => IconName::Reader, - ContextKind::FetchedUrl => IconName::ToolWeb, - ContextKind::Thread => IconName::Thread, - ContextKind::TextThread => IconName::TextThread, - ContextKind::Rules => RULES_ICON, - ContextKind::Image => IconName::Image, - } - } -} - -/// Handle for context that can be attached to a user message. -/// -/// This uses IDs that are stable enough for tracking renames and identifying when context has -/// already been added to the thread. To use this in a set, wrap it in `AgentContextKey` to opt in -/// to `PartialEq` and `Hash` impls that use the subset of the fields used for this stable identity. -#[derive(Debug, Clone)] -pub enum AgentContextHandle { - File(FileContextHandle), - Directory(DirectoryContextHandle), - Symbol(SymbolContextHandle), - Selection(SelectionContextHandle), - FetchedUrl(FetchedUrlContext), - Thread(ThreadContextHandle), - TextThread(TextThreadContextHandle), - Rules(RulesContextHandle), - Image(ImageContext), -} - -impl AgentContextHandle { - pub fn id(&self) -> ContextId { - match self { - Self::File(context) => context.context_id, - Self::Directory(context) => context.context_id, - Self::Symbol(context) => context.context_id, - Self::Selection(context) => context.context_id, - Self::FetchedUrl(context) => context.context_id, - Self::Thread(context) => context.context_id, - Self::TextThread(context) => context.context_id, - Self::Rules(context) => context.context_id, - Self::Image(context) => context.context_id, - } - } - - pub fn element_id(&self, name: SharedString) -> ElementId { - ElementId::NamedInteger(name, self.id().0) - } -} - -/// Loaded context that can be attached to a user message. This can be thought of as a -/// snapshot of the context along with an `AgentContextHandle`. -#[derive(Debug, Clone)] -pub enum AgentContext { - File(FileContext), - Directory(DirectoryContext), - Symbol(SymbolContext), - Selection(SelectionContext), - FetchedUrl(FetchedUrlContext), - Thread(ThreadContext), - TextThread(TextThreadContext), - Rules(RulesContext), - Image(ImageContext), -} - -impl AgentContext { - pub fn handle(&self) -> AgentContextHandle { - match self { - AgentContext::File(context) => AgentContextHandle::File(context.handle.clone()), - AgentContext::Directory(context) => { - AgentContextHandle::Directory(context.handle.clone()) - } - AgentContext::Symbol(context) => AgentContextHandle::Symbol(context.handle.clone()), - AgentContext::Selection(context) => { - AgentContextHandle::Selection(context.handle.clone()) - } - AgentContext::FetchedUrl(context) => AgentContextHandle::FetchedUrl(context.clone()), - AgentContext::Thread(context) => AgentContextHandle::Thread(context.handle.clone()), - AgentContext::TextThread(context) => { - AgentContextHandle::TextThread(context.handle.clone()) - } - AgentContext::Rules(context) => AgentContextHandle::Rules(context.handle.clone()), - AgentContext::Image(context) => AgentContextHandle::Image(context.clone()), - } - } -} - -/// ID created at time of context add, for use in ElementId. This is not the stable identity of a -/// context, instead that's handled by the `PartialEq` and `Hash` impls of `AgentContextKey`. -#[derive(Debug, Copy, Clone)] -pub struct ContextId(u64); - -impl ContextId { - pub fn zero() -> Self { - ContextId(0) - } - - fn for_lookup() -> Self { - ContextId(u64::MAX) - } - - pub fn post_inc(&mut self) -> Self { - Self(post_inc(&mut self.0)) - } -} - -/// File context provides the entire contents of a file. -/// -/// This holds an `Entity` so that file path renames affect its display and so that it can -/// be opened even if the file has been deleted. An alternative might be to use `ProjectEntryId`, -/// but then when deleted there is no path info or ability to open. -#[derive(Debug, Clone)] -pub struct FileContextHandle { - pub buffer: Entity, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct FileContext { - pub handle: FileContextHandle, - pub full_path: String, - pub text: SharedString, - pub is_outline: bool, -} - -impl FileContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.buffer == other.buffer - } - - pub fn hash_for_key(&self, state: &mut H) { - self.buffer.hash(state) - } - - pub fn project_path(&self, cx: &App) -> Option { - let file = self.buffer.read(cx).file()?; - Some(ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }) - } - - fn load(self, cx: &App) -> Task>)>> { - let buffer_ref = self.buffer.read(cx); - let Some(file) = buffer_ref.file() else { - log::error!("file context missing path"); - return Task::ready(None); - }; - let full_path = file.full_path(cx).to_string_lossy().into_owned(); - let rope = buffer_ref.as_rope().clone(); - let buffer = self.buffer.clone(); - - cx.spawn(async move |cx| { - let buffer_content = - outline::get_buffer_content_or_outline(buffer.clone(), Some(&full_path), &cx) - .await - .unwrap_or_else(|_| outline::BufferContent { - text: rope.to_string(), - is_outline: false, - }); - - let context = AgentContext::File(FileContext { - handle: self, - full_path, - text: buffer_content.text.into(), - is_outline: buffer_content.is_outline, - }); - Some((context, vec![buffer])) - }) - } -} - -impl Display for FileContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - MarkdownCodeBlock { - tag: &codeblock_tag(&self.full_path, None), - text: &self.text, - } - ) - } -} - -/// Directory contents provides the entire contents of text files in a directory. -/// -/// This has a `ProjectEntryId` so that it follows renames. -#[derive(Debug, Clone)] -pub struct DirectoryContextHandle { - pub entry_id: ProjectEntryId, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct DirectoryContext { - pub handle: DirectoryContextHandle, - pub full_path: String, - pub descendants: Vec, -} - -#[derive(Debug, Clone)] -pub struct DirectoryContextDescendant { - /// Path within the directory. - pub rel_path: Arc, - pub fenced_codeblock: SharedString, -} - -impl DirectoryContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.entry_id == other.entry_id - } - - pub fn hash_for_key(&self, state: &mut H) { - self.entry_id.hash(state) - } - - fn load( - self, - project: Entity, - cx: &mut App, - ) -> Task>)>> { - let Some(worktree) = project.read(cx).worktree_for_entry(self.entry_id, cx) else { - return Task::ready(None); - }; - let worktree_ref = worktree.read(cx); - let Some(entry) = worktree_ref.entry_for_id(self.entry_id) else { - return Task::ready(None); - }; - if entry.is_file() { - log::error!("DirectoryContext unexpectedly refers to a file."); - return Task::ready(None); - } - - let directory_path = entry.path.clone(); - let directory_full_path = worktree_ref - .full_path(&directory_path) - .to_string_lossy() - .to_string(); - - let file_paths = collect_files_in_path(worktree_ref, &directory_path); - let descendants_future = future::join_all(file_paths.into_iter().map(|path| { - let worktree_ref = worktree.read(cx); - let worktree_id = worktree_ref.id(); - let full_path = worktree_ref.full_path(&path).to_string_lossy().into_owned(); - - let rel_path = path - .strip_prefix(&directory_path) - .log_err() - .map_or_else(|| path.clone(), |rel_path| rel_path.into()); - - let open_task = project.update(cx, |project, cx| { - project.buffer_store().update(cx, |buffer_store, cx| { - let project_path = ProjectPath { worktree_id, path }; - buffer_store.open_buffer(project_path, cx) - }) - }); - - // TODO: report load errors instead of just logging - let rope_task = cx.spawn(async move |cx| { - let buffer = open_task.await.log_err()?; - let rope = buffer - .read_with(cx, |buffer, _cx| buffer.as_rope().clone()) - .log_err()?; - Some((rope, buffer)) - }); - - cx.background_spawn(async move { - let (rope, buffer) = rope_task.await?; - let fenced_codeblock = MarkdownCodeBlock { - tag: &codeblock_tag(&full_path, None), - text: &rope.to_string(), - } - .to_string() - .into(); - let descendant = DirectoryContextDescendant { - rel_path, - fenced_codeblock, - }; - Some((descendant, buffer)) - }) - })); - - cx.background_spawn(async move { - let (descendants, buffers) = descendants_future.await.into_iter().flatten().unzip(); - let context = AgentContext::Directory(DirectoryContext { - handle: self, - full_path: directory_full_path, - descendants, - }); - Some((context, buffers)) - }) - } -} - -impl Display for DirectoryContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut is_first = true; - for descendant in &self.descendants { - if !is_first { - writeln!(f)?; - } else { - is_first = false; - } - write!(f, "{}", descendant.fenced_codeblock)?; - } - Ok(()) - } -} - -#[derive(Debug, Clone)] -pub struct SymbolContextHandle { - pub buffer: Entity, - pub symbol: SharedString, - pub range: Range, - /// The range that fully contains the symbol. e.g. for function symbol, this will include not - /// only the signature, but also the body. Not used by `PartialEq` or `Hash` for - /// `AgentContextKey`. - pub enclosing_range: Range, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct SymbolContext { - pub handle: SymbolContextHandle, - pub full_path: String, - pub line_range: Range, - pub text: SharedString, -} - -impl SymbolContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.buffer == other.buffer && self.symbol == other.symbol && self.range == other.range - } - - pub fn hash_for_key(&self, state: &mut H) { - self.buffer.hash(state); - self.symbol.hash(state); - self.range.hash(state); - } - - pub fn full_path(&self, cx: &App) -> Option { - Some(self.buffer.read(cx).file()?.full_path(cx)) - } - - pub fn enclosing_line_range(&self, cx: &App) -> Range { - self.enclosing_range - .to_point(&self.buffer.read(cx).snapshot()) - } - - pub fn text(&self, cx: &App) -> SharedString { - self.buffer - .read(cx) - .text_for_range(self.enclosing_range.clone()) - .collect::() - .into() - } - - fn load(self, cx: &App) -> Task>)>> { - let buffer_ref = self.buffer.read(cx); - let Some(file) = buffer_ref.file() else { - log::error!("symbol context's file has no path"); - return Task::ready(None); - }; - let full_path = file.full_path(cx).to_string_lossy().into_owned(); - let line_range = self.enclosing_range.to_point(&buffer_ref.snapshot()); - let text = self.text(cx); - let buffer = self.buffer.clone(); - let context = AgentContext::Symbol(SymbolContext { - handle: self, - full_path, - line_range, - text, - }); - Task::ready(Some((context, vec![buffer]))) - } -} - -impl Display for SymbolContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let code_block = MarkdownCodeBlock { - tag: &codeblock_tag(&self.full_path, Some(self.line_range.clone())), - text: &self.text, - }; - write!(f, "{code_block}",) - } -} - -#[derive(Debug, Clone)] -pub struct SelectionContextHandle { - pub buffer: Entity, - pub range: Range, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct SelectionContext { - pub handle: SelectionContextHandle, - pub full_path: String, - pub line_range: Range, - pub text: SharedString, -} - -impl SelectionContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.buffer == other.buffer && self.range == other.range - } - - pub fn hash_for_key(&self, state: &mut H) { - self.buffer.hash(state); - self.range.hash(state); - } - - pub fn full_path(&self, cx: &App) -> Option { - Some(self.buffer.read(cx).file()?.full_path(cx)) - } - - pub fn line_range(&self, cx: &App) -> Range { - self.range.to_point(&self.buffer.read(cx).snapshot()) - } - - pub fn text(&self, cx: &App) -> SharedString { - self.buffer - .read(cx) - .text_for_range(self.range.clone()) - .collect::() - .into() - } - - fn load(self, cx: &App) -> Task>)>> { - let Some(full_path) = self.full_path(cx) else { - log::error!("selection context's file has no path"); - return Task::ready(None); - }; - let text = self.text(cx); - let buffer = self.buffer.clone(); - let context = AgentContext::Selection(SelectionContext { - full_path: full_path.to_string_lossy().into_owned(), - line_range: self.line_range(cx), - text, - handle: self, - }); - - Task::ready(Some((context, vec![buffer]))) - } -} - -impl Display for SelectionContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let code_block = MarkdownCodeBlock { - tag: &codeblock_tag(&self.full_path, Some(self.line_range.clone())), - text: &self.text, - }; - write!(f, "{code_block}",) - } -} - -#[derive(Debug, Clone)] -pub struct FetchedUrlContext { - pub url: SharedString, - /// Text contents of the fetched url. Unlike other context types, the contents of this gets - /// populated when added rather than when sending the message. Not used by `PartialEq` or `Hash` - /// for `AgentContextKey`. - pub text: SharedString, - pub context_id: ContextId, -} - -impl FetchedUrlContext { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.url == other.url - } - - pub fn hash_for_key(&self, state: &mut H) { - self.url.hash(state); - } - - pub fn lookup_key(url: SharedString) -> AgentContextKey { - AgentContextKey(AgentContextHandle::FetchedUrl(FetchedUrlContext { - url, - text: "".into(), - context_id: ContextId::for_lookup(), - })) - } - - pub fn load(self) -> Task>)>> { - Task::ready(Some((AgentContext::FetchedUrl(self), vec![]))) - } -} - -impl Display for FetchedUrlContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // TODO: Better format - url and contents are not delimited. - write!(f, "{}\n{}\n", self.url, self.text) - } -} - -#[derive(Debug, Clone)] -pub struct ThreadContextHandle { - pub thread: Entity, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct ThreadContext { - pub handle: ThreadContextHandle, - pub title: SharedString, - pub text: SharedString, -} - -impl ThreadContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.thread == other.thread - } - - pub fn hash_for_key(&self, state: &mut H) { - self.thread.hash(state) - } - - pub fn title(&self, cx: &App) -> SharedString { - self.thread.read(cx).summary().or_default() - } - - fn load(self, cx: &App) -> Task>)>> { - cx.spawn(async move |cx| { - let text = Thread::wait_for_detailed_summary_or_text(&self.thread, cx).await?; - let title = self - .thread - .read_with(cx, |thread, _cx| thread.summary().or_default()) - .ok()?; - let context = AgentContext::Thread(ThreadContext { - title, - text, - handle: self, - }); - Some((context, vec![])) - }) - } -} - -impl Display for ThreadContext { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - // TODO: Better format for this - doesn't distinguish title and contents. - write!(f, "{}\n{}\n", &self.title, &self.text.trim()) - } -} - -#[derive(Debug, Clone)] -pub struct TextThreadContextHandle { - pub context: Entity, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct TextThreadContext { - pub handle: TextThreadContextHandle, - pub title: SharedString, - pub text: SharedString, -} - -impl TextThreadContextHandle { - // pub fn lookup_key() -> - pub fn eq_for_key(&self, other: &Self) -> bool { - self.context == other.context - } - - pub fn hash_for_key(&self, state: &mut H) { - self.context.hash(state) - } - - pub fn title(&self, cx: &App) -> SharedString { - self.context.read(cx).summary().or_default() - } - - fn load(self, cx: &App) -> Task>)>> { - let title = self.title(cx); - let text = self.context.read(cx).to_xml(cx); - let context = AgentContext::TextThread(TextThreadContext { - title, - text: text.into(), - handle: self, - }); - Task::ready(Some((context, vec![]))) - } -} - -impl Display for TextThreadContext { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - // TODO: escape title? - writeln!(f, "", self.title)?; - write!(f, "{}", self.text.trim())?; - write!(f, "\n") - } -} - -#[derive(Debug, Clone)] -pub struct RulesContextHandle { - pub prompt_id: UserPromptId, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct RulesContext { - pub handle: RulesContextHandle, - pub title: Option, - pub text: SharedString, -} - -impl RulesContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.prompt_id == other.prompt_id - } - - pub fn hash_for_key(&self, state: &mut H) { - self.prompt_id.hash(state) - } - - pub fn lookup_key(prompt_id: UserPromptId) -> AgentContextKey { - AgentContextKey(AgentContextHandle::Rules(RulesContextHandle { - prompt_id, - context_id: ContextId::for_lookup(), - })) - } - - pub fn load( - self, - prompt_store: &Option>, - cx: &App, - ) -> Task>)>> { - let Some(prompt_store) = prompt_store.as_ref() else { - return Task::ready(None); - }; - let prompt_store = prompt_store.read(cx); - let prompt_id = self.prompt_id.into(); - let Some(metadata) = prompt_store.metadata(prompt_id) else { - return Task::ready(None); - }; - let title = metadata.title; - let text_task = prompt_store.load(prompt_id, cx); - cx.background_spawn(async move { - // TODO: report load errors instead of just logging - let text = text_task.await.log_err()?.into(); - let context = AgentContext::Rules(RulesContext { - handle: self, - title, - text, - }); - Some((context, vec![])) - }) - } -} - -impl Display for RulesContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(title) = &self.title { - writeln!(f, "Rules title: {}", title)?; - } - let code_block = MarkdownCodeBlock { - tag: "", - text: self.text.trim(), - }; - write!(f, "{code_block}") - } -} - -#[derive(Debug, Clone)] -pub struct ImageContext { - pub project_path: Option, - pub full_path: Option, - pub original_image: Arc, - // TODO: handle this elsewhere and remove `ignore-interior-mutability` opt-out in clippy.toml - // needed due to a false positive of `clippy::mutable_key_type`. - pub image_task: Shared>>, - pub context_id: ContextId, -} - -pub enum ImageStatus { - Loading, - Error, - Warning, - Ready, -} - -impl ImageContext { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.original_image.id() == other.original_image.id() - } - - pub fn hash_for_key(&self, state: &mut H) { - self.original_image.id().hash(state); - } - - pub fn image(&self) -> Option { - self.image_task.clone().now_or_never().flatten() - } - - pub fn status(&self, model: Option<&Arc>) -> ImageStatus { - match self.image_task.clone().now_or_never() { - None => ImageStatus::Loading, - Some(None) => ImageStatus::Error, - Some(Some(_)) => { - if model.is_some_and(|model| !model.supports_images()) { - ImageStatus::Warning - } else { - ImageStatus::Ready - } - } - } - } - - pub fn load(self, cx: &App) -> Task>)>> { - cx.background_spawn(async move { - self.image_task.clone().await; - Some((AgentContext::Image(self), vec![])) - }) - } -} - -#[derive(Debug, Clone, Default)] -pub struct ContextLoadResult { - pub loaded_context: LoadedContext, - pub referenced_buffers: HashSet>, -} - -#[derive(Debug, Clone, Default)] -pub struct LoadedContext { - pub contexts: Vec, - pub text: String, - pub images: Vec, -} - -impl LoadedContext { - pub fn is_empty(&self) -> bool { - self.text.is_empty() && self.images.is_empty() - } - - pub fn add_to_request_message(&self, request_message: &mut LanguageModelRequestMessage) { - if !self.text.is_empty() { - request_message - .content - .push(MessageContent::Text(self.text.to_string())); - } - - if !self.images.is_empty() { - // Some providers only support image parts after an initial text part - if request_message.content.is_empty() { - request_message - .content - .push(MessageContent::Text("Images attached by user:".to_string())); - } - - for image in &self.images { - request_message - .content - .push(MessageContent::Image(image.clone())) - } - } - } -} - -/// Loads and formats a collection of contexts. -pub fn load_context( - contexts: Vec, - project: &Entity, - prompt_store: &Option>, - cx: &mut App, -) -> Task { - let load_tasks: Vec<_> = contexts - .into_iter() - .map(|context| match context { - AgentContextHandle::File(context) => context.load(cx), - AgentContextHandle::Directory(context) => context.load(project.clone(), cx), - AgentContextHandle::Symbol(context) => context.load(cx), - AgentContextHandle::Selection(context) => context.load(cx), - AgentContextHandle::FetchedUrl(context) => context.load(), - AgentContextHandle::Thread(context) => context.load(cx), - AgentContextHandle::TextThread(context) => context.load(cx), - AgentContextHandle::Rules(context) => context.load(prompt_store, cx), - AgentContextHandle::Image(context) => context.load(cx), - }) - .collect(); - - cx.background_spawn(async move { - let load_results = future::join_all(load_tasks).await; - - let mut contexts = Vec::new(); - let mut text = String::new(); - let mut referenced_buffers = HashSet::default(); - for context in load_results { - let Some((context, buffers)) = context else { - continue; - }; - contexts.push(context); - referenced_buffers.extend(buffers); - } - - let mut file_context = Vec::new(); - let mut directory_context = Vec::new(); - let mut symbol_context = Vec::new(); - let mut selection_context = Vec::new(); - let mut fetched_url_context = Vec::new(); - let mut thread_context = Vec::new(); - let mut text_thread_context = Vec::new(); - let mut rules_context = Vec::new(); - let mut images = Vec::new(); - for context in &contexts { - match context { - AgentContext::File(context) => file_context.push(context), - AgentContext::Directory(context) => directory_context.push(context), - AgentContext::Symbol(context) => symbol_context.push(context), - AgentContext::Selection(context) => selection_context.push(context), - AgentContext::FetchedUrl(context) => fetched_url_context.push(context), - AgentContext::Thread(context) => thread_context.push(context), - AgentContext::TextThread(context) => text_thread_context.push(context), - AgentContext::Rules(context) => rules_context.push(context), - AgentContext::Image(context) => images.extend(context.image()), - } - } - - // Use empty text if there are no contexts that contribute to text (everything but image - // context). - if file_context.is_empty() - && directory_context.is_empty() - && symbol_context.is_empty() - && selection_context.is_empty() - && fetched_url_context.is_empty() - && thread_context.is_empty() - && text_thread_context.is_empty() - && rules_context.is_empty() - { - return ContextLoadResult { - loaded_context: LoadedContext { - contexts, - text, - images, - }, - referenced_buffers, - }; - } - - text.push_str( - "\n\n\ - The following items were attached by the user. \ - They are up-to-date and don't need to be re-read.\n\n", - ); - - if !file_context.is_empty() { - text.push_str(""); - for context in file_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !directory_context.is_empty() { - text.push_str(""); - for context in directory_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !symbol_context.is_empty() { - text.push_str(""); - for context in symbol_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !selection_context.is_empty() { - text.push_str(""); - for context in selection_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !fetched_url_context.is_empty() { - text.push_str(""); - for context in fetched_url_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !thread_context.is_empty() { - text.push_str(""); - for context in thread_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !text_thread_context.is_empty() { - text.push_str(""); - for context in text_thread_context { - text.push('\n'); - let _ = writeln!(text, "{context}"); - } - text.push_str(""); - } - - if !rules_context.is_empty() { - text.push_str( - "\n\ - The user has specified the following rules that should be applied:\n", - ); - for context in rules_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - text.push_str("\n"); - - ContextLoadResult { - loaded_context: LoadedContext { - contexts, - text, - images, - }, - referenced_buffers, - } - }) -} - -fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec> { - let mut files = Vec::new(); - - for entry in worktree.child_entries(path) { - if entry.is_dir() { - files.extend(collect_files_in_path(worktree, &entry.path)); - } else if entry.is_file() { - files.push(entry.path.clone()); - } - } - - files -} - -fn codeblock_tag(full_path: &str, line_range: Option>) -> String { - let mut result = String::new(); - - if let Some(extension) = Path::new(full_path) - .extension() - .and_then(|ext| ext.to_str()) - { - let _ = write!(result, "{} ", extension); - } - - let _ = write!(result, "{}", full_path); - - if let Some(range) = line_range { - if range.start.row == range.end.row { - let _ = write!(result, ":{}", range.start.row + 1); - } else { - let _ = write!(result, ":{}-{}", range.start.row + 1, range.end.row + 1); - } - } - - result -} - -/// Wraps `AgentContext` to opt-in to `PartialEq` and `Hash` impls which use a subset of fields -/// needed for stable context identity. -#[derive(Debug, Clone, RefCast)] -#[repr(transparent)] -pub struct AgentContextKey(pub AgentContextHandle); - -impl AsRef for AgentContextKey { - fn as_ref(&self) -> &AgentContextHandle { - &self.0 - } -} - -impl Eq for AgentContextKey {} - -impl PartialEq for AgentContextKey { - fn eq(&self, other: &Self) -> bool { - match &self.0 { - AgentContextHandle::File(context) => { - if let AgentContextHandle::File(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Directory(context) => { - if let AgentContextHandle::Directory(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Symbol(context) => { - if let AgentContextHandle::Symbol(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Selection(context) => { - if let AgentContextHandle::Selection(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::FetchedUrl(context) => { - if let AgentContextHandle::FetchedUrl(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Thread(context) => { - if let AgentContextHandle::Thread(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Rules(context) => { - if let AgentContextHandle::Rules(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Image(context) => { - if let AgentContextHandle::Image(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::TextThread(context) => { - if let AgentContextHandle::TextThread(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - } - false - } -} - -impl Hash for AgentContextKey { - fn hash(&self, state: &mut H) { - match &self.0 { - AgentContextHandle::File(context) => context.hash_for_key(state), - AgentContextHandle::Directory(context) => context.hash_for_key(state), - AgentContextHandle::Symbol(context) => context.hash_for_key(state), - AgentContextHandle::Selection(context) => context.hash_for_key(state), - AgentContextHandle::FetchedUrl(context) => context.hash_for_key(state), - AgentContextHandle::Thread(context) => context.hash_for_key(state), - AgentContextHandle::TextThread(context) => context.hash_for_key(state), - AgentContextHandle::Rules(context) => context.hash_for_key(state), - AgentContextHandle::Image(context) => context.hash_for_key(state), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - fn init_test_settings(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } - - // Helper to create a test project with test files - async fn create_test_project( - cx: &mut TestAppContext, - files: serde_json::Value, - ) -> Entity { - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree(path!("/test"), files).await; - Project::test(fs, [path!("/test").as_ref()], cx).await - } - - #[gpui::test] - async fn test_large_file_uses_outline(cx: &mut TestAppContext) { - init_test_settings(cx); - - // Create a large file that exceeds AUTO_OUTLINE_SIZE - const LINE: &str = "Line with some text\n"; - let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len())); - let content_len = large_content.len(); - - assert!(content_len > outline::AUTO_OUTLINE_SIZE); - - let file_context = file_context_for(large_content, cx).await; - - assert!( - file_context.is_outline, - "Large file should use outline format" - ); - - assert!( - file_context.text.len() < content_len, - "Outline should be smaller than original content" - ); - } - - #[gpui::test] - async fn test_small_file_uses_full_content(cx: &mut TestAppContext) { - init_test_settings(cx); - - let small_content = "This is a small file.\n"; - let content_len = small_content.len(); - - assert!(content_len < outline::AUTO_OUTLINE_SIZE); - - let file_context = file_context_for(small_content.to_string(), cx).await; - - assert!( - !file_context.is_outline, - "Small files should not get an outline" - ); - - assert_eq!(file_context.text, small_content); - } - - async fn file_context_for(content: String, cx: &mut TestAppContext) -> FileContext { - // Create a test project with the file - let project = create_test_project( - cx, - json!({ - "file.txt": content, - }), - ) - .await; - - // Open the buffer - let buffer_path = project - .read_with(cx, |project, cx| project.find_project_path("file.txt", cx)) - .unwrap(); - - let buffer = project - .update(cx, |project, cx| project.open_buffer(buffer_path, cx)) - .await - .unwrap(); - - let context_handle = AgentContextHandle::File(FileContextHandle { - buffer: buffer.clone(), - context_id: ContextId::zero(), - }); - - cx.update(|cx| load_context(vec![context_handle], &project, &None, cx)) - .await - .loaded_context - .contexts - .into_iter() - .find_map(|ctx| { - if let AgentContext::File(file_ctx) = ctx { - Some(file_ctx) - } else { - None - } - }) - .expect("Should have found a file context") - } -} diff --git a/crates/agent/src/context_server_tool.rs b/crates/agent/src/context_server_tool.rs deleted file mode 100644 index 696c569356..0000000000 --- a/crates/agent/src/context_server_tool.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::sync::Arc; - -use action_log::ActionLog; -use anyhow::{Result, anyhow, bail}; -use assistant_tool::{Tool, ToolResult, ToolSource}; -use context_server::{ContextServerId, types}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use icons::IconName; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::{Project, context_server_store::ContextServerStore}; - -pub struct ContextServerTool { - store: Entity, - server_id: ContextServerId, - tool: types::Tool, -} - -impl ContextServerTool { - pub fn new( - store: Entity, - server_id: ContextServerId, - tool: types::Tool, - ) -> Self { - Self { - store, - server_id, - tool, - } - } -} - -impl Tool for ContextServerTool { - fn name(&self) -> String { - self.tool.name.clone() - } - - fn description(&self) -> String { - self.tool.description.clone().unwrap_or_default() - } - - fn icon(&self) -> IconName { - IconName::ToolHammer - } - - fn source(&self) -> ToolSource { - ToolSource::ContextServer { - id: self.server_id.clone().0.into(), - } - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - true - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - let mut schema = self.tool.input_schema.clone(); - assistant_tool::adapt_schema_to_format(&mut schema, format)?; - Ok(match schema { - serde_json::Value::Null => { - serde_json::json!({ "type": "object", "properties": [] }) - } - serde_json::Value::Object(map) if map.is_empty() => { - serde_json::json!({ "type": "object", "properties": [] }) - } - _ => schema, - }) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - format!("Run MCP tool `{}`", self.tool.name) - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - if let Some(server) = self.store.read(cx).get_running_server(&self.server_id) { - let tool_name = self.tool.name.clone(); - - cx.spawn(async move |_cx| { - let Some(protocol) = server.client() else { - bail!("Context server not initialized"); - }; - - let arguments = if let serde_json::Value::Object(map) = input { - Some(map.into_iter().collect()) - } else { - None - }; - - log::trace!( - "Running tool: {} with arguments: {:?}", - tool_name, - arguments - ); - let response = protocol - .request::( - context_server::types::CallToolParams { - name: tool_name, - arguments, - meta: None, - }, - ) - .await?; - - let mut result = String::new(); - for content in response.content { - match content { - types::ToolResponseContent::Text { text } => { - result.push_str(&text); - } - types::ToolResponseContent::Image { .. } => { - log::warn!("Ignoring image content from tool response"); - } - types::ToolResponseContent::Audio { .. } => { - log::warn!("Ignoring audio content from tool response"); - } - types::ToolResponseContent::Resource { .. } => { - log::warn!("Ignoring resource content from tool response"); - } - } - } - Ok(result.into()) - }) - .into() - } else { - Task::ready(Err(anyhow!("Context server not found"))).into() - } - } -} diff --git a/crates/agent/src/context_store.rs b/crates/agent/src/context_store.rs deleted file mode 100644 index cf35840cc4..0000000000 --- a/crates/agent/src/context_store.rs +++ /dev/null @@ -1,658 +0,0 @@ -use crate::{ - context::{ - AgentContextHandle, AgentContextKey, ContextId, ContextKind, DirectoryContextHandle, - FetchedUrlContext, FileContextHandle, ImageContext, RulesContextHandle, - SelectionContextHandle, SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, - }, - thread::{MessageId, Thread, ThreadId}, - thread_store::ThreadStore, -}; -use anyhow::{Context as _, Result, anyhow}; -use assistant_context::AssistantContext; -use collections::{HashSet, IndexSet}; -use futures::{self, FutureExt}; -use gpui::{App, Context, Entity, EventEmitter, Image, SharedString, Task, WeakEntity}; -use language::{Buffer, File as _}; -use language_model::LanguageModelImage; -use project::{ - Project, ProjectItem, ProjectPath, Symbol, image_store::is_image_file, - lsp_store::SymbolLocation, -}; -use prompt_store::UserPromptId; -use ref_cast::RefCast as _; -use std::{ - ops::Range, - path::{Path, PathBuf}, - sync::Arc, -}; -use text::{Anchor, OffsetRangeExt}; - -pub struct ContextStore { - project: WeakEntity, - thread_store: Option>, - next_context_id: ContextId, - context_set: IndexSet, - context_thread_ids: HashSet, - context_text_thread_paths: HashSet>, -} - -pub enum ContextStoreEvent { - ContextRemoved(AgentContextKey), -} - -impl EventEmitter for ContextStore {} - -impl ContextStore { - pub fn new( - project: WeakEntity, - thread_store: Option>, - ) -> Self { - Self { - project, - thread_store, - next_context_id: ContextId::zero(), - context_set: IndexSet::default(), - context_thread_ids: HashSet::default(), - context_text_thread_paths: HashSet::default(), - } - } - - pub fn context(&self) -> impl Iterator { - self.context_set.iter().map(|entry| entry.as_ref()) - } - - pub fn clear(&mut self, cx: &mut Context) { - self.context_set.clear(); - self.context_thread_ids.clear(); - cx.notify(); - } - - pub fn new_context_for_thread( - &self, - thread: &Thread, - exclude_messages_from_id: Option, - ) -> Vec { - let existing_context = thread - .messages() - .take_while(|message| exclude_messages_from_id.is_none_or(|id| message.id != id)) - .flat_map(|message| { - message - .loaded_context - .contexts - .iter() - .map(|context| AgentContextKey(context.handle())) - }) - .collect::>(); - self.context_set - .iter() - .filter(|context| !existing_context.contains(context)) - .map(|entry| entry.0.clone()) - .collect::>() - } - - pub fn add_file_from_path( - &mut self, - project_path: ProjectPath, - remove_if_exists: bool, - cx: &mut Context, - ) -> Task>> { - let Some(project) = self.project.upgrade() else { - return Task::ready(Err(anyhow!("failed to read project"))); - }; - - if is_image_file(&project, &project_path, cx) { - self.add_image_from_path(project_path, remove_if_exists, cx) - } else { - cx.spawn(async move |this, cx| { - let open_buffer_task = project.update(cx, |project, cx| { - project.open_buffer(project_path.clone(), cx) - })?; - let buffer = open_buffer_task.await?; - this.update(cx, |this, cx| { - this.add_file_from_buffer(&project_path, buffer, remove_if_exists, cx) - }) - }) - } - } - - pub fn add_file_from_buffer( - &mut self, - project_path: &ProjectPath, - buffer: Entity, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::File(FileContextHandle { buffer, context_id }); - - if let Some(key) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(key.as_ref().clone()) - } - } else if self.path_included_in_directory(project_path, cx).is_some() { - None - } else { - self.insert_context(context.clone(), cx); - Some(context) - } - } - - pub fn add_directory( - &mut self, - project_path: &ProjectPath, - remove_if_exists: bool, - cx: &mut Context, - ) -> Result> { - let project = self.project.upgrade().context("failed to read project")?; - let entry_id = project - .read(cx) - .entry_for_path(project_path, cx) - .map(|entry| entry.id) - .context("no entry found for directory context")?; - - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Directory(DirectoryContextHandle { - entry_id, - context_id, - }); - - let context = - if let Some(existing) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(existing.as_ref().clone()) - } - } else { - self.insert_context(context.clone(), cx); - Some(context) - }; - - anyhow::Ok(context) - } - - pub fn add_symbol( - &mut self, - buffer: Entity, - symbol: SharedString, - range: Range, - enclosing_range: Range, - remove_if_exists: bool, - cx: &mut Context, - ) -> (Option, bool) { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Symbol(SymbolContextHandle { - buffer, - symbol, - range, - enclosing_range, - context_id, - }); - - if let Some(key) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - let handle = if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(key.as_ref().clone()) - }; - return (handle, false); - } - - let included = self.insert_context(context.clone(), cx); - (Some(context), included) - } - - pub fn add_thread( - &mut self, - thread: Entity, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Thread(ThreadContextHandle { thread, context_id }); - - if let Some(existing) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(existing.as_ref().clone()) - } - } else { - self.insert_context(context.clone(), cx); - Some(context) - } - } - - pub fn add_text_thread( - &mut self, - context: Entity, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::TextThread(TextThreadContextHandle { - context, - context_id, - }); - - if let Some(existing) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(existing.as_ref().clone()) - } - } else { - self.insert_context(context.clone(), cx); - Some(context) - } - } - - pub fn add_rules( - &mut self, - prompt_id: UserPromptId, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Rules(RulesContextHandle { - prompt_id, - context_id, - }); - - if let Some(existing) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(existing.as_ref().clone()) - } - } else { - self.insert_context(context.clone(), cx); - Some(context) - } - } - - pub fn add_fetched_url( - &mut self, - url: String, - text: impl Into, - cx: &mut Context, - ) -> AgentContextHandle { - let context = AgentContextHandle::FetchedUrl(FetchedUrlContext { - url: url.into(), - text: text.into(), - context_id: self.next_context_id.post_inc(), - }); - - self.insert_context(context.clone(), cx); - context - } - - pub fn add_image_from_path( - &mut self, - project_path: ProjectPath, - remove_if_exists: bool, - cx: &mut Context, - ) -> Task>> { - let project = self.project.clone(); - cx.spawn(async move |this, cx| { - let open_image_task = project.update(cx, |project, cx| { - project.open_image(project_path.clone(), cx) - })?; - let image_item = open_image_task.await?; - - this.update(cx, |this, cx| { - let item = image_item.read(cx); - this.insert_image( - Some(item.project_path(cx)), - Some(item.file.full_path(cx).to_string_lossy().into_owned()), - item.image.clone(), - remove_if_exists, - cx, - ) - }) - }) - } - - pub fn add_image_instance(&mut self, image: Arc, cx: &mut Context) { - self.insert_image(None, None, image, false, cx); - } - - fn insert_image( - &mut self, - project_path: Option, - full_path: Option, - image: Arc, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let image_task = LanguageModelImage::from_image(image.clone(), cx).shared(); - let context = AgentContextHandle::Image(ImageContext { - project_path, - full_path, - original_image: image, - image_task, - context_id: self.next_context_id.post_inc(), - }); - if self.has_context(&context) && remove_if_exists { - self.remove_context(&context, cx); - return None; - } - - self.insert_context(context.clone(), cx); - Some(context) - } - - pub fn add_selection( - &mut self, - buffer: Entity, - range: Range, - cx: &mut Context, - ) { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Selection(SelectionContextHandle { - buffer, - range, - context_id, - }); - self.insert_context(context, cx); - } - - pub fn add_suggested_context( - &mut self, - suggested: &SuggestedContext, - cx: &mut Context, - ) { - match suggested { - SuggestedContext::File { - buffer, - icon_path: _, - name: _, - } => { - if let Some(buffer) = buffer.upgrade() { - let context_id = self.next_context_id.post_inc(); - self.insert_context( - AgentContextHandle::File(FileContextHandle { buffer, context_id }), - cx, - ); - }; - } - SuggestedContext::Thread { thread, name: _ } => { - if let Some(thread) = thread.upgrade() { - let context_id = self.next_context_id.post_inc(); - self.insert_context( - AgentContextHandle::Thread(ThreadContextHandle { thread, context_id }), - cx, - ); - } - } - SuggestedContext::TextThread { context, name: _ } => { - if let Some(context) = context.upgrade() { - let context_id = self.next_context_id.post_inc(); - self.insert_context( - AgentContextHandle::TextThread(TextThreadContextHandle { - context, - context_id, - }), - cx, - ); - } - } - } - } - - fn insert_context(&mut self, context: AgentContextHandle, cx: &mut Context) -> bool { - match &context { - AgentContextHandle::Thread(thread_context) => { - if let Some(thread_store) = self.thread_store.clone() { - thread_context.thread.update(cx, |thread, cx| { - thread.start_generating_detailed_summary_if_needed(thread_store, cx); - }); - self.context_thread_ids - .insert(thread_context.thread.read(cx).id().clone()); - } else { - return false; - } - } - AgentContextHandle::TextThread(text_thread_context) => { - self.context_text_thread_paths - .extend(text_thread_context.context.read(cx).path().cloned()); - } - _ => {} - } - let inserted = self.context_set.insert(AgentContextKey(context)); - if inserted { - cx.notify(); - } - inserted - } - - pub fn remove_context(&mut self, context: &AgentContextHandle, cx: &mut Context) { - if let Some((_, key)) = self - .context_set - .shift_remove_full(AgentContextKey::ref_cast(context)) - { - match context { - AgentContextHandle::Thread(thread_context) => { - self.context_thread_ids - .remove(thread_context.thread.read(cx).id()); - } - AgentContextHandle::TextThread(text_thread_context) => { - if let Some(path) = text_thread_context.context.read(cx).path() { - self.context_text_thread_paths.remove(path); - } - } - _ => {} - } - cx.emit(ContextStoreEvent::ContextRemoved(key)); - cx.notify(); - } - } - - pub fn has_context(&mut self, context: &AgentContextHandle) -> bool { - self.context_set - .contains(AgentContextKey::ref_cast(context)) - } - - /// Returns whether this file path is already included directly in the context, or if it will be - /// included in the context via a directory. - pub fn file_path_included(&self, path: &ProjectPath, cx: &App) -> Option { - let project = self.project.upgrade()?.read(cx); - self.context().find_map(|context| match context { - AgentContextHandle::File(file_context) => { - FileInclusion::check_file(file_context, path, cx) - } - AgentContextHandle::Image(image_context) => { - FileInclusion::check_image(image_context, path) - } - AgentContextHandle::Directory(directory_context) => { - FileInclusion::check_directory(directory_context, path, project, cx) - } - _ => None, - }) - } - - pub fn path_included_in_directory( - &self, - path: &ProjectPath, - cx: &App, - ) -> Option { - let project = self.project.upgrade()?.read(cx); - self.context().find_map(|context| match context { - AgentContextHandle::Directory(directory_context) => { - FileInclusion::check_directory(directory_context, path, project, cx) - } - _ => None, - }) - } - - pub fn includes_symbol(&self, symbol: &Symbol, cx: &App) -> bool { - self.context().any(|context| match context { - AgentContextHandle::Symbol(context) => { - if context.symbol != symbol.name { - return false; - } - let buffer = context.buffer.read(cx); - let Some(context_path) = buffer.project_path(cx) else { - return false; - }; - if symbol.path != SymbolLocation::InProject(context_path) { - return false; - } - let context_range = context.range.to_point_utf16(&buffer.snapshot()); - context_range.start == symbol.range.start.0 - && context_range.end == symbol.range.end.0 - } - _ => false, - }) - } - - pub fn includes_thread(&self, thread_id: &ThreadId) -> bool { - self.context_thread_ids.contains(thread_id) - } - - pub fn includes_text_thread(&self, path: &Arc) -> bool { - self.context_text_thread_paths.contains(path) - } - - pub fn includes_user_rules(&self, prompt_id: UserPromptId) -> bool { - self.context_set - .contains(&RulesContextHandle::lookup_key(prompt_id)) - } - - pub fn includes_url(&self, url: impl Into) -> bool { - self.context_set - .contains(&FetchedUrlContext::lookup_key(url.into())) - } - - pub fn get_url_context(&self, url: SharedString) -> Option { - self.context_set - .get(&FetchedUrlContext::lookup_key(url)) - .map(|key| key.as_ref().clone()) - } - - pub fn file_paths(&self, cx: &App) -> HashSet { - self.context() - .filter_map(|context| match context { - AgentContextHandle::File(file) => { - let buffer = file.buffer.read(cx); - buffer.project_path(cx) - } - AgentContextHandle::Directory(_) - | AgentContextHandle::Symbol(_) - | AgentContextHandle::Selection(_) - | AgentContextHandle::FetchedUrl(_) - | AgentContextHandle::Thread(_) - | AgentContextHandle::TextThread(_) - | AgentContextHandle::Rules(_) - | AgentContextHandle::Image(_) => None, - }) - .collect() - } - - pub fn thread_ids(&self) -> &HashSet { - &self.context_thread_ids - } -} - -#[derive(Clone)] -pub enum SuggestedContext { - File { - name: SharedString, - icon_path: Option, - buffer: WeakEntity, - }, - Thread { - name: SharedString, - thread: WeakEntity, - }, - TextThread { - name: SharedString, - context: WeakEntity, - }, -} - -impl SuggestedContext { - pub fn name(&self) -> &SharedString { - match self { - Self::File { name, .. } => name, - Self::Thread { name, .. } => name, - Self::TextThread { name, .. } => name, - } - } - - pub fn icon_path(&self) -> Option { - match self { - Self::File { icon_path, .. } => icon_path.clone(), - Self::Thread { .. } => None, - Self::TextThread { .. } => None, - } - } - - pub fn kind(&self) -> ContextKind { - match self { - Self::File { .. } => ContextKind::File, - Self::Thread { .. } => ContextKind::Thread, - Self::TextThread { .. } => ContextKind::TextThread, - } - } -} - -pub enum FileInclusion { - Direct, - InDirectory { full_path: PathBuf }, -} - -impl FileInclusion { - fn check_file(file_context: &FileContextHandle, path: &ProjectPath, cx: &App) -> Option { - let file_path = file_context.buffer.read(cx).project_path(cx)?; - if path == &file_path { - Some(FileInclusion::Direct) - } else { - None - } - } - - fn check_image(image_context: &ImageContext, path: &ProjectPath) -> Option { - let image_path = image_context.project_path.as_ref()?; - if path == image_path { - Some(FileInclusion::Direct) - } else { - None - } - } - - fn check_directory( - directory_context: &DirectoryContextHandle, - path: &ProjectPath, - project: &Project, - cx: &App, - ) -> Option { - let worktree = project - .worktree_for_entry(directory_context.entry_id, cx)? - .read(cx); - let entry = worktree.entry_for_id(directory_context.entry_id)?; - let directory_path = ProjectPath { - worktree_id: worktree.id(), - path: entry.path.clone(), - }; - if path.starts_with(&directory_path) { - if path == &directory_path { - Some(FileInclusion::Direct) - } else { - Some(FileInclusion::InDirectory { - full_path: worktree.full_path(&entry.path), - }) - } - } else { - None - } - } -} diff --git a/crates/agent2/src/db.rs b/crates/agent/src/db.rs similarity index 78% rename from crates/agent2/src/db.rs rename to crates/agent/src/db.rs index 563ccdd7ca..7a88c58705 100644 --- a/crates/agent2/src/db.rs +++ b/crates/agent/src/db.rs @@ -1,6 +1,5 @@ use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent}; use acp_thread::UserMessageId; -use agent::{thread::DetailedSummaryState, thread_store}; use agent_client_protocol as acp; use agent_settings::{AgentProfileId, CompletionMode}; use anyhow::{Result, anyhow}; @@ -21,8 +20,8 @@ use ui::{App, SharedString}; use zed_env_vars::ZED_STATELESS; pub type DbMessage = crate::Message; -pub type DbSummary = DetailedSummaryState; -pub type DbLanguageModel = thread_store::SerializedLanguageModel; +pub type DbSummary = crate::legacy_thread::DetailedSummaryState; +pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DbThreadMetadata { @@ -40,7 +39,7 @@ pub struct DbThread { #[serde(default)] pub detailed_summary: Option, #[serde(default)] - pub initial_project_snapshot: Option>, + pub initial_project_snapshot: Option>, #[serde(default)] pub cumulative_token_usage: language_model::TokenUsage, #[serde(default)] @@ -61,13 +60,17 @@ impl DbThread { match saved_thread_json.get("version") { Some(serde_json::Value::String(version)) => match version.as_str() { Self::VERSION => Ok(serde_json::from_value(saved_thread_json)?), - _ => Self::upgrade_from_agent_1(agent::SerializedThread::from_json(json)?), + _ => Self::upgrade_from_agent_1(crate::legacy_thread::SerializedThread::from_json( + json, + )?), }, - _ => Self::upgrade_from_agent_1(agent::SerializedThread::from_json(json)?), + _ => { + Self::upgrade_from_agent_1(crate::legacy_thread::SerializedThread::from_json(json)?) + } } } - fn upgrade_from_agent_1(thread: agent::SerializedThread) -> Result { + fn upgrade_from_agent_1(thread: crate::legacy_thread::SerializedThread) -> Result { let mut messages = Vec::new(); let mut request_token_usage = HashMap::default(); @@ -80,14 +83,19 @@ impl DbThread { // Convert segments to content for segment in msg.segments { match segment { - thread_store::SerializedMessageSegment::Text { text } => { + crate::legacy_thread::SerializedMessageSegment::Text { text } => { content.push(UserMessageContent::Text(text)); } - thread_store::SerializedMessageSegment::Thinking { text, .. } => { + crate::legacy_thread::SerializedMessageSegment::Thinking { + text, + .. + } => { // User messages don't have thinking segments, but handle gracefully content.push(UserMessageContent::Text(text)); } - thread_store::SerializedMessageSegment::RedactedThinking { .. } => { + crate::legacy_thread::SerializedMessageSegment::RedactedThinking { + .. + } => { // User messages don't have redacted thinking, skip. } } @@ -113,16 +121,18 @@ impl DbThread { // Convert segments to content for segment in msg.segments { match segment { - thread_store::SerializedMessageSegment::Text { text } => { + crate::legacy_thread::SerializedMessageSegment::Text { text } => { content.push(AgentMessageContent::Text(text)); } - thread_store::SerializedMessageSegment::Thinking { + crate::legacy_thread::SerializedMessageSegment::Thinking { text, signature, } => { content.push(AgentMessageContent::Thinking { text, signature }); } - thread_store::SerializedMessageSegment::RedactedThinking { data } => { + crate::legacy_thread::SerializedMessageSegment::RedactedThinking { + data, + } => { content.push(AgentMessageContent::RedactedThinking(data)); } } @@ -140,6 +150,7 @@ impl DbThread { .unwrap_or_default(), input: tool_use.input, is_input_complete: true, + thought_signature: None, }, )); } @@ -171,6 +182,7 @@ impl DbThread { crate::Message::Agent(AgentMessage { content, tool_results, + reasoning_details: None, }) } language_model::Role::System => { @@ -187,10 +199,9 @@ impl DbThread { messages, updated_at: thread.updated_at, detailed_summary: match thread.detailed_summary_state { - DetailedSummaryState::NotGenerated | DetailedSummaryState::Generating { .. } => { - None - } - DetailedSummaryState::Generated { text, .. } => Some(text), + crate::legacy_thread::DetailedSummaryState::NotGenerated + | crate::legacy_thread::DetailedSummaryState::Generating => None, + crate::legacy_thread::DetailedSummaryState::Generated { text, .. } => Some(text), }, initial_project_snapshot: thread.initial_project_snapshot, cumulative_token_usage: thread.cumulative_token_usage, @@ -355,7 +366,7 @@ impl ThreadsDatabase { for (id, summary, updated_at) in rows { threads.push(DbThreadMetadata { - id: acp::SessionId(id), + id: acp::SessionId::new(id), title: summary.into(), updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), }); @@ -413,85 +424,20 @@ impl ThreadsDatabase { Ok(()) }) } -} -#[cfg(test)] -mod tests { + pub fn delete_threads(&self) -> Task> { + let connection = self.connection.clone(); - use super::*; - use agent::MessageSegment; - use agent::context::LoadedContext; - use client::Client; - use fs::{FakeFs, Fs}; - use gpui::AppContext; - use gpui::TestAppContext; - use http_client::FakeHttpClient; - use language_model::Role; - use project::Project; - use settings::SettingsStore; + self.executor.spawn(async move { + let connection = connection.lock(); - fn init_test(fs: Arc, 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); + let mut delete = connection.exec_bound::<()>(indoc! {" + DELETE FROM threads + "})?; - let http_client = FakeHttpClient::with_404_response(); - let clock = Arc::new(clock::FakeSystemClock::new()); - let client = Client::new(clock, http_client, cx); - agent::init(fs, cx); - agent_settings::init(cx); - language_model::init(client, cx); - }); - } + delete(())?; - #[gpui::test] - async fn test_retrieving_old_thread(cx: &mut TestAppContext) { - let fs = FakeFs::new(cx.executor()); - init_test(fs.clone(), cx); - let project = Project::test(fs, [], cx).await; - - // Save a thread using the old agent. - let thread_store = cx.new(|cx| agent::ThreadStore::fake(project, cx)); - let thread = thread_store.update(cx, |thread_store, cx| thread_store.create_thread(cx)); - thread.update(cx, |thread, cx| { - thread.insert_message( - Role::User, - vec![MessageSegment::Text("Hey!".into())], - LoadedContext::default(), - vec![], - false, - cx, - ); - thread.insert_message( - Role::Assistant, - vec![MessageSegment::Text("How're you doing?".into())], - LoadedContext::default(), - vec![], - false, - cx, - ) - }); - thread_store - .update(cx, |thread_store, cx| thread_store.save_thread(&thread, cx)) - .await - .unwrap(); - - // Open that same thread using the new agent. - let db = cx.update(ThreadsDatabase::connect).await.unwrap(); - let threads = db.list_threads().await.unwrap(); - assert_eq!(threads.len(), 1); - let thread = db - .load_thread(threads[0].id.clone()) - .await - .unwrap() - .unwrap(); - assert_eq!(thread.messages[0].to_markdown(), "## User\n\nHey!\n"); - assert_eq!( - thread.messages[1].to_markdown(), - "## Assistant\n\nHow're you doing?\n" - ); + Ok(()) + }) } } diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/agent/src/edit_agent.rs similarity index 97% rename from crates/assistant_tools/src/edit_agent.rs rename to crates/agent/src/edit_agent.rs index 829287f654..5ea04729a4 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/agent/src/edit_agent.rs @@ -172,14 +172,14 @@ impl EditAgent { project.set_agent_location( Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX, + position: language::Anchor::max_for_buffer(buffer.read(cx).remote_id()), }), cx, ) }); output_events_tx .unbounded_send(EditAgentOutputEvent::Edited( - language::Anchor::MIN..language::Anchor::MAX, + Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), )) .ok(); })?; @@ -187,7 +187,7 @@ impl EditAgent { while let Some(event) = parse_rx.next().await { match event? { CreateFileParserEvent::NewTextChunk { chunk } => { - cx.update(|cx| { + let buffer_id = cx.update(|cx| { buffer.update(cx, |buffer, cx| buffer.append(chunk, cx)); self.action_log .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); @@ -195,15 +195,18 @@ impl EditAgent { project.set_agent_location( Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX, + position: language::Anchor::max_for_buffer( + buffer.read(cx).remote_id(), + ), }), cx, ) }); + buffer.read(cx).remote_id() })?; output_events_tx .unbounded_send(EditAgentOutputEvent::Edited( - language::Anchor::MIN..language::Anchor::MAX, + Anchor::min_max_range_for_buffer(buffer_id), )) .ok(); } @@ -703,6 +706,7 @@ impl EditAgent { role: Role::User, content: vec![MessageContent::Text(prompt)], cache: false, + reasoning_details: None, }); // Include tools in the request so that we can take advantage of @@ -1199,7 +1203,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); @@ -1217,7 +1223,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); @@ -1235,7 +1243,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); @@ -1253,7 +1263,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); @@ -1268,7 +1280,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); } @@ -1394,7 +1408,7 @@ mod tests { async fn init_test(cx: &mut TestAppContext) -> EditAgent { cx.update(settings::init); - cx.update(Project::init_settings); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; let model = Arc::new(FakeLanguageModel::default()); let action_log = cx.new(|_| ActionLog::new(project.clone())); diff --git a/crates/assistant_tools/src/edit_agent/create_file_parser.rs b/crates/agent/src/edit_agent/create_file_parser.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/create_file_parser.rs rename to crates/agent/src/edit_agent/create_file_parser.rs diff --git a/crates/assistant_tools/src/edit_agent/edit_parser.rs b/crates/agent/src/edit_agent/edit_parser.rs similarity index 94% rename from crates/assistant_tools/src/edit_agent/edit_parser.rs rename to crates/agent/src/edit_agent/edit_parser.rs index 8411171ba4..c1aa61e18d 100644 --- a/crates/assistant_tools/src/edit_agent/edit_parser.rs +++ b/crates/agent/src/edit_agent/edit_parser.rs @@ -13,7 +13,17 @@ const EDITS_END_TAG: &str = ""; const SEARCH_MARKER: &str = "<<<<<<< SEARCH"; const SEPARATOR_MARKER: &str = "======="; const REPLACE_MARKER: &str = ">>>>>>> REPLACE"; -const END_TAGS: [&str; 3] = [OLD_TEXT_END_TAG, NEW_TEXT_END_TAG, EDITS_END_TAG]; +const SONNET_PARAMETER_INVOKE_1: &str = "\n"; +const SONNET_PARAMETER_INVOKE_2: &str = ""; +const SONNET_PARAMETER_INVOKE_3: &str = ""; +const END_TAGS: [&str; 6] = [ + OLD_TEXT_END_TAG, + NEW_TEXT_END_TAG, + EDITS_END_TAG, + SONNET_PARAMETER_INVOKE_1, // Remove these after switching to streaming tool call + SONNET_PARAMETER_INVOKE_2, + SONNET_PARAMETER_INVOKE_3, +]; #[derive(Debug)] pub enum EditParserEvent { @@ -547,6 +557,45 @@ mod tests { ); } + #[gpui::test(iterations = 1000)] + fn test_xml_edits_with_closing_parameter_invoke(mut rng: StdRng) { + // This case is a regression with Claude Sonnet 4.5. + // Sometimes Sonnet thinks that it's doing a tool call + // and closes its response with '' + // instead of properly closing + + let mut parser = EditParser::new(EditFormat::XmlTags); + assert_eq!( + parse_random_chunks( + indoc! {" + some textupdated text + more textupd + "}, + &mut parser, + &mut rng + ), + vec![ + Edit { + old_text: "some text".to_string(), + new_text: "updated text".to_string(), + line_hint: None, + }, + Edit { + old_text: "more text".to_string(), + new_text: "upd".to_string(), + line_hint: None, + }, + ] + ); + assert_eq!( + parser.finish(), + EditParserMetrics { + tags: 4, + mismatched_tags: 2 + } + ); + } + #[gpui::test(iterations = 1000)] fn test_xml_nested_tags(mut rng: StdRng) { let mut parser = EditParser::new(EditFormat::XmlTags); @@ -1035,6 +1084,11 @@ mod tests { last_ix = chunk_ix; } + if new_text.is_some() { + pending_edit.new_text = new_text.take().unwrap(); + edits.push(pending_edit); + } + edits } } diff --git a/crates/assistant_tools/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs similarity index 69% rename from crates/assistant_tools/src/edit_agent/evals.rs rename to crates/agent/src/edit_agent/evals.rs index 515e22d5f8..01c81e0103 100644 --- a/crates/assistant_tools/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -1,41 +1,83 @@ use super::*; use crate::{ - ReadFileToolInput, - edit_file_tool::{EditFileMode, EditFileToolInput}, - grep_tool::GrepToolInput, - list_directory_tool::ListDirectoryToolInput, + EditFileMode, EditFileToolInput, GrepToolInput, ListDirectoryToolInput, ReadFileToolInput, }; use Role::*; -use assistant_tool::ToolRegistry; use client::{Client, UserStore}; -use collections::HashMap; +use eval_utils::{EvalOutput, EvalOutputProcessor, OutcomeKind}; use fs::FakeFs; use futures::{FutureExt, future::LocalBoxFuture}; use gpui::{AppContext, TestAppContext, Timer}; use http_client::StatusCode; use indoc::{formatdoc, indoc}; use language_model::{ - LanguageModelRegistry, LanguageModelRequestTool, LanguageModelToolResult, - LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, SelectedModel, + LanguageModelRegistry, LanguageModelToolResult, LanguageModelToolResultContent, + LanguageModelToolUse, LanguageModelToolUseId, SelectedModel, }; use project::Project; -use prompt_store::{ModelContext, ProjectContext, PromptBuilder, WorktreeContext}; +use prompt_store::{ProjectContext, WorktreeContext}; use rand::prelude::*; use reqwest_client::ReqwestClient; use serde_json::json; use std::{ - cmp::Reverse, fmt::{self, Display}, - io::Write as _, path::Path, str::FromStr, - sync::mpsc, time::Duration, }; use util::path; +#[derive(Default, Clone, Debug)] +struct EditAgentOutputProcessor { + mismatched_tag_threshold: f32, + cumulative_tags: usize, + cumulative_mismatched_tags: usize, + eval_outputs: Vec>, +} + +fn mismatched_tag_threshold(mismatched_tag_threshold: f32) -> EditAgentOutputProcessor { + EditAgentOutputProcessor { + mismatched_tag_threshold, + cumulative_tags: 0, + cumulative_mismatched_tags: 0, + eval_outputs: Vec::new(), + } +} + +#[derive(Clone, Debug)] +struct EditEvalMetadata { + tags: usize, + mismatched_tags: usize, +} + +impl EvalOutputProcessor for EditAgentOutputProcessor { + type Metadata = EditEvalMetadata; + + fn process(&mut self, output: &EvalOutput) { + if matches!(output.outcome, OutcomeKind::Passed | OutcomeKind::Failed) { + self.cumulative_mismatched_tags += output.metadata.mismatched_tags; + self.cumulative_tags += output.metadata.tags; + self.eval_outputs.push(output.clone()); + } + } + + fn assert(&mut self) { + let mismatched_tag_ratio = + self.cumulative_mismatched_tags as f32 / self.cumulative_tags as f32; + if mismatched_tag_ratio > self.mismatched_tag_threshold { + for eval_output in &self.eval_outputs { + println!("{}", eval_output.data); + } + panic!( + "Too many mismatched tags: {:?}", + self.cumulative_mismatched_tags + ); + } + } +} + #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_extract_handle_command_output() { // Test how well agent generates multiple edit hunks. // @@ -59,22 +101,19 @@ fn eval_extract_handle_command_output() { include_str!("evals/fixtures/extract_handle_command_output/possible-07.diff"), ]; let edit_description = "Extract `handle_command_output` method from `run_git_blame`."; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and extract a method in - the final stanza of `run_git_blame` to deal with command failures, - call it `handle_command_output` and take the std::process::Output as the only parameter. - Do not document the method and do not add any comments. + Read the `{input_file_path}` file and extract a method in + the final stanza of `run_git_blame` to deal with command failures, + call it `handle_command_output` and take the std::process::Output as the only parameter. + Do not document the method and do not add any comments. - Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. - "})], + Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. + "})], ), message( Assistant, @@ -106,13 +145,13 @@ fn eval_extract_handle_command_output() { ), ], Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs), - ), - ); + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_delete_run_git_blame() { // Model | Pass rate // ----------------------------|---------- @@ -121,22 +160,21 @@ fn eval_delete_run_git_blame() { // gemini-2.5-pro-06-05 | 1.0 (2025-06-16) // gemini-2.5-flash | // gpt-4.1 | + let input_file_path = "root/blame.rs"; let input_file_content = include_str!("evals/fixtures/delete_run_git_blame/before.rs"); let output_file_content = include_str!("evals/fixtures/delete_run_git_blame/after.rs"); let edit_description = "Delete the `run_git_blame` function."; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and delete `run_git_blame`. Just that - one function, not its usages. - "})], + Read the `{input_file_path}` file and delete `run_git_blame`. Just that + one function, not its usages. + "})], ), message( Assistant, @@ -169,12 +207,12 @@ fn eval_delete_run_git_blame() { ], Some(input_file_content.into()), EvalAssertion::assert_eq(output_file_content), - ), - ); + )) + }); } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_translate_doc_comments() { // Model | Pass rate // ============================================ @@ -184,21 +222,20 @@ fn eval_translate_doc_comments() { // gemini-2.5-pro-preview-03-25 | 1.0 (2025-05-22) // gemini-2.5-flash-preview-04-17 | // gpt-4.1 | + let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/translate_doc_comments/before.rs"); let edit_description = "Translate all doc comments to Italian"; - eval( - 200, - 1., - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(200, 1., mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the {input_file_path} file and edit it (without overwriting it), - translating all the doc comments to italian. - "})], + Read the {input_file_path} file and edit it (without overwriting it), + translating all the doc comments to italian. + "})], ), message( Assistant, @@ -231,12 +268,12 @@ fn eval_translate_doc_comments() { ], Some(input_file_content.into()), EvalAssertion::judge_diff("Doc comments were translated to Italian"), - ), - ); + )) + }); } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { // Model | Pass rate // ============================================ @@ -246,37 +283,36 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { // gemini-2.5-pro-preview-latest | 0.99 (2025-06-16) // gemini-2.5-flash-preview-04-17 | // gpt-4.1 | + let input_file_path = "root/lib.rs"; let input_file_content = include_str!("evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs"); let edit_description = "Update compile_parser_to_wasm to use wasi-sdk instead of emscripten"; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. - Use `ureq` to download the SDK for the current platform and architecture. - Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. - Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) - that's inside of the archive. - Don't re-download the SDK if that executable already exists. + Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. + Use `ureq` to download the SDK for the current platform and architecture. + Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. + Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) + that's inside of the archive. + Don't re-download the SDK if that executable already exists. - Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} + Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} - Here are the available wasi-sdk assets: - - wasi-sdk-25.0-x86_64-macos.tar.gz - - wasi-sdk-25.0-arm64-macos.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-windows.tar.gz - "})], + Here are the available wasi-sdk assets: + - wasi-sdk-25.0-x86_64-macos.tar.gz + - wasi-sdk-25.0-arm64-macos.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-windows.tar.gz + "})], ), message( Assistant, @@ -353,15 +389,15 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { ], Some(input_file_content.into()), EvalAssertion::judge_diff(indoc! {" - - The compile_parser_to_wasm method has been changed to use wasi-sdk - - ureq is used to download the SDK for current platform and architecture - "}), - ), - ); + - The compile_parser_to_wasm method has been changed to use wasi-sdk + - ureq is used to download the SDK for current platform and architecture + "}), + )) + }); } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_disable_cursor_blinking() { // Model | Pass rate // ============================================ @@ -371,6 +407,7 @@ fn eval_disable_cursor_blinking() { // gemini-2.5-pro | 0.95 (2025-07-14) // gemini-2.5-flash-preview-04-17 | 0.78 (2025-07-14) // gpt-4.1 | 0.00 (2025-07-14) (follows edit_description too literally) + let input_file_path = "root/editor.rs"; let input_file_content = include_str!("evals/fixtures/disable_cursor_blinking/before.rs"); let edit_description = "Comment out the call to `BlinkManager::enable`"; @@ -380,11 +417,8 @@ fn eval_disable_cursor_blinking() { include_str!("evals/fixtures/disable_cursor_blinking/possible-03.diff"), include_str!("evals/fixtures/disable_cursor_blinking/possible-04.diff"), ]; - eval( - 100, - 0.51, - 0.05, - EvalInput::from_conversation( + eval_utils::eval(100, 0.51, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text("Let's research how to cursor blinking works.")]), message( @@ -421,10 +455,10 @@ fn eval_disable_cursor_blinking() { message( User, [text(indoc! {" - Comment out the lines that interact with the BlinkManager. - Keep the outer `update` blocks, but comments everything that's inside (including if statements). - Don't add additional comments. - "})], + Comment out the lines that interact with the BlinkManager. + Keep the outer `update` blocks, but comments everything that's inside (including if statements). + Don't add additional comments. + "})], ), message( Assistant, @@ -440,13 +474,13 @@ fn eval_disable_cursor_blinking() { ), ], Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs), - ), - ); + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_from_pixels_constructor() { // Results for 2025-06-13 // @@ -463,23 +497,20 @@ fn eval_from_pixels_constructor() { // claude-3.7-sonnet | 2025-06-14 | 0.88 // gemini-2.5-pro-preview-06-05 | 2025-06-16 | 0.98 // gpt-4.1 | + let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/from_pixels_constructor/before.rs"); let edit_description = "Implement from_pixels constructor and add tests."; - eval( - 100, - 0.95, - // For whatever reason, this eval produces more mismatched tags. - // Increasing for now, let's see if we can bring this down. - 0.25, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.25), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(indoc! {" - Introduce a new `from_pixels` constructor in Canvas and - also add tests for it in the same file. - "})], + Introduce a new `from_pixels` constructor in Canvas and + also add tests for it in the same file. + "})], ), message( Assistant, @@ -544,92 +575,92 @@ fn eval_from_pixels_constructor() { "tool_4", "grep", indoc! {" - Found 6 matches: + Found 6 matches: - ## Matches in font-kit/src/loaders/core_text.rs + ## Matches in font-kit/src/loaders/core_text.rs - ### mod test › L926-936 - ``` - mod test { - use super::Font; - use crate::properties::{Stretch, Weight}; + ### mod test › L926-936 + ``` + mod test { + use super::Font; + use crate::properties::{Stretch, Weight}; - #[cfg(feature = \"source\")] - use crate::source::SystemSource; + #[cfg(feature = \"source\")] + use crate::source::SystemSource; - static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; + static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; - #[cfg(feature = \"source\")] - #[test] - ``` + #[cfg(feature = \"source\")] + #[test] + ``` - 55 lines remaining in ancestor node. Read the file to see all. + 55 lines remaining in ancestor node. Read the file to see all. - ### mod test › L947-951 - ``` - } + ### mod test › L947-951 + ``` + } - #[test] - fn test_core_text_to_css_font_weight() { - // Exact matches - ``` + #[test] + fn test_core_text_to_css_font_weight() { + // Exact matches + ``` - ### mod test › L959-963 - ``` - } + ### mod test › L959-963 + ``` + } - #[test] - fn test_core_text_to_css_font_stretch() { - // Exact matches - ``` + #[test] + fn test_core_text_to_css_font_stretch() { + // Exact matches + ``` - ## Matches in font-kit/src/loaders/freetype.rs + ## Matches in font-kit/src/loaders/freetype.rs - ### mod test › L1238-1248 - ``` - mod test { - use crate::loaders::freetype::Font; + ### mod test › L1238-1248 + ``` + mod test { + use crate::loaders::freetype::Font; - static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; - static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; + static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; + static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; - #[test] - fn get_pcf_postscript_name() { - let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); - assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); - } - ``` + #[test] + fn get_pcf_postscript_name() { + let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); + assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); + } + ``` - 1 lines remaining in ancestor node. Read the file to see all. + 1 lines remaining in ancestor node. Read the file to see all. - ## Matches in font-kit/src/sources/core_text.rs + ## Matches in font-kit/src/sources/core_text.rs - ### mod test › L265-275 - ``` - mod test { - use crate::properties::{Stretch, Weight}; + ### mod test › L265-275 + ``` + mod test { + use crate::properties::{Stretch, Weight}; - #[test] - fn test_css_to_core_text_font_weight() { - // Exact matches - assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); - assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); - assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); - assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); + #[test] + fn test_css_to_core_text_font_weight() { + // Exact matches + assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); + assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); + assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); + assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); - ``` + ``` - 27 lines remaining in ancestor node. Read the file to see all. + 27 lines remaining in ancestor node. Read the file to see all. - ### mod test › L278-282 - ``` - } + ### mod test › L278-282 + ``` + } - #[test] - fn test_css_to_core_text_font_stretch() { - // Exact matches - ``` - "}, + #[test] + fn test_css_to_core_text_font_stretch() { + // Exact matches + ``` + "}, )], ), message( @@ -647,15 +678,15 @@ fn eval_from_pixels_constructor() { ], Some(input_file_content.into()), EvalAssertion::judge_diff(indoc! {" - - The diff contains a new `from_pixels` constructor - - The diff contains new tests for the `from_pixels` constructor - "}), - ), - ); + - The diff contains a new `from_pixels` constructor + - The diff contains new tests for the `from_pixels` constructor + "}), + )) + }); } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_zode() { // Model | Pass rate // ============================================ @@ -665,14 +696,13 @@ fn eval_zode() { // gemini-2.5-pro-preview-03-25 | 1.0 (2025-05-22) // gemini-2.5-flash-preview-04-17 | 1.0 (2025-05-22) // gpt-4.1 | 1.0 (2025-05-22) + let input_file_path = "root/zode.py"; let input_content = None; let edit_description = "Create the main Zode CLI script"; - eval( - 50, - 1., - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(50, 1., mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text(include_str!("evals/fixtures/zode/prompt.md"))]), message( @@ -731,7 +761,7 @@ fn eval_zode() { ], ), ], - input_content, + input_content.clone(), EvalAssertion::new(async move |sample, _, _cx| { let invalid_starts = [' ', '`', '\n']; let mut message = String::new(); @@ -756,12 +786,12 @@ fn eval_zode() { }) } }), - ), - ); + )) + }); } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_add_overwrite_test() { // Model | Pass rate // ============================================ @@ -771,22 +801,21 @@ fn eval_add_overwrite_test() { // gemini-2.5-pro-preview-03-25 | 0.35 (2025-05-22) // gemini-2.5-flash-preview-04-17 | // gpt-4.1 | + let input_file_path = "root/action_log.rs"; let input_file_content = include_str!("evals/fixtures/add_overwrite_test/before.rs"); let edit_description = "Add a new test for overwriting a file in action_log.rs"; - eval( - 200, - 0.5, // TODO: make this eval better - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(200, 0.5, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(indoc! {" - Introduce a new test in `action_log.rs` to test overwriting a file. - That is, a file already exists, but we call `buffer_created` as if the file were new. - Take inspiration from all the other tests in the file. - "})], + Introduce a new test in `action_log.rs` to test overwriting a file. + That is, a file already exists, but we call `buffer_created` as if the file were new. + Take inspiration from all the other tests in the file. + "})], ), message( Assistant, @@ -806,81 +835,81 @@ fn eval_add_overwrite_test() { "tool_1", "read_file", indoc! {" - pub struct ActionLog [L13-20] - tracked_buffers [L15] - edited_since_project_diagnostics_check [L17] - project [L19] - impl ActionLog [L22-498] - pub fn new [L24-30] - pub fn project [L32-34] - pub fn checked_project_diagnostics [L37-39] - pub fn has_edited_files_since_project_diagnostics_check [L42-44] - fn track_buffer_internal [L46-101] - fn handle_buffer_event [L103-116] - fn handle_buffer_edited [L118-123] - fn handle_buffer_file_changed [L125-158] - async fn maintain_diff [L160-264] - pub fn buffer_read [L267-269] - pub fn buffer_created [L272-276] - pub fn buffer_edited [L279-287] - pub fn will_delete_buffer [L289-304] - pub fn keep_edits_in_range [L306-364] - pub fn reject_edits_in_ranges [L366-459] - pub fn keep_all_edits [L461-473] - pub fn changed_buffers [L476-482] - pub fn stale_buffers [L485-497] - fn apply_non_conflicting_edits [L500-561] - fn diff_snapshots [L563-585] - fn point_to_row_edit [L587-614] - enum ChangeAuthor [L617-620] - User [L618] - Agent [L619] - enum TrackedBufferStatus [L623-627] - Created [L624] - Modified [L625] - Deleted [L626] - struct TrackedBuffer [L629-641] - buffer [L630] - base_text [L631] - unreviewed_changes [L632] - status [L633] - version [L634] - diff [L635] - snapshot [L636] - diff_update [L637] - _open_lsp_handle [L638] - _maintain_diff [L639] - _subscription [L640] - impl TrackedBuffer [L643-657] - fn has_changes [L644-650] - fn schedule_diff_update [L652-656] - pub struct ChangedBuffer [L659-661] - pub diff [L660] - mod tests [L664-1574] - fn init_logger [L678-682] - fn init_test [L684-691] - async fn test_keep_edits [L694-769] - async fn test_deletions [L772-854] - async fn test_overlapping_user_edits [L857-951] - async fn test_creating_files [L954-1010] - async fn test_deleting_files [L1013-1120] - async fn test_reject_edits [L1123-1255] - async fn test_reject_multiple_edits [L1258-1331] - async fn test_reject_deleted_file [L1334-1388] - async fn test_reject_created_file [L1391-1443] - async fn test_random_diffs [L1446-1535] - fn quiesce [L1510-1534] - struct HunkStatus [L1538-1542] - range [L1539] - diff_status [L1540] - old_text [L1541] - fn unreviewed_hunks [L1544-1573] + pub struct ActionLog [L13-20] + tracked_buffers [L15] + edited_since_project_diagnostics_check [L17] + project [L19] + impl ActionLog [L22-498] + pub fn new [L24-30] + pub fn project [L32-34] + pub fn checked_project_diagnostics [L37-39] + pub fn has_edited_files_since_project_diagnostics_check [L42-44] + fn track_buffer_internal [L46-101] + fn handle_buffer_event [L103-116] + fn handle_buffer_edited [L118-123] + fn handle_buffer_file_changed [L125-158] + async fn maintain_diff [L160-264] + pub fn buffer_read [L267-269] + pub fn buffer_created [L272-276] + pub fn buffer_edited [L279-287] + pub fn will_delete_buffer [L289-304] + pub fn keep_edits_in_range [L306-364] + pub fn reject_edits_in_ranges [L366-459] + pub fn keep_all_edits [L461-473] + pub fn changed_buffers [L476-482] + pub fn stale_buffers [L485-497] + fn apply_non_conflicting_edits [L500-561] + fn diff_snapshots [L563-585] + fn point_to_row_edit [L587-614] + enum ChangeAuthor [L617-620] + User [L618] + Agent [L619] + enum TrackedBufferStatus [L623-627] + Created [L624] + Modified [L625] + Deleted [L626] + struct TrackedBuffer [L629-641] + buffer [L630] + base_text [L631] + unreviewed_changes [L632] + status [L633] + version [L634] + diff [L635] + snapshot [L636] + diff_update [L637] + _open_lsp_handle [L638] + _maintain_diff [L639] + _subscription [L640] + impl TrackedBuffer [L643-657] + fn has_changes [L644-650] + fn schedule_diff_update [L652-656] + pub struct ChangedBuffer [L659-661] + pub diff [L660] + mod tests [L664-1574] + fn init_logger [L678-682] + fn init_test [L684-691] + async fn test_keep_edits [L694-769] + async fn test_deletions [L772-854] + async fn test_overlapping_user_edits [L857-951] + async fn test_creating_files [L954-1010] + async fn test_deleting_files [L1013-1120] + async fn test_reject_edits [L1123-1255] + async fn test_reject_multiple_edits [L1258-1331] + async fn test_reject_deleted_file [L1334-1388] + async fn test_reject_created_file [L1391-1443] + async fn test_random_diffs [L1446-1535] + fn quiesce [L1510-1534] + struct HunkStatus [L1538-1542] + range [L1539] + diff_status [L1540] + old_text [L1541] + fn unreviewed_hunks [L1544-1573] - Showing symbols 1-69 (total symbols: 69) + Showing symbols 1-69 (total symbols: 69) - Using the line numbers in this outline, you can call this tool again while specifying - the start_line and end_line fields to see the implementations of symbols in the outline. - "}, + Using the line numbers in this outline, you can call this tool again while specifying + the start_line and end_line fields to see the implementations of symbols in the outline. + "}, )], ), message( @@ -960,17 +989,17 @@ fn eval_add_overwrite_test() { Assistant, [ text(indoc! {" - Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: + Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: - 1. Create a file - 2. Open a buffer for it - 3. Track it in the action log - 4. Modify the buffer - 5. Call `buffer_created` again as if the file were new - 6. Check that the changes are properly tracked + 1. Create a file + 2. Open a buffer for it + 3. Track it in the action log + 4. Modify the buffer + 5. Call `buffer_created` again as if the file were new + 6. Check that the changes are properly tracked - Let's write the test: - "}), + Let's write the test: + "}), tool_use( "tool_5", "edit_file", @@ -987,12 +1016,12 @@ fn eval_add_overwrite_test() { EvalAssertion::judge_diff( "A new test for overwritten files was created, without changing any previous test", ), - ), - ); + )) + }); } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_create_empty_file() { // Check that Edit Agent can create a file without writing its // thoughts into it. This issue is not specific to empty files, but @@ -1010,23 +1039,21 @@ fn eval_create_empty_file() { // // TODO: gpt-4.1-mini errored 38 times: // "data did not match any variant of untagged enum ResponseStreamResult" - // + let input_file_content = None; let expected_output_content = String::new(); - eval( - 100, - 0.99, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.99, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text("Create a second empty todo file ")]), message( Assistant, [ text(formatdoc! {" - I'll help you create a second empty todo file. - First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. - "}), + I'll help you create a second empty todo file. + First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. + "}), tool_use( "toolu_01GAF8TtsgpjKxCr8fgQLDgR", "list_directory", @@ -1048,8 +1075,8 @@ fn eval_create_empty_file() { Assistant, [ text(formatdoc! {" - I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: - "}), + I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: + "}), tool_use( "toolu_01Tb3iQ9griqSYMmVuykQPWU", "edit_file", @@ -1062,12 +1089,12 @@ fn eval_create_empty_file() { ], ), ], - input_file_content, + input_file_content.clone(), // Bad behavior is to write something like // "I'll create an empty TODO3 file as requested." - EvalAssertion::assert_eq(expected_output_content), - ), - ); + EvalAssertion::assert_eq(expected_output_content.clone()), + )) + }); } fn message( @@ -1078,6 +1105,7 @@ fn message( role, content: contents.into_iter().collect(), cache: false, + reasoning_details: None, } } @@ -1105,6 +1133,7 @@ fn tool_use( raw_input: serde_json::to_string_pretty(&input).unwrap(), input: serde_json::to_value(input).unwrap(), is_input_complete: true, + thought_signature: None, }) } @@ -1264,6 +1293,7 @@ impl EvalAssertion { role: Role::User, content: vec![prompt.into()], cache: false, + reasoning_details: None, }], thinking_allowed: true, ..Default::default() @@ -1306,115 +1336,45 @@ impl EvalAssertion { } } -fn eval( - iterations: usize, - expected_pass_ratio: f32, - mismatched_tag_threshold: f32, - mut eval: EvalInput, -) { - let mut evaluated_count = 0; - let mut failed_count = 0; - report_progress(evaluated_count, failed_count, iterations); - - let (tx, rx) = mpsc::channel(); - - // Cache the last message in the conversation, and run one instance of the eval so that - // all the next ones are cached. - eval.conversation.last_mut().unwrap().cache = true; - run_eval(eval.clone(), tx.clone()); - - let executor = gpui::background_executor(); - let semaphore = Arc::new(smol::lock::Semaphore::new(32)); - for _ in 1..iterations { - let eval = eval.clone(); - let tx = tx.clone(); - let semaphore = semaphore.clone(); - executor - .spawn(async move { - let _guard = semaphore.acquire().await; - run_eval(eval, tx) - }) - .detach(); - } - drop(tx); - - let mut failed_evals = HashMap::default(); - let mut errored_evals = HashMap::default(); - let mut eval_outputs = Vec::new(); - let mut cumulative_parser_metrics = EditParserMetrics::default(); - while let Ok(output) = rx.recv() { - match output { - Ok(output) => { - cumulative_parser_metrics += output.sample.edit_output.parser_metrics.clone(); - eval_outputs.push(output.clone()); - if output.assertion.score < 80 { - failed_count += 1; - failed_evals - .entry(output.sample.text_after.clone()) - .or_insert(Vec::new()) - .push(output); - } - } - Err(error) => { - failed_count += 1; - *errored_evals.entry(format!("{:?}", error)).or_insert(0) += 1; - } - } - - evaluated_count += 1; - report_progress(evaluated_count, failed_count, iterations); - } - - let actual_pass_ratio = (iterations - failed_count) as f32 / iterations as f32; - println!("Actual pass ratio: {}\n", actual_pass_ratio); - if actual_pass_ratio < expected_pass_ratio { - let mut errored_evals = errored_evals.into_iter().collect::>(); - errored_evals.sort_by_key(|(_, count)| Reverse(*count)); - for (error, count) in errored_evals { - println!("Eval errored {} times. Error: {}", count, error); - } - - let mut failed_evals = failed_evals.into_iter().collect::>(); - failed_evals.sort_by_key(|(_, evals)| Reverse(evals.len())); - for (_buffer_output, failed_evals) in failed_evals { - let eval_output = failed_evals.first().unwrap(); - println!("Eval failed {} times", failed_evals.len()); - println!("{}", eval_output); - } - - panic!( - "Actual pass ratio: {}\nExpected pass ratio: {}", - actual_pass_ratio, expected_pass_ratio - ); - } - - let mismatched_tag_ratio = - cumulative_parser_metrics.mismatched_tags as f32 / cumulative_parser_metrics.tags as f32; - if mismatched_tag_ratio > mismatched_tag_threshold { - for eval_output in eval_outputs { - println!("{}", eval_output); - } - panic!("Too many mismatched tags: {:?}", cumulative_parser_metrics); - } -} - -fn run_eval(eval: EvalInput, tx: mpsc::Sender>) { +fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput { let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); let mut cx = TestAppContext::build(dispatcher, None); - let output = cx.executor().block_test(async { + let result = cx.executor().block_test(async { let test = EditAgentTest::new(&mut cx).await; test.eval(eval, &mut cx).await }); - tx.send(output).unwrap(); + cx.quit(); + match result { + Ok(output) => eval_utils::EvalOutput { + data: output.to_string(), + outcome: if output.assertion.score < 80 { + eval_utils::OutcomeKind::Failed + } else { + eval_utils::OutcomeKind::Passed + }, + metadata: EditEvalMetadata { + tags: output.sample.edit_output.parser_metrics.tags, + mismatched_tags: output.sample.edit_output.parser_metrics.mismatched_tags, + }, + }, + Err(e) => eval_utils::EvalOutput { + data: format!("{e:?}"), + outcome: eval_utils::OutcomeKind::Error, + metadata: EditEvalMetadata { + tags: 0, + mismatched_tags: 0, + }, + }, + } } #[derive(Clone)] -struct EvalOutput { +struct EditEvalOutput { sample: EvalSample, assertion: EvalAssertionOutcome, } -impl Display for EvalOutput { +impl Display for EditEvalOutput { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { writeln!(f, "Score: {:?}", self.assertion.score)?; if let Some(message) = self.assertion.message.as_ref() { @@ -1433,22 +1393,6 @@ impl Display for EvalOutput { } } -fn report_progress(evaluated_count: usize, failed_count: usize, iterations: usize) { - let passed_count = evaluated_count - failed_count; - let passed_ratio = if evaluated_count == 0 { - 0.0 - } else { - passed_count as f64 / evaluated_count as f64 - }; - print!( - "\r\x1b[KEvaluated {}/{} ({:.2}% passed)", - evaluated_count, - iterations, - passed_ratio * 100.0 - ); - std::io::stdout().flush().unwrap(); -} - struct EditAgentTest { agent: EditAgent, project: Entity, @@ -1465,34 +1409,37 @@ impl EditAgentTest { gpui_tokio::init(cx); let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap()); cx.set_http_client(http_client); - - client::init_settings(cx); let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - settings::init(cx); - Project::init_settings(cx); - language::init(cx); language_model::init(client.clone(), cx); language_models::init(user_store, client.clone(), cx); - crate::init(client.http_client(), cx); }); fs.insert_tree("/root", json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let agent_model = SelectedModel::from_str( - &std::env::var("ZED_AGENT_MODEL") - .unwrap_or("anthropic/claude-3-7-sonnet-latest".into()), + &std::env::var("ZED_AGENT_MODEL").unwrap_or("anthropic/claude-sonnet-4-latest".into()), ) .unwrap(); let judge_model = SelectedModel::from_str( - &std::env::var("ZED_JUDGE_MODEL") - .unwrap_or("anthropic/claude-3-7-sonnet-latest".into()), + &std::env::var("ZED_JUDGE_MODEL").unwrap_or("anthropic/claude-sonnet-4-latest".into()), ) .unwrap(); + + let authenticate_provider_tasks = cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .providers() + .iter() + .map(|p| p.authenticate(cx)) + .collect::>() + }) + }); let (agent_model, judge_model) = cx .update(|cx| { cx.spawn(async move |cx| { + futures::future::join_all(authenticate_provider_tasks).await; let agent_model = Self::load_model(&agent_model, cx).await; let judge_model = Self::load_model(&judge_model, cx).await; (agent_model.unwrap(), judge_model.unwrap()) @@ -1536,12 +1483,15 @@ impl EditAgentTest { model.provider_id() == selected_model.provider && model.id() == selected_model.model }) - .expect("Model not found"); + .unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0)); model }) } - async fn eval(&self, eval: EvalInput, cx: &mut TestAppContext) -> Result { + async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result { + // Make sure the last message in the conversation is cached. + eval.conversation.last_mut().unwrap().cache = true; + let path = self .project .read_with(cx, |project, cx| { @@ -1553,39 +1503,28 @@ impl EditAgentTest { .update(cx, |project, cx| project.open_buffer(path, cx)) .await .unwrap(); - let tools = cx.update(|cx| { - ToolRegistry::default_global(cx) - .tools() - .into_iter() - .filter_map(|tool| { - let input_schema = tool - .input_schema(self.agent.model.tool_input_format()) - .ok()?; - Some(LanguageModelRequestTool { - name: tool.name(), - description: tool.description(), - input_schema, - }) - }) - .collect::>() - }); - let tool_names = tools - .iter() - .map(|tool| tool.name.clone()) - .collect::>(); - let worktrees = vec![WorktreeContext { - root_name: "root".to_string(), - abs_path: Path::new("/path/to/root").into(), - rules_file: None, - }]; - let prompt_builder = PromptBuilder::new(None)?; - let project_context = ProjectContext::new(worktrees, Vec::default()); - let system_prompt = prompt_builder.generate_assistant_system_prompt( - &project_context, - &ModelContext { + + let tools = crate::built_in_tools().collect::>(); + + let system_prompt = { + let worktrees = vec![WorktreeContext { + root_name: "root".to_string(), + abs_path: Path::new("/path/to/root").into(), + rules_file: None, + }]; + let project_context = ProjectContext::new(worktrees, Vec::default()); + let tool_names = tools + .iter() + .map(|tool| tool.name.clone().into()) + .collect::>(); + let template = crate::SystemPromptTemplate { + project: &project_context, available_tools: tool_names, - }, - )?; + model_name: None, + }; + let templates = Templates::new(); + template.render(&templates).unwrap() + }; let has_system_prompt = eval .conversation @@ -1598,6 +1537,7 @@ impl EditAgentTest { role: Role::System, content: vec![MessageContent::Text(system_prompt)], cache: true, + reasoning_details: None, }] .into_iter() .chain(eval.conversation) @@ -1657,7 +1597,7 @@ impl EditAgentTest { .run(&sample, self.judge_model.clone(), cx) .await?; - Ok(EvalOutput { assertion, sample }) + Ok(EditEvalOutput { assertion, sample }) } } diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs b/crates/agent/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs b/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs rename to crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs b/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs b/crates/agent/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs b/crates/agent/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs b/crates/agent/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/zode/prompt.md b/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md similarity index 99% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/zode/prompt.md rename to crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md index 902e43857c..29755d441f 100644 --- a/crates/assistant_tools/src/edit_agent/evals/fixtures/zode/prompt.md +++ b/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md @@ -2,12 +2,12 @@ - We're starting from a completely blank project - Like Aider/Claude Code you take the user's initial prompt and then call the LLM and perform tool calls in a loop until the ultimate goal is achieved. - Unlike Aider or Claude code, it's not intended to be interactive. Once the initial prompt is passed in, there will be no further input from the user. -- The system you will build must reach the stated goal just by performing too calls and calling the LLM +- The system you will build must reach the stated goal just by performing tool calls and calling the LLM - I want you to build this in python. Use the anthropic python sdk and the model context protocol sdk. Use a virtual env and pip to install dependencies - Follow the anthropic guidance on tool calls: https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview - Use this Anthropic model: `claude-3-7-sonnet-20250219` - Use this Anthropic API Key: `sk-ant-api03-qweeryiofdjsncmxquywefidopsugus` -- One of the most important pieces to this is having good too calls. We will be using the tools provided by the Claude MCP server. You can start this server using `claude mcp serve` and then you will need to write code that acts as an MCP **client** to connect to this mcp server via MCP. Likely you want to start this using a subprocess. The JSON schema showing the tools available via this sdk are available below. Via this MCP server you have access to all the tools that zode needs: Bash, GlobTool, GrepTool, LS, View, Edit, Replace, WebFetchTool +- One of the most important pieces to this is having good tool calls. We will be using the tools provided by the Claude MCP server. You can start this server using `claude mcp serve` and then you will need to write code that acts as an MCP **client** to connect to this mcp server via MCP. Likely you want to start this using a subprocess. The JSON schema showing the tools available via this sdk are available below. Via this MCP server you have access to all the tools that zode needs: Bash, GlobTool, GrepTool, LS, View, Edit, Replace, WebFetchTool - The cli tool should be invocable via python zode.py file.md where file.md is any possible file that contains the users prompt. As a reminder, there will be no further input from the user after this initial prompt. Zode must take it from there and call the LLM and tools until the user goal is accomplished - Try and keep all code in zode.py and make heavy use of the asks I mentioned - Once you’ve implemented this, you must run python zode.py eval/instructions.md to see how well our new agent tool does! diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/zode/react.py b/crates/agent/src/edit_agent/evals/fixtures/zode/react.py similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/zode/react.py rename to crates/agent/src/edit_agent/evals/fixtures/zode/react.py diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/zode/react_test.py b/crates/agent/src/edit_agent/evals/fixtures/zode/react_test.py similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/zode/react_test.py rename to crates/agent/src/edit_agent/evals/fixtures/zode/react_test.py diff --git a/crates/assistant_tools/src/edit_agent/streaming_fuzzy_matcher.rs b/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs similarity index 98% rename from crates/assistant_tools/src/edit_agent/streaming_fuzzy_matcher.rs rename to crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs index 386b820440..904ec05a8c 100644 --- a/crates/assistant_tools/src/edit_agent/streaming_fuzzy_matcher.rs +++ b/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs @@ -308,12 +308,13 @@ mod tests { use indoc::indoc; use language::{BufferId, TextBuffer}; use rand::prelude::*; + use text::ReplicaId; use util::test::{generate_marked_text, marked_text_ranges}; #[test] fn test_empty_query() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), "Hello world\nThis is a test\nFoo bar baz", ); @@ -327,7 +328,7 @@ mod tests { #[test] fn test_streaming_exact_match() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), "Hello world\nThis is a test\nFoo bar baz", ); @@ -351,7 +352,7 @@ mod tests { #[test] fn test_streaming_fuzzy_match() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), indoc! {" function foo(a, b) { @@ -385,7 +386,7 @@ mod tests { #[test] fn test_incremental_improvement() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), "Line 1\nLine 2\nLine 3\nLine 4\nLine 5", ); @@ -410,7 +411,7 @@ mod tests { #[test] fn test_incomplete_lines_buffering() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), indoc! {" The quick brown fox @@ -437,7 +438,7 @@ mod tests { #[test] fn test_multiline_fuzzy_match() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), indoc! {r#" impl Display for User { @@ -691,7 +692,11 @@ mod tests { } "#}; - let buffer = TextBuffer::new(0, BufferId::new(1).unwrap(), text.to_string()); + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); let snapshot = buffer.snapshot(); let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); @@ -724,7 +729,7 @@ mod tests { #[track_caller] fn assert_location_resolution(text_with_expected_range: &str, query: &str, rng: &mut StdRng) { let (text, expected_ranges) = marked_text_ranges(text_with_expected_range, false); - let buffer = TextBuffer::new(0, BufferId::new(1).unwrap(), text.clone()); + let buffer = TextBuffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), text.clone()); let snapshot = buffer.snapshot(); let mut matcher = StreamingFuzzyMatcher::new(snapshot); diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index 4b1795047b..5a1b923d13 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -1,64 +1,128 @@ -use crate::{ThreadId, thread_store::SerializedThreadMetadata}; -use anyhow::{Context as _, Result}; -use assistant_context::SavedContextMetadata; +use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; +use acp_thread::MentionUri; +use agent_client_protocol as acp; +use anyhow::{Context as _, Result, anyhow}; +use assistant_text_thread::{SavedTextThreadMetadata, TextThread}; use chrono::{DateTime, Utc}; +use db::kvp::KEY_VALUE_STORE; use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::*}; use itertools::Itertools; -use paths::contexts_dir; +use paths::text_threads_dir; +use project::Project; use serde::{Deserialize, Serialize}; -use std::{collections::VecDeque, path::Path, sync::Arc, time::Duration}; +use std::{collections::VecDeque, path::Path, rc::Rc, sync::Arc, time::Duration}; +use ui::ElementId; use util::ResultExt as _; const MAX_RECENTLY_OPENED_ENTRIES: usize = 6; -const NAVIGATION_HISTORY_PATH: &str = "agent-navigation-history.json"; +const RECENTLY_OPENED_THREADS_KEY: &str = "recent-agent-threads"; const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50); +const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); + +//todo: We should remove this function once we support loading all acp thread +pub fn load_agent_thread( + session_id: acp::SessionId, + history_store: Entity, + project: Entity, + cx: &mut App, +) -> Task>> { + use agent_servers::{AgentServer, AgentServerDelegate}; + + let server = Rc::new(crate::NativeAgentServer::new( + project.read(cx).fs().clone(), + history_store, + )); + let delegate = AgentServerDelegate::new( + project.read(cx).agent_server_store().clone(), + project.clone(), + None, + None, + ); + let connection = server.connect(None, delegate, cx); + cx.spawn(async move |cx| { + let (agent, _) = connection.await?; + let agent = agent.downcast::().unwrap(); + cx.update(|cx| agent.load_thread(session_id, cx))?.await + }) +} + #[derive(Clone, Debug)] pub enum HistoryEntry { - Thread(SerializedThreadMetadata), - Context(SavedContextMetadata), + AcpThread(DbThreadMetadata), + TextThread(SavedTextThreadMetadata), } impl HistoryEntry { pub fn updated_at(&self) -> DateTime { match self { - HistoryEntry::Thread(thread) => thread.updated_at, - HistoryEntry::Context(context) => context.mtime.to_utc(), + HistoryEntry::AcpThread(thread) => thread.updated_at, + HistoryEntry::TextThread(text_thread) => text_thread.mtime.to_utc(), } } pub fn id(&self) -> HistoryEntryId { match self { - HistoryEntry::Thread(thread) => HistoryEntryId::Thread(thread.id.clone()), - HistoryEntry::Context(context) => HistoryEntryId::Context(context.path.clone()), + HistoryEntry::AcpThread(thread) => HistoryEntryId::AcpThread(thread.id.clone()), + HistoryEntry::TextThread(text_thread) => { + HistoryEntryId::TextThread(text_thread.path.clone()) + } + } + } + + pub fn mention_uri(&self) -> MentionUri { + match self { + HistoryEntry::AcpThread(thread) => MentionUri::Thread { + id: thread.id.clone(), + name: thread.title.to_string(), + }, + HistoryEntry::TextThread(text_thread) => MentionUri::TextThread { + path: text_thread.path.as_ref().to_owned(), + name: text_thread.title.to_string(), + }, } } pub fn title(&self) -> &SharedString { match self { - HistoryEntry::Thread(thread) => &thread.summary, - HistoryEntry::Context(context) => &context.title, + HistoryEntry::AcpThread(thread) => { + if thread.title.is_empty() { + DEFAULT_TITLE + } else { + &thread.title + } + } + HistoryEntry::TextThread(text_thread) => &text_thread.title, } } } /// Generic identifier for a history entry. -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug, Hash)] pub enum HistoryEntryId { - Thread(ThreadId), - Context(Arc), + AcpThread(acp::SessionId), + TextThread(Arc), } -#[derive(Serialize, Deserialize)] +impl Into for HistoryEntryId { + fn into(self) -> ElementId { + match self { + HistoryEntryId::AcpThread(session_id) => ElementId::Name(session_id.0.into()), + HistoryEntryId::TextThread(path) => ElementId::Path(path), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] enum SerializedRecentOpen { - Thread(String), - ContextName(String), - /// Old format which stores the full path - Context(String), + AcpThread(String), + TextThread(String), } pub struct HistoryStore { - context_store: Entity, + threads: Vec, + entries: Vec, + text_thread_store: Entity, recently_opened_entries: VecDeque, _subscriptions: Vec, _save_recently_opened_entries_task: Task<()>, @@ -66,57 +130,142 @@ pub struct HistoryStore { impl HistoryStore { pub fn new( - context_store: Entity, - initial_recent_entries: impl IntoIterator, + text_thread_store: Entity, cx: &mut Context, ) -> Self { - let subscriptions = vec![cx.observe(&context_store, |_, _, cx| cx.notify())]; + let subscriptions = + vec![cx.observe(&text_thread_store, |this, _, cx| this.update_entries(cx))]; cx.spawn(async move |this, cx| { - let entries = Self::load_recently_opened_entries(cx).await.log_err()?; - this.update(cx, |this, _| { - this.recently_opened_entries - .extend( - entries.into_iter().take( - MAX_RECENTLY_OPENED_ENTRIES - .saturating_sub(this.recently_opened_entries.len()), - ), - ); + let entries = Self::load_recently_opened_entries(cx).await; + this.update(cx, |this, cx| { + if let Some(entries) = entries.log_err() { + this.recently_opened_entries = entries; + } + + this.reload(cx); }) - .ok() + .ok(); }) .detach(); Self { - context_store, - recently_opened_entries: initial_recent_entries.into_iter().collect(), + text_thread_store, + recently_opened_entries: VecDeque::default(), + threads: Vec::default(), + entries: Vec::default(), _subscriptions: subscriptions, _save_recently_opened_entries_task: Task::ready(()), } } - pub fn entries(&self, cx: &mut Context) -> Vec { - let mut history_entries = Vec::new(); + pub fn thread_from_session_id(&self, session_id: &acp::SessionId) -> Option<&DbThreadMetadata> { + self.threads.iter().find(|thread| &thread.id == session_id) + } + pub fn load_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task>> { + let database_future = ThreadsDatabase::connect(cx); + cx.background_spawn(async move { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.load_thread(id).await + }) + } + + pub fn delete_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.delete_thread(id.clone()).await?; + this.update(cx, |this, cx| this.reload(cx)) + }) + } + + pub fn delete_threads(&mut self, cx: &mut Context) -> Task> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.delete_threads().await?; + this.update(cx, |this, cx| this.reload(cx)) + }) + } + + pub fn delete_text_thread( + &mut self, + path: Arc, + cx: &mut Context, + ) -> Task> { + self.text_thread_store + .update(cx, |store, cx| store.delete_local(path, cx)) + } + + pub fn load_text_thread( + &self, + path: Arc, + cx: &mut Context, + ) -> Task>> { + self.text_thread_store + .update(cx, |store, cx| store.open_local(path, cx)) + } + + pub fn reload(&self, cx: &mut Context) { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let threads = database_future + .await + .map_err(|err| anyhow!(err))? + .list_threads() + .await?; + + this.update(cx, |this, cx| { + if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES { + for thread in threads + .iter() + .take(MAX_RECENTLY_OPENED_ENTRIES - this.recently_opened_entries.len()) + .rev() + { + this.push_recently_opened_entry( + HistoryEntryId::AcpThread(thread.id.clone()), + cx, + ) + } + } + this.threads = threads; + this.update_entries(cx); + }) + }) + .detach_and_log_err(cx); + } + + fn update_entries(&mut self, cx: &mut Context) { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() { - return history_entries; + return; } - + let mut history_entries = Vec::new(); + history_entries.extend(self.threads.iter().cloned().map(HistoryEntry::AcpThread)); history_entries.extend( - self.context_store + self.text_thread_store .read(cx) - .unordered_contexts() + .unordered_text_threads() .cloned() - .map(HistoryEntry::Context), + .map(HistoryEntry::TextThread), ); history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at())); - history_entries + self.entries = history_entries; + cx.notify() } - pub fn recent_entries(&self, limit: usize, cx: &mut Context) -> Vec { - self.entries(cx).into_iter().take(limit).collect() + pub fn is_empty(&self, _cx: &App) -> bool { + self.entries.is_empty() } pub fn recently_opened_entries(&self, cx: &App) -> Vec { @@ -125,23 +274,36 @@ impl HistoryStore { return Vec::new(); } - let context_entries = - self.context_store - .read(cx) - .unordered_contexts() - .flat_map(|context| { - self.recently_opened_entries - .iter() - .enumerate() - .flat_map(|(index, entry)| match entry { - HistoryEntryId::Context(path) if &context.path == path => { - Some((index, HistoryEntry::Context(context.clone()))) - } - _ => None, - }) - }); + let thread_entries = self.threads.iter().flat_map(|thread| { + self.recently_opened_entries + .iter() + .enumerate() + .flat_map(|(index, entry)| match entry { + HistoryEntryId::AcpThread(id) if &thread.id == id => { + Some((index, HistoryEntry::AcpThread(thread.clone()))) + } + _ => None, + }) + }); - context_entries + let context_entries = self + .text_thread_store + .read(cx) + .unordered_text_threads() + .flat_map(|text_thread| { + self.recently_opened_entries + .iter() + .enumerate() + .flat_map(|(index, entry)| match entry { + HistoryEntryId::TextThread(path) if &text_thread.path == path => { + Some((index, HistoryEntry::TextThread(text_thread.clone()))) + } + _ => None, + }) + }); + + thread_entries + .chain(context_entries) // optimization to halt iteration early .take(self.recently_opened_entries.len()) .sorted_unstable_by_key(|(index, _)| *index) @@ -154,59 +316,52 @@ impl HistoryStore { .recently_opened_entries .iter() .filter_map(|entry| match entry { - HistoryEntryId::Context(path) => path.file_name().map(|file| { - SerializedRecentOpen::ContextName(file.to_string_lossy().into_owned()) + HistoryEntryId::TextThread(path) => path.file_name().map(|file| { + SerializedRecentOpen::TextThread(file.to_string_lossy().into_owned()) }), - HistoryEntryId::Thread(id) => Some(SerializedRecentOpen::Thread(id.to_string())), + HistoryEntryId::AcpThread(id) => { + Some(SerializedRecentOpen::AcpThread(id.to_string())) + } }) .collect::>(); self._save_recently_opened_entries_task = cx.spawn(async move |_, cx| { + let content = serde_json::to_string(&serialized_entries).unwrap(); cx.background_executor() .timer(SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE) .await; - cx.background_spawn(async move { - let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH); - let content = serde_json::to_string(&serialized_entries)?; - std::fs::write(path, content)?; - anyhow::Ok(()) - }) - .await - .log_err(); + + if cfg!(any(feature = "test-support", test)) { + return; + } + KEY_VALUE_STORE + .write_kvp(RECENTLY_OPENED_THREADS_KEY.to_owned(), content) + .await + .log_err(); }); } - fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { + fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { cx.background_spawn(async move { - let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH); - let contents = match smol::fs::read_to_string(path).await { - Ok(it) => it, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Ok(Vec::new()); - } - Err(e) => { - return Err(e) - .context("deserializing persisted agent panel navigation history"); - } - }; - let entries = serde_json::from_str::>(&contents) + if cfg!(any(feature = "test-support", test)) { + anyhow::bail!("history store does not persist in tests"); + } + let json = KEY_VALUE_STORE + .read_kvp(RECENTLY_OPENED_THREADS_KEY)? + .unwrap_or("[]".to_string()); + let entries = serde_json::from_str::>(&json) .context("deserializing persisted agent panel navigation history")? .into_iter() .take(MAX_RECENTLY_OPENED_ENTRIES) .flat_map(|entry| match entry { - SerializedRecentOpen::Thread(id) => { - Some(HistoryEntryId::Thread(id.as_str().into())) - } - SerializedRecentOpen::ContextName(file_name) => Some(HistoryEntryId::Context( - contexts_dir().join(file_name).into(), - )), - SerializedRecentOpen::Context(path) => { - Path::new(&path).file_name().map(|file_name| { - HistoryEntryId::Context(contexts_dir().join(file_name).into()) - }) + SerializedRecentOpen::AcpThread(id) => { + Some(HistoryEntryId::AcpThread(acp::SessionId::new(id.as_str()))) } + SerializedRecentOpen::TextThread(file_name) => Some( + HistoryEntryId::TextThread(text_threads_dir().join(file_name).into()), + ), }) - .collect::>(); + .collect(); Ok(entries) }) } @@ -220,9 +375,9 @@ impl HistoryStore { self.save_recently_opened_entries(cx); } - pub fn remove_recently_opened_thread(&mut self, id: ThreadId, cx: &mut Context) { + pub fn remove_recently_opened_thread(&mut self, id: acp::SessionId, cx: &mut Context) { self.recently_opened_entries.retain( - |entry| !matches!(entry, HistoryEntryId::Thread(thread_id) if thread_id == &id), + |entry| !matches!(entry, HistoryEntryId::AcpThread(thread_id) if thread_id == &id), ); self.save_recently_opened_entries(cx); } @@ -235,8 +390,8 @@ impl HistoryStore { ) { for entry in &mut self.recently_opened_entries { match entry { - HistoryEntryId::Context(path) if path.as_ref() == old_path => { - *entry = HistoryEntryId::Context(new_path.clone()); + HistoryEntryId::TextThread(path) if path.as_ref() == old_path => { + *entry = HistoryEntryId::TextThread(new_path.clone()); break; } _ => {} @@ -250,4 +405,8 @@ impl HistoryStore { .retain(|old_entry| old_entry != entry); self.save_recently_opened_entries(cx); } + + pub fn entries(&self) -> impl Iterator { + self.entries.iter().cloned() + } } diff --git a/crates/agent/src/legacy_thread.rs b/crates/agent/src/legacy_thread.rs new file mode 100644 index 0000000000..34babb8006 --- /dev/null +++ b/crates/agent/src/legacy_thread.rs @@ -0,0 +1,402 @@ +use crate::ProjectSnapshot; +use agent_settings::{AgentProfileId, CompletionMode}; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use gpui::SharedString; +use language_model::{LanguageModelToolResultContent, LanguageModelToolUseId, Role, TokenUsage}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub enum DetailedSummaryState { + #[default] + NotGenerated, + Generating, + Generated { + text: SharedString, + }, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] +pub struct MessageId(pub usize); + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct SerializedThread { + pub version: String, + pub summary: SharedString, + pub updated_at: DateTime, + pub messages: Vec, + #[serde(default)] + pub initial_project_snapshot: Option>, + #[serde(default)] + pub cumulative_token_usage: TokenUsage, + #[serde(default)] + pub request_token_usage: Vec, + #[serde(default)] + pub detailed_summary_state: DetailedSummaryState, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub completion_mode: Option, + #[serde(default)] + pub tool_use_limit_reached: bool, + #[serde(default)] + pub profile: Option, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct SerializedLanguageModel { + pub provider: String, + pub model: String, +} + +impl SerializedThread { + pub const VERSION: &'static str = "0.2.0"; + + pub fn from_json(json: &[u8]) -> Result { + let saved_thread_json = serde_json::from_slice::(json)?; + match saved_thread_json.get("version") { + Some(serde_json::Value::String(version)) => match version.as_str() { + SerializedThreadV0_1_0::VERSION => { + let saved_thread = + serde_json::from_value::(saved_thread_json)?; + Ok(saved_thread.upgrade()) + } + SerializedThread::VERSION => Ok(serde_json::from_value::( + saved_thread_json, + )?), + _ => anyhow::bail!("unrecognized serialized thread version: {version:?}"), + }, + None => { + let saved_thread = + serde_json::from_value::(saved_thread_json)?; + Ok(saved_thread.upgrade()) + } + version => anyhow::bail!("unrecognized serialized thread version: {version:?}"), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SerializedThreadV0_1_0( + // The structure did not change, so we are reusing the latest SerializedThread. + // When making the next version, make sure this points to SerializedThreadV0_2_0 + SerializedThread, +); + +impl SerializedThreadV0_1_0 { + pub const VERSION: &'static str = "0.1.0"; + + pub fn upgrade(self) -> SerializedThread { + debug_assert_eq!(SerializedThread::VERSION, "0.2.0"); + + let mut messages: Vec = Vec::with_capacity(self.0.messages.len()); + + for message in self.0.messages { + if message.role == Role::User + && !message.tool_results.is_empty() + && let Some(last_message) = messages.last_mut() + { + debug_assert!(last_message.role == Role::Assistant); + + last_message.tool_results = message.tool_results; + continue; + } + + messages.push(message); + } + + SerializedThread { + messages, + version: SerializedThread::VERSION.to_string(), + ..self.0 + } + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SerializedMessage { + pub id: MessageId, + pub role: Role, + #[serde(default)] + pub segments: Vec, + #[serde(default)] + pub tool_uses: Vec, + #[serde(default)] + pub tool_results: Vec, + #[serde(default)] + pub context: String, + #[serde(default)] + pub creases: Vec, + #[serde(default)] + pub is_hidden: bool, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type")] +pub enum SerializedMessageSegment { + #[serde(rename = "text")] + Text { + text: String, + }, + #[serde(rename = "thinking")] + Thinking { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + }, + RedactedThinking { + data: String, + }, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SerializedToolUse { + pub id: LanguageModelToolUseId, + pub name: SharedString, + pub input: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SerializedToolResult { + pub tool_use_id: LanguageModelToolUseId, + pub is_error: bool, + pub content: LanguageModelToolResultContent, + pub output: Option, +} + +#[derive(Serialize, Deserialize)] +struct LegacySerializedThread { + pub summary: SharedString, + pub updated_at: DateTime, + pub messages: Vec, + #[serde(default)] + pub initial_project_snapshot: Option>, +} + +impl LegacySerializedThread { + pub fn upgrade(self) -> SerializedThread { + SerializedThread { + version: SerializedThread::VERSION.to_string(), + summary: self.summary, + updated_at: self.updated_at, + messages: self.messages.into_iter().map(|msg| msg.upgrade()).collect(), + initial_project_snapshot: self.initial_project_snapshot, + cumulative_token_usage: TokenUsage::default(), + request_token_usage: Vec::new(), + detailed_summary_state: DetailedSummaryState::default(), + model: None, + completion_mode: None, + tool_use_limit_reached: false, + profile: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct LegacySerializedMessage { + pub id: MessageId, + pub role: Role, + pub text: String, + #[serde(default)] + pub tool_uses: Vec, + #[serde(default)] + pub tool_results: Vec, +} + +impl LegacySerializedMessage { + fn upgrade(self) -> SerializedMessage { + SerializedMessage { + id: self.id, + role: self.role, + segments: vec![SerializedMessageSegment::Text { text: self.text }], + tool_uses: self.tool_uses, + tool_results: self.tool_results, + context: String::new(), + creases: Vec::new(), + is_hidden: false, + } + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SerializedCrease { + pub start: usize, + pub end: usize, + pub icon_path: SharedString, + pub label: SharedString, +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use language_model::{Role, TokenUsage}; + use pretty_assertions::assert_eq; + + #[test] + fn test_legacy_serialized_thread_upgrade() { + let updated_at = Utc::now(); + let legacy_thread = LegacySerializedThread { + summary: "Test conversation".into(), + updated_at, + messages: vec![LegacySerializedMessage { + id: MessageId(1), + role: Role::User, + text: "Hello, world!".to_string(), + tool_uses: vec![], + tool_results: vec![], + }], + initial_project_snapshot: None, + }; + + let upgraded = legacy_thread.upgrade(); + + assert_eq!( + upgraded, + SerializedThread { + summary: "Test conversation".into(), + updated_at, + messages: vec![SerializedMessage { + id: MessageId(1), + role: Role::User, + segments: vec![SerializedMessageSegment::Text { + text: "Hello, world!".to_string() + }], + tool_uses: vec![], + tool_results: vec![], + context: "".to_string(), + creases: vec![], + is_hidden: false + }], + version: SerializedThread::VERSION.to_string(), + initial_project_snapshot: None, + cumulative_token_usage: TokenUsage::default(), + request_token_usage: vec![], + detailed_summary_state: DetailedSummaryState::default(), + model: None, + completion_mode: None, + tool_use_limit_reached: false, + profile: None + } + ) + } + + #[test] + fn test_serialized_threadv0_1_0_upgrade() { + let updated_at = Utc::now(); + let thread_v0_1_0 = SerializedThreadV0_1_0(SerializedThread { + summary: "Test conversation".into(), + updated_at, + messages: vec![ + SerializedMessage { + id: MessageId(1), + role: Role::User, + segments: vec![SerializedMessageSegment::Text { + text: "Use tool_1".to_string(), + }], + tool_uses: vec![], + tool_results: vec![], + context: "".to_string(), + creases: vec![], + is_hidden: false, + }, + SerializedMessage { + id: MessageId(2), + role: Role::Assistant, + segments: vec![SerializedMessageSegment::Text { + text: "I want to use a tool".to_string(), + }], + tool_uses: vec![SerializedToolUse { + id: "abc".into(), + name: "tool_1".into(), + input: serde_json::Value::Null, + }], + tool_results: vec![], + context: "".to_string(), + creases: vec![], + is_hidden: false, + }, + SerializedMessage { + id: MessageId(1), + role: Role::User, + segments: vec![SerializedMessageSegment::Text { + text: "Here is the tool result".to_string(), + }], + tool_uses: vec![], + tool_results: vec![SerializedToolResult { + tool_use_id: "abc".into(), + is_error: false, + content: LanguageModelToolResultContent::Text("abcdef".into()), + output: Some(serde_json::Value::Null), + }], + context: "".to_string(), + creases: vec![], + is_hidden: false, + }, + ], + version: SerializedThreadV0_1_0::VERSION.to_string(), + initial_project_snapshot: None, + cumulative_token_usage: TokenUsage::default(), + request_token_usage: vec![], + detailed_summary_state: DetailedSummaryState::default(), + model: None, + completion_mode: None, + tool_use_limit_reached: false, + profile: None, + }); + let upgraded = thread_v0_1_0.upgrade(); + + assert_eq!( + upgraded, + SerializedThread { + summary: "Test conversation".into(), + updated_at, + messages: vec![ + SerializedMessage { + id: MessageId(1), + role: Role::User, + segments: vec![SerializedMessageSegment::Text { + text: "Use tool_1".to_string() + }], + tool_uses: vec![], + tool_results: vec![], + context: "".to_string(), + creases: vec![], + is_hidden: false + }, + SerializedMessage { + id: MessageId(2), + role: Role::Assistant, + segments: vec![SerializedMessageSegment::Text { + text: "I want to use a tool".to_string(), + }], + tool_uses: vec![SerializedToolUse { + id: "abc".into(), + name: "tool_1".into(), + input: serde_json::Value::Null, + }], + tool_results: vec![SerializedToolResult { + tool_use_id: "abc".into(), + is_error: false, + content: LanguageModelToolResultContent::Text("abcdef".into()), + output: Some(serde_json::Value::Null), + }], + context: "".to_string(), + creases: vec![], + is_hidden: false, + }, + ], + version: SerializedThread::VERSION.to_string(), + initial_project_snapshot: None, + cumulative_token_usage: TokenUsage::default(), + request_token_usage: vec![], + detailed_summary_state: DetailedSummaryState::default(), + model: None, + completion_mode: None, + tool_use_limit_reached: false, + profile: None + } + ) + } +} diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs similarity index 91% rename from crates/agent2/src/native_agent_server.rs rename to crates/agent/src/native_agent_server.rs index 0dde0ff985..a9ade8141a 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -21,10 +21,6 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn telemetry_id(&self) -> &'static str { - "zed" - } - fn name(&self) -> SharedString { "Zed Agent".into() } @@ -81,15 +77,13 @@ impl AgentServer for NativeAgentServer { mod tests { use super::*; - use assistant_context::ContextStore; + use assistant_text_thread::TextThreadStore; use gpui::AppContext; agent_servers::e2e_tests::common_e2e_tests!( async |fs, project, cx| { let auth = cx.update(|cx| { prompt_store::init(cx); - terminal::init(cx); - let registry = language_model::LanguageModelRegistry::read_global(cx); let auth = registry .provider(&language_model::ANTHROPIC_PROVIDER_ID) @@ -116,8 +110,9 @@ mod tests { }); let history = cx.update(|cx| { - let context_store = cx.new(move |cx| ContextStore::fake(project.clone(), cx)); - cx.new(move |cx| HistoryStore::new(context_store, cx)) + let text_thread_store = + cx.new(move |cx| TextThreadStore::fake(project.clone(), cx)); + cx.new(move |cx| HistoryStore::new(text_thread_store, cx)) }); NativeAgentServer::new(fs.clone(), history) diff --git a/crates/assistant_tool/src/outline.rs b/crates/agent/src/outline.rs similarity index 57% rename from crates/assistant_tool/src/outline.rs rename to crates/agent/src/outline.rs index 4c8e2efefd..77af4849ff 100644 --- a/crates/assistant_tool/src/outline.rs +++ b/crates/agent/src/outline.rs @@ -1,8 +1,6 @@ -use action_log::ActionLog; -use anyhow::{Context as _, Result}; +use anyhow::Result; use gpui::{AsyncApp, Entity}; -use language::{Buffer, OutlineItem, ParseStatus}; -use project::Project; +use language::{Buffer, OutlineItem}; use regex::Regex; use std::fmt::Write; use text::Point; @@ -11,51 +9,82 @@ use text::Point; /// we automatically provide the file's symbol outline instead, with line numbers. pub const AUTO_OUTLINE_SIZE: usize = 16384; -pub async fn file_outline( - project: Entity, - path: String, - action_log: Entity, - regex: Option, - cx: &mut AsyncApp, -) -> anyhow::Result { - let buffer = { - let project_path = project.read_with(cx, |project, cx| { - project - .find_project_path(&path, cx) - .with_context(|| format!("Path {path} not found in project")) - })??; - - project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? - .await? - }; - - action_log.update(cx, |action_log, cx| { - action_log.buffer_read(buffer.clone(), cx); - })?; - - // Wait until the buffer has been fully parsed, so that we can read its outline. - let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; - } - - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - let outline = snapshot.outline(None); - - render_outline( - outline - .items - .into_iter() - .map(|item| item.to_point(&snapshot)), - regex, - 0, - usize::MAX, - ) - .await +/// Result of getting buffer content, which can be either full content or an outline. +pub struct BufferContent { + /// The actual content (either full text or outline) + pub text: String, + /// Whether this is an outline (true) or full content (false) + pub is_outline: bool, } -pub async fn render_outline( +/// Returns either the full content of a buffer or its outline, depending on size. +/// For files larger than AUTO_OUTLINE_SIZE, returns an outline with a header. +/// For smaller files, returns the full content. +pub async fn get_buffer_content_or_outline( + buffer: Entity, + path: Option<&str>, + cx: &AsyncApp, +) -> Result { + let file_size = buffer.read_with(cx, |buffer, _| buffer.text().len())?; + + if file_size > AUTO_OUTLINE_SIZE { + // For large files, use outline instead of full content + // Wait until the buffer has been fully parsed, so we can read its outline + buffer + .read_with(cx, |buffer, _| buffer.parsing_idle())? + .await; + + let outline_items = buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot + .outline(None) + .items + .into_iter() + .map(|item| item.to_point(&snapshot)) + .collect::>() + })?; + + // If no outline exists, fall back to first 1KB so the agent has some context + if outline_items.is_empty() { + let text = buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + let len = snapshot.len().min(snapshot.as_rope().floor_char_boundary(1024)); + let content = snapshot.text_for_range(0..len).collect::(); + if let Some(path) = path { + format!("# First 1KB of {path} (file too large to show full content, and no outline available)\n\n{content}") + } else { + format!("# First 1KB of file (file too large to show full content, and no outline available)\n\n{content}") + } + })?; + + return Ok(BufferContent { + text, + is_outline: false, + }); + } + + let outline_text = render_outline(outline_items, None, 0, usize::MAX).await?; + + let text = if let Some(path) = path { + format!("# File outline for {path}\n\n{outline_text}",) + } else { + format!("# File outline\n\n{outline_text}",) + }; + Ok(BufferContent { + text, + is_outline: true, + }) + } else { + // File is small enough, return full content + let text = buffer.read_with(cx, |buffer, _| buffer.text())?; + Ok(BufferContent { + text, + is_outline: false, + }) + } +} + +async fn render_outline( items: impl IntoIterator>, regex: Option, offset: usize, @@ -129,61 +158,61 @@ fn render_entries( entries_rendered } -/// Result of getting buffer content, which can be either full content or an outline. -pub struct BufferContent { - /// The actual content (either full text or outline) - pub text: String, - /// Whether this is an outline (true) or full content (false) - pub is_outline: bool, -} +#[cfg(test)] +mod tests { + use super::*; + use fs::FakeFs; + use gpui::TestAppContext; + use project::Project; + use settings::SettingsStore; -/// Returns either the full content of a buffer or its outline, depending on size. -/// For files larger than AUTO_OUTLINE_SIZE, returns an outline with a header. -/// For smaller files, returns the full content. -pub async fn get_buffer_content_or_outline( - buffer: Entity, - path: Option<&str>, - cx: &AsyncApp, -) -> Result { - let file_size = buffer.read_with(cx, |buffer, _| buffer.text().len())?; + #[gpui::test] + async fn test_large_file_fallback_to_subset(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + }); - if file_size > AUTO_OUTLINE_SIZE { - // For large files, use outline instead of full content - // Wait until the buffer has been fully parsed, so we can read its outline - let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; - } + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; - let outline_items = buffer.read_with(cx, |buffer, _| { - let snapshot = buffer.snapshot(); - snapshot - .outline(None) - .items - .into_iter() - .map(|item| item.to_point(&snapshot)) - .collect::>() - })?; + let content = "⚡".repeat(100 * 1024); // 100KB + let content_len = content.len(); + let buffer = project + .update(cx, |project, cx| project.create_buffer(true, cx)) + .await + .expect("failed to create buffer"); - let outline_text = render_outline(outline_items, None, 0, usize::MAX).await?; + buffer.update(cx, |buffer, cx| buffer.set_text(content, cx)); - let text = if let Some(path) = path { - format!( - "# File outline for {path} (file too large to show full content)\n\n{outline_text}", - ) - } else { - format!("# File outline (file too large to show full content)\n\n{outline_text}",) - }; - Ok(BufferContent { - text, - is_outline: true, - }) - } else { - // File is small enough, return full content - let text = buffer.read_with(cx, |buffer, _| buffer.text())?; - Ok(BufferContent { - text, - is_outline: false, - }) + let result = cx + .spawn(|cx| async move { get_buffer_content_or_outline(buffer, None, &cx).await }) + .await + .unwrap(); + + // Should contain some of the actual file content + assert!( + result.text.contains("⚡⚡⚡⚡⚡⚡⚡"), + "Result did not contain content subset" + ); + + // Should be marked as not an outline (it's truncated content) + assert!( + !result.is_outline, + "Large file without outline should not be marked as outline" + ); + + // Should be reasonably sized (much smaller than original) + assert!( + result.text.len() < 50 * 1024, + "Result size {} should be smaller than 50KB", + result.text.len() + ); + + // Should be significantly smaller than the original content + assert!( + result.text.len() < content_len / 10, + "Result should be much smaller than original content" + ); } } diff --git a/crates/agent/src/prompts/stale_files_prompt_header.txt b/crates/agent/src/prompts/stale_files_prompt_header.txt deleted file mode 100644 index f743e239c8..0000000000 --- a/crates/agent/src/prompts/stale_files_prompt_header.txt +++ /dev/null @@ -1,3 +0,0 @@ -[The following is an auto-generated notification; do not reply] - -These files have changed since the last read: diff --git a/crates/agent2/src/templates.rs b/crates/agent/src/templates.rs similarity index 94% rename from crates/agent2/src/templates.rs rename to crates/agent/src/templates.rs index 72a8f6633c..db787d834e 100644 --- a/crates/agent2/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -38,6 +38,7 @@ pub struct SystemPromptTemplate<'a> { #[serde(flatten)] pub project: &'a prompt_store::ProjectContext, pub available_tools: Vec, + pub model_name: Option, } impl Template for SystemPromptTemplate<'_> { @@ -79,9 +80,11 @@ mod tests { let template = SystemPromptTemplate { project: &project, available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); assert!(rendered.contains("## Fixing Diagnostics")); + assert!(rendered.contains("test-model")); } } diff --git a/crates/assistant_tools/src/templates/create_file_prompt.hbs b/crates/agent/src/templates/create_file_prompt.hbs similarity index 100% rename from crates/assistant_tools/src/templates/create_file_prompt.hbs rename to crates/agent/src/templates/create_file_prompt.hbs diff --git a/crates/assistant_tools/src/templates/diff_judge.hbs b/crates/agent/src/templates/diff_judge.hbs similarity index 100% rename from crates/assistant_tools/src/templates/diff_judge.hbs rename to crates/agent/src/templates/diff_judge.hbs diff --git a/crates/assistant_tools/src/templates/edit_file_prompt_diff_fenced.hbs b/crates/agent/src/templates/edit_file_prompt_diff_fenced.hbs similarity index 100% rename from crates/assistant_tools/src/templates/edit_file_prompt_diff_fenced.hbs rename to crates/agent/src/templates/edit_file_prompt_diff_fenced.hbs diff --git a/crates/assistant_tools/src/templates/edit_file_prompt_xml.hbs b/crates/agent/src/templates/edit_file_prompt_xml.hbs similarity index 100% rename from crates/assistant_tools/src/templates/edit_file_prompt_xml.hbs rename to crates/agent/src/templates/edit_file_prompt_xml.hbs diff --git a/crates/agent2/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs similarity index 94% rename from crates/agent2/src/templates/system_prompt.hbs rename to crates/agent/src/templates/system_prompt.hbs index ca324fad7a..2477e46a85 100644 --- a/crates/agent2/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -16,7 +16,7 @@ You are a highly skilled software engineer with extensive knowledge in many prog 3. DO NOT use tools to access items that are already available in the context section. 4. Use only the tools that are currently available. 5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off. -6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers. +6. When running commands that may run indefinitely or for a long time (such as build scripts, tests, servers, or file watchers), specify `timeout_ms` to bound runtime. If the command times out, the user can always ask you to run it again with a longer timeout or no timeout if they're willing to wait or cancel manually. 7. Avoid HTML entity escaping - use plain characters instead. ## Searching and Reading @@ -150,6 +150,12 @@ Otherwise, follow debugging best practices: Operating System: {{os}} Default Shell: {{shell}} +{{#if model_name}} +## Model Information + +You are powered by the model named {{model_name}}. + +{{/if}} {{#if (or has_rules has_user_rules)}} ## User's Custom Instructions diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent/src/tests/mod.rs similarity index 85% rename from crates/agent2/src/tests/mod.rs rename to crates/agent/src/tests/mod.rs index 2e63aa5856..5a581c5db8 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -9,14 +9,16 @@ use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; use futures::{ - StreamExt, + FutureExt as _, StreamExt, channel::{ mpsc::{self, UnboundedReceiver}, oneshot, }, + future::{Fuse, Shared}, }; use gpui::{ - App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, + App, AppContext, AsyncApp, Entity, Task, TestAppContext, UpdateGlobal, + http_client::FakeHttpClient, }; use indoc::indoc; use language_model::{ @@ -35,12 +37,109 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; use settings::{Settings, SettingsStore}; -use std::{path::Path, rc::Rc, sync::Arc, time::Duration}; +use std::{ + path::Path, + pin::Pin, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use util::path; mod test_tools; use test_tools::*; +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); +} + +struct FakeTerminalHandle { + killed: Arc, + wait_for_exit: Shared>, + output: acp::TerminalOutputResponse, + id: acp::TerminalId, +} + +impl FakeTerminalHandle { + fn new_never_exits(cx: &mut App) -> Self { + let killed = Arc::new(AtomicBool::new(false)); + + let killed_for_task = killed.clone(); + let wait_for_exit = cx + .spawn(async move |cx| { + loop { + if killed_for_task.load(Ordering::SeqCst) { + return acp::TerminalExitStatus::new(); + } + cx.background_executor() + .timer(Duration::from_millis(1)) + .await; + } + }) + .shared(); + + Self { + killed, + wait_for_exit, + output: acp::TerminalOutputResponse::new("partial output".to_string(), false), + id: acp::TerminalId::new("fake_terminal".to_string()), + } + } + + fn was_killed(&self) -> bool { + self.killed.load(Ordering::SeqCst) + } +} + +impl crate::TerminalHandle for FakeTerminalHandle { + fn id(&self, _cx: &AsyncApp) -> Result { + Ok(self.id.clone()) + } + + fn current_output(&self, _cx: &AsyncApp) -> Result { + Ok(self.output.clone()) + } + + fn wait_for_exit(&self, _cx: &AsyncApp) -> Result>> { + Ok(self.wait_for_exit.clone()) + } + + fn kill(&self, _cx: &AsyncApp) -> Result<()> { + self.killed.store(true, Ordering::SeqCst); + Ok(()) + } +} + +struct FakeThreadEnvironment { + handle: Rc, +} + +impl crate::ThreadEnvironment for FakeThreadEnvironment { + fn create_terminal( + &self, + _command: String, + _cwd: Option, + _output_byte_limit: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + Task::ready(Ok(self.handle.clone() as Rc)) + } +} + +fn always_allow_tools(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); +} + #[gpui::test] async fn test_echo(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -71,6 +170,120 @@ async fn test_echo(cx: &mut TestAppContext) { assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); } +#[gpui::test] +async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: Some(5), + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + let mut task_future: Pin>>>> = Box::pin(task.fuse()); + + let deadline = std::time::Instant::now() + Duration::from_millis(500); + loop { + if let Some(result) = task_future.as_mut().now_or_never() { + let result = result.expect("terminal tool task should complete"); + + assert!( + handle.was_killed(), + "expected terminal handle to be killed on timeout" + ); + assert!( + result.contains("partial output"), + "expected result to include terminal output, got: {result}" + ); + return; + } + + if std::time::Instant::now() >= deadline { + panic!("timed out waiting for terminal tool task to complete"); + } + + cx.run_until_parked(); + cx.background_executor.timer(Duration::from_millis(1)).await; + } +} + +#[gpui::test] +#[ignore] +async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let _task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: None, + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + smol::Timer::after(Duration::from_millis(25)).await; + + assert!( + !handle.was_killed(), + "did not expect terminal handle to be killed without a timeout" + ); +} + #[gpui::test] async fn test_thinking(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -160,6 +373,42 @@ async fn test_system_prompt(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_system_prompt_without_tools(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); + cx.run_until_parked(); + let mut pending_completions = fake_model.pending_completions(); + assert_eq!( + pending_completions.len(), + 1, + "unexpected pending completions: {:?}", + pending_completions + ); + + let pending_completion = pending_completions.pop().unwrap(); + assert_eq!(pending_completion.messages[0].role, Role::System); + + let system_message = &pending_completion.messages[0]; + let system_prompt = system_message.content[0].to_str().unwrap(); + assert!( + !system_prompt.contains("## Tool Use"), + "unexpected system message: {:?}", + system_message + ); + assert!( + !system_prompt.contains("## Fixing Diagnostics"), + "unexpected system message: {:?}", + system_message + ); +} + #[gpui::test] async fn test_prompt_caching(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -179,7 +428,8 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { vec![LanguageModelRequestMessage { role: Role::User, content: vec!["Message 1".into()], - cache: true + cache: true, + reasoning_details: None, }] ); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text( @@ -203,17 +453,20 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["Message 1".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec!["Response to Message 1".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["Message 2".into()], - cache: true + cache: true, + reasoning_details: None, } ] ); @@ -238,6 +491,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { raw_input: json!({"text": "test"}).to_string(), input: json!({"text": "test"}), is_input_complete: true, + thought_signature: None, }; fake_model .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); @@ -258,37 +512,44 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["Message 1".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec!["Response to Message 1".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["Message 2".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec!["Response to Message 2".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["Use the echo tool".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::ToolResult(tool_result)], - cache: true + cache: true, + reasoning_details: None, } ] ); @@ -425,6 +686,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { raw_input: "{}".into(), input: json!({}), is_input_complete: true, + thought_signature: None, }, )); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( @@ -434,6 +696,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { raw_input: "{}".into(), input: json!({}), is_input_complete: true, + thought_signature: None, }, )); fake_model.end_last_completion_stream(); @@ -443,14 +706,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { // Approve the first tool_call_auth_1 .response - .send(tool_call_auth_1.options[1].id.clone()) + .send(tool_call_auth_1.options[1].option_id.clone()) .unwrap(); cx.run_until_parked(); // Reject the second tool_call_auth_2 .response - .send(tool_call_auth_1.options[2].id.clone()) + .send(tool_call_auth_1.options[2].option_id.clone()) .unwrap(); cx.run_until_parked(); @@ -460,14 +723,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { message.content, vec![ language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_1.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), output: Some("Allowed".into()) }), language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_2.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: true, content: "Permission to run tool denied by user".into(), @@ -484,6 +747,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { raw_input: "{}".into(), input: json!({}), is_input_complete: true, + thought_signature: None, }, )); fake_model.end_last_completion_stream(); @@ -492,7 +756,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; tool_call_auth_3 .response - .send(tool_call_auth_3.options[0].id.clone()) + .send(tool_call_auth_3.options[0].option_id.clone()) .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -501,7 +765,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { message.content, vec![language_model::MessageContent::ToolResult( LanguageModelToolResult { - tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_3.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), @@ -518,6 +782,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { raw_input: "{}".into(), input: json!({}), is_input_complete: true, + thought_signature: None, }, )); fake_model.end_last_completion_stream(); @@ -556,6 +821,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { raw_input: "{}".into(), input: json!({}), is_input_complete: true, + thought_signature: None, }, )); fake_model.end_last_completion_stream(); @@ -585,6 +851,7 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { raw_input: "{}".into(), input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(), is_input_complete: true, + thought_signature: None, }; fake_model .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); @@ -605,25 +872,26 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["abc".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use.clone())], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::ToolResult(tool_result.clone())], - cache: true + cache: true, + reasoning_details: None, }, ] ); // Simulate reaching tool use limit. - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate( - cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached, - )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUseLimitReached); fake_model.end_last_completion_stream(); let last_event = events.collect::>().await.pop().unwrap(); assert!( @@ -641,22 +909,26 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["abc".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::ToolResult(tool_result)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["Continue where you left off".into()], - cache: true + cache: true, + reasoning_details: None, } ] ); @@ -695,6 +967,7 @@ async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { raw_input: "{}".into(), input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(), is_input_complete: true, + thought_signature: None, }; let tool_result = LanguageModelToolResult { tool_use_id: "tool_id_1".into(), @@ -705,9 +978,7 @@ async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { }; fake_model .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate( - cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached, - )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUseLimitReached); fake_model.end_last_completion_stream(); let last_event = events.collect::>().await.pop().unwrap(); assert!( @@ -729,22 +1000,26 @@ async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["abc".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::ToolResult(tool_result)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["ghi".into()], - cache: true + cache: true, + reasoning_details: None, } ] ); @@ -897,7 +1172,7 @@ async fn test_profiles(cx: &mut TestAppContext) { // Test that test-1 profile (default) has echo and delay tools thread .update(cx, |thread, cx| { - thread.set_profile(AgentProfileId("test-1".into())); + thread.set_profile(AgentProfileId("test-1".into()), cx); thread.send(UserMessageId::new(), ["test"], cx) }) .unwrap(); @@ -917,7 +1192,7 @@ async fn test_profiles(cx: &mut TestAppContext) { // Switch to test-2 profile, and verify that it has only the infinite tool. thread .update(cx, |thread, cx| { - thread.set_profile(AgentProfileId("test-2".into())); + thread.set_profile(AgentProfileId("test-2".into()), cx); thread.send(UserMessageId::new(), ["test2"], cx) }) .unwrap(); @@ -966,8 +1241,8 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { ) .await; cx.run_until_parked(); - thread.update(cx, |thread, _| { - thread.set_profile(AgentProfileId("test".into())) + thread.update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test".into()), cx) }); let mut mcp_tool_calls = setup_context_server( @@ -975,9 +1250,9 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { vec![context_server::types::Tool { name: "echo".into(), description: None, - input_schema: serde_json::to_value( - EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema), - ) + input_schema: serde_json::to_value(EchoTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + )) .unwrap(), output_schema: None, annotations: None, @@ -1001,6 +1276,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { raw_input: json!({"text": "test"}).to_string(), input: json!({"text": "test"}), is_input_complete: true, + thought_signature: None, }, )); fake_model.end_last_completion_stream(); @@ -1044,6 +1320,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { raw_input: json!({"text": "mcp"}).to_string(), input: json!({"text": "mcp"}), is_input_complete: true, + thought_signature: None, }, )); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( @@ -1053,6 +1330,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { raw_input: json!({"text": "native"}).to_string(), input: json!({"text": "native"}), is_input_complete: true, + thought_signature: None, }, )); fake_model.end_last_completion_stream(); @@ -1133,8 +1411,8 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { .await; cx.run_until_parked(); - thread.update(cx, |thread, _| { - thread.set_profile(AgentProfileId("test".into())); + thread.update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test".into()), cx); thread.add_tool(EchoTool); thread.add_tool(DelayTool); thread.add_tool(WordListTool); @@ -1149,9 +1427,9 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { context_server::types::Tool { name: "echo".into(), // Conflicts with native EchoTool description: None, - input_schema: serde_json::to_value( - EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema), - ) + input_schema: serde_json::to_value(EchoTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + )) .unwrap(), output_schema: None, annotations: None, @@ -1174,9 +1452,9 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { context_server::types::Tool { name: "echo".into(), // Also conflicts with native EchoTool description: None, - input_schema: serde_json::to_value( - EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema), - ) + input_schema: serde_json::to_value(EchoTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + )) .unwrap(), output_schema: None, annotations: None, @@ -1288,20 +1566,20 @@ async fn test_cancellation(cx: &mut TestAppContext) { ThreadEvent::ToolCall(tool_call) => { assert_eq!(tool_call.title, expected_tools.remove(0)); if tool_call.title == "Echo" { - echo_id = Some(tool_call.id); + echo_id = Some(tool_call.tool_call_id); } } ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( acp::ToolCallUpdate { - id, + tool_call_id, fields: acp::ToolCallUpdateFields { status: Some(acp::ToolCallStatus::Completed), .. }, - meta: None, + .. }, - )) if Some(&id) == echo_id.as_ref() => { + )) if Some(&tool_call_id) == echo_id.as_ref() => { echo_completed = true; } _ => {} @@ -1752,6 +2030,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { raw_input: "{}".into(), input: json!({}), is_input_complete: true, + thought_signature: None, }; let echo_tool_use = LanguageModelToolUse { id: "tool_id_2".into(), @@ -1759,6 +2038,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { raw_input: json!({"text": "test"}).to_string(), input: json!({"text": "test"}), is_input_complete: true, + thought_signature: None, }; fake_model.send_last_completion_stream_text_chunk("Hi!"); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( @@ -1782,7 +2062,8 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["Hey!".into()], - cache: true + cache: true, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, @@ -1790,7 +2071,8 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { MessageContent::Text("Hi!".into()), MessageContent::ToolUse(echo_tool_use.clone()) ], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, @@ -1801,7 +2083,8 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { content: "test".into(), output: Some("test".into()) })], - cache: false + cache: false, + reasoning_details: None, }, ], ); @@ -1815,7 +2098,6 @@ async fn test_agent_connection(cx: &mut TestAppContext) { // Initialize language model system with test provider cx.update(|cx| { gpui_tokio::init(cx); - client::init_settings(cx); let http_client = FakeHttpClient::with_404_response(); let clock = Arc::new(clock::FakeSystemClock::new()); @@ -1823,9 +2105,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); language_model::init(client.clone(), cx); language_models::init(user_store, client.clone(), cx); - Project::init_settings(cx); LanguageModelRegistry::test(cx); - agent_settings::init(cx); }); cx.executor().forbid_parking(); @@ -1834,8 +2114,9 @@ async fn test_agent_connection(cx: &mut TestAppContext) { fake_fs.insert_tree(path!("/test"), json!({})).await; let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await; let cwd = Path::new("/test"); - let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); // Create agent and connection let agent = NativeAgent::new( @@ -1864,7 +2145,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let selector_opt = connection.model_selector(&session_id); assert!( selector_opt.is_some(), - "agent2 should always support ModelSelector" + "agent should always support ModelSelector" ); let selector = selector_opt.unwrap(); @@ -1927,11 +2208,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { .update(|cx| { connection.prompt( Some(acp_thread::UserMessageId::new()), - acp::PromptRequest { - session_id: session_id.clone(), - prompt: vec!["ghi".into()], - meta: None, - }, + acp::PromptRequest::new(session_id.clone(), vec!["ghi".into()]), cx, ) }) @@ -1966,6 +2243,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { raw_input: input.to_string(), input, is_input_complete: false, + thought_signature: None, }, )); @@ -1978,6 +2256,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { raw_input: input.to_string(), input, is_input_complete: true, + thought_signature: None, }, )); fake_model.end_last_completion_stream(); @@ -1986,68 +2265,50 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let tool_call = expect_tool_call(&mut events).await; assert_eq!( tool_call, - acp::ToolCall { - id: acp::ToolCallId("1".into()), - title: "Thinking".into(), - kind: acp::ToolKind::Think, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(json!({})), - raw_output: None, - meta: None, - } + acp::ToolCall::new("1", "Thinking") + .kind(acp::ToolKind::Think) + .raw_input(json!({})) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + "thinking".into() + )])) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - title: Some("Thinking".into()), - kind: Some(acp::ToolKind::Think), - raw_input: Some(json!({ "content": "Thinking hard!" })), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new() + .title("Thinking") + .kind(acp::ToolKind::Think) + .raw_input(json!({ "content": "Thinking hard!"})) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - content: Some(vec!["Thinking hard!".into()]), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new().content(vec!["Thinking hard!".into()]) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - raw_output: Some("Finished thinking.".into()), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .raw_output("Finished thinking.") + ) ); } @@ -2180,6 +2441,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { raw_input: json!({"text": "test"}).to_string(), input: json!({"text": "test"}), is_input_complete: true, + thought_signature: None, }; fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( tool_use_1.clone(), @@ -2198,12 +2460,14 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["Call the echo tool!".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![language_model::MessageContent::ToolUse(tool_use_1.clone())], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, @@ -2216,7 +2480,8 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { output: Some("test".into()) } )], - cache: true + cache: true, + reasoning_details: None, }, ] ); @@ -2230,7 +2495,8 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { thread.last_message(), Some(Message::Agent(AgentMessage { content: vec![AgentMessageContent::Text("Done".into())], - tool_results: IndexMap::default() + tool_results: IndexMap::default(), + reasoning_details: None, })) ); }) @@ -2358,8 +2624,6 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { cx.update(|cx| { settings::init(cx); - Project::init_settings(cx); - agent_settings::init(cx); match model { TestModel::Fake => {} @@ -2367,7 +2631,6 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { gpui_tokio::init(cx); let http_client = ReqwestClient::user_agent("agent tests").unwrap(); cx.set_http_client(Arc::new(http_client)); - client::init_settings(cx); let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); language_model::init(client.clone(), cx); @@ -2481,7 +2744,7 @@ fn setup_context_server( let mut settings = ProjectSettings::get_global(cx).clone(); settings.context_servers.insert( name.into(), - project::project_settings::ContextServerSettings::Custom { + project::project_settings::ContextServerSettings::Stdio { enabled: true, command: ContextServerCommand { path: "somebinary".into(), diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent/src/tests/test_tools.rs similarity index 100% rename from crates/agent2/src/tests/test_tools.rs rename to crates/agent/src/tests/test_tools.rs diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 9891f66adf..dbf29c6876 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,95 +1,61 @@ use crate::{ - agent_profile::AgentProfile, - context::{AgentContext, AgentContextHandle, ContextLoadResult, LoadedContext}, - thread_store::{ - SerializedCrease, SerializedLanguageModel, SerializedMessage, SerializedMessageSegment, - SerializedThread, SerializedToolResult, SerializedToolUse, SharedProjectContext, - ThreadStore, - }, - tool_use::{PendingToolUse, ToolUse, ToolUseMetadata, ToolUseState}, + ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, + DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, + ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, + SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool, }; +use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; + +use agent_client_protocol as acp; use agent_settings::{ - AgentProfileId, AgentSettings, CompletionMode, SUMMARIZE_THREAD_DETAILED_PROMPT, - SUMMARIZE_THREAD_PROMPT, + AgentProfileId, AgentProfileSettings, AgentSettings, CompletionMode, + SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, }; -use anyhow::{Result, anyhow}; -use assistant_tool::{AnyToolCard, Tool, ToolWorkingSet}; +use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; -use client::{ModelRequestUsage, RequestUsage}; -use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, Plan, UsageLimit}; -use collections::HashMap; -use futures::{FutureExt, StreamExt as _, future::Shared}; -use git::repository::DiffType; +use client::{ModelRequestUsage, RequestUsage, UserStore}; +use cloud_llm_client::{CompletionIntent, Plan, UsageLimit}; +use collections::{HashMap, HashSet, IndexMap}; +use fs::Fs; +use futures::stream; +use futures::{ + FutureExt, + channel::{mpsc, oneshot}, + future::Shared, + stream::FuturesUnordered, +}; use gpui::{ - AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, - WeakEntity, Window, + App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, }; -use http_client::StatusCode; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelExt as _, LanguageModelId, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, - LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, - ModelRequestLimitReachedError, PaymentRequiredError, Role, SelectedModel, StopReason, - TokenUsage, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelExt, + LanguageModelId, LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, + LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, + LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, + LanguageModelToolUse, LanguageModelToolUseId, Role, SelectedModel, StopReason, TokenUsage, + ZED_CLOUD_PROVIDER_ID, }; -use postage::stream::Stream as _; -use project::{ - Project, - git_store::{GitStore, GitStoreCheckpoint, RepositoryState}, -}; -use prompt_store::{ModelContext, PromptBuilder}; -use schemars::JsonSchema; +use project::Project; +use prompt_store::ProjectContext; +use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; -use settings::Settings; +use settings::{LanguageModelSelection, Settings, update_settings_file}; +use smol::stream::StreamExt; use std::{ - io::Write, - ops::Range, + collections::BTreeMap, + ops::RangeInclusive, + path::Path, + rc::Rc, sync::Arc, time::{Duration, Instant}, }; -use thiserror::Error; -use util::{ResultExt as _, post_inc}; +use std::{fmt::Write, path::PathBuf}; +use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock, paths::PathStyle}; use uuid::Uuid; -const MAX_RETRY_ATTEMPTS: u8 = 4; -const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); - -#[derive(Debug, Clone)] -enum RetryStrategy { - ExponentialBackoff { - initial_delay: Duration, - max_attempts: u8, - }, - Fixed { - delay: Duration, - max_attempts: u8, - }, -} - -#[derive( - Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize, JsonSchema, -)] -pub struct ThreadId(Arc); - -impl ThreadId { - pub fn new() -> Self { - Self(Uuid::new_v4().to_string().into()) - } -} - -impl std::fmt::Display for ThreadId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From<&str> for ThreadId { - fn from(value: &str) -> Self { - Self(value.into()) - } -} +const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; +pub const MAX_TOOL_NAME_LENGTH: usize = 64; /// The ID of the user prompt that initiated a request. /// @@ -109,422 +75,731 @@ impl std::fmt::Display for PromptId { } } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] -pub struct MessageId(pub usize); +pub(crate) const MAX_RETRY_ATTEMPTS: u8 = 4; +pub(crate) const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); -impl MessageId { - fn post_inc(&mut self) -> Self { - Self(post_inc(&mut self.0)) - } - - pub fn as_usize(&self) -> usize { - self.0 - } -} - -/// Stored information that can be used to resurrect a context crease when creating an editor for a past message. -#[derive(Clone, Debug)] -pub struct MessageCrease { - pub range: Range, - pub icon_path: SharedString, - pub label: SharedString, - /// None for a deserialized message, Some otherwise. - pub context: Option, -} - -/// A message in a [`Thread`]. #[derive(Debug, Clone)] -pub struct Message { - pub id: MessageId, - pub role: Role, - pub segments: Vec, - pub loaded_context: LoadedContext, - pub creases: Vec, - pub is_hidden: bool, - pub ui_only: bool, +enum RetryStrategy { + ExponentialBackoff { + initial_delay: Duration, + max_attempts: u8, + }, + Fixed { + delay: Duration, + max_attempts: u8, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Message { + User(UserMessage), + Agent(AgentMessage), + Resume, } impl Message { - /// Returns whether the message contains any meaningful text that should be displayed - /// The model sometimes runs tool without producing any text or just a marker ([`USING_TOOL_MARKER`]) - pub fn should_display_content(&self) -> bool { - self.segments.iter().all(|segment| segment.should_display()) - } - - pub fn push_thinking(&mut self, text: &str, signature: Option) { - if let Some(MessageSegment::Thinking { - text: segment, - signature: current_signature, - }) = self.segments.last_mut() - { - if let Some(signature) = signature { - *current_signature = Some(signature); - } - segment.push_str(text); - } else { - self.segments.push(MessageSegment::Thinking { - text: text.to_string(), - signature, - }); + pub fn as_agent_message(&self) -> Option<&AgentMessage> { + match self { + Message::Agent(agent_message) => Some(agent_message), + _ => None, } } - pub fn push_redacted_thinking(&mut self, data: String) { - self.segments.push(MessageSegment::RedactedThinking(data)); - } - - pub fn push_text(&mut self, text: &str) { - if let Some(MessageSegment::Text(segment)) = self.segments.last_mut() { - segment.push_str(text); - } else { - self.segments.push(MessageSegment::Text(text.to_string())); + pub fn to_request(&self) -> Vec { + match self { + Message::User(message) => vec![message.to_request()], + Message::Agent(message) => message.to_request(), + Message::Resume => vec![LanguageModelRequestMessage { + role: Role::User, + content: vec!["Continue where you left off".into()], + cache: false, + reasoning_details: None, + }], } } - pub fn to_message_content(&self) -> String { - let mut result = String::new(); - - if !self.loaded_context.text.is_empty() { - result.push_str(&self.loaded_context.text); + pub fn to_markdown(&self) -> String { + match self { + Message::User(message) => message.to_markdown(), + Message::Agent(message) => message.to_markdown(), + Message::Resume => "[resume]\n".into(), } + } - for segment in &self.segments { - match segment { - MessageSegment::Text(text) => result.push_str(text), - MessageSegment::Thinking { text, .. } => { - result.push_str("\n"); - result.push_str(text); - result.push_str("\n"); - } - MessageSegment::RedactedThinking(_) => {} - } + pub fn role(&self) -> Role { + match self { + Message::User(_) | Message::Resume => Role::User, + Message::Agent(_) => Role::Assistant, } - - result } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MessageSegment { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserMessage { + pub id: UserMessageId, + pub content: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum UserMessageContent { + Text(String), + Mention { uri: MentionUri, content: String }, + Image(LanguageModelImage), +} + +impl UserMessage { + pub fn to_markdown(&self) -> String { + let mut markdown = String::from("## User\n\n"); + + for content in &self.content { + match content { + UserMessageContent::Text(text) => { + markdown.push_str(text); + markdown.push('\n'); + } + UserMessageContent::Image(_) => { + markdown.push_str("\n"); + } + UserMessageContent::Mention { uri, content } => { + if !content.is_empty() { + let _ = writeln!(&mut markdown, "{}\n\n{}", uri.as_link(), content); + } else { + let _ = writeln!(&mut markdown, "{}", uri.as_link()); + } + } + } + } + + markdown + } + + fn to_request(&self) -> LanguageModelRequestMessage { + let mut message = LanguageModelRequestMessage { + role: Role::User, + content: Vec::with_capacity(self.content.len()), + cache: false, + reasoning_details: None, + }; + + const OPEN_CONTEXT: &str = "\n\ + The following items were attached by the user. \ + They are up-to-date and don't need to be re-read.\n\n"; + + const OPEN_FILES_TAG: &str = ""; + const OPEN_DIRECTORIES_TAG: &str = ""; + const OPEN_SYMBOLS_TAG: &str = ""; + const OPEN_SELECTIONS_TAG: &str = ""; + const OPEN_THREADS_TAG: &str = ""; + const OPEN_FETCH_TAG: &str = ""; + const OPEN_RULES_TAG: &str = + "\nThe user has specified the following rules that should be applied:\n"; + + let mut file_context = OPEN_FILES_TAG.to_string(); + let mut directory_context = OPEN_DIRECTORIES_TAG.to_string(); + let mut symbol_context = OPEN_SYMBOLS_TAG.to_string(); + let mut selection_context = OPEN_SELECTIONS_TAG.to_string(); + let mut thread_context = OPEN_THREADS_TAG.to_string(); + let mut fetch_context = OPEN_FETCH_TAG.to_string(); + let mut rules_context = OPEN_RULES_TAG.to_string(); + + for chunk in &self.content { + let chunk = match chunk { + UserMessageContent::Text(text) => { + language_model::MessageContent::Text(text.clone()) + } + UserMessageContent::Image(value) => { + language_model::MessageContent::Image(value.clone()) + } + UserMessageContent::Mention { uri, content } => { + match uri { + MentionUri::File { abs_path } => { + write!( + &mut file_context, + "\n{}", + MarkdownCodeBlock { + tag: &codeblock_tag(abs_path, None), + text: &content.to_string(), + } + ) + .ok(); + } + MentionUri::PastedImage => { + debug_panic!("pasted image URI should not be used in mention content") + } + MentionUri::Directory { .. } => { + write!(&mut directory_context, "\n{}\n", content).ok(); + } + MentionUri::Symbol { + abs_path: path, + line_range, + .. + } => { + write!( + &mut symbol_context, + "\n{}", + MarkdownCodeBlock { + tag: &codeblock_tag(path, Some(line_range)), + text: content + } + ) + .ok(); + } + MentionUri::Selection { + abs_path: path, + line_range, + .. + } => { + write!( + &mut selection_context, + "\n{}", + MarkdownCodeBlock { + tag: &codeblock_tag( + path.as_deref().unwrap_or("Untitled".as_ref()), + Some(line_range) + ), + text: content + } + ) + .ok(); + } + MentionUri::Thread { .. } => { + write!(&mut thread_context, "\n{}\n", content).ok(); + } + MentionUri::TextThread { .. } => { + write!(&mut thread_context, "\n{}\n", content).ok(); + } + MentionUri::Rule { .. } => { + write!( + &mut rules_context, + "\n{}", + MarkdownCodeBlock { + tag: "", + text: content + } + ) + .ok(); + } + MentionUri::Fetch { url } => { + write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok(); + } + } + + language_model::MessageContent::Text(uri.as_link().to_string()) + } + }; + + message.content.push(chunk); + } + + let len_before_context = message.content.len(); + + if file_context.len() > OPEN_FILES_TAG.len() { + file_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(file_context)); + } + + if directory_context.len() > OPEN_DIRECTORIES_TAG.len() { + directory_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(directory_context)); + } + + if symbol_context.len() > OPEN_SYMBOLS_TAG.len() { + symbol_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(symbol_context)); + } + + if selection_context.len() > OPEN_SELECTIONS_TAG.len() { + selection_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(selection_context)); + } + + if thread_context.len() > OPEN_THREADS_TAG.len() { + thread_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(thread_context)); + } + + if fetch_context.len() > OPEN_FETCH_TAG.len() { + fetch_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(fetch_context)); + } + + if rules_context.len() > OPEN_RULES_TAG.len() { + rules_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(rules_context)); + } + + if message.content.len() > len_before_context { + message.content.insert( + len_before_context, + language_model::MessageContent::Text(OPEN_CONTEXT.into()), + ); + message + .content + .push(language_model::MessageContent::Text("".into())); + } + + message + } +} + +fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive>) -> String { + let mut result = String::new(); + + if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { + let _ = write!(result, "{} ", extension); + } + + let _ = write!(result, "{}", full_path.display()); + + if let Some(range) = line_range { + if range.start() == range.end() { + let _ = write!(result, ":{}", range.start() + 1); + } else { + let _ = write!(result, ":{}-{}", range.start() + 1, range.end() + 1); + } + } + + result +} + +impl AgentMessage { + pub fn to_markdown(&self) -> String { + let mut markdown = String::from("## Assistant\n\n"); + + for content in &self.content { + match content { + AgentMessageContent::Text(text) => { + markdown.push_str(text); + markdown.push('\n'); + } + AgentMessageContent::Thinking { text, .. } => { + markdown.push_str(""); + markdown.push_str(text); + markdown.push_str("\n"); + } + AgentMessageContent::RedactedThinking(_) => { + markdown.push_str("\n") + } + AgentMessageContent::ToolUse(tool_use) => { + markdown.push_str(&format!( + "**Tool Use**: {} (ID: {})\n", + tool_use.name, tool_use.id + )); + markdown.push_str(&format!( + "{}\n", + MarkdownCodeBlock { + tag: "json", + text: &format!("{:#}", tool_use.input) + } + )); + } + } + } + + for tool_result in self.tool_results.values() { + markdown.push_str(&format!( + "**Tool Result**: {} (ID: {})\n\n", + tool_result.tool_name, tool_result.tool_use_id + )); + if tool_result.is_error { + markdown.push_str("**ERROR:**\n"); + } + + match &tool_result.content { + LanguageModelToolResultContent::Text(text) => { + writeln!(markdown, "{text}\n").ok(); + } + LanguageModelToolResultContent::Image(_) => { + writeln!(markdown, "\n").ok(); + } + } + + if let Some(output) = tool_result.output.as_ref() { + writeln!( + markdown, + "**Debug Output**:\n\n```json\n{}\n```\n", + serde_json::to_string_pretty(output).unwrap() + ) + .unwrap(); + } + } + + markdown + } + + pub fn to_request(&self) -> Vec { + let mut assistant_message = LanguageModelRequestMessage { + role: Role::Assistant, + content: Vec::with_capacity(self.content.len()), + cache: false, + reasoning_details: self.reasoning_details.clone(), + }; + for chunk in &self.content { + match chunk { + AgentMessageContent::Text(text) => { + assistant_message + .content + .push(language_model::MessageContent::Text(text.clone())); + } + AgentMessageContent::Thinking { text, signature } => { + assistant_message + .content + .push(language_model::MessageContent::Thinking { + text: text.clone(), + signature: signature.clone(), + }); + } + AgentMessageContent::RedactedThinking(value) => { + assistant_message.content.push( + language_model::MessageContent::RedactedThinking(value.clone()), + ); + } + AgentMessageContent::ToolUse(tool_use) => { + if self.tool_results.contains_key(&tool_use.id) { + assistant_message + .content + .push(language_model::MessageContent::ToolUse(tool_use.clone())); + } + } + }; + } + + let mut user_message = LanguageModelRequestMessage { + role: Role::User, + content: Vec::new(), + cache: false, + reasoning_details: None, + }; + + for tool_result in self.tool_results.values() { + let mut tool_result = tool_result.clone(); + // Surprisingly, the API fails if we return an empty string here. + // It thinks we are sending a tool use without a tool result. + if tool_result.content.is_empty() { + tool_result.content = "".into(); + } + user_message + .content + .push(language_model::MessageContent::ToolResult(tool_result)); + } + + let mut messages = Vec::new(); + if !assistant_message.content.is_empty() { + messages.push(assistant_message); + } + if !user_message.content.is_empty() { + messages.push(user_message); + } + messages + } +} + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentMessage { + pub content: Vec, + pub tool_results: IndexMap, + pub reasoning_details: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AgentMessageContent { Text(String), Thinking { text: String, signature: Option, }, RedactedThinking(String), + ToolUse(LanguageModelToolUse), } -impl MessageSegment { - pub fn should_display(&self) -> bool { - match self { - Self::Text(text) => text.is_empty(), - Self::Thinking { text, .. } => text.is_empty(), - Self::RedactedThinking(_) => false, - } - } - - pub fn text(&self) -> Option<&str> { - match self { - MessageSegment::Text(text) => Some(text), - _ => None, - } - } +pub trait TerminalHandle { + fn id(&self, cx: &AsyncApp) -> Result; + fn current_output(&self, cx: &AsyncApp) -> Result; + fn wait_for_exit(&self, cx: &AsyncApp) -> Result>>; + fn kill(&self, cx: &AsyncApp) -> Result<()>; } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ProjectSnapshot { - pub worktree_snapshots: Vec, - pub timestamp: DateTime, +pub trait ThreadEnvironment { + fn create_terminal( + &self, + command: String, + cwd: Option, + output_byte_limit: Option, + cx: &mut AsyncApp, + ) -> Task>>; } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct WorktreeSnapshot { - pub worktree_path: String, - pub git_state: Option, +#[derive(Debug)] +pub enum ThreadEvent { + UserMessage(UserMessage), + AgentText(String), + AgentThinking(String), + ToolCall(acp::ToolCall), + ToolCallUpdate(acp_thread::ToolCallUpdate), + ToolCallAuthorization(ToolCallAuthorization), + Retry(acp_thread::RetryStatus), + Stop(acp::StopReason), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct GitState { - pub remote_url: Option, - pub head_sha: Option, - pub current_branch: Option, - pub diff: Option, +#[derive(Debug)] +pub struct NewTerminal { + pub command: String, + pub output_byte_limit: Option, + pub cwd: Option, + pub response: oneshot::Sender>>, } -#[derive(Clone, Debug)] -pub struct ThreadCheckpoint { - message_id: MessageId, - git_checkpoint: GitStoreCheckpoint, +#[derive(Debug)] +pub struct ToolCallAuthorization { + pub tool_call: acp::ToolCallUpdate, + pub options: Vec, + pub response: oneshot::Sender, } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum ThreadFeedback { - Positive, - Negative, +#[derive(Debug, thiserror::Error)] +enum CompletionError { + #[error("max tokens")] + MaxTokens, + #[error("refusal")] + Refusal, + #[error(transparent)] + Other(#[from] anyhow::Error), } -pub enum LastRestoreCheckpoint { - Pending { - message_id: MessageId, - }, - Error { - message_id: MessageId, - error: String, - }, -} - -impl LastRestoreCheckpoint { - pub fn message_id(&self) -> MessageId { - match self { - LastRestoreCheckpoint::Pending { message_id } => *message_id, - LastRestoreCheckpoint::Error { message_id, .. } => *message_id, - } - } -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub enum DetailedSummaryState { - #[default] - NotGenerated, - Generating { - message_id: MessageId, - }, - Generated { - text: SharedString, - message_id: MessageId, - }, -} - -impl DetailedSummaryState { - fn text(&self) -> Option { - if let Self::Generated { text, .. } = self { - Some(text.clone()) - } else { - None - } - } -} - -#[derive(Default, Debug)] -pub struct TotalTokenUsage { - pub total: u64, - pub max: u64, -} - -impl TotalTokenUsage { - pub fn ratio(&self) -> TokenUsageRatio { - #[cfg(debug_assertions)] - let warning_threshold: f32 = std::env::var("ZED_THREAD_WARNING_THRESHOLD") - .unwrap_or("0.8".to_string()) - .parse() - .unwrap(); - #[cfg(not(debug_assertions))] - let warning_threshold: f32 = 0.8; - - // When the maximum is unknown because there is no selected model, - // avoid showing the token limit warning. - if self.max == 0 { - TokenUsageRatio::Normal - } else if self.total >= self.max { - TokenUsageRatio::Exceeded - } else if self.total as f32 / self.max as f32 >= warning_threshold { - TokenUsageRatio::Warning - } else { - TokenUsageRatio::Normal - } - } - - pub fn add(&self, tokens: u64) -> TotalTokenUsage { - TotalTokenUsage { - total: self.total + tokens, - max: self.max, - } - } -} - -#[derive(Debug, Default, PartialEq, Eq)] -pub enum TokenUsageRatio { - #[default] - Normal, - Warning, - Exceeded, -} - -#[derive(Debug, Clone, Copy)] -pub enum QueueState { - Sending, - Queued { position: usize }, - Started, -} - -/// A thread of conversation with the LLM. pub struct Thread { - id: ThreadId, + id: acp::SessionId, + prompt_id: PromptId, updated_at: DateTime, - summary: ThreadSummary, - pending_summary: Task>, - detailed_summary_task: Task>, - detailed_summary_tx: postage::watch::Sender, - detailed_summary_rx: postage::watch::Receiver, - completion_mode: agent_settings::CompletionMode, + title: Option, + pending_title_generation: Option>, + pending_summary_generation: Option>>>, + summary: Option, messages: Vec, - next_message_id: MessageId, - last_prompt_id: PromptId, - project_context: SharedProjectContext, - checkpoints_by_message: HashMap, - completion_count: usize, - pending_completions: Vec, - project: Entity, - prompt_builder: Arc, - tools: Entity, - tool_use: ToolUseState, - action_log: Entity, - last_restore_checkpoint: Option, - pending_checkpoint: Option, - initial_project_snapshot: Shared>>>, - request_token_usage: Vec, - cumulative_token_usage: TokenUsage, - exceeded_window_error: Option, + user_store: Entity, + completion_mode: CompletionMode, + /// Holds the task that handles agent interaction until the end of the turn. + /// Survives across multiple requests as the model performs tool calls and + /// we run tools, report their results. + running_turn: Option, + pending_message: Option, + tools: BTreeMap>, tool_use_limit_reached: bool, - retry_state: Option, - message_feedback: HashMap, - last_received_chunk_at: Option, - request_callback: Option< - Box])>, - >, - remaining_turns: u32, - configured_model: Option, - profile: AgentProfile, - last_error_context: Option<(Arc, CompletionIntent)>, -} - -#[derive(Clone, Debug)] -struct RetryState { - attempt: u8, - max_attempts: u8, - intent: CompletionIntent, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ThreadSummary { - Pending, - Generating, - Ready(SharedString), - Error, -} - -impl ThreadSummary { - pub const DEFAULT: SharedString = SharedString::new_static("New Thread"); - - pub fn or_default(&self) -> SharedString { - self.unwrap_or(Self::DEFAULT) - } - - pub fn unwrap_or(&self, message: impl Into) -> SharedString { - self.ready().unwrap_or_else(|| message.into()) - } - - pub fn ready(&self) -> Option { - match self { - ThreadSummary::Ready(summary) => Some(summary.clone()), - ThreadSummary::Pending | ThreadSummary::Generating | ThreadSummary::Error => None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ExceededWindowError { - /// Model used when last message exceeded context window - model_id: LanguageModelId, - /// Token count including last message - token_count: u64, + request_token_usage: HashMap, + #[allow(unused)] + cumulative_token_usage: TokenUsage, + #[allow(unused)] + initial_project_snapshot: Shared>>>, + context_server_registry: Entity, + profile_id: AgentProfileId, + project_context: Entity, + templates: Arc, + model: Option>, + summarization_model: Option>, + prompt_capabilities_tx: watch::Sender, + pub(crate) prompt_capabilities_rx: watch::Receiver, + pub(crate) project: Entity, + pub(crate) action_log: Entity, + /// Tracks the last time files were read by the agent, to detect external modifications + pub(crate) file_read_times: HashMap, } impl Thread { + fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities { + let image = model.map_or(true, |model| model.supports_images()); + acp::PromptCapabilities::new() + .image(image) + .embedded_context(true) + } + pub fn new( project: Entity, - tools: Entity, - prompt_builder: Arc, - system_prompt: SharedProjectContext, + project_context: Entity, + context_server_registry: Entity, + templates: Arc, + model: Option>, cx: &mut Context, ) -> Self { - let (detailed_summary_tx, detailed_summary_rx) = postage::watch::channel(); - let configured_model = LanguageModelRegistry::read_global(cx).default_model(); let profile_id = AgentSettings::get_global(cx).default_profile.clone(); - + let action_log = cx.new(|_cx| ActionLog::new(project.clone())); + let (prompt_capabilities_tx, prompt_capabilities_rx) = + watch::channel(Self::prompt_capabilities(model.as_deref())); Self { - id: ThreadId::new(), + id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()), + prompt_id: PromptId::new(), updated_at: Utc::now(), - summary: ThreadSummary::Pending, - pending_summary: Task::ready(None), - detailed_summary_task: Task::ready(None), - detailed_summary_tx, - detailed_summary_rx, - completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, + title: None, + pending_title_generation: None, + pending_summary_generation: None, + summary: None, messages: Vec::new(), - next_message_id: MessageId(0), - last_prompt_id: PromptId::new(), - project_context: system_prompt, - checkpoints_by_message: HashMap::default(), - completion_count: 0, - pending_completions: Vec::new(), - project: project.clone(), - prompt_builder, - tools: tools.clone(), - last_restore_checkpoint: None, - pending_checkpoint: None, - tool_use: ToolUseState::new(tools.clone()), - action_log: cx.new(|_| ActionLog::new(project.clone())), + user_store: project.read(cx).user_store(), + completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, + running_turn: None, + pending_message: None, + tools: BTreeMap::default(), + tool_use_limit_reached: false, + request_token_usage: HashMap::default(), + cumulative_token_usage: TokenUsage::default(), initial_project_snapshot: { - let project_snapshot = Self::project_snapshot(project, cx); + let project_snapshot = Self::project_snapshot(project.clone(), cx); cx.foreground_executor() .spawn(async move { Some(project_snapshot.await) }) .shared() }, - request_token_usage: Vec::new(), - cumulative_token_usage: TokenUsage::default(), - exceeded_window_error: None, - tool_use_limit_reached: false, - retry_state: None, - message_feedback: HashMap::default(), - last_error_context: None, - last_received_chunk_at: None, - request_callback: None, - remaining_turns: u32::MAX, - configured_model, - profile: AgentProfile::new(profile_id, tools), + context_server_registry, + profile_id, + project_context, + templates, + model, + summarization_model: None, + prompt_capabilities_tx, + prompt_capabilities_rx, + project, + action_log, + file_read_times: HashMap::default(), } } - pub fn deserialize( - id: ThreadId, - serialized: SerializedThread, + pub fn id(&self) -> &acp::SessionId { + &self.id + } + + pub fn replay( + &mut self, + cx: &mut Context, + ) -> mpsc::UnboundedReceiver> { + let (tx, rx) = mpsc::unbounded(); + let stream = ThreadEventStream(tx); + for message in &self.messages { + match message { + Message::User(user_message) => stream.send_user_message(user_message), + Message::Agent(assistant_message) => { + for content in &assistant_message.content { + match content { + AgentMessageContent::Text(text) => stream.send_text(text), + AgentMessageContent::Thinking { text, .. } => { + stream.send_thinking(text) + } + AgentMessageContent::RedactedThinking(_) => {} + AgentMessageContent::ToolUse(tool_use) => { + self.replay_tool_call( + tool_use, + assistant_message.tool_results.get(&tool_use.id), + &stream, + cx, + ); + } + } + } + } + Message::Resume => {} + } + } + rx + } + + fn replay_tool_call( + &self, + tool_use: &LanguageModelToolUse, + tool_result: Option<&LanguageModelToolResult>, + stream: &ThreadEventStream, + cx: &mut Context, + ) { + let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| { + self.context_server_registry + .read(cx) + .servers() + .find_map(|(_, tools)| { + if let Some(tool) = tools.get(tool_use.name.as_ref()) { + Some(tool.clone()) + } else { + None + } + }) + }); + + let Some(tool) = tool else { + stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCall( + acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) + .status(acp::ToolCallStatus::Failed) + .raw_input(tool_use.input.clone()), + ))) + .ok(); + return; + }; + + let title = tool.initial_title(tool_use.input.clone(), cx); + let kind = tool.kind(); + stream.send_tool_call( + &tool_use.id, + &tool_use.name, + title, + kind, + tool_use.input.clone(), + ); + + let output = tool_result + .as_ref() + .and_then(|result| result.output.clone()); + if let Some(output) = output.clone() { + let tool_event_stream = ToolCallEventStream::new( + tool_use.id.clone(), + stream.clone(), + Some(self.project.read(cx).fs().clone()), + ); + tool.replay(tool_use.input.clone(), output, tool_event_stream, cx) + .log_err(); + } + + stream.update_tool_call_fields( + &tool_use.id, + acp::ToolCallUpdateFields::new() + .status( + tool_result + .as_ref() + .map_or(acp::ToolCallStatus::Failed, |result| { + if result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + } + }), + ) + .raw_output(output), + ); + } + + pub fn from_db( + id: acp::SessionId, + db_thread: DbThread, project: Entity, - tools: Entity, - prompt_builder: Arc, - project_context: SharedProjectContext, - window: Option<&mut Window>, // None in headless mode + project_context: Entity, + context_server_registry: Entity, + templates: Arc, cx: &mut Context, ) -> Self { - let next_message_id = MessageId( - serialized - .messages - .last() - .map(|message| message.id.0 + 1) - .unwrap_or(0), - ); - let tool_use = ToolUseState::from_serialized_messages( - tools.clone(), - &serialized.messages, - project.clone(), - window, - cx, - ); - let (detailed_summary_tx, detailed_summary_rx) = - postage::watch::channel_with(serialized.detailed_summary_state); + let profile_id = db_thread + .profile + .unwrap_or_else(|| AgentSettings::get_global(cx).default_profile.clone()); - let configured_model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - serialized + let mut model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + db_thread .model .and_then(|model| { let model = SelectedModel { @@ -534,1546 +809,951 @@ impl Thread { registry.select_model(&model, cx) }) .or_else(|| registry.default_model()) + .map(|model| model.model) }); - let completion_mode = serialized - .completion_mode - .unwrap_or_else(|| AgentSettings::get_global(cx).preferred_completion_mode); - let profile_id = serialized - .profile - .unwrap_or_else(|| AgentSettings::get_global(cx).default_profile.clone()); + if model.is_none() { + model = Self::resolve_profile_model(&profile_id, cx); + } + if model.is_none() { + model = LanguageModelRegistry::global(cx).update(cx, |registry, _cx| { + registry.default_model().map(|model| model.model) + }); + } + + let (prompt_capabilities_tx, prompt_capabilities_rx) = + watch::channel(Self::prompt_capabilities(model.as_deref())); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); Self { id, - updated_at: serialized.updated_at, - summary: ThreadSummary::Ready(serialized.summary), - pending_summary: Task::ready(None), - detailed_summary_task: Task::ready(None), - detailed_summary_tx, - detailed_summary_rx, - completion_mode, - retry_state: None, - messages: serialized - .messages - .into_iter() - .map(|message| Message { - id: message.id, - role: message.role, - segments: message - .segments - .into_iter() - .map(|segment| match segment { - SerializedMessageSegment::Text { text } => MessageSegment::Text(text), - SerializedMessageSegment::Thinking { text, signature } => { - MessageSegment::Thinking { text, signature } - } - SerializedMessageSegment::RedactedThinking { data } => { - MessageSegment::RedactedThinking(data) - } - }) - .collect(), - loaded_context: LoadedContext { - contexts: Vec::new(), - text: message.context, - images: Vec::new(), - }, - creases: message - .creases - .into_iter() - .map(|crease| MessageCrease { - range: crease.start..crease.end, - icon_path: crease.icon_path, - label: crease.label, - context: None, - }) - .collect(), - is_hidden: message.is_hidden, - ui_only: false, // UI-only messages are not persisted - }) - .collect(), - next_message_id, - last_prompt_id: PromptId::new(), + prompt_id: PromptId::new(), + title: if db_thread.title.is_empty() { + None + } else { + Some(db_thread.title.clone()) + }, + pending_title_generation: None, + pending_summary_generation: None, + summary: db_thread.detailed_summary, + messages: db_thread.messages, + user_store: project.read(cx).user_store(), + completion_mode: db_thread.completion_mode.unwrap_or_default(), + running_turn: None, + pending_message: None, + tools: BTreeMap::default(), + tool_use_limit_reached: false, + request_token_usage: db_thread.request_token_usage.clone(), + cumulative_token_usage: db_thread.cumulative_token_usage, + initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), + context_server_registry, + profile_id, project_context, - checkpoints_by_message: HashMap::default(), - completion_count: 0, - pending_completions: Vec::new(), - last_restore_checkpoint: None, - pending_checkpoint: None, - project: project.clone(), - prompt_builder, - tools: tools.clone(), - tool_use, - action_log: cx.new(|_| ActionLog::new(project)), - initial_project_snapshot: Task::ready(serialized.initial_project_snapshot).shared(), - request_token_usage: serialized.request_token_usage, - cumulative_token_usage: serialized.cumulative_token_usage, - exceeded_window_error: None, - tool_use_limit_reached: serialized.tool_use_limit_reached, - message_feedback: HashMap::default(), - last_error_context: None, - last_received_chunk_at: None, - request_callback: None, - remaining_turns: u32::MAX, - configured_model, - profile: AgentProfile::new(profile_id, tools), + templates, + model, + summarization_model: None, + project, + action_log, + updated_at: db_thread.updated_at, + prompt_capabilities_tx, + prompt_capabilities_rx, + file_read_times: HashMap::default(), } } - pub fn set_request_callback( - &mut self, - callback: impl 'static - + FnMut(&LanguageModelRequest, &[Result]), - ) { - self.request_callback = Some(Box::new(callback)); + pub fn to_db(&self, cx: &App) -> Task { + let initial_project_snapshot = self.initial_project_snapshot.clone(); + let mut thread = DbThread { + title: self.title(), + messages: self.messages.clone(), + updated_at: self.updated_at, + detailed_summary: self.summary.clone(), + initial_project_snapshot: None, + cumulative_token_usage: self.cumulative_token_usage, + request_token_usage: self.request_token_usage.clone(), + model: self.model.as_ref().map(|model| DbLanguageModel { + provider: model.provider_id().to_string(), + model: model.name().0.to_string(), + }), + completion_mode: Some(self.completion_mode), + profile: Some(self.profile_id.clone()), + }; + + cx.background_spawn(async move { + let initial_project_snapshot = initial_project_snapshot.await; + thread.initial_project_snapshot = initial_project_snapshot; + thread + }) } - pub fn id(&self) -> &ThreadId { - &self.id + /// Create a snapshot of the current project state including git information and unsaved buffers. + fn project_snapshot( + project: Entity, + cx: &mut Context, + ) -> Task> { + let task = project::telemetry_snapshot::TelemetrySnapshot::new(&project, cx); + cx.spawn(async move |_, _| { + let snapshot = task.await; + + Arc::new(ProjectSnapshot { + worktree_snapshots: snapshot.worktree_snapshots, + timestamp: Utc::now(), + }) + }) } - pub fn profile(&self) -> &AgentProfile { - &self.profile + pub fn project_context(&self) -> &Entity { + &self.project_context } - pub fn set_profile(&mut self, id: AgentProfileId, cx: &mut Context) { - if &id != self.profile.id() { - self.profile = AgentProfile::new(id, self.tools.clone()); - cx.emit(ThreadEvent::ProfileChanged); - } + pub fn project(&self) -> &Entity { + &self.project + } + + pub fn action_log(&self) -> &Entity { + &self.action_log } pub fn is_empty(&self) -> bool { - self.messages.is_empty() + self.messages.is_empty() && self.title.is_none() } - pub fn updated_at(&self) -> DateTime { - self.updated_at + pub fn model(&self) -> Option<&Arc> { + self.model.as_ref() } - pub fn touch_updated_at(&mut self) { - self.updated_at = Utc::now(); - } - - pub fn advance_prompt_id(&mut self) { - self.last_prompt_id = PromptId::new(); - } - - pub fn project_context(&self) -> SharedProjectContext { - self.project_context.clone() - } - - pub fn get_or_init_configured_model(&mut self, cx: &App) -> Option { - if self.configured_model.is_none() { - self.configured_model = LanguageModelRegistry::read_global(cx).default_model(); + pub fn set_model(&mut self, model: Arc, cx: &mut Context) { + let old_usage = self.latest_token_usage(); + self.model = Some(model); + let new_caps = Self::prompt_capabilities(self.model.as_deref()); + let new_usage = self.latest_token_usage(); + if old_usage != new_usage { + cx.emit(TokenUsageUpdated(new_usage)); } - self.configured_model.clone() + self.prompt_capabilities_tx.send(new_caps).log_err(); + cx.notify() } - pub fn configured_model(&self) -> Option { - self.configured_model.clone() + pub fn summarization_model(&self) -> Option<&Arc> { + self.summarization_model.as_ref() } - pub fn set_configured_model(&mut self, model: Option, cx: &mut Context) { - self.configured_model = model; - cx.notify(); - } - - pub fn summary(&self) -> &ThreadSummary { - &self.summary - } - - pub fn set_summary(&mut self, new_summary: impl Into, cx: &mut Context) { - let current_summary = match &self.summary { - ThreadSummary::Pending | ThreadSummary::Generating => return, - ThreadSummary::Ready(summary) => summary, - ThreadSummary::Error => &ThreadSummary::DEFAULT, - }; - - let mut new_summary = new_summary.into(); - - if new_summary.is_empty() { - new_summary = ThreadSummary::DEFAULT; - } - - if current_summary != &new_summary { - self.summary = ThreadSummary::Ready(new_summary); - cx.emit(ThreadEvent::SummaryChanged); - } + pub fn set_summarization_model( + &mut self, + model: Option>, + cx: &mut Context, + ) { + self.summarization_model = model; + cx.notify() } pub fn completion_mode(&self) -> CompletionMode { self.completion_mode } - pub fn set_completion_mode(&mut self, mode: CompletionMode) { + pub fn set_completion_mode(&mut self, mode: CompletionMode, cx: &mut Context) { + let old_usage = self.latest_token_usage(); self.completion_mode = mode; + let new_usage = self.latest_token_usage(); + if old_usage != new_usage { + cx.emit(TokenUsageUpdated(new_usage)); + } + cx.notify() } - pub fn message(&self, id: MessageId) -> Option<&Message> { - let index = self - .messages - .binary_search_by(|message| message.id.cmp(&id)) - .ok()?; - - self.messages.get(index) - } - - pub fn messages(&self) -> impl ExactSizeIterator { - self.messages.iter() - } - - pub fn is_generating(&self) -> bool { - !self.pending_completions.is_empty() || !self.all_tools_finished() - } - - /// Indicates whether streaming of language model events is stale. - /// When `is_generating()` is false, this method returns `None`. - pub fn is_generation_stale(&self) -> Option { - const STALE_THRESHOLD: u128 = 250; - - self.last_received_chunk_at - .map(|instant| instant.elapsed().as_millis() > STALE_THRESHOLD) - } - - fn received_chunk(&mut self) { - self.last_received_chunk_at = Some(Instant::now()); - } - - pub fn queue_state(&self) -> Option { - self.pending_completions - .first() - .map(|pending_completion| pending_completion.queue_state) - } - - pub fn tools(&self) -> &Entity { - &self.tools - } - - pub fn pending_tool(&self, id: &LanguageModelToolUseId) -> Option<&PendingToolUse> { - self.tool_use - .pending_tool_uses() - .into_iter() - .find(|tool_use| &tool_use.id == id) - } - - pub fn tools_needing_confirmation(&self) -> impl Iterator { - self.tool_use - .pending_tool_uses() - .into_iter() - .filter(|tool_use| tool_use.status.needs_confirmation()) - } - - pub fn has_pending_tool_uses(&self) -> bool { - !self.tool_use.pending_tool_uses().is_empty() - } - - pub fn checkpoint_for_message(&self, id: MessageId) -> Option { - self.checkpoints_by_message.get(&id).cloned() - } - - pub fn restore_checkpoint( - &mut self, - checkpoint: ThreadCheckpoint, - cx: &mut Context, - ) -> Task> { - self.last_restore_checkpoint = Some(LastRestoreCheckpoint::Pending { - message_id: checkpoint.message_id, - }); - cx.emit(ThreadEvent::CheckpointChanged); - cx.notify(); - - let git_store = self.project().read(cx).git_store().clone(); - let restore = git_store.update(cx, |git_store, cx| { - git_store.restore_checkpoint(checkpoint.git_checkpoint.clone(), cx) - }); - - cx.spawn(async move |this, cx| { - let result = restore.await; - this.update(cx, |this, cx| { - if let Err(err) = result.as_ref() { - this.last_restore_checkpoint = Some(LastRestoreCheckpoint::Error { - message_id: checkpoint.message_id, - error: err.to_string(), - }); - } else { - this.truncate(checkpoint.message_id, cx); - this.last_restore_checkpoint = None; - } - this.pending_checkpoint = None; - cx.emit(ThreadEvent::CheckpointChanged); - cx.notify(); - })?; - result - }) - } - - fn finalize_pending_checkpoint(&mut self, cx: &mut Context) { - let pending_checkpoint = if self.is_generating() { - return; - } else if let Some(checkpoint) = self.pending_checkpoint.take() { - checkpoint + #[cfg(any(test, feature = "test-support"))] + pub fn last_message(&self) -> Option { + if let Some(message) = self.pending_message.clone() { + Some(Message::Agent(message)) } else { + self.messages.last().cloned() + } + } + + pub fn add_default_tools( + &mut self, + environment: Rc, + cx: &mut Context, + ) { + let language_registry = self.project.read(cx).languages().clone(); + self.add_tool(CopyPathTool::new(self.project.clone())); + self.add_tool(CreateDirectoryTool::new(self.project.clone())); + self.add_tool(DeletePathTool::new( + self.project.clone(), + self.action_log.clone(), + )); + self.add_tool(DiagnosticsTool::new(self.project.clone())); + self.add_tool(EditFileTool::new( + self.project.clone(), + cx.weak_entity(), + language_registry, + Templates::new(), + )); + self.add_tool(FetchTool::new(self.project.read(cx).client().http_client())); + self.add_tool(FindPathTool::new(self.project.clone())); + self.add_tool(GrepTool::new(self.project.clone())); + self.add_tool(ListDirectoryTool::new(self.project.clone())); + self.add_tool(MovePathTool::new(self.project.clone())); + self.add_tool(NowTool); + self.add_tool(OpenTool::new(self.project.clone())); + self.add_tool(ReadFileTool::new( + cx.weak_entity(), + self.project.clone(), + self.action_log.clone(), + )); + self.add_tool(TerminalTool::new(self.project.clone(), environment)); + self.add_tool(ThinkingTool); + self.add_tool(WebSearchTool); + } + + pub fn add_tool(&mut self, tool: T) { + self.tools.insert(T::name().into(), tool.erase()); + } + + pub fn remove_tool(&mut self, name: &str) -> bool { + self.tools.remove(name).is_some() + } + + pub fn profile(&self) -> &AgentProfileId { + &self.profile_id + } + + pub fn set_profile(&mut self, profile_id: AgentProfileId, cx: &mut Context) { + if self.profile_id == profile_id { + return; + } + + self.profile_id = profile_id; + + // Swap to the profile's preferred model when available. + if let Some(model) = Self::resolve_profile_model(&self.profile_id, cx) { + self.set_model(model, cx); + } + } + + pub fn cancel(&mut self, cx: &mut Context) { + if let Some(running_turn) = self.running_turn.take() { + running_turn.cancel(); + } + self.flush_pending_message(cx); + } + + fn update_token_usage(&mut self, update: language_model::TokenUsage, cx: &mut Context) { + let Some(last_user_message) = self.last_user_message() else { return; }; - self.finalize_checkpoint(pending_checkpoint, cx); + self.request_token_usage + .insert(last_user_message.id.clone(), update); + cx.emit(TokenUsageUpdated(self.latest_token_usage())); + cx.notify(); } - fn finalize_checkpoint( - &mut self, - pending_checkpoint: ThreadCheckpoint, - cx: &mut Context, - ) { - let git_store = self.project.read(cx).git_store().clone(); - let final_checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx)); - cx.spawn(async move |this, cx| match final_checkpoint.await { - Ok(final_checkpoint) => { - let equal = git_store - .update(cx, |store, cx| { - store.compare_checkpoints( - pending_checkpoint.git_checkpoint.clone(), - final_checkpoint.clone(), - cx, - ) - })? - .await - .unwrap_or(false); + pub fn truncate(&mut self, message_id: UserMessageId, cx: &mut Context) -> Result<()> { + self.cancel(cx); + let Some(position) = self.messages.iter().position( + |msg| matches!(msg, Message::User(UserMessage { id, .. }) if id == &message_id), + ) else { + return Err(anyhow!("Message not found")); + }; - this.update(cx, |this, cx| { - this.pending_checkpoint = if equal { - Some(pending_checkpoint) - } else { - this.insert_checkpoint(pending_checkpoint, cx); - Some(ThreadCheckpoint { - message_id: this.next_message_id, - git_checkpoint: final_checkpoint, + for message in self.messages.drain(position..) { + match message { + Message::User(message) => { + self.request_token_usage.remove(&message.id); + } + Message::Agent(_) | Message::Resume => {} + } + } + self.clear_summary(); + cx.notify(); + Ok(()) + } + + pub fn latest_request_token_usage(&self) -> Option { + let last_user_message = self.last_user_message()?; + let tokens = self.request_token_usage.get(&last_user_message.id)?; + Some(*tokens) + } + + pub fn latest_token_usage(&self) -> Option { + let usage = self.latest_request_token_usage()?; + let model = self.model.clone()?; + Some(acp_thread::TokenUsage { + max_tokens: model.max_token_count_for_mode(self.completion_mode.into()), + used_tokens: usage.total_tokens(), + }) + } + + /// Look up the active profile and resolve its preferred model if one is configured. + fn resolve_profile_model( + profile_id: &AgentProfileId, + cx: &mut Context, + ) -> Option> { + let selection = AgentSettings::get_global(cx) + .profiles + .get(profile_id)? + .default_model + .clone()?; + Self::resolve_model_from_selection(&selection, cx) + } + + /// Translate a stored model selection into the configured model from the registry. + fn resolve_model_from_selection( + selection: &LanguageModelSelection, + cx: &mut Context, + ) -> Option> { + let selected = SelectedModel { + provider: LanguageModelProviderId::from(selection.provider.0.clone()), + model: LanguageModelId::from(selection.model.clone()), + }; + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .select_model(&selected, cx) + .map(|configured| configured.model) + }) + } + + pub fn resume( + &mut self, + cx: &mut Context, + ) -> Result>> { + self.messages.push(Message::Resume); + cx.notify(); + + log::debug!("Total messages in thread: {}", self.messages.len()); + self.run_turn(cx) + } + + /// Sending a message results in the model streaming a response, which could include tool calls. + /// After calling tools, the model will stops and waits for any outstanding tool calls to be completed and their results sent. + /// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn. + pub fn send( + &mut self, + id: UserMessageId, + content: impl IntoIterator, + cx: &mut Context, + ) -> Result>> + where + T: Into, + { + let model = self.model().context("No language model configured")?; + + log::info!("Thread::send called with model: {}", model.name().0); + self.advance_prompt_id(); + + let content = content.into_iter().map(Into::into).collect::>(); + log::debug!("Thread::send content: {:?}", content); + + self.messages + .push(Message::User(UserMessage { id, content })); + cx.notify(); + + log::debug!("Total messages in thread: {}", self.messages.len()); + self.run_turn(cx) + } + + #[cfg(feature = "eval")] + pub fn proceed( + &mut self, + cx: &mut Context, + ) -> Result>> { + self.run_turn(cx) + } + + fn run_turn( + &mut self, + cx: &mut Context, + ) -> Result>> { + self.cancel(cx); + + let model = self.model.clone().context("No language model configured")?; + let profile = AgentSettings::get_global(cx) + .profiles + .get(&self.profile_id) + .context("Profile not found")?; + let (events_tx, events_rx) = mpsc::unbounded::>(); + let event_stream = ThreadEventStream(events_tx); + let message_ix = self.messages.len().saturating_sub(1); + self.tool_use_limit_reached = false; + self.clear_summary(); + self.running_turn = Some(RunningTurn { + event_stream: event_stream.clone(), + tools: self.enabled_tools(profile, &model, cx), + _task: cx.spawn(async move |this, cx| { + log::debug!("Starting agent turn execution"); + + let turn_result = Self::run_turn_internal(&this, model, &event_stream, cx).await; + _ = this.update(cx, |this, cx| this.flush_pending_message(cx)); + + match turn_result { + Ok(()) => { + log::debug!("Turn execution completed"); + event_stream.send_stop(acp::StopReason::EndTurn); + } + Err(error) => { + log::error!("Turn execution failed: {:?}", error); + match error.downcast::() { + Ok(CompletionError::Refusal) => { + event_stream.send_stop(acp::StopReason::Refusal); + _ = this.update(cx, |this, _| this.messages.truncate(message_ix)); + } + Ok(CompletionError::MaxTokens) => { + event_stream.send_stop(acp::StopReason::MaxTokens); + } + Ok(CompletionError::Other(error)) | Err(error) => { + event_stream.send_error(error); + } + } + } + } + + _ = this.update(cx, |this, _| this.running_turn.take()); + }), + }); + Ok(events_rx) + } + + async fn run_turn_internal( + this: &WeakEntity, + model: Arc, + event_stream: &ThreadEventStream, + cx: &mut AsyncApp, + ) -> Result<()> { + let mut attempt = 0; + let mut intent = CompletionIntent::UserPrompt; + loop { + let request = + this.update(cx, |this, cx| this.build_completion_request(intent, cx))??; + + telemetry::event!( + "Agent Thread Completion", + thread_id = this.read_with(cx, |this, _| this.id.to_string())?, + prompt_id = this.read_with(cx, |this, _| this.prompt_id.to_string())?, + model = model.telemetry_id(), + model_provider = model.provider_id().to_string(), + attempt + ); + + log::debug!("Calling model.stream_completion, attempt {}", attempt); + + let (mut events, mut error) = match model.stream_completion(request, cx).await { + Ok(events) => (events, None), + Err(err) => (stream::empty().boxed(), Some(err)), + }; + let mut tool_results = FuturesUnordered::new(); + while let Some(event) = events.next().await { + log::trace!("Received completion event: {:?}", event); + match event { + Ok(event) => { + tool_results.extend(this.update(cx, |this, cx| { + this.handle_completion_event(event, event_stream, cx) + })??); + } + Err(err) => { + error = Some(err); + break; + } + } + } + + let end_turn = tool_results.is_empty(); + while let Some(tool_result) = tool_results.next().await { + log::debug!("Tool finished {:?}", tool_result); + + event_stream.update_tool_call_fields( + &tool_result.tool_use_id, + acp::ToolCallUpdateFields::new() + .status(if tool_result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed }) + .raw_output(tool_result.output.clone()), + ); + this.update(cx, |this, _cx| { + this.pending_message() + .tool_results + .insert(tool_result.tool_use_id.clone(), tool_result); + })?; + } + + this.update(cx, |this, cx| { + this.flush_pending_message(cx); + if this.title.is_none() && this.pending_title_generation.is_none() { + this.generate_title(cx); + } + })?; + + if let Some(error) = error { + attempt += 1; + let retry = this.update(cx, |this, cx| { + let user_store = this.user_store.read(cx); + this.handle_completion_error(error, attempt, user_store.plan()) + })??; + let timer = cx.background_executor().timer(retry.duration); + event_stream.send_retry(retry); + timer.await; + this.update(cx, |this, _cx| { + if let Some(Message::Agent(message)) = this.messages.last() { + if message.tool_results.is_empty() { + intent = CompletionIntent::UserPrompt; + this.messages.push(Message::Resume); + } } })?; - - Ok(()) - } - Err(_) => this.update(cx, |this, cx| { - this.insert_checkpoint(pending_checkpoint, cx) - }), - }) - .detach(); - } - - fn insert_checkpoint(&mut self, checkpoint: ThreadCheckpoint, cx: &mut Context) { - self.checkpoints_by_message - .insert(checkpoint.message_id, checkpoint); - cx.emit(ThreadEvent::CheckpointChanged); - cx.notify(); - } - - pub fn last_restore_checkpoint(&self) -> Option<&LastRestoreCheckpoint> { - self.last_restore_checkpoint.as_ref() - } - - pub fn truncate(&mut self, message_id: MessageId, cx: &mut Context) { - let Some(message_ix) = self - .messages - .iter() - .rposition(|message| message.id == message_id) - else { - return; - }; - for deleted_message in self.messages.drain(message_ix..) { - self.checkpoints_by_message.remove(&deleted_message.id); - } - cx.notify(); - } - - pub fn context_for_message(&self, id: MessageId) -> impl Iterator { - self.messages - .iter() - .find(|message| message.id == id) - .into_iter() - .flat_map(|message| message.loaded_context.contexts.iter()) - } - - pub fn is_turn_end(&self, ix: usize) -> bool { - if self.messages.is_empty() { - return false; - } - - if !self.is_generating() && ix == self.messages.len() - 1 { - return true; - } - - let Some(message) = self.messages.get(ix) else { - return false; - }; - - if message.role != Role::Assistant { - return false; - } - - self.messages - .get(ix + 1) - .and_then(|message| { - self.message(message.id) - .map(|next_message| next_message.role == Role::User && !next_message.is_hidden) - }) - .unwrap_or(false) - } - - pub fn tool_use_limit_reached(&self) -> bool { - self.tool_use_limit_reached - } - - /// Returns whether all of the tool uses have finished running. - pub fn all_tools_finished(&self) -> bool { - // If the only pending tool uses left are the ones with errors, then - // that means that we've finished running all of the pending tools. - self.tool_use - .pending_tool_uses() - .iter() - .all(|pending_tool_use| pending_tool_use.status.is_error()) - } - - /// Returns whether any pending tool uses may perform edits - pub fn has_pending_edit_tool_uses(&self) -> bool { - self.tool_use - .pending_tool_uses() - .iter() - .filter(|pending_tool_use| !pending_tool_use.status.is_error()) - .any(|pending_tool_use| pending_tool_use.may_perform_edits) - } - - pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { - self.tool_use.tool_uses_for_message(id, &self.project, cx) - } - - pub fn tool_results_for_message( - &self, - assistant_message_id: MessageId, - ) -> Vec<&LanguageModelToolResult> { - self.tool_use.tool_results_for_message(assistant_message_id) - } - - pub fn tool_result(&self, id: &LanguageModelToolUseId) -> Option<&LanguageModelToolResult> { - self.tool_use.tool_result(id) - } - - pub fn output_for_tool(&self, id: &LanguageModelToolUseId) -> Option<&Arc> { - match &self.tool_use.tool_result(id)?.content { - LanguageModelToolResultContent::Text(text) => Some(text), - LanguageModelToolResultContent::Image(_) => { - // TODO: We should display image - None + } else if this.read_with(cx, |this, _| this.tool_use_limit_reached)? { + return Err(language_model::ToolUseLimitReachedError.into()); + } else if end_turn { + return Ok(()); + } else { + intent = CompletionIntent::ToolResults; + attempt = 0; } } } - pub fn card_for_tool(&self, id: &LanguageModelToolUseId) -> Option { - self.tool_use.tool_result_card(id).cloned() - } + fn handle_completion_error( + &mut self, + error: LanguageModelCompletionError, + attempt: u8, + plan: Option, + ) -> Result { + let Some(model) = self.model.as_ref() else { + return Err(anyhow!(error)); + }; - /// Return tools that are both enabled and supported by the model - pub fn available_tools( - &self, - cx: &App, - model: Arc, - ) -> Vec { - if model.supports_tools() { - self.profile - .enabled_tools(cx) - .into_iter() - .filter_map(|(name, tool)| { - // Skip tools that cannot be supported - let input_schema = tool.input_schema(model.tool_input_format()).ok()?; - Some(LanguageModelRequestTool { - name: name.into(), - description: tool.description(), - input_schema, - }) - }) - .collect() + let auto_retry = if model.provider_id() == ZED_CLOUD_PROVIDER_ID { + match plan { + Some(Plan::V2(_)) => true, + Some(Plan::V1(_)) => self.completion_mode == CompletionMode::Burn, + None => false, + } } else { - Vec::default() - } - } - - pub fn insert_user_message( - &mut self, - text: impl Into, - loaded_context: ContextLoadResult, - git_checkpoint: Option, - creases: Vec, - cx: &mut Context, - ) -> MessageId { - if !loaded_context.referenced_buffers.is_empty() { - self.action_log.update(cx, |log, cx| { - for buffer in loaded_context.referenced_buffers { - log.buffer_read(buffer, cx); - } - }); - } - - let message_id = self.insert_message( - Role::User, - vec![MessageSegment::Text(text.into())], - loaded_context.loaded_context, - creases, - false, - cx, - ); - - if let Some(git_checkpoint) = git_checkpoint { - self.pending_checkpoint = Some(ThreadCheckpoint { - message_id, - git_checkpoint, - }); - } - - message_id - } - - pub fn insert_invisible_continue_message(&mut self, cx: &mut Context) -> MessageId { - let id = self.insert_message( - Role::User, - vec![MessageSegment::Text("Continue where you left off".into())], - LoadedContext::default(), - vec![], - true, - cx, - ); - self.pending_checkpoint = None; - - id - } - - pub fn insert_assistant_message( - &mut self, - segments: Vec, - cx: &mut Context, - ) -> MessageId { - self.insert_message( - Role::Assistant, - segments, - LoadedContext::default(), - Vec::new(), - false, - cx, - ) - } - - pub fn insert_message( - &mut self, - role: Role, - segments: Vec, - loaded_context: LoadedContext, - creases: Vec, - is_hidden: bool, - cx: &mut Context, - ) -> MessageId { - let id = self.next_message_id.post_inc(); - self.messages.push(Message { - id, - role, - segments, - loaded_context, - creases, - is_hidden, - ui_only: false, - }); - self.touch_updated_at(); - cx.emit(ThreadEvent::MessageAdded(id)); - id - } - - pub fn edit_message( - &mut self, - id: MessageId, - new_role: Role, - new_segments: Vec, - creases: Vec, - loaded_context: Option, - checkpoint: Option, - cx: &mut Context, - ) -> bool { - let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else { - return false; + true }; - message.role = new_role; - message.segments = new_segments; - message.creases = creases; - if let Some(context) = loaded_context { - message.loaded_context = context; - } - if let Some(git_checkpoint) = checkpoint { - self.checkpoints_by_message.insert( - id, - ThreadCheckpoint { - message_id: id, - git_checkpoint, - }, - ); - } - self.touch_updated_at(); - cx.emit(ThreadEvent::MessageEdited(id)); - true - } - pub fn delete_message(&mut self, id: MessageId, cx: &mut Context) -> bool { - let Some(index) = self.messages.iter().position(|message| message.id == id) else { - return false; + if !auto_retry { + return Err(anyhow!(error)); + } + + let Some(strategy) = Self::retry_strategy_for(&error) else { + return Err(anyhow!(error)); }; - self.messages.remove(index); - self.touch_updated_at(); - cx.emit(ThreadEvent::MessageDeleted(id)); - true - } - /// Returns the representation of this [`Thread`] in a textual form. - /// - /// This is the representation we use when attaching a thread as context to another thread. - pub fn text(&self) -> String { - let mut text = String::new(); + let max_attempts = match &strategy { + RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, + RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, + }; - for message in &self.messages { - text.push_str(match message.role { - language_model::Role::User => "User:", - language_model::Role::Assistant => "Agent:", - language_model::Role::System => "System:", - }); - text.push('\n'); + if attempt > max_attempts { + return Err(anyhow!(error)); + } - for segment in &message.segments { - match segment { - MessageSegment::Text(content) => text.push_str(content), - MessageSegment::Thinking { text: content, .. } => { - text.push_str(&format!("{}", content)) - } - MessageSegment::RedactedThinking(_) => {} - } + let delay = match &strategy { + RetryStrategy::ExponentialBackoff { initial_delay, .. } => { + let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); + Duration::from_secs(delay_secs) } - text.push('\n'); - } + RetryStrategy::Fixed { delay, .. } => *delay, + }; + log::debug!("Retry attempt {attempt} with delay {delay:?}"); - text - } - - /// Serializes this thread into a format for storage or telemetry. - pub fn serialize(&self, cx: &mut Context) -> Task> { - let initial_project_snapshot = self.initial_project_snapshot.clone(); - cx.spawn(async move |this, cx| { - let initial_project_snapshot = initial_project_snapshot.await; - this.read_with(cx, |this, cx| SerializedThread { - version: SerializedThread::VERSION.to_string(), - summary: this.summary().or_default(), - updated_at: this.updated_at(), - messages: this - .messages() - .filter(|message| !message.ui_only) - .map(|message| SerializedMessage { - id: message.id, - role: message.role, - segments: message - .segments - .iter() - .map(|segment| match segment { - MessageSegment::Text(text) => { - SerializedMessageSegment::Text { text: text.clone() } - } - MessageSegment::Thinking { text, signature } => { - SerializedMessageSegment::Thinking { - text: text.clone(), - signature: signature.clone(), - } - } - MessageSegment::RedactedThinking(data) => { - SerializedMessageSegment::RedactedThinking { - data: data.clone(), - } - } - }) - .collect(), - tool_uses: this - .tool_uses_for_message(message.id, cx) - .into_iter() - .map(|tool_use| SerializedToolUse { - id: tool_use.id, - name: tool_use.name, - input: tool_use.input, - }) - .collect(), - tool_results: this - .tool_results_for_message(message.id) - .into_iter() - .map(|tool_result| SerializedToolResult { - tool_use_id: tool_result.tool_use_id.clone(), - is_error: tool_result.is_error, - content: tool_result.content.clone(), - output: tool_result.output.clone(), - }) - .collect(), - context: message.loaded_context.text.clone(), - creases: message - .creases - .iter() - .map(|crease| SerializedCrease { - start: crease.range.start, - end: crease.range.end, - icon_path: crease.icon_path.clone(), - label: crease.label.clone(), - }) - .collect(), - is_hidden: message.is_hidden, - }) - .collect(), - initial_project_snapshot, - cumulative_token_usage: this.cumulative_token_usage, - request_token_usage: this.request_token_usage.clone(), - detailed_summary_state: this.detailed_summary_rx.borrow().clone(), - exceeded_window_error: this.exceeded_window_error.clone(), - model: this - .configured_model - .as_ref() - .map(|model| SerializedLanguageModel { - provider: model.provider.id().0.to_string(), - model: model.model.id().0.to_string(), - }), - completion_mode: Some(this.completion_mode), - tool_use_limit_reached: this.tool_use_limit_reached, - profile: Some(this.profile.id().clone()), - }) + Ok(acp_thread::RetryStatus { + last_error: error.to_string().into(), + attempt: attempt as usize, + max_attempts: max_attempts as usize, + started_at: Instant::now(), + duration: delay, }) } - pub fn remaining_turns(&self) -> u32 { - self.remaining_turns - } - - pub fn set_remaining_turns(&mut self, remaining_turns: u32) { - self.remaining_turns = remaining_turns; - } - - pub fn send_to_model( + /// A helper method that's called on every streamed completion event. + /// Returns an optional tool result task, which the main agentic loop will + /// send back to the model when it resolves. + fn handle_completion_event( &mut self, - model: Arc, - intent: CompletionIntent, - window: Option, + event: LanguageModelCompletionEvent, + event_stream: &ThreadEventStream, + cx: &mut Context, + ) -> Result>> { + log::trace!("Handling streamed completion event: {:?}", event); + use LanguageModelCompletionEvent::*; + + match event { + StartMessage { .. } => { + self.flush_pending_message(cx); + self.pending_message = Some(AgentMessage::default()); + } + Text(new_text) => self.handle_text_event(new_text, event_stream, cx), + Thinking { text, signature } => { + self.handle_thinking_event(text, signature, event_stream, cx) + } + RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx), + ReasoningDetails(details) => { + let last_message = self.pending_message(); + // Store the last non-empty reasoning_details (overwrites earlier ones) + // This ensures we keep the encrypted reasoning with signatures, not the early text reasoning + if let serde_json::Value::Array(ref arr) = details { + if !arr.is_empty() { + last_message.reasoning_details = Some(details); + } + } else { + last_message.reasoning_details = Some(details); + } + } + ToolUse(tool_use) => { + return Ok(self.handle_tool_use_event(tool_use, event_stream, cx)); + } + ToolUseJsonParseError { + id, + tool_name, + raw_input, + json_parse_error, + } => { + return Ok(Some(Task::ready( + self.handle_tool_use_json_parse_error_event( + id, + tool_name, + raw_input, + json_parse_error, + ), + ))); + } + UsageUpdate(usage) => { + telemetry::event!( + "Agent Thread Completion Usage Updated", + thread_id = self.id.to_string(), + prompt_id = self.prompt_id.to_string(), + model = self.model.as_ref().map(|m| m.telemetry_id()), + model_provider = self.model.as_ref().map(|m| m.provider_id().to_string()), + input_tokens = usage.input_tokens, + output_tokens = usage.output_tokens, + cache_creation_input_tokens = usage.cache_creation_input_tokens, + cache_read_input_tokens = usage.cache_read_input_tokens, + ); + self.update_token_usage(usage, cx); + } + UsageUpdated { amount, limit } => { + self.update_model_request_usage(amount, limit, cx); + } + ToolUseLimitReached => { + self.tool_use_limit_reached = true; + } + Stop(StopReason::Refusal) => return Err(CompletionError::Refusal.into()), + Stop(StopReason::MaxTokens) => return Err(CompletionError::MaxTokens.into()), + Stop(StopReason::ToolUse | StopReason::EndTurn) => {} + Started | Queued { .. } => {} + } + + Ok(None) + } + + fn handle_text_event( + &mut self, + new_text: String, + event_stream: &ThreadEventStream, cx: &mut Context, ) { - if self.remaining_turns == 0 { - return; + event_stream.send_text(&new_text); + + let last_message = self.pending_message(); + if let Some(AgentMessageContent::Text(text)) = last_message.content.last_mut() { + text.push_str(&new_text); + } else { + last_message + .content + .push(AgentMessageContent::Text(new_text)); } - self.remaining_turns -= 1; - - self.flush_notifications(model.clone(), intent, cx); - - let _checkpoint = self.finalize_pending_checkpoint(cx); - self.stream_completion( - self.to_completion_request(model.clone(), intent, cx), - model, - intent, - window, - cx, - ); + cx.notify(); } - pub fn to_completion_request( - &self, - model: Arc, - intent: CompletionIntent, + fn handle_thinking_event( + &mut self, + new_text: String, + new_signature: Option, + event_stream: &ThreadEventStream, cx: &mut Context, - ) -> LanguageModelRequest { - let mut request = LanguageModelRequest { - thread_id: Some(self.id.to_string()), - prompt_id: Some(self.last_prompt_id.to_string()), - intent: Some(intent), - mode: None, - messages: vec![], - tools: Vec::new(), - tool_choice: None, - stop: Vec::new(), - temperature: AgentSettings::temperature_for_model(&model, cx), - thinking_allowed: true, - }; + ) { + event_stream.send_thinking(&new_text); - let available_tools = self.available_tools(cx, model.clone()); - let available_tool_names = available_tools - .iter() - .map(|tool| tool.name.clone()) - .collect(); - - let model_context = &ModelContext { - available_tools: available_tool_names, - }; - - if let Some(project_context) = self.project_context.borrow().as_ref() { - match self - .prompt_builder - .generate_assistant_system_prompt(project_context, model_context) - { - Err(err) => { - let message = format!("{err:?}").into(); - log::error!("{message}"); - cx.emit(ThreadEvent::ShowError(ThreadError::Message { - header: "Error generating system prompt".into(), - message, - })); - } - Ok(system_prompt) => { - request.messages.push(LanguageModelRequestMessage { - role: Role::System, - content: vec![MessageContent::Text(system_prompt)], - cache: true, - }); - } - } + let last_message = self.pending_message(); + if let Some(AgentMessageContent::Thinking { text, signature }) = + last_message.content.last_mut() + { + text.push_str(&new_text); + *signature = new_signature.or(signature.take()); } else { - let message = "Context for system prompt unexpectedly not ready.".into(); - log::error!("{message}"); - cx.emit(ThreadEvent::ShowError(ThreadError::Message { - header: "Error generating system prompt".into(), - message, - })); + last_message.content.push(AgentMessageContent::Thinking { + text: new_text, + signature: new_signature, + }); } - let mut message_ix_to_cache = None; - for message in &self.messages { - // ui_only messages are for the UI only, not for the model - if message.ui_only { - continue; - } - - let mut request_message = LanguageModelRequestMessage { - role: message.role, - content: Vec::new(), - cache: false, - }; - - message - .loaded_context - .add_to_request_message(&mut request_message); - - for segment in &message.segments { - match segment { - MessageSegment::Text(text) => { - let text = text.trim_end(); - if !text.is_empty() { - request_message - .content - .push(MessageContent::Text(text.into())); - } - } - MessageSegment::Thinking { text, signature } => { - if !text.is_empty() { - request_message.content.push(MessageContent::Thinking { - text: text.into(), - signature: signature.clone(), - }); - } - } - MessageSegment::RedactedThinking(data) => { - request_message - .content - .push(MessageContent::RedactedThinking(data.clone())); - } - }; - } - - let mut cache_message = true; - let mut tool_results_message = LanguageModelRequestMessage { - role: Role::User, - content: Vec::new(), - cache: false, - }; - for (tool_use, tool_result) in self.tool_use.tool_results(message.id) { - if let Some(tool_result) = tool_result { - request_message - .content - .push(MessageContent::ToolUse(tool_use.clone())); - tool_results_message - .content - .push(MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_use.id.clone(), - tool_name: tool_result.tool_name.clone(), - is_error: tool_result.is_error, - content: if tool_result.content.is_empty() { - // Surprisingly, the API fails if we return an empty string here. - // It thinks we are sending a tool use without a tool result. - "".into() - } else { - tool_result.content.clone() - }, - output: None, - })); - } else { - cache_message = false; - log::debug!( - "skipped tool use {:?} because it is still pending", - tool_use - ); - } - } - - if cache_message { - message_ix_to_cache = Some(request.messages.len()); - } - request.messages.push(request_message); - - if !tool_results_message.content.is_empty() { - if cache_message { - message_ix_to_cache = Some(request.messages.len()); - } - request.messages.push(tool_results_message); - } - } - - // https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching - if let Some(message_ix_to_cache) = message_ix_to_cache { - request.messages[message_ix_to_cache].cache = true; - } - - request.tools = available_tools; - request.mode = if model.supports_burn_mode() { - Some(self.completion_mode.into()) - } else { - Some(CompletionMode::Normal.into()) - }; - - request + cx.notify(); } - fn to_summarize_request( - &self, - model: &Arc, - intent: CompletionIntent, - added_user_message: String, - cx: &App, - ) -> LanguageModelRequest { + fn handle_redacted_thinking_event(&mut self, data: String, cx: &mut Context) { + let last_message = self.pending_message(); + last_message + .content + .push(AgentMessageContent::RedactedThinking(data)); + cx.notify(); + } + + fn handle_tool_use_event( + &mut self, + tool_use: LanguageModelToolUse, + event_stream: &ThreadEventStream, + cx: &mut Context, + ) -> Option> { + cx.notify(); + + let tool = self.tool(tool_use.name.as_ref()); + let mut title = SharedString::from(&tool_use.name); + let mut kind = acp::ToolKind::Other; + if let Some(tool) = tool.as_ref() { + title = tool.initial_title(tool_use.input.clone(), cx); + kind = tool.kind(); + } + + // Ensure the last message ends in the current tool use + let last_message = self.pending_message(); + let push_new_tool_use = last_message.content.last_mut().is_none_or(|content| { + if let AgentMessageContent::ToolUse(last_tool_use) = content { + if last_tool_use.id == tool_use.id { + *last_tool_use = tool_use.clone(); + false + } else { + true + } + } else { + true + } + }); + + if push_new_tool_use { + event_stream.send_tool_call( + &tool_use.id, + &tool_use.name, + title, + kind, + tool_use.input.clone(), + ); + last_message + .content + .push(AgentMessageContent::ToolUse(tool_use.clone())); + } else { + event_stream.update_tool_call_fields( + &tool_use.id, + acp::ToolCallUpdateFields::new() + .title(title.as_str()) + .kind(kind) + .raw_input(tool_use.input.clone()), + ); + } + + if !tool_use.is_input_complete { + return None; + } + + let Some(tool) = tool else { + let content = format!("No tool named {} exists", tool_use.name); + return Some(Task::ready(LanguageModelToolResult { + content: LanguageModelToolResultContent::Text(Arc::from(content)), + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + })); + }; + + let fs = self.project.read(cx).fs().clone(); + let tool_event_stream = + ToolCallEventStream::new(tool_use.id.clone(), event_stream.clone(), Some(fs)); + tool_event_stream.update_fields( + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), + ); + let supports_images = self.model().is_some_and(|model| model.supports_images()); + let tool_result = tool.run(tool_use.input, tool_event_stream, cx); + log::debug!("Running tool {}", tool_use.name); + Some(cx.foreground_executor().spawn(async move { + let tool_result = tool_result.await.and_then(|output| { + if let LanguageModelToolResultContent::Image(_) = &output.llm_output + && !supports_images + { + return Err(anyhow!( + "Attempted to read an image, but this model doesn't support it.", + )); + } + Ok(output) + }); + + match tool_result { + Ok(output) => LanguageModelToolResult { + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: false, + content: output.llm_output, + output: Some(output.raw_output), + }, + Err(error) => LanguageModelToolResult { + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), + output: Some(error.to_string().into()), + }, + } + })) + } + + fn handle_tool_use_json_parse_error_event( + &mut self, + tool_use_id: LanguageModelToolUseId, + tool_name: Arc, + raw_input: Arc, + json_parse_error: String, + ) -> LanguageModelToolResult { + let tool_output = format!("Error parsing input JSON: {json_parse_error}"); + LanguageModelToolResult { + tool_use_id, + tool_name, + is_error: true, + content: LanguageModelToolResultContent::Text(tool_output.into()), + output: Some(serde_json::Value::String(raw_input.to_string())), + } + } + + fn update_model_request_usage(&self, amount: usize, limit: UsageLimit, cx: &mut Context) { + self.project + .read(cx) + .user_store() + .update(cx, |user_store, cx| { + user_store.update_model_request_usage( + ModelRequestUsage(RequestUsage { + amount: amount as i32, + limit, + }), + cx, + ) + }); + } + + pub fn title(&self) -> SharedString { + self.title.clone().unwrap_or("New Thread".into()) + } + + pub fn is_generating_summary(&self) -> bool { + self.pending_summary_generation.is_some() + } + + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { + if let Some(summary) = self.summary.as_ref() { + return Task::ready(Some(summary.clone())).shared(); + } + if let Some(task) = self.pending_summary_generation.clone() { + return task; + } + let Some(model) = self.summarization_model.clone() else { + log::error!("No summarization model available"); + return Task::ready(None).shared(); + }; let mut request = LanguageModelRequest { - thread_id: None, - prompt_id: None, - intent: Some(intent), - mode: None, - messages: vec![], - tools: Vec::new(), - tool_choice: None, - stop: Vec::new(), - temperature: AgentSettings::temperature_for_model(model, cx), - thinking_allowed: false, + intent: Some(CompletionIntent::ThreadContextSummarization), + temperature: AgentSettings::temperature_for_model(&model, cx), + ..Default::default() }; for message in &self.messages { - let mut request_message = LanguageModelRequestMessage { - role: message.role, - content: Vec::new(), - cache: false, - }; - - for segment in &message.segments { - match segment { - MessageSegment::Text(text) => request_message - .content - .push(MessageContent::Text(text.clone())), - MessageSegment::Thinking { .. } => {} - MessageSegment::RedactedThinking(_) => {} - } - } - - if request_message.content.is_empty() { - continue; - } - - request.messages.push(request_message); + request.messages.extend(message.to_request()); } request.messages.push(LanguageModelRequestMessage { role: Role::User, - content: vec![MessageContent::Text(added_user_message)], + content: vec![SUMMARIZE_THREAD_DETAILED_PROMPT.into()], cache: false, + reasoning_details: None, }); - 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 { - // Represent notification as a simulated `project_notifications` tool call - let tool_name = Arc::from("project_notifications"); - let tool = self.tools.read(cx).tool(&tool_name, cx)?; - - if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) { - return None; - } - - if self - .action_log - .update(cx, |log, cx| log.unnotified_user_edits(cx).is_none()) - { - 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, cx); - - self.tool_use.insert_tool_output( - tool_use_id, - tool_name, - tool_output, - self.configured_model.as_ref(), - self.completion_mode, - ) - } - - pub fn stream_completion( - &mut self, - request: LanguageModelRequest, - model: Arc, - intent: CompletionIntent, - window: Option, - cx: &mut Context, - ) { - self.tool_use_limit_reached = false; - - let pending_completion_id = post_inc(&mut self.completion_count); - let mut request_callback_parameters = if self.request_callback.is_some() { - Some((request.clone(), Vec::new())) - } else { - None - }; - let prompt_id = self.last_prompt_id.clone(); - let tool_use_metadata = ToolUseMetadata { - model: model.clone(), - thread_id: self.id.clone(), - prompt_id: prompt_id.clone(), - }; - - let completion_mode = request - .mode - .unwrap_or(cloud_llm_client::CompletionMode::Normal); - - self.last_received_chunk_at = Some(Instant::now()); - - let task = cx.spawn(async move |thread, cx| { - let stream_completion_future = model.stream_completion(request, cx); - let initial_token_usage = - thread.read_with(cx, |thread, _cx| thread.cumulative_token_usage); - let stream_completion = async { - let mut events = stream_completion_future.await?; - - let mut stop_reason = StopReason::EndTurn; - let mut current_token_usage = TokenUsage::default(); - - thread - .update(cx, |_thread, cx| { - cx.emit(ThreadEvent::NewRequest); - }) - .ok(); - - let mut request_assistant_message_id = None; - - while let Some(event) = events.next().await { - if let Some((_, response_events)) = request_callback_parameters.as_mut() { - response_events - .push(event.as_ref().map_err(|error| error.to_string()).cloned()); - } - - thread.update(cx, |thread, cx| { - match event? { - LanguageModelCompletionEvent::StartMessage { .. } => { - request_assistant_message_id = - Some(thread.insert_assistant_message( - vec![MessageSegment::Text(String::new())], - cx, - )); - } - LanguageModelCompletionEvent::Stop(reason) => { - stop_reason = reason; - } - LanguageModelCompletionEvent::UsageUpdate(token_usage) => { - thread.update_token_usage_at_last_message(token_usage); - thread.cumulative_token_usage = thread.cumulative_token_usage - + token_usage - - current_token_usage; - current_token_usage = token_usage; - } - LanguageModelCompletionEvent::Text(chunk) => { - thread.received_chunk(); - - cx.emit(ThreadEvent::ReceivedTextChunk); - if let Some(last_message) = thread.messages.last_mut() { - if last_message.role == Role::Assistant - && !thread.tool_use.has_tool_results(last_message.id) - { - last_message.push_text(&chunk); - cx.emit(ThreadEvent::StreamedAssistantText( - last_message.id, - chunk, - )); - } else { - // If we won't have an Assistant message yet, assume this chunk marks the beginning - // of a new Assistant response. - // - // Importantly: We do *not* want to emit a `StreamedAssistantText` event here, as it - // will result in duplicating the text of the chunk in the rendered Markdown. - request_assistant_message_id = - Some(thread.insert_assistant_message( - vec![MessageSegment::Text(chunk.to_string())], - cx, - )); - }; - } - } - LanguageModelCompletionEvent::Thinking { - text: chunk, - signature, - } => { - thread.received_chunk(); - - if let Some(last_message) = thread.messages.last_mut() { - if last_message.role == Role::Assistant - && !thread.tool_use.has_tool_results(last_message.id) - { - last_message.push_thinking(&chunk, signature); - cx.emit(ThreadEvent::StreamedAssistantThinking( - last_message.id, - chunk, - )); - } else { - // If we won't have an Assistant message yet, assume this chunk marks the beginning - // of a new Assistant response. - // - // Importantly: We do *not* want to emit a `StreamedAssistantText` event here, as it - // will result in duplicating the text of the chunk in the rendered Markdown. - request_assistant_message_id = - Some(thread.insert_assistant_message( - vec![MessageSegment::Thinking { - text: chunk.to_string(), - signature, - }], - cx, - )); - }; - } - } - LanguageModelCompletionEvent::RedactedThinking { data } => { - thread.received_chunk(); - - if let Some(last_message) = thread.messages.last_mut() { - if last_message.role == Role::Assistant - && !thread.tool_use.has_tool_results(last_message.id) - { - last_message.push_redacted_thinking(data); - } else { - request_assistant_message_id = - Some(thread.insert_assistant_message( - vec![MessageSegment::RedactedThinking(data)], - cx, - )); - }; - } - } - LanguageModelCompletionEvent::ToolUse(tool_use) => { - let last_assistant_message_id = request_assistant_message_id - .unwrap_or_else(|| { - let new_assistant_message_id = - thread.insert_assistant_message(vec![], cx); - request_assistant_message_id = - Some(new_assistant_message_id); - new_assistant_message_id - }); - - let tool_use_id = tool_use.id.clone(); - let streamed_input = if tool_use.is_input_complete { - None - } else { - Some(tool_use.input.clone()) - }; - - let ui_text = thread.tool_use.request_tool_use( - last_assistant_message_id, - tool_use, - tool_use_metadata.clone(), - cx, - ); - - if let Some(input) = streamed_input { - cx.emit(ThreadEvent::StreamedToolUse { - tool_use_id, - ui_text, - input, - }); - } - } - LanguageModelCompletionEvent::ToolUseJsonParseError { - id, - tool_name, - raw_input: invalid_input_json, - json_parse_error, - } => { - thread.receive_invalid_tool_json( - id, - tool_name, - invalid_input_json, - json_parse_error, - window, - cx, - ); - } - LanguageModelCompletionEvent::StatusUpdate(status_update) => { - if let Some(completion) = thread - .pending_completions - .iter_mut() - .find(|completion| completion.id == pending_completion_id) - { - match status_update { - CompletionRequestStatus::Queued { position } => { - completion.queue_state = - QueueState::Queued { position }; - } - CompletionRequestStatus::Started => { - completion.queue_state = QueueState::Started; - } - CompletionRequestStatus::Failed { - code, - message, - request_id: _, - retry_after, - } => { - return Err( - LanguageModelCompletionError::from_cloud_failure( - model.upstream_provider_name(), - code, - message, - retry_after.map(Duration::from_secs_f64), - ), - ); - } - CompletionRequestStatus::UsageUpdated { amount, limit } => { - thread.update_model_request_usage( - amount as u32, - limit, - cx, - ); - } - CompletionRequestStatus::ToolUseLimitReached => { - thread.tool_use_limit_reached = true; - cx.emit(ThreadEvent::ToolUseLimitReached); - } - } - } - } - } - - thread.touch_updated_at(); - cx.emit(ThreadEvent::StreamedCompletion); - cx.notify(); - - Ok(()) - })??; - - smol::future::yield_now().await; - } - - thread.update(cx, |thread, cx| { - thread.last_received_chunk_at = None; - thread - .pending_completions - .retain(|completion| completion.id != pending_completion_id); - - // If there is a response without tool use, summarize the message. Otherwise, - // allow two tool uses before summarizing. - if matches!(thread.summary, ThreadSummary::Pending) - && thread.messages.len() >= 2 - && (!thread.has_pending_tool_uses() || thread.messages.len() >= 6) - { - thread.summarize(cx); - } - })?; - - anyhow::Ok(stop_reason) - }; - - let result = stream_completion.await; - let mut retry_scheduled = false; - - thread - .update(cx, |thread, cx| { - thread.finalize_pending_checkpoint(cx); - match result.as_ref() { - Ok(stop_reason) => { - match stop_reason { - StopReason::ToolUse => { - let tool_uses = - thread.use_pending_tools(window, model.clone(), cx); - cx.emit(ThreadEvent::UsePendingTools { tool_uses }); - } - StopReason::EndTurn | StopReason::MaxTokens => { - thread.project.update(cx, |project, cx| { - project.set_agent_location(None, cx); - }); - } - StopReason::Refusal => { - thread.project.update(cx, |project, cx| { - project.set_agent_location(None, cx); - }); - - // Remove the turn that was refused. - // - // https://docs.anthropic.com/en/docs/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals#reset-context-after-refusal - { - let mut messages_to_remove = Vec::new(); - - for (ix, message) in - thread.messages.iter().enumerate().rev() - { - messages_to_remove.push(message.id); - - if message.role == Role::User { - if ix == 0 { - break; - } - - if let Some(prev_message) = - thread.messages.get(ix - 1) - && prev_message.role == Role::Assistant { - break; - } - } - } - - for message_id in messages_to_remove { - thread.delete_message(message_id, cx); - } - } - - cx.emit(ThreadEvent::ShowError(ThreadError::Message { - header: "Language model refusal".into(), - message: - "Model refused to generate content for safety reasons." - .into(), - })); - } - } - - // We successfully completed, so cancel any remaining retries. - thread.retry_state = None; - } - Err(error) => { - thread.project.update(cx, |project, cx| { - project.set_agent_location(None, cx); - }); - - if error.is::() { - cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired)); - } else if let Some(error) = - error.downcast_ref::() - { - cx.emit(ThreadEvent::ShowError( - ThreadError::ModelRequestLimitReached { plan: error.plan }, - )); - } else if let Some(completion_error) = - error.downcast_ref::() - { - match &completion_error { - LanguageModelCompletionError::PromptTooLarge { - tokens, .. - } => { - let tokens = tokens.unwrap_or_else(|| { - // We didn't get an exact token count from the API, so fall back on our estimate. - thread - .total_token_usage() - .map(|usage| usage.total) - .unwrap_or(0) - // We know the context window was exceeded in practice, so if our estimate was - // lower than max tokens, the estimate was wrong; return that we exceeded by 1. - .max( - model - .max_token_count_for_mode(completion_mode) - .saturating_add(1), - ) - }); - thread.exceeded_window_error = Some(ExceededWindowError { - model_id: model.id(), - token_count: tokens, - }); - cx.notify(); - } - _ => { - if let Some(retry_strategy) = - Thread::get_retry_strategy(completion_error) - { - log::info!( - "Retrying with {:?} for language model completion error {:?}", - retry_strategy, - completion_error - ); - - retry_scheduled = thread - .handle_retryable_error_with_delay( - completion_error, - Some(retry_strategy), - model.clone(), - intent, - window, - cx, - ); - } - } - } - } - - if !retry_scheduled { - thread.cancel_last_completion(window, cx); - } - } - } - - if !retry_scheduled { - cx.emit(ThreadEvent::Stopped(result.map_err(Arc::new))); - } - - if let Some((request_callback, (request, response_events))) = thread - .request_callback - .as_mut() - .zip(request_callback_parameters.as_ref()) - { - request_callback(request, response_events); - } - - if let Ok(initial_usage) = initial_token_usage { - let usage = thread.cumulative_token_usage - initial_usage; - - telemetry::event!( - "Assistant Thread Completion", - thread_id = thread.id().to_string(), - prompt_id = prompt_id, - model = model.telemetry_id(), - model_provider = model.provider_id().to_string(), - input_tokens = usage.input_tokens, - output_tokens = usage.output_tokens, - cache_creation_input_tokens = usage.cache_creation_input_tokens, - cache_read_input_tokens = usage.cache_read_input_tokens, - ); - } - }) - .ok(); - }); - - self.pending_completions.push(PendingCompletion { - id: pending_completion_id, - queue_state: QueueState::Sending, - _task: task, - }); - } - - pub fn summarize(&mut self, cx: &mut Context) { - let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else { - println!("No thread summary model"); - return; - }; - - if !model.provider.is_authenticated(cx) { - return; - } - - let request = self.to_summarize_request( - &model.model, - CompletionIntent::ThreadSummarization, - SUMMARIZE_THREAD_PROMPT.into(), - cx, - ); - - self.summary = ThreadSummary::Generating; - - self.pending_summary = cx.spawn(async move |this, cx| { - let result = async { - let mut messages = model.model.stream_completion(request, cx).await?; - - let mut new_summary = String::new(); + let task = cx + .spawn(async move |this, cx| { + let mut summary = String::new(); + let mut messages = model.stream_completion(request, cx).await.log_err()?; while let Some(event) = messages.next().await { - let Ok(event) = event else { - continue; - }; + let event = event.log_err()?; let text = match event { LanguageModelCompletionEvent::Text(text) => text, - LanguageModelCompletionEvent::StatusUpdate( - CompletionRequestStatus::UsageUpdated { amount, limit }, - ) => { + LanguageModelCompletionEvent::UsageUpdated { amount, limit } => { this.update(cx, |thread, cx| { - thread.update_model_request_usage(amount as u32, limit, cx); + thread.update_model_request_usage(amount, limit, cx); + }) + .ok()?; + continue; + } + _ => continue, + }; + + let mut lines = text.lines(); + summary.extend(lines.next()); + } + + log::debug!("Setting summary: {}", summary); + let summary = SharedString::from(summary); + + this.update(cx, |this, cx| { + this.summary = Some(summary.clone()); + this.pending_summary_generation = None; + cx.notify() + }) + .ok()?; + + Some(summary) + }) + .shared(); + self.pending_summary_generation = Some(task.clone()); + task + } + + fn generate_title(&mut self, cx: &mut Context) { + let Some(model) = self.summarization_model.clone() else { + return; + }; + + log::debug!( + "Generating title with model: {:?}", + self.summarization_model.as_ref().map(|model| model.name()) + ); + let mut request = LanguageModelRequest { + intent: Some(CompletionIntent::ThreadSummarization), + temperature: AgentSettings::temperature_for_model(&model, cx), + ..Default::default() + }; + + for message in &self.messages { + request.messages.extend(message.to_request()); + } + + request.messages.push(LanguageModelRequestMessage { + role: Role::User, + content: vec![SUMMARIZE_THREAD_PROMPT.into()], + cache: false, + reasoning_details: None, + }); + self.pending_title_generation = Some(cx.spawn(async move |this, cx| { + let mut title = String::new(); + + let generate = async { + let mut messages = model.stream_completion(request, cx).await?; + while let Some(event) = messages.next().await { + let event = event?; + let text = match event { + LanguageModelCompletionEvent::Text(text) => text, + LanguageModelCompletionEvent::UsageUpdated { amount, limit } => { + this.update(cx, |thread, cx| { + thread.update_model_request_usage(amount, limit, cx); })?; continue; } @@ -2081,42 +1761,274 @@ impl Thread { }; let mut lines = text.lines(); - new_summary.extend(lines.next()); + title.extend(lines.next()); // Stop if the LLM generated multiple lines. if lines.next().is_some() { break; } } + anyhow::Ok(()) + }; - anyhow::Ok(new_summary) + if generate.await.context("failed to generate title").is_ok() { + _ = this.update(cx, |this, cx| this.set_title(title.into(), cx)); } - .await; - - this.update(cx, |this, cx| { - match result { - Ok(new_summary) => { - if new_summary.is_empty() { - this.summary = ThreadSummary::Error; - } else { - this.summary = ThreadSummary::Ready(new_summary.into()); - } - } - Err(err) => { - this.summary = ThreadSummary::Error; - log::error!("Failed to generate thread summary: {}", err); - } - } - cx.emit(ThreadEvent::SummaryGenerated); - }) - .log_err()?; - - Some(()) - }); + _ = this.update(cx, |this, _| this.pending_title_generation = None); + })); } - fn get_retry_strategy(error: &LanguageModelCompletionError) -> Option { + pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { + self.pending_title_generation = None; + if Some(&title) != self.title.as_ref() { + self.title = Some(title); + cx.emit(TitleUpdated); + cx.notify(); + } + } + + fn clear_summary(&mut self) { + self.summary = None; + self.pending_summary_generation = None; + } + + fn last_user_message(&self) -> Option<&UserMessage> { + self.messages + .iter() + .rev() + .find_map(|message| match message { + Message::User(user_message) => Some(user_message), + Message::Agent(_) => None, + Message::Resume => None, + }) + } + + fn pending_message(&mut self) -> &mut AgentMessage { + self.pending_message.get_or_insert_default() + } + + fn flush_pending_message(&mut self, cx: &mut Context) { + let Some(mut message) = self.pending_message.take() else { + return; + }; + + if message.content.is_empty() { + return; + } + + for content in &message.content { + let AgentMessageContent::ToolUse(tool_use) = content else { + continue; + }; + + if !message.tool_results.contains_key(&tool_use.id) { + message.tool_results.insert( + tool_use.id.clone(), + LanguageModelToolResult { + tool_use_id: tool_use.id.clone(), + tool_name: tool_use.name.clone(), + is_error: true, + content: LanguageModelToolResultContent::Text(TOOL_CANCELED_MESSAGE.into()), + output: None, + }, + ); + } + } + + self.messages.push(Message::Agent(message)); + self.updated_at = Utc::now(); + self.clear_summary(); + cx.notify() + } + + pub(crate) fn build_completion_request( + &self, + completion_intent: CompletionIntent, + cx: &App, + ) -> Result { + let model = self.model().context("No language model configured")?; + let tools = if let Some(turn) = self.running_turn.as_ref() { + turn.tools + .iter() + .filter_map(|(tool_name, tool)| { + log::trace!("Including tool: {}", tool_name); + Some(LanguageModelRequestTool { + name: tool_name.to_string(), + description: tool.description().to_string(), + input_schema: tool.input_schema(model.tool_input_format()).log_err()?, + }) + }) + .collect::>() + } else { + Vec::new() + }; + + log::debug!("Building completion request"); + log::debug!("Completion intent: {:?}", completion_intent); + log::debug!("Completion mode: {:?}", self.completion_mode); + + let available_tools: Vec<_> = self + .running_turn + .as_ref() + .map(|turn| turn.tools.keys().cloned().collect()) + .unwrap_or_default(); + + log::debug!("Request includes {} tools", available_tools.len()); + let messages = self.build_request_messages(available_tools, cx); + log::debug!("Request will include {} messages", messages.len()); + + let request = LanguageModelRequest { + thread_id: Some(self.id.to_string()), + prompt_id: Some(self.prompt_id.to_string()), + intent: Some(completion_intent), + mode: Some(self.completion_mode.into()), + messages, + tools, + tool_choice: None, + stop: Vec::new(), + temperature: AgentSettings::temperature_for_model(model, cx), + thinking_allowed: true, + }; + + log::debug!("Completion request built successfully"); + Ok(request) + } + + fn enabled_tools( + &self, + profile: &AgentProfileSettings, + model: &Arc, + cx: &App, + ) -> BTreeMap> { + fn truncate(tool_name: &SharedString) -> SharedString { + if tool_name.len() > MAX_TOOL_NAME_LENGTH { + let mut truncated = tool_name.to_string(); + truncated.truncate(MAX_TOOL_NAME_LENGTH); + truncated.into() + } else { + tool_name.clone() + } + } + + let mut tools = self + .tools + .iter() + .filter_map(|(tool_name, tool)| { + if tool.supports_provider(&model.provider_id()) + && profile.is_tool_enabled(tool_name) + { + Some((truncate(tool_name), tool.clone())) + } else { + None + } + }) + .collect::>(); + + let mut context_server_tools = Vec::new(); + let mut seen_tools = tools.keys().cloned().collect::>(); + let mut duplicate_tool_names = HashSet::default(); + for (server_id, server_tools) in self.context_server_registry.read(cx).servers() { + for (tool_name, tool) in server_tools { + if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) { + let tool_name = truncate(tool_name); + if !seen_tools.insert(tool_name.clone()) { + duplicate_tool_names.insert(tool_name.clone()); + } + context_server_tools.push((server_id.clone(), tool_name, tool.clone())); + } + } + } + + // When there are duplicate tool names, disambiguate by prefixing them + // with the server ID. In the rare case there isn't enough space for the + // disambiguated tool name, keep only the last tool with this name. + for (server_id, tool_name, tool) in context_server_tools { + if duplicate_tool_names.contains(&tool_name) { + let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); + if available >= 2 { + let mut disambiguated = server_id.0.to_string(); + disambiguated.truncate(available - 1); + disambiguated.push('_'); + disambiguated.push_str(&tool_name); + tools.insert(disambiguated.into(), tool.clone()); + } else { + tools.insert(tool_name, tool.clone()); + } + } else { + tools.insert(tool_name, tool.clone()); + } + } + + tools + } + + fn tool(&self, name: &str) -> Option> { + self.running_turn.as_ref()?.tools.get(name).cloned() + } + + fn build_request_messages( + &self, + available_tools: Vec, + cx: &App, + ) -> Vec { + log::trace!( + "Building request messages from {} thread messages", + self.messages.len() + ); + + let system_prompt = SystemPromptTemplate { + project: self.project_context.read(cx), + available_tools, + model_name: self.model.as_ref().map(|m| m.name().0.to_string()), + } + .render(&self.templates) + .context("failed to build system prompt") + .expect("Invalid template"); + let mut messages = vec![LanguageModelRequestMessage { + role: Role::System, + content: vec![system_prompt.into()], + cache: false, + reasoning_details: None, + }]; + for message in &self.messages { + messages.extend(message.to_request()); + } + + if let Some(last_message) = messages.last_mut() { + last_message.cache = true; + } + + if let Some(message) = self.pending_message.as_ref() { + messages.extend(message.to_request()); + } + + messages + } + + pub fn to_markdown(&self) -> String { + let mut markdown = String::new(); + for (ix, message) in self.messages.iter().enumerate() { + if ix > 0 { + markdown.push('\n'); + } + markdown.push_str(&message.to_markdown()); + } + + if let Some(message) = self.pending_message.as_ref() { + markdown.push('\n'); + markdown.push_str(&message.to_markdown()); + } + + markdown + } + + fn advance_prompt_id(&mut self) { + self.prompt_id = PromptId::new(); + } + + fn retry_strategy_for(error: &LanguageModelCompletionError) -> Option { use LanguageModelCompletionError::*; + use http_client::StatusCode; // General strategy here: // - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all. @@ -2205,8 +2117,8 @@ impl Thread { }) } Other(err) - if err.is::() - || err.is::() => + if err.is::() + || err.is::() => { // Retrying won't help for Payment Required or Model Request Limit errors (where // the user must upgrade to usage-based billing to get more requests, or else wait @@ -2220,3167 +2132,533 @@ impl Thread { }), } } +} - fn handle_retryable_error_with_delay( - &mut self, - error: &LanguageModelCompletionError, - strategy: Option, - model: Arc, - intent: CompletionIntent, - window: Option, - cx: &mut Context, - ) -> bool { - // Store context for the Retry button - self.last_error_context = Some((model.clone(), intent)); +struct RunningTurn { + /// Holds the task that handles agent interaction until the end of the turn. + /// Survives across multiple requests as the model performs tool calls and + /// we run tools, report their results. + _task: Task<()>, + /// The current event stream for the running turn. Used to report a final + /// cancellation event if we cancel the turn. + event_stream: ThreadEventStream, + /// The tools that were enabled for this turn. + tools: BTreeMap>, +} - // Only auto-retry if Burn Mode is enabled - if self.completion_mode != CompletionMode::Burn { - // Show error with retry options - cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError { - message: format!( - "{}\n\nTo automatically retry when similar errors happen, enable Burn Mode.", - error - ) - .into(), - can_enable_burn_mode: true, - })); - return false; - } - - let Some(strategy) = strategy.or_else(|| Self::get_retry_strategy(error)) else { - return false; - }; - - let max_attempts = match &strategy { - RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, - RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, - }; - - let retry_state = self.retry_state.get_or_insert(RetryState { - attempt: 0, - max_attempts, - intent, - }); - - retry_state.attempt += 1; - let attempt = retry_state.attempt; - let max_attempts = retry_state.max_attempts; - let intent = retry_state.intent; - - if attempt <= max_attempts { - let delay = match &strategy { - RetryStrategy::ExponentialBackoff { initial_delay, .. } => { - let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); - Duration::from_secs(delay_secs) - } - RetryStrategy::Fixed { delay, .. } => *delay, - }; - - // Add a transient message to inform the user - let delay_secs = delay.as_secs(); - let retry_message = if max_attempts == 1 { - format!("{error}. Retrying in {delay_secs} seconds...") - } else { - format!( - "{error}. Retrying (attempt {attempt} of {max_attempts}) \ - in {delay_secs} seconds..." - ) - }; - log::warn!( - "Retrying completion request (attempt {attempt} of {max_attempts}) \ - in {delay_secs} seconds: {error:?}", - ); - - // Add a UI-only message instead of a regular message - let id = self.next_message_id.post_inc(); - self.messages.push(Message { - id, - role: Role::System, - segments: vec![MessageSegment::Text(retry_message)], - loaded_context: LoadedContext::default(), - creases: Vec::new(), - is_hidden: false, - ui_only: true, - }); - cx.emit(ThreadEvent::MessageAdded(id)); - - // Schedule the retry - let thread_handle = cx.entity().downgrade(); - - cx.spawn(async move |_thread, cx| { - cx.background_executor().timer(delay).await; - - thread_handle - .update(cx, |thread, cx| { - // Retry the completion - thread.send_to_model(model, intent, window, cx); - }) - .log_err(); - }) - .detach(); - - true - } else { - // Max retries exceeded - self.retry_state = None; - - // Stop generating since we're giving up on retrying. - self.pending_completions.clear(); - - // Show error alongside a Retry button, but no - // Enable Burn Mode button (since it's already enabled) - cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError { - message: format!("Failed after retrying: {}", error).into(), - can_enable_burn_mode: false, - })); - - false - } +impl RunningTurn { + fn cancel(self) { + log::debug!("Cancelling in progress turn"); + self.event_stream.send_canceled(); } +} - pub fn start_generating_detailed_summary_if_needed( - &mut self, - thread_store: WeakEntity, - cx: &mut Context, - ) { - let Some(last_message_id) = self.messages.last().map(|message| message.id) else { - return; - }; +pub struct TokenUsageUpdated(pub Option); - match &*self.detailed_summary_rx.borrow() { - DetailedSummaryState::Generating { message_id, .. } - | DetailedSummaryState::Generated { message_id, .. } - if *message_id == last_message_id => - { - // Already up-to-date - return; - } - _ => {} - } +impl EventEmitter for Thread {} - let Some(ConfiguredModel { model, provider }) = - LanguageModelRegistry::read_global(cx).thread_summary_model() - else { - return; - }; +pub struct TitleUpdated; - if !provider.is_authenticated(cx) { - return; - } +impl EventEmitter for Thread {} - let request = self.to_summarize_request( - &model, - CompletionIntent::ThreadContextSummarization, - SUMMARIZE_THREAD_DETAILED_PROMPT.into(), - cx, - ); +pub trait AgentTool +where + Self: 'static + Sized, +{ + type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema; + type Output: for<'de> Deserialize<'de> + Serialize + Into; - *self.detailed_summary_tx.borrow_mut() = DetailedSummaryState::Generating { - message_id: last_message_id, - }; + fn name() -> &'static str; - // Replace the detailed summarization task if there is one, cancelling it. It would probably - // be better to allow the old task to complete, but this would require logic for choosing - // which result to prefer (the old task could complete after the new one, resulting in a - // stale summary). - self.detailed_summary_task = cx.spawn(async move |thread, cx| { - let stream = model.stream_completion_text(request, cx); - let Some(mut messages) = stream.await.log_err() else { - thread - .update(cx, |thread, _cx| { - *thread.detailed_summary_tx.borrow_mut() = - DetailedSummaryState::NotGenerated; - }) - .ok()?; - return None; - }; - - let mut new_detailed_summary = String::new(); - - while let Some(chunk) = messages.stream.next().await { - if let Some(chunk) = chunk.log_err() { - new_detailed_summary.push_str(&chunk); - } - } - - thread - .update(cx, |thread, _cx| { - *thread.detailed_summary_tx.borrow_mut() = DetailedSummaryState::Generated { - text: new_detailed_summary.into(), - message_id: last_message_id, - }; - }) - .ok()?; - - // Save thread so its summary can be reused later - if let Some(thread) = thread.upgrade() - && let Ok(Ok(save_task)) = cx.update(|cx| { - thread_store - .update(cx, |thread_store, cx| thread_store.save_thread(&thread, cx)) - }) - { - save_task.await.log_err(); - } - - Some(()) - }); - } - - pub async fn wait_for_detailed_summary_or_text( - this: &Entity, - cx: &mut AsyncApp, - ) -> Option { - let mut detailed_summary_rx = this - .read_with(cx, |this, _cx| this.detailed_summary_rx.clone()) - .ok()?; - loop { - match detailed_summary_rx.recv().await? { - DetailedSummaryState::Generating { .. } => {} - DetailedSummaryState::NotGenerated => { - return this.read_with(cx, |this, _cx| this.text().into()).ok(); - } - DetailedSummaryState::Generated { text, .. } => return Some(text), - } - } - } - - pub fn latest_detailed_summary_or_text(&self) -> SharedString { - self.detailed_summary_rx - .borrow() - .text() - .unwrap_or_else(|| self.text().into()) - } - - pub fn is_generating_detailed_summary(&self) -> bool { - matches!( - &*self.detailed_summary_rx.borrow(), - DetailedSummaryState::Generating { .. } + fn description() -> SharedString { + let schema = schemars::schema_for!(Self::Input); + SharedString::new( + schema + .get("description") + .and_then(|description| description.as_str()) + .unwrap_or_default(), ) } - pub fn use_pending_tools( - &mut self, - window: Option, - model: Arc, - cx: &mut Context, - ) -> Vec { - let request = - Arc::new(self.to_completion_request(model.clone(), CompletionIntent::ToolResults, cx)); - let pending_tool_uses = self - .tool_use - .pending_tool_uses() - .into_iter() - .filter(|tool_use| tool_use.status.is_idle()) - .cloned() - .collect::>(); + fn kind() -> acp::ToolKind; - for tool_use in pending_tool_uses.iter() { - self.use_pending_tool(tool_use.clone(), request.clone(), model.clone(), window, cx); - } + /// The initial tool title to display. Can be updated during the tool run. + fn initial_title( + &self, + input: Result, + cx: &mut App, + ) -> SharedString; - pending_tool_uses + /// Returns the JSON schema that describes the tool's input. + fn input_schema(format: LanguageModelToolSchemaFormat) -> Schema { + language_model::tool_schema::root_schema_for::(format) } - fn use_pending_tool( - &mut self, - tool_use: PendingToolUse, - request: Arc, - model: Arc, - window: Option, - cx: &mut Context, - ) { - let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) else { - return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx); - }; - - if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) { - return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx); - } - - if tool.needs_confirmation(&tool_use.input, &self.project, cx) - && !AgentSettings::get_global(cx).always_allow_tool_actions - { - self.tool_use.confirm_tool_use( - tool_use.id, - tool_use.ui_text, - tool_use.input, - request, - tool, - ); - cx.emit(ThreadEvent::ToolConfirmationNeeded); - } else { - self.run_tool( - tool_use.id, - tool_use.ui_text, - tool_use.input, - request, - tool, - model, - window, - cx, - ); - } + /// Some tools rely on a provider for the underlying billing or other reasons. + /// Allow the tool to check if they are compatible, or should be filtered out. + fn supports_provider(_provider: &LanguageModelProviderId) -> bool { + true } - pub fn handle_hallucinated_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - hallucinated_tool_name: Arc, - window: Option, - cx: &mut Context, - ) { - let available_tools = self.profile.enabled_tools(cx); + /// Runs the tool with the provided input. + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task>; - let tool_list = available_tools - .iter() - .map(|(name, tool)| format!("- {}: {}", name, tool.description())) - .collect::>() - .join("\n"); - - let error_message = format!( - "The tool '{}' doesn't exist or is not enabled. Available tools:\n{}", - hallucinated_tool_name, tool_list - ); - - let pending_tool_use = self.tool_use.insert_tool_output( - tool_use_id.clone(), - hallucinated_tool_name, - Err(anyhow!("Missing tool call: {error_message}")), - self.configured_model.as_ref(), - self.completion_mode, - ); - - cx.emit(ThreadEvent::MissingToolUse { - tool_use_id: tool_use_id.clone(), - ui_text: error_message.into(), - }); - - self.tool_finished(tool_use_id, pending_tool_use, false, window, cx); + /// Emits events for a previous execution of the tool. + fn replay( + &self, + _input: Self::Input, + _output: Self::Output, + _event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Result<()> { + Ok(()) } - pub fn receive_invalid_tool_json( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - invalid_json: Arc, - error: String, - window: Option, - cx: &mut Context, - ) { - log::error!("The model returned invalid input JSON: {invalid_json}"); - - let pending_tool_use = self.tool_use.insert_tool_output( - tool_use_id.clone(), - tool_name, - Err(anyhow!("Error parsing input JSON: {error}")), - self.configured_model.as_ref(), - self.completion_mode, - ); - let ui_text = if let Some(pending_tool_use) = &pending_tool_use { - pending_tool_use.ui_text.clone() - } else { - log::error!( - "There was no pending tool use for tool use {tool_use_id}, even though it finished (with invalid input JSON)." - ); - format!("Unknown tool {}", tool_use_id).into() - }; - - cx.emit(ThreadEvent::InvalidToolInput { - tool_use_id: tool_use_id.clone(), - ui_text, - invalid_input_json: invalid_json, - }); - - self.tool_finished(tool_use_id, pending_tool_use, false, window, cx); + fn erase(self) -> Arc { + Arc::new(Erased(Arc::new(self))) } +} - pub fn run_tool( - &mut self, - tool_use_id: LanguageModelToolUseId, - ui_text: impl Into, +pub struct Erased(T); + +pub struct AgentToolOutput { + pub llm_output: LanguageModelToolResultContent, + pub raw_output: serde_json::Value, +} + +pub trait AnyAgentTool { + fn name(&self) -> SharedString; + fn description(&self) -> SharedString; + fn kind(&self) -> acp::ToolKind; + fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString; + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; + fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool { + true + } + fn run( + self: Arc, input: serde_json::Value, - request: Arc, - tool: Arc, - model: Arc, - window: Option, - cx: &mut Context, - ) { - let task = - self.spawn_tool_use(tool_use_id.clone(), request, input, tool, model, window, cx); - self.tool_use - .run_pending_tool(tool_use_id, ui_text.into(), task); + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task>; + fn replay( + &self, + input: serde_json::Value, + output: serde_json::Value, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Result<()>; +} + +impl AnyAgentTool for Erased> +where + T: AgentTool, +{ + fn name(&self) -> SharedString { + T::name().into() } - fn spawn_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - request: Arc, + fn description(&self) -> SharedString { + T::description() + } + + fn kind(&self) -> agent_client_protocol::ToolKind { + T::kind() + } + + fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString { + let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input); + self.0.initial_title(parsed_input, _cx) + } + + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { + let mut json = serde_json::to_value(T::input_schema(format))?; + language_model::tool_schema::adapt_schema_to_format(&mut json, format)?; + Ok(json) + } + + fn supports_provider(&self, provider: &LanguageModelProviderId) -> bool { + T::supports_provider(provider) + } + + fn run( + self: Arc, input: serde_json::Value, - tool: Arc, - model: Arc, - window: Option, - cx: &mut Context, - ) -> Task<()> { - let tool_name: Arc = tool.name().into(); - - let tool_result = tool.run( - input, - request, - self.project.clone(), - self.action_log.clone(), - model, - window, - cx, - ); - - // Store the card separately if it exists - if let Some(card) = tool_result.card.clone() { - self.tool_use - .insert_tool_result_card(tool_use_id.clone(), card); - } - - cx.spawn({ - async move |thread: WeakEntity, cx| { - let output = tool_result.output.await; - - thread - .update(cx, |thread, cx| { - let pending_tool_use = thread.tool_use.insert_tool_output( - tool_use_id.clone(), - tool_name, - output, - thread.configured_model.as_ref(), - thread.completion_mode, - ); - thread.tool_finished(tool_use_id, pending_tool_use, false, window, cx); - }) - .ok(); - } + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + cx.spawn(async move |cx| { + let input = serde_json::from_value(input)?; + let output = cx + .update(|cx| self.0.clone().run(input, event_stream, cx))? + .await?; + let raw_output = serde_json::to_value(&output)?; + Ok(AgentToolOutput { + llm_output: output.into(), + raw_output, + }) }) } - fn tool_finished( - &mut self, - tool_use_id: LanguageModelToolUseId, - pending_tool_use: Option, - canceled: bool, - window: Option, - cx: &mut Context, + fn replay( + &self, + input: serde_json::Value, + output: serde_json::Value, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Result<()> { + let input = serde_json::from_value(input)?; + let output = serde_json::from_value(output)?; + self.0.replay(input, output, event_stream, cx) + } +} + +#[derive(Clone)] +struct ThreadEventStream(mpsc::UnboundedSender>); + +impl ThreadEventStream { + fn send_user_message(&self, message: &UserMessage) { + self.0 + .unbounded_send(Ok(ThreadEvent::UserMessage(message.clone()))) + .ok(); + } + + fn send_text(&self, text: &str) { + self.0 + .unbounded_send(Ok(ThreadEvent::AgentText(text.to_string()))) + .ok(); + } + + fn send_thinking(&self, text: &str) { + self.0 + .unbounded_send(Ok(ThreadEvent::AgentThinking(text.to_string()))) + .ok(); + } + + fn send_tool_call( + &self, + id: &LanguageModelToolUseId, + tool_name: &str, + title: SharedString, + kind: acp::ToolKind, + input: serde_json::Value, ) { - if self.all_tools_finished() - && let Some(ConfiguredModel { model, .. }) = self.configured_model.as_ref() - && !canceled - { - self.send_to_model(model.clone(), CompletionIntent::ToolResults, window, cx); - } + self.0 + .unbounded_send(Ok(ThreadEvent::ToolCall(Self::initial_tool_call( + id, + tool_name, + title.to_string(), + kind, + input, + )))) + .ok(); + } - cx.emit(ThreadEvent::ToolFinished { + fn initial_tool_call( + id: &LanguageModelToolUseId, + tool_name: &str, + title: String, + kind: acp::ToolKind, + input: serde_json::Value, + ) -> acp::ToolCall { + acp::ToolCall::new(id.to_string(), title) + .kind(kind) + .raw_input(input) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + tool_name.into(), + )])) + } + + fn update_tool_call_fields( + &self, + tool_use_id: &LanguageModelToolUseId, + fields: acp::ToolCallUpdateFields, + ) { + self.0 + .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( + acp::ToolCallUpdate::new(tool_use_id.to_string(), fields).into(), + ))) + .ok(); + } + + fn send_retry(&self, status: acp_thread::RetryStatus) { + self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); + } + + fn send_stop(&self, reason: acp::StopReason) { + self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok(); + } + + fn send_canceled(&self) { + self.0 + .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled))) + .ok(); + } + + fn send_error(&self, error: impl Into) { + self.0.unbounded_send(Err(error.into())).ok(); + } +} + +#[derive(Clone)] +pub struct ToolCallEventStream { + tool_use_id: LanguageModelToolUseId, + stream: ThreadEventStream, + fs: Option>, +} + +impl ToolCallEventStream { + #[cfg(any(test, feature = "test-support"))] + pub fn test() -> (Self, ToolCallEventStreamReceiver) { + let (events_tx, events_rx) = mpsc::unbounded::>(); + + let stream = ToolCallEventStream::new("test_id".into(), ThreadEventStream(events_tx), None); + + (stream, ToolCallEventStreamReceiver(events_rx)) + } + + fn new( + tool_use_id: LanguageModelToolUseId, + stream: ThreadEventStream, + fs: Option>, + ) -> Self { + Self { tool_use_id, - pending_tool_use, - }); - } - - /// Cancels the last pending completion, if there are any pending. - /// - /// Returns whether a completion was canceled. - pub fn cancel_last_completion( - &mut self, - window: Option, - cx: &mut Context, - ) -> bool { - let mut canceled = self.pending_completions.pop().is_some() || self.retry_state.is_some(); - - self.retry_state = None; - - for pending_tool_use in self.tool_use.cancel_pending() { - canceled = true; - self.tool_finished( - pending_tool_use.id.clone(), - Some(pending_tool_use), - true, - window, - cx, - ); + stream, + fs, } - - if canceled { - cx.emit(ThreadEvent::CompletionCanceled); - - // When canceled, we always want to insert the checkpoint. - // (We skip over finalize_pending_checkpoint, because it - // would conclude we didn't have anything to insert here.) - if let Some(checkpoint) = self.pending_checkpoint.take() { - self.insert_checkpoint(checkpoint, cx); - } - } else { - self.finalize_pending_checkpoint(cx); - } - - canceled } - /// Signals that any in-progress editing should be canceled. - /// - /// This method is used to notify listeners (like ActiveThread) that - /// they should cancel any editing operations. - pub fn cancel_editing(&mut self, cx: &mut Context) { - cx.emit(ThreadEvent::CancelEditing); + pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) { + self.stream + .update_tool_call_fields(&self.tool_use_id, fields); } - pub fn message_feedback(&self, message_id: MessageId) -> Option { - self.message_feedback.get(&message_id).copied() + pub fn update_diff(&self, diff: Entity) { + self.stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( + acp_thread::ToolCallUpdateDiff { + id: acp::ToolCallId::new(self.tool_use_id.to_string()), + diff, + } + .into(), + ))) + .ok(); } - pub fn report_message_feedback( - &mut self, - message_id: MessageId, - feedback: ThreadFeedback, - cx: &mut Context, - ) -> Task> { - if self.message_feedback.get(&message_id) == Some(&feedback) { + pub fn authorize(&self, title: impl Into, cx: &mut App) -> Task> { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { return Task::ready(Ok(())); } - let final_project_snapshot = Self::project_snapshot(self.project.clone(), cx); - let serialized_thread = self.serialize(cx); - let thread_id = self.id().clone(); - let client = self.project.read(cx).client(); - - let enabled_tool_names: Vec = self - .profile - .enabled_tools(cx) - .iter() - .map(|(name, _)| name.clone().into()) - .collect(); - - self.message_feedback.insert(message_id, feedback); - - cx.notify(); - - let message_content = self - .message(message_id) - .map(|msg| msg.to_message_content()) - .unwrap_or_default(); - - cx.background_spawn(async move { - let final_project_snapshot = final_project_snapshot.await; - let serialized_thread = serialized_thread.await?; - let thread_data = - serde_json::to_value(serialized_thread).unwrap_or_else(|_| serde_json::Value::Null); - - let rating = match feedback { - ThreadFeedback::Positive => "positive", - ThreadFeedback::Negative => "negative", - }; - telemetry::event!( - "Assistant Thread Rated", - rating, - thread_id, - enabled_tool_names, - message_id = message_id.0, - message_content, - thread_data, - final_project_snapshot - ); - client.telemetry().flush_events().await; - - Ok(()) - }) - } - - /// Create a snapshot of the current project state including git information and unsaved buffers. - fn project_snapshot( - project: Entity, - cx: &mut Context, - ) -> Task> { - let git_store = project.read(cx).git_store().clone(); - let worktree_snapshots: Vec<_> = project - .read(cx) - .visible_worktrees(cx) - .map(|worktree| Self::worktree_snapshot(worktree, git_store.clone(), cx)) - .collect(); - - cx.spawn(async move |_, _| { - let worktree_snapshots = futures::future::join_all(worktree_snapshots).await; - - Arc::new(ProjectSnapshot { - worktree_snapshots, - timestamp: Utc::now(), - }) - }) - } - - fn worktree_snapshot( - worktree: Entity, - git_store: Entity, - cx: &App, - ) -> Task { - cx.spawn(async move |cx| { - // Get worktree path and snapshot - let worktree_info = cx.update(|app_cx| { - let worktree = worktree.read(app_cx); - let path = worktree.abs_path().to_string_lossy().into_owned(); - let snapshot = worktree.snapshot(); - (path, snapshot) - }); - - let Ok((worktree_path, _snapshot)) = worktree_info else { - return WorktreeSnapshot { - worktree_path: String::new(), - git_state: None, - }; - }; - - let git_state = git_store - .update(cx, |git_store, cx| { - git_store - .repositories() - .values() - .find(|repo| { - repo.read(cx) - .abs_path_to_repo_path(&worktree.read(cx).abs_path()) - .is_some() - }) - .cloned() - }) - .ok() - .flatten() - .map(|repo| { - repo.update(cx, |repo, _| { - let current_branch = - repo.branch.as_ref().map(|branch| branch.name().to_owned()); - repo.send_job(None, |state, _| async move { - let RepositoryState::Local { backend, .. } = state else { - return GitState { - remote_url: None, - head_sha: None, - current_branch, - diff: None, - }; - }; - - let remote_url = backend.remote_url("origin"); - let head_sha = backend.head_sha().await; - let diff = backend.diff(DiffType::HeadToWorktree).await.ok(); - - GitState { - remote_url, - head_sha, - current_branch, - diff, - } - }) - }) - }); - - let git_state = match git_state { - Some(git_state) => match git_state.ok() { - Some(git_state) => git_state.await.ok(), - None => None, + let (response_tx, response_rx) = oneshot::channel(); + self.stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new( + self.tool_use_id.to_string(), + acp::ToolCallUpdateFields::new().title(title.into()), + ), + options: vec![ + acp::PermissionOption::new( + acp::PermissionOptionId::new("always_allow"), + "Always Allow", + acp::PermissionOptionKind::AllowAlways, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("deny"), + "Deny", + acp::PermissionOptionKind::RejectOnce, + ), + ], + response: response_tx, }, - None => None, - }; + ))) + .ok(); + let fs = self.fs.clone(); + cx.spawn(async move |cx| match response_rx.await?.0.as_ref() { + "always_allow" => { + if let Some(fs) = fs.clone() { + cx.update(|cx| { + update_settings_file(fs, cx, |settings, _| { + settings + .agent + .get_or_insert_default() + .set_always_allow_tool_actions(true); + }); + })?; + } - WorktreeSnapshot { - worktree_path, - git_state, + Ok(()) } + "allow" => Ok(()), + _ => Err(anyhow!("Permission to run tool denied by user")), }) } - - pub fn to_markdown(&self, cx: &App) -> Result { - let mut markdown = Vec::new(); - - let summary = self.summary().or_default(); - writeln!(markdown, "# {summary}\n")?; - - for message in self.messages() { - writeln!( - markdown, - "## {role}\n", - role = match message.role { - Role::User => "User", - Role::Assistant => "Agent", - Role::System => "System", - } - )?; - - if !message.loaded_context.text.is_empty() { - writeln!(markdown, "{}", message.loaded_context.text)?; - } - - if !message.loaded_context.images.is_empty() { - writeln!( - markdown, - "\n{} images attached as context.\n", - message.loaded_context.images.len() - )?; - } - - for segment in &message.segments { - match segment { - MessageSegment::Text(text) => writeln!(markdown, "{}\n", text)?, - MessageSegment::Thinking { text, .. } => { - writeln!(markdown, "\n{}\n\n", text)? - } - MessageSegment::RedactedThinking(_) => {} - } - } - - for tool_use in self.tool_uses_for_message(message.id, cx) { - writeln!( - markdown, - "**Use Tool: {} ({})**", - tool_use.name, tool_use.id - )?; - writeln!(markdown, "```json")?; - writeln!( - markdown, - "{}", - serde_json::to_string_pretty(&tool_use.input)? - )?; - writeln!(markdown, "```")?; - } - - for tool_result in self.tool_results_for_message(message.id) { - write!(markdown, "\n**Tool Results: {}", tool_result.tool_use_id)?; - if tool_result.is_error { - write!(markdown, " (Error)")?; - } - - writeln!(markdown, "**\n")?; - match &tool_result.content { - LanguageModelToolResultContent::Text(text) => { - writeln!(markdown, "{text}")?; - } - LanguageModelToolResultContent::Image(image) => { - writeln!(markdown, "![Image](data:base64,{})", image.source)?; - } - } - - if let Some(output) = tool_result.output.as_ref() { - writeln!( - markdown, - "\n\nDebug Output:\n\n```json\n{}\n```\n", - serde_json::to_string_pretty(output)? - )?; - } - } - } - - Ok(String::from_utf8_lossy(&markdown).to_string()) - } - - pub fn keep_edits_in_range( - &mut self, - buffer: Entity, - buffer_range: Range, - cx: &mut Context, - ) { - self.action_log.update(cx, |action_log, cx| { - action_log.keep_edits_in_range(buffer, buffer_range, cx) - }); - } - - pub fn keep_all_edits(&mut self, cx: &mut Context) { - self.action_log - .update(cx, |action_log, cx| action_log.keep_all_edits(cx)); - } - - pub fn reject_edits_in_ranges( - &mut self, - buffer: Entity, - buffer_ranges: Vec>, - cx: &mut Context, - ) -> Task> { - self.action_log.update(cx, |action_log, cx| { - action_log.reject_edits_in_ranges(buffer, buffer_ranges, cx) - }) - } - - pub fn action_log(&self) -> &Entity { - &self.action_log - } - - pub fn project(&self) -> &Entity { - &self.project - } - - pub fn cumulative_token_usage(&self) -> TokenUsage { - self.cumulative_token_usage - } - - pub fn token_usage_up_to_message(&self, message_id: MessageId) -> TotalTokenUsage { - let Some(model) = self.configured_model.as_ref() else { - return TotalTokenUsage::default(); - }; - - let max = model - .model - .max_token_count_for_mode(self.completion_mode().into()); - - let index = self - .messages - .iter() - .position(|msg| msg.id == message_id) - .unwrap_or(0); - - if index == 0 { - return TotalTokenUsage { total: 0, max }; - } - - let token_usage = &self - .request_token_usage - .get(index - 1) - .cloned() - .unwrap_or_default(); - - TotalTokenUsage { - total: token_usage.total_tokens(), - max, - } - } - - pub fn total_token_usage(&self) -> Option { - let model = self.configured_model.as_ref()?; - - let max = model - .model - .max_token_count_for_mode(self.completion_mode().into()); - - if let Some(exceeded_error) = &self.exceeded_window_error - && model.model.id() == exceeded_error.model_id - { - return Some(TotalTokenUsage { - total: exceeded_error.token_count, - max, - }); - } - - let total = self - .token_usage_at_last_message() - .unwrap_or_default() - .total_tokens(); - - Some(TotalTokenUsage { total, max }) - } - - fn token_usage_at_last_message(&self) -> Option { - self.request_token_usage - .get(self.messages.len().saturating_sub(1)) - .or_else(|| self.request_token_usage.last()) - .cloned() - } - - fn update_token_usage_at_last_message(&mut self, token_usage: TokenUsage) { - let placeholder = self.token_usage_at_last_message().unwrap_or_default(); - self.request_token_usage - .resize(self.messages.len(), placeholder); - - if let Some(last) = self.request_token_usage.last_mut() { - *last = token_usage; - } - } - - fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut Context) { - self.project - .read(cx) - .user_store() - .update(cx, |user_store, cx| { - user_store.update_model_request_usage( - ModelRequestUsage(RequestUsage { - amount: amount as i32, - limit, - }), - cx, - ) - }); - } - - pub fn deny_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - window: Option, - cx: &mut Context, - ) { - let err = Err(anyhow::anyhow!( - "Permission to run tool action denied by user" - )); - - self.tool_use.insert_tool_output( - tool_use_id.clone(), - tool_name, - err, - self.configured_model.as_ref(), - self.completion_mode, - ); - self.tool_finished(tool_use_id, None, true, window, cx); - } } -#[derive(Debug, Clone, Error)] -pub enum ThreadError { - #[error("Payment required")] - PaymentRequired, - #[error("Model request limit reached")] - ModelRequestLimitReached { plan: Plan }, - #[error("Message {header}: {message}")] - Message { - header: SharedString, - message: SharedString, - }, - #[error("Retryable error: {message}")] - RetryableError { - message: SharedString, - can_enable_burn_mode: bool, - }, -} - -#[derive(Debug, Clone)] -pub enum ThreadEvent { - ShowError(ThreadError), - StreamedCompletion, - ReceivedTextChunk, - NewRequest, - StreamedAssistantText(MessageId, String), - StreamedAssistantThinking(MessageId, String), - StreamedToolUse { - tool_use_id: LanguageModelToolUseId, - ui_text: Arc, - input: serde_json::Value, - }, - MissingToolUse { - tool_use_id: LanguageModelToolUseId, - ui_text: Arc, - }, - InvalidToolInput { - tool_use_id: LanguageModelToolUseId, - ui_text: Arc, - invalid_input_json: Arc, - }, - Stopped(Result>), - MessageAdded(MessageId), - MessageEdited(MessageId), - MessageDeleted(MessageId), - SummaryGenerated, - SummaryChanged, - UsePendingTools { - tool_uses: Vec, - }, - ToolFinished { - #[allow(unused)] - tool_use_id: LanguageModelToolUseId, - /// The pending tool use that corresponds to this tool. - pending_tool_use: Option, - }, - CheckpointChanged, - ToolConfirmationNeeded, - ToolUseLimitReached, - CancelEditing, - CompletionCanceled, - ProfileChanged, -} - -impl EventEmitter for Thread {} - -struct PendingCompletion { - id: usize, - queue_state: QueueState, - _task: Task<()>, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - context::load_context, context_store::ContextStore, thread_store, thread_store::ThreadStore, - }; - - // Test-specific constants - const TEST_RATE_LIMIT_RETRY_SECS: u64 = 30; - use agent_settings::{AgentProfileId, AgentSettings}; - use assistant_tool::ToolRegistry; - use assistant_tools; - use fs::Fs; - use futures::StreamExt; - use futures::future::BoxFuture; - use futures::stream::BoxStream; - use gpui::TestAppContext; - use http_client; - use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; - use language_model::{ - LanguageModelCompletionError, LanguageModelName, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelToolChoice, - }; - use parking_lot::Mutex; - use project::{FakeFs, Project}; - use prompt_store::PromptBuilder; - use serde_json::json; - use settings::{LanguageModelParameters, Settings, SettingsStore}; - use std::sync::Arc; - use std::time::Duration; - use theme::ThemeSettings; - use util::path; - use workspace::Workspace; - - #[gpui::test] - async fn test_message_with_context(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project( - &fs, - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, _thread_store, thread, context_store, model) = - setup_test_environment(cx, project.clone()).await; - - add_file_to_context(&project, &context_store, "test/code.rs", cx) - .await - .unwrap(); - - let context = - context_store.read_with(cx, |store, _| store.context().next().cloned().unwrap()); - let loaded_context = cx - .update(|cx| load_context(vec![context], &project, &None, cx)) - .await; - - // Insert user message with context - let message_id = thread.update(cx, |thread, cx| { - thread.insert_user_message( - "Please explain this code", - loaded_context, - None, - Vec::new(), - cx, - ) - }); - - // Check content and context in message object - let message = thread.read_with(cx, |thread, _| thread.message(message_id).unwrap().clone()); - - // Use different path format strings based on platform for the test - #[cfg(windows)] - let path_part = r"test\code.rs"; - #[cfg(not(windows))] - let path_part = "test/code.rs"; - - let expected_context = format!( - r#" - -The following items were attached by the user. They are up-to-date and don't need to be re-read. - - -```rs {path_part} -fn main() {{ - println!("Hello, world!"); -}} -``` - - -"# - ); - - assert_eq!(message.role, Role::User); - assert_eq!(message.segments.len(), 1); - assert_eq!( - message.segments[0], - MessageSegment::Text("Please explain this code".to_string()) - ); - assert_eq!(message.loaded_context.text, expected_context); - - // Check message in request - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - assert_eq!(request.messages.len(), 2); - let expected_full_message = format!("{}Please explain this code", expected_context); - assert_eq!(request.messages[1].string_contents(), expected_full_message); - } - - #[gpui::test] - async fn test_only_include_new_contexts(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project( - &fs, - cx, - json!({ - "file1.rs": "fn function1() {}\n", - "file2.rs": "fn function2() {}\n", - "file3.rs": "fn function3() {}\n", - "file4.rs": "fn function4() {}\n", - }), - ) - .await; - - let (_, _thread_store, thread, context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // First message with context 1 - add_file_to_context(&project, &context_store, "test/file1.rs", cx) - .await - .unwrap(); - let new_contexts = context_store.update(cx, |store, cx| { - store.new_context_for_thread(thread.read(cx), None) - }); - assert_eq!(new_contexts.len(), 1); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await; - let message1_id = thread.update(cx, |thread, cx| { - thread.insert_user_message("Message 1", loaded_context, None, Vec::new(), cx) - }); - - // Second message with contexts 1 and 2 (context 1 should be skipped as it's already included) - add_file_to_context(&project, &context_store, "test/file2.rs", cx) - .await - .unwrap(); - let new_contexts = context_store.update(cx, |store, cx| { - store.new_context_for_thread(thread.read(cx), None) - }); - assert_eq!(new_contexts.len(), 1); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await; - let message2_id = thread.update(cx, |thread, cx| { - thread.insert_user_message("Message 2", loaded_context, None, Vec::new(), cx) - }); - - // Third message with all three contexts (contexts 1 and 2 should be skipped) - // - add_file_to_context(&project, &context_store, "test/file3.rs", cx) - .await - .unwrap(); - let new_contexts = context_store.update(cx, |store, cx| { - store.new_context_for_thread(thread.read(cx), None) - }); - assert_eq!(new_contexts.len(), 1); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await; - let message3_id = thread.update(cx, |thread, cx| { - thread.insert_user_message("Message 3", loaded_context, None, Vec::new(), cx) - }); - - // Check what contexts are included in each message - let (message1, message2, message3) = thread.read_with(cx, |thread, _| { - ( - thread.message(message1_id).unwrap().clone(), - thread.message(message2_id).unwrap().clone(), - thread.message(message3_id).unwrap().clone(), - ) - }); - - // First message should include context 1 - assert!(message1.loaded_context.text.contains("file1.rs")); - - // Second message should include only context 2 (not 1) - assert!(!message2.loaded_context.text.contains("file1.rs")); - assert!(message2.loaded_context.text.contains("file2.rs")); - - // Third message should include only context 3 (not 1 or 2) - assert!(!message3.loaded_context.text.contains("file1.rs")); - assert!(!message3.loaded_context.text.contains("file2.rs")); - assert!(message3.loaded_context.text.contains("file3.rs")); - - // Check entire request to make sure all contexts are properly included - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - // The request should contain all 3 messages - assert_eq!(request.messages.len(), 4); - - // Check that the contexts are properly formatted in each message - assert!(request.messages[1].string_contents().contains("file1.rs")); - assert!(!request.messages[1].string_contents().contains("file2.rs")); - assert!(!request.messages[1].string_contents().contains("file3.rs")); - - assert!(!request.messages[2].string_contents().contains("file1.rs")); - assert!(request.messages[2].string_contents().contains("file2.rs")); - assert!(!request.messages[2].string_contents().contains("file3.rs")); - - assert!(!request.messages[3].string_contents().contains("file1.rs")); - assert!(!request.messages[3].string_contents().contains("file2.rs")); - assert!(request.messages[3].string_contents().contains("file3.rs")); - - add_file_to_context(&project, &context_store, "test/file4.rs", cx) - .await - .unwrap(); - let new_contexts = context_store.update(cx, |store, cx| { - store.new_context_for_thread(thread.read(cx), Some(message2_id)) - }); - assert_eq!(new_contexts.len(), 3); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await - .loaded_context; - - assert!(!loaded_context.text.contains("file1.rs")); - assert!(loaded_context.text.contains("file2.rs")); - assert!(loaded_context.text.contains("file3.rs")); - assert!(loaded_context.text.contains("file4.rs")); - - let new_contexts = context_store.update(cx, |store, cx| { - // Remove file4.rs - store.remove_context(&loaded_context.contexts[2].handle(), cx); - store.new_context_for_thread(thread.read(cx), Some(message2_id)) - }); - assert_eq!(new_contexts.len(), 2); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await - .loaded_context; - - assert!(!loaded_context.text.contains("file1.rs")); - assert!(loaded_context.text.contains("file2.rs")); - assert!(loaded_context.text.contains("file3.rs")); - assert!(!loaded_context.text.contains("file4.rs")); - - let new_contexts = context_store.update(cx, |store, cx| { - // Remove file3.rs - store.remove_context(&loaded_context.contexts[1].handle(), cx); - store.new_context_for_thread(thread.read(cx), Some(message2_id)) - }); - assert_eq!(new_contexts.len(), 1); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await - .loaded_context; - - assert!(!loaded_context.text.contains("file1.rs")); - assert!(loaded_context.text.contains("file2.rs")); - assert!(!loaded_context.text.contains("file3.rs")); - assert!(!loaded_context.text.contains("file4.rs")); - } - - #[gpui::test] - async fn test_message_without_files(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project( - &fs, - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Insert user message without any context (empty context vector) - let message_id = thread.update(cx, |thread, cx| { - thread.insert_user_message( - "What is the best way to learn Rust?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - - // Check content and context in message object - let message = thread.read_with(cx, |thread, _| thread.message(message_id).unwrap().clone()); - - // Context should be empty when no files are included - assert_eq!(message.role, Role::User); - assert_eq!(message.segments.len(), 1); - assert_eq!( - message.segments[0], - MessageSegment::Text("What is the best way to learn Rust?".to_string()) - ); - assert_eq!(message.loaded_context.text, ""); - - // Check message in request - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - assert_eq!(request.messages.len(), 2); - assert_eq!( - request.messages[1].string_contents(), - "What is the best way to learn Rust?" - ); - - // Add second message, also without context - let message2_id = thread.update(cx, |thread, cx| { - thread.insert_user_message( - "Are there any good books?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - - let message2 = - thread.read_with(cx, |thread, _| thread.message(message2_id).unwrap().clone()); - assert_eq!(message2.loaded_context.text, ""); - - // Check that both messages appear in the request - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - assert_eq!(request.messages.len(), 3); - assert_eq!( - request.messages[1].string_contents(), - "What is the best way to learn Rust?" - ); - assert_eq!( - request.messages[2].string_contents(), - "Are there any good books?" - ); - } - - #[gpui::test] - #[ignore] // turn this test on when project_notifications tool is re-enabled - async fn test_stale_buffer_notification(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project( - &fs, - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, _thread_store, thread, context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Add a buffer to the context. This will be a tracked buffer - let buffer = add_file_to_context(&project, &context_store, "test/code.rs", cx) - .await - .unwrap(); - - let context = context_store - .read_with(cx, |store, _| store.context().next().cloned()) - .unwrap(); - let loaded_context = cx - .update(|cx| load_context(vec![context], &project, &None, cx)) - .await; - - // Insert user message and assistant response - thread.update(cx, |thread, cx| { - thread.insert_user_message("Explain this code", loaded_context, None, Vec::new(), cx); - thread.insert_assistant_message( - vec![MessageSegment::Text("This code prints 42.".into())], - cx, - ); - }); - cx.run_until_parked(); - - // We shouldn't have a stale buffer notification yet - let notifications = thread.read_with(cx, |thread, _| { - find_tool_uses(thread, "project_notifications") - }); - assert!( - notifications.is_empty(), - "Should not have stale buffer notification before buffer is modified" - ); - - // Modify the buffer - buffer.update(cx, |buffer, cx| { - buffer.edit( - [(1..1, "\n println!(\"Added a new line\");\n")], - None, - cx, - ); - }); - - // Insert another user message - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "What does the code do now?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - cx.run_until_parked(); - - // Check for the stale buffer warning - thread.update(cx, |thread, cx| { - thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) - }); - cx.run_until_parked(); - - let notifications = thread.read_with(cx, |thread, _cx| { - find_tool_uses(thread, "project_notifications") - }); - - let [notification] = notifications.as_slice() else { - panic!("Should have a `project_notifications` tool use"); - }; - - let Some(notification_content) = notification.content.to_str() else { - panic!("`project_notifications` should return text"); - }; - - assert!(notification_content.contains("These files have changed since the last read:")); - assert!(notification_content.contains("code.rs")); - - // Insert another user message and flush notifications again - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "Can you tell me more?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - - thread.update(cx, |thread, cx| { - thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) - }); - cx.run_until_parked(); - - // There should be no new notifications (we already flushed one) - let notifications = thread.read_with(cx, |thread, _cx| { - find_tool_uses(thread, "project_notifications") - }); - - assert_eq!( - notifications.len(), - 1, - "Should still have only one notification after second flush - no duplicates" - ); - } - - fn find_tool_uses(thread: &Thread, tool_name: &str) -> Vec { - thread - .messages() - .flat_map(|message| { - thread - .tool_results_for_message(message.id) - .into_iter() - .filter(|result| result.tool_name == tool_name.into()) - .cloned() - .collect::>() - }) - .collect() - } - - #[gpui::test] - async fn test_storing_profile_setting_per_thread(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project( - &fs, - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, thread_store, thread, _context_store, _model) = - setup_test_environment(cx, project.clone()).await; - - // Check that we are starting with the default profile - let profile = cx.read(|cx| thread.read(cx).profile.clone()); - let tool_set = cx.read(|cx| thread_store.read(cx).tools()); - assert_eq!( - profile, - AgentProfile::new(AgentProfileId::default(), tool_set) - ); - } - - #[gpui::test] - async fn test_serializing_thread_profile(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project( - &fs, - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, thread_store, thread, _context_store, _model) = - setup_test_environment(cx, project.clone()).await; - - // Profile gets serialized with default values - let serialized = thread - .update(cx, |thread, cx| thread.serialize(cx)) - .await - .unwrap(); - - assert_eq!(serialized.profile, Some(AgentProfileId::default())); - - let deserialized = cx.update(|cx| { - thread.update(cx, |thread, cx| { - Thread::deserialize( - thread.id.clone(), - serialized, - thread.project.clone(), - thread.tools.clone(), - thread.prompt_builder.clone(), - thread.project_context.clone(), - None, - cx, - ) - }) - }); - let tool_set = cx.read(|cx| thread_store.read(cx).tools()); - - assert_eq!( - deserialized.profile, - AgentProfile::new(AgentProfileId::default(), tool_set) - ); - } - - #[gpui::test] - async fn test_temperature_setting(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project( - &fs, - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Both model and provider - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - model_parameters: vec![LanguageModelParameters { - provider: Some(model.provider_id().0.to_string().into()), - model: Some(model.id().0), - temperature: Some(0.66), - }], - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - assert_eq!(request.temperature, Some(0.66)); - - // Only model - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - model_parameters: vec![LanguageModelParameters { - provider: None, - model: Some(model.id().0), - temperature: Some(0.66), - }], - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - assert_eq!(request.temperature, Some(0.66)); - - // Only provider - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - model_parameters: vec![LanguageModelParameters { - provider: Some(model.provider_id().0.to_string().into()), - model: None, - temperature: Some(0.66), - }], - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - assert_eq!(request.temperature, Some(0.66)); - - // Same model name, different provider - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - model_parameters: vec![LanguageModelParameters { - provider: Some("anthropic".into()), - model: Some(model.id().0), - temperature: Some(0.66), - }], - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - assert_eq!(request.temperature, None); - } - - #[gpui::test] - async fn test_thread_summary(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - - let (_, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Initial state should be pending - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Pending)); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - - // Manually setting the summary should not be allowed in this state - thread.update(cx, |thread, cx| { - thread.set_summary("This should not work", cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Pending)); - }); - - // Send a message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hi!", ContextLoadResult::default(), None, vec![], cx); - thread.send_to_model( - model.clone(), - CompletionIntent::ThreadSummarization, - None, - cx, - ); - }); - - let fake_model = model.as_fake(); - simulate_successful_response(fake_model, cx); - - // Should start generating summary when there are >= 2 messages - thread.read_with(cx, |thread, _| { - assert_eq!(*thread.summary(), ThreadSummary::Generating); - }); - - // Should not be able to set the summary while generating - thread.update(cx, |thread, cx| { - thread.set_summary("This should not work either", cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Generating)); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - - cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("Brief"); - fake_model.send_last_completion_stream_text_chunk(" Introduction"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // Summary should be set - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Ready(_))); - assert_eq!(thread.summary().or_default(), "Brief Introduction"); - }); - - // Now we should be able to set a summary - thread.update(cx, |thread, cx| { - thread.set_summary("Brief Intro", cx); - }); - - thread.read_with(cx, |thread, _| { - assert_eq!(thread.summary().or_default(), "Brief Intro"); - }); - - // Test setting an empty summary (should default to DEFAULT) - thread.update(cx, |thread, cx| { - thread.set_summary("", cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Ready(_))); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - } - - #[gpui::test] - async fn test_thread_summary_error_set_manually(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - - let (_, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - test_summarize_error(&model, &thread, cx); - - // Now we should be able to set a summary - thread.update(cx, |thread, cx| { - thread.set_summary("Brief Intro", cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Ready(_))); - assert_eq!(thread.summary().or_default(), "Brief Intro"); - }); - } - - #[gpui::test] - async fn test_thread_summary_error_retry(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - - let (_, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - test_summarize_error(&model, &thread, cx); - - // Sending another message should not trigger another summarize request - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "How are you?", - ContextLoadResult::default(), - None, - vec![], - cx, - ); - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - let fake_model = model.as_fake(); - simulate_successful_response(fake_model, cx); - - thread.read_with(cx, |thread, _| { - // State is still Error, not Generating - assert!(matches!(thread.summary(), ThreadSummary::Error)); - }); - - // But the summarize request can be invoked manually - thread.update(cx, |thread, cx| { - thread.summarize(cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Generating)); - }); - - cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("A successful summary"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Ready(_))); - assert_eq!(thread.summary().or_default(), "A successful summary"); - }); - } - - // Helper to create a model that returns errors - enum TestError { - Overloaded, - InternalServerError, - } - - struct ErrorInjector { - inner: Arc, - error_type: TestError, - } - - impl ErrorInjector { - fn new(error_type: TestError) -> Self { - Self { - inner: Arc::new(FakeLanguageModel::default()), - error_type, - } - } - } - - impl LanguageModel for ErrorInjector { - fn id(&self) -> LanguageModelId { - self.inner.id() - } - - fn name(&self) -> LanguageModelName { - self.inner.name() - } - - fn provider_id(&self) -> LanguageModelProviderId { - self.inner.provider_id() - } - - fn provider_name(&self) -> LanguageModelProviderName { - self.inner.provider_name() - } - - fn supports_tools(&self) -> bool { - self.inner.supports_tools() - } - - fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { - self.inner.supports_tool_choice(choice) - } - - fn supports_images(&self) -> bool { - self.inner.supports_images() - } - - fn telemetry_id(&self) -> String { - self.inner.telemetry_id() - } - - fn max_token_count(&self) -> u64 { - self.inner.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - self.inner.count_tokens(request, cx) - } - - fn stream_completion( - &self, - _request: LanguageModelRequest, - _cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result< - BoxStream< - 'static, - Result, - >, - LanguageModelCompletionError, - >, - > { - let error = match self.error_type { - TestError::Overloaded => LanguageModelCompletionError::ServerOverloaded { - provider: self.provider_name(), - retry_after: None, - }, - TestError::InternalServerError => { - LanguageModelCompletionError::ApiInternalServerError { - provider: self.provider_name(), - message: "I'm a teapot orbiting the sun".to_string(), - } - } - }; - async move { - let stream = futures::stream::once(async move { Err(error) }); - Ok(stream.boxed()) - } - .boxed() - } - - fn as_fake(&self) -> &FakeLanguageModel { - &self.inner - } - } - - #[gpui::test] - async fn test_retry_on_overloaded_error(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert!(thread.retry_state.is_some(), "Should have retry state"); - let retry_state = thread.retry_state.as_ref().unwrap(); - assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); - assert_eq!( - retry_state.max_attempts, MAX_RETRY_ATTEMPTS, - "Should retry MAX_RETRY_ATTEMPTS times for overloaded errors" - ); - }); - - // Check that a retry message was added - thread.read_with(cx, |thread, _| { - let mut messages = thread.messages(); - assert!( - messages.any(|msg| { - msg.role == Role::System - && msg.ui_only - && msg.segments.iter().any(|seg| { - if let MessageSegment::Text(text) = seg { - text.contains("overloaded") - && text - .contains(&format!("attempt 1 of {}", MAX_RETRY_ATTEMPTS)) - } else { - false - } - }) - }), - "Should have added a system retry message" - ); - }); - - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - - assert_eq!(retry_count, 1, "Should have one retry message"); - } - - #[gpui::test] - async fn test_retry_on_internal_server_error(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns internal server error - let model = Arc::new(ErrorInjector::new(TestError::InternalServerError)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - // Check retry state on thread - thread.read_with(cx, |thread, _| { - assert!(thread.retry_state.is_some(), "Should have retry state"); - let retry_state = thread.retry_state.as_ref().unwrap(); - assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); - assert_eq!( - retry_state.max_attempts, 3, - "Should have correct max attempts" - ); - }); - - // Check that a retry message was added with provider name - thread.read_with(cx, |thread, _| { - let mut messages = thread.messages(); - assert!( - messages.any(|msg| { - msg.role == Role::System - && msg.ui_only - && msg.segments.iter().any(|seg| { - if let MessageSegment::Text(text) = seg { - text.contains("internal") - && text.contains("Fake") - && text.contains("Retrying") - && text.contains("attempt 1 of 3") - && text.contains("seconds") - } else { - false - } - }) - }), - "Should have added a system retry message with provider name" - ); - }); - - // Count retry messages - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - - assert_eq!(retry_count, 1, "Should have one retry message"); - } - - #[gpui::test] - async fn test_exponential_backoff_on_retries(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns internal server error - let model = Arc::new(ErrorInjector::new(TestError::InternalServerError)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Track retry events and completion count - // Track completion events - let completion_count = Arc::new(Mutex::new(0)); - let completion_count_clone = completion_count.clone(); - - let _subscription = thread.update(cx, |_, cx| { - cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::NewRequest = event { - *completion_count_clone.lock() += 1; - } - }) - }); - - // First attempt - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - cx.run_until_parked(); - - // Should have scheduled first retry - count retry messages - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - assert_eq!(retry_count, 1, "Should have scheduled first retry"); - - // Check retry state - thread.read_with(cx, |thread, _| { - assert!(thread.retry_state.is_some(), "Should have retry state"); - let retry_state = thread.retry_state.as_ref().unwrap(); - assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); - assert_eq!( - retry_state.max_attempts, 3, - "Internal server errors should retry up to 3 times" - ); - }); - - // Advance clock for first retry - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // Advance clock for second retry - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // Advance clock for third retry - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // Should have completed all retries - count retry messages - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - assert_eq!( - retry_count, 3, - "Should have 3 retries for internal server errors" - ); - - // For internal server errors, we retry 3 times and then give up - // Check that retry_state is cleared after all retries - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_none(), - "Retry state should be cleared after all retries" - ); - }); - - // Verify total attempts (1 initial + 3 retries) - assert_eq!( - *completion_count.lock(), - 4, - "Should have attempted once plus 3 retries" - ); - } - - #[gpui::test] - async fn test_max_retries_exceeded(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Track events - let stopped_with_error = Arc::new(Mutex::new(false)); - let stopped_with_error_clone = stopped_with_error.clone(); - - let _subscription = thread.update(cx, |_, cx| { - cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::Stopped(Err(_)) = event { - *stopped_with_error_clone.lock() = true; - } - }) - }); - - // Start initial completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - cx.run_until_parked(); - - // Advance through all retries - for _ in 0..MAX_RETRY_ATTEMPTS { - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - } - - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - - // After max retries, should emit Stopped(Err(...)) event - assert_eq!( - retry_count, MAX_RETRY_ATTEMPTS as usize, - "Should have attempted MAX_RETRY_ATTEMPTS retries for overloaded errors" - ); - assert!( - *stopped_with_error.lock(), - "Should emit Stopped(Err(...)) event after max retries exceeded" - ); - - // Retry state should be cleared - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_none(), - "Retry state should be cleared after max retries" - ); - - // Verify we have the expected number of retry messages - let retry_messages = thread - .messages - .iter() - .filter(|msg| msg.ui_only && msg.role == Role::System) - .count(); - assert_eq!( - retry_messages, MAX_RETRY_ATTEMPTS as usize, - "Should have MAX_RETRY_ATTEMPTS retry messages for overloaded errors" - ); - }); - } - - #[gpui::test] - async fn test_retry_message_removed_on_retry(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // We'll use a wrapper to switch behavior after first failure - struct RetryTestModel { - inner: Arc, - failed_once: Arc>, - } - - impl LanguageModel for RetryTestModel { - fn id(&self) -> LanguageModelId { - self.inner.id() - } - - fn name(&self) -> LanguageModelName { - self.inner.name() - } - - fn provider_id(&self) -> LanguageModelProviderId { - self.inner.provider_id() - } - - fn provider_name(&self) -> LanguageModelProviderName { - self.inner.provider_name() - } - - fn supports_tools(&self) -> bool { - self.inner.supports_tools() - } - - fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { - self.inner.supports_tool_choice(choice) - } - - fn supports_images(&self) -> bool { - self.inner.supports_images() - } - - fn telemetry_id(&self) -> String { - self.inner.telemetry_id() - } - - fn max_token_count(&self) -> u64 { - self.inner.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - self.inner.count_tokens(request, cx) - } - - fn stream_completion( - &self, - request: LanguageModelRequest, - cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result< - BoxStream< - 'static, - Result, - >, - LanguageModelCompletionError, - >, - > { - if !*self.failed_once.lock() { - *self.failed_once.lock() = true; - let provider = self.provider_name(); - // Return error on first attempt - let stream = futures::stream::once(async move { - Err(LanguageModelCompletionError::ServerOverloaded { - provider, - retry_after: None, - }) - }); - async move { Ok(stream.boxed()) }.boxed() - } else { - // Succeed on retry - self.inner.stream_completion(request, cx) - } - } - - fn as_fake(&self) -> &FakeLanguageModel { - &self.inner - } - } - - let model = Arc::new(RetryTestModel { - inner: Arc::new(FakeLanguageModel::default()), - failed_once: Arc::new(Mutex::new(false)), - }); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Track message deletions - // Track when retry completes successfully - let retry_completed = Arc::new(Mutex::new(false)); - let retry_completed_clone = retry_completed.clone(); - - let _subscription = thread.update(cx, |_, cx| { - cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::StreamedCompletion = event { - *retry_completed_clone.lock() = true; - } - }) - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - cx.run_until_parked(); - - // Get the retry message ID - let retry_message_id = thread.read_with(cx, |thread, _| { - thread - .messages() - .find(|msg| msg.role == Role::System && msg.ui_only) - .map(|msg| msg.id) - .expect("Should have a retry message") - }); - - // Wait for retry - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // Stream some successful content - let fake_model = model.as_fake(); - // After the retry, there should be a new pending completion - let pending = fake_model.pending_completions(); - assert!( - !pending.is_empty(), - "Should have a pending completion after retry" - ); - fake_model.send_completion_stream_text_chunk(&pending[0], "Success!"); - fake_model.end_completion_stream(&pending[0]); - cx.run_until_parked(); - - // Check that the retry completed successfully - assert!( - *retry_completed.lock(), - "Retry should have completed successfully" - ); - - // Retry message should still exist but be marked as ui_only - thread.read_with(cx, |thread, _| { - let retry_msg = thread - .message(retry_message_id) - .expect("Retry message should still exist"); - assert!(retry_msg.ui_only, "Retry message should be ui_only"); - assert_eq!( - retry_msg.role, - Role::System, - "Retry message should have System role" - ); - }); - } - - #[gpui::test] - async fn test_successful_completion_clears_retry_state(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create a model that fails once then succeeds - struct FailOnceModel { - inner: Arc, - failed_once: Arc>, - } - - impl LanguageModel for FailOnceModel { - fn id(&self) -> LanguageModelId { - self.inner.id() - } - - fn name(&self) -> LanguageModelName { - self.inner.name() - } - - fn provider_id(&self) -> LanguageModelProviderId { - self.inner.provider_id() - } - - fn provider_name(&self) -> LanguageModelProviderName { - self.inner.provider_name() - } - - fn supports_tools(&self) -> bool { - self.inner.supports_tools() - } - - fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { - self.inner.supports_tool_choice(choice) - } - - fn supports_images(&self) -> bool { - self.inner.supports_images() - } - - fn telemetry_id(&self) -> String { - self.inner.telemetry_id() - } - - fn max_token_count(&self) -> u64 { - self.inner.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - self.inner.count_tokens(request, cx) - } - - fn stream_completion( - &self, - request: LanguageModelRequest, - cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result< - BoxStream< - 'static, - Result, - >, - LanguageModelCompletionError, - >, - > { - if !*self.failed_once.lock() { - *self.failed_once.lock() = true; - let provider = self.provider_name(); - // Return error on first attempt - let stream = futures::stream::once(async move { - Err(LanguageModelCompletionError::ServerOverloaded { - provider, - retry_after: None, - }) - }); - async move { Ok(stream.boxed()) }.boxed() - } else { - // Succeed on retry - self.inner.stream_completion(request, cx) - } - } - } - - let fail_once_model = Arc::new(FailOnceModel { - inner: Arc::new(FakeLanguageModel::default()), - failed_once: Arc::new(Mutex::new(false)), - }); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "Test message", - ContextLoadResult::default(), - None, - vec![], - cx, - ); - }); - - // Start completion with fail-once model - thread.update(cx, |thread, cx| { - thread.send_to_model( - fail_once_model.clone(), - CompletionIntent::UserPrompt, - None, - cx, - ); - }); - - cx.run_until_parked(); - - // Verify retry state exists after first failure - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_some(), - "Should have retry state after failure" - ); - }); - - // Wait for retry delay - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // The retry should now use our FailOnceModel which should succeed - // We need to help the FakeLanguageModel complete the stream - let inner_fake = fail_once_model.inner.clone(); - - // Wait a bit for the retry to start - cx.run_until_parked(); - - // Check for pending completions and complete them - if let Some(pending) = inner_fake.pending_completions().first() { - inner_fake.send_completion_stream_text_chunk(pending, "Success!"); - inner_fake.end_completion_stream(pending); - } - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_none(), - "Retry state should be cleared after successful completion" - ); - - let has_assistant_message = thread - .messages - .iter() - .any(|msg| msg.role == Role::Assistant && !msg.ui_only); - assert!( - has_assistant_message, - "Should have an assistant message after successful retry" - ); - }); - } - - #[gpui::test] - async fn test_rate_limit_retry_single_attempt(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create a model that returns rate limit error with retry_after - struct RateLimitModel { - inner: Arc, - } - - impl LanguageModel for RateLimitModel { - fn id(&self) -> LanguageModelId { - self.inner.id() - } - - fn name(&self) -> LanguageModelName { - self.inner.name() - } - - fn provider_id(&self) -> LanguageModelProviderId { - self.inner.provider_id() - } - - fn provider_name(&self) -> LanguageModelProviderName { - self.inner.provider_name() - } - - fn supports_tools(&self) -> bool { - self.inner.supports_tools() - } - - fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { - self.inner.supports_tool_choice(choice) - } - - fn supports_images(&self) -> bool { - self.inner.supports_images() - } - - fn telemetry_id(&self) -> String { - self.inner.telemetry_id() - } - - fn max_token_count(&self) -> u64 { - self.inner.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - self.inner.count_tokens(request, cx) - } - - fn stream_completion( - &self, - _request: LanguageModelRequest, - _cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result< - BoxStream< - 'static, - Result, - >, - LanguageModelCompletionError, - >, - > { - let provider = self.provider_name(); - async move { - let stream = futures::stream::once(async move { - Err(LanguageModelCompletionError::RateLimitExceeded { - provider, - retry_after: Some(Duration::from_secs(TEST_RATE_LIMIT_RETRY_SECS)), - }) - }); - Ok(stream.boxed()) - } - .boxed() - } - - fn as_fake(&self) -> &FakeLanguageModel { - &self.inner - } - } - - let model = Arc::new(RateLimitModel { - inner: Arc::new(FakeLanguageModel::default()), - }); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("rate limit exceeded") - } else { - false - } - }) - }) - .count() - }); - assert_eq!(retry_count, 1, "Should have scheduled one retry"); - - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_some(), - "Rate limit errors should set retry_state" - ); - if let Some(retry_state) = &thread.retry_state { - assert_eq!( - retry_state.max_attempts, MAX_RETRY_ATTEMPTS, - "Rate limit errors should use MAX_RETRY_ATTEMPTS" - ); - } - }); - - // Verify we have one retry message - thread.read_with(cx, |thread, _| { - let retry_messages = thread - .messages - .iter() - .filter(|msg| { - msg.ui_only - && msg.segments.iter().any(|seg| { - if let MessageSegment::Text(text) = seg { - text.contains("rate limit exceeded") - } else { - false - } - }) - }) - .count(); - assert_eq!( - retry_messages, 1, - "Should have one rate limit retry message" - ); - }); - - // Check that retry message doesn't include attempt count - thread.read_with(cx, |thread, _| { - let retry_message = thread - .messages - .iter() - .find(|msg| msg.role == Role::System && msg.ui_only) - .expect("Should have a retry message"); - - // Check that the message contains attempt count since we use retry_state - if let Some(MessageSegment::Text(text)) = retry_message.segments.first() { - assert!( - text.contains(&format!("attempt 1 of {}", MAX_RETRY_ATTEMPTS)), - "Rate limit retry message should contain attempt count with MAX_RETRY_ATTEMPTS" - ); - assert!( - text.contains("Retrying"), - "Rate limit retry message should contain retry text" - ); - } - }); - } - - #[gpui::test] - async fn test_ui_only_messages_not_sent_to_model(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, model) = setup_test_environment(cx, project.clone()).await; - - // Insert a regular user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Insert a UI-only message (like our retry notifications) - thread.update(cx, |thread, cx| { - let id = thread.next_message_id.post_inc(); - thread.messages.push(Message { - id, - role: Role::System, - segments: vec![MessageSegment::Text( - "This is a UI-only message that should not be sent to the model".to_string(), - )], - loaded_context: LoadedContext::default(), - creases: Vec::new(), - is_hidden: true, - ui_only: true, - }); - cx.emit(ThreadEvent::MessageAdded(id)); - }); - - // Insert another regular message - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "How are you?", - ContextLoadResult::default(), - None, - vec![], - cx, - ); - }); - - // Generate the completion request - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - // Verify that the request only contains non-UI-only messages - // Should have system prompt + 2 user messages, but not the UI-only message - let user_messages: Vec<_> = request - .messages - .iter() - .filter(|msg| msg.role == Role::User) - .collect(); - assert_eq!( - user_messages.len(), - 2, - "Should have exactly 2 user messages" - ); - - // Verify the UI-only content is not present anywhere in the request - let request_text = request - .messages - .iter() - .flat_map(|msg| &msg.content) - .filter_map(|content| match content { - MessageContent::Text(text) => Some(text.as_str()), - _ => None, - }) - .collect::(); - - assert!( - !request_text.contains("UI-only message"), - "UI-only message content should not be in the request" - ); - - // Verify the thread still has all 3 messages (including UI-only) - thread.read_with(cx, |thread, _| { - assert_eq!( - thread.messages().count(), - 3, - "Thread should have 3 messages" - ); - assert_eq!( - thread.messages().filter(|m| m.ui_only).count(), - 1, - "Thread should have 1 UI-only message" - ); - }); - - // Verify that UI-only messages are not serialized - let serialized = thread - .update(cx, |thread, cx| thread.serialize(cx)) - .await - .unwrap(); - assert_eq!( - serialized.messages.len(), - 2, - "Serialized thread should only have 2 messages (no UI-only)" - ); - } - - #[gpui::test] - async fn test_no_retry_without_burn_mode(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Ensure we're in Normal mode (not Burn mode) - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Normal); - }); - - // Track error events - let error_events = Arc::new(Mutex::new(Vec::new())); - let error_events_clone = error_events.clone(); - - let _subscription = thread.update(cx, |_, cx| { - cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::ShowError(error) = event { - error_events_clone.lock().push(error.clone()); - } - }) - }); - - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - // Verify no retry state was created - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_none(), - "Should not have retry state in Normal mode" - ); - }); - - // Check that a retryable error was reported - let errors = error_events.lock(); - assert!(!errors.is_empty(), "Should have received an error event"); - - if let ThreadError::RetryableError { - message: _, - can_enable_burn_mode, - } = &errors[0] - { - assert!( - *can_enable_burn_mode, - "Error should indicate burn mode can be enabled" - ); +#[cfg(any(test, feature = "test-support"))] +pub struct ToolCallEventStreamReceiver(mpsc::UnboundedReceiver>); + +#[cfg(any(test, feature = "test-support"))] +impl ToolCallEventStreamReceiver { + pub async fn expect_authorization(&mut self) -> ToolCallAuthorization { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallAuthorization(auth))) = event { + auth } else { - panic!("Expected RetryableError, got {:?}", errors[0]); + panic!("Expected ToolCallAuthorization but got: {:?}", event); } - - // Verify the thread is no longer generating - thread.read_with(cx, |thread, _| { - assert!( - !thread.is_generating(), - "Should not be generating after error without retry" - ); - }); } - #[gpui::test] - async fn test_retry_canceled_on_stop(cx: &mut TestAppContext) { - let fs = init_test_settings(cx); - - let project = create_test_project(&fs, cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - // Verify retry was scheduled by checking for retry message - let has_retry_message = thread.read_with(cx, |thread, _| { - thread.messages.iter().any(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - }); - assert!(has_retry_message, "Should have scheduled a retry"); - - // Cancel the completion before the retry happens - thread.update(cx, |thread, cx| { - thread.cancel_last_completion(None, cx); - }); - - cx.run_until_parked(); - - // The retry should not have happened - no pending completions - let fake_model = model.as_fake(); - assert_eq!( - fake_model.pending_completions().len(), - 0, - "Should have no pending completions after cancellation" - ); - - // Verify the retry was canceled by checking retry state - thread.read_with(cx, |thread, _| { - if let Some(retry_state) = &thread.retry_state { - panic!( - "retry_state should be cleared after cancellation, but found: attempt={}, max_attempts={}, intent={:?}", - retry_state.attempt, retry_state.max_attempts, retry_state.intent - ); - } - }); + pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( + update, + )))) = event + { + update.fields + } else { + panic!("Expected update fields but got: {:?}", event); + } } - fn test_summarize_error( - model: &Arc, - thread: &Entity, - cx: &mut TestAppContext, - ) { - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hi!", ContextLoadResult::default(), None, vec![], cx); - thread.send_to_model( - model.clone(), - CompletionIntent::ThreadSummarization, - None, - cx, - ); - }); - - let fake_model = model.as_fake(); - simulate_successful_response(fake_model, cx); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Generating)); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - - // Simulate summary request ending - cx.run_until_parked(); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // State is set to Error and default message - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Error)); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); + pub async fn expect_diff(&mut self) -> Entity { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff( + update, + )))) = event + { + update.diff + } else { + panic!("Expected diff but got: {:?}", event); + } } - fn simulate_successful_response(fake_model: &FakeLanguageModel, cx: &mut TestAppContext) { - cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("Assistant response"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - } - - fn init_test_settings(cx: &mut TestAppContext) -> Arc { - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - AgentSettings::register(cx); - prompt_store::init(cx); - thread_store::init(fs.clone(), cx); - workspace::init_settings(cx); - language_model::init_settings(cx); - ThemeSettings::register(cx); - ToolRegistry::default_global(cx); - assistant_tool::init(cx); - - let http_client = Arc::new(http_client::HttpClientWithUrl::new( - http_client::FakeHttpClient::with_200_response(), - "http://localhost".to_string(), - None, - )); - assistant_tools::init(http_client, cx); - }); - fs - } - - // Helper to create a test project with test files - async fn create_test_project( - fs: &Arc, - cx: &mut TestAppContext, - files: serde_json::Value, - ) -> Entity { - fs.as_fake().insert_tree(path!("/test"), files).await; - Project::test(fs.clone(), [path!("/test").as_ref()], cx).await - } - - async fn setup_test_environment( - cx: &mut TestAppContext, - project: Entity, - ) -> ( - Entity, - Entity, - Entity, - Entity, - Arc, - ) { - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - - let thread_store = cx - .update(|_, cx| { - ThreadStore::load( - project.clone(), - cx.new(|_| ToolWorkingSet::default()), - None, - Arc::new(PromptBuilder::new(None).unwrap()), - cx, - ) - }) - .await - .unwrap(); - - let thread = thread_store.update(cx, |store, cx| store.create_thread(cx)); - let context_store = cx.new(|_cx| ContextStore::new(project.downgrade(), None)); - - let provider = Arc::new(FakeLanguageModelProvider::default()); - let model = provider.test_model(); - let model: Arc = Arc::new(model); - - cx.update(|_, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.set_default_model( - Some(ConfiguredModel { - provider: provider.clone(), - model: model.clone(), - }), - cx, - ); - registry.set_thread_summary_model( - Some(ConfiguredModel { - provider, - model: model.clone(), - }), - cx, - ); - }) - }); - - (workspace, thread_store, thread, context_store, model) - } - - async fn add_file_to_context( - project: &Entity, - context_store: &Entity, - path: &str, - cx: &mut TestAppContext, - ) -> Result> { - let buffer_path = project - .read_with(cx, |project, cx| project.find_project_path(path, cx)) - .unwrap(); - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(buffer_path.clone(), cx) - }) - .await - .unwrap(); - - context_store.update(cx, |context_store, cx| { - context_store.add_file_from_buffer(&buffer_path, buffer.clone(), false, cx); - }); - - Ok(buffer) + pub async fn expect_terminal(&mut self) -> Entity { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal( + update, + )))) = event + { + update.terminal + } else { + panic!("Expected terminal but got: {:?}", event); + } + } +} + +#[cfg(any(test, feature = "test-support"))] +impl std::ops::Deref for ToolCallEventStreamReceiver { + type Target = mpsc::UnboundedReceiver>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(any(test, feature = "test-support"))] +impl std::ops::DerefMut for ToolCallEventStreamReceiver { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From<&str> for UserMessageContent { + fn from(text: &str) -> Self { + Self::Text(text.into()) + } +} + +impl UserMessageContent { + pub fn from_content_block(value: acp::ContentBlock, path_style: PathStyle) -> Self { + match value { + acp::ContentBlock::Text(text_content) => Self::Text(text_content.text), + acp::ContentBlock::Image(image_content) => Self::Image(convert_image(image_content)), + acp::ContentBlock::Audio(_) => { + // TODO + Self::Text("[audio]".to_string()) + } + acp::ContentBlock::ResourceLink(resource_link) => { + match MentionUri::parse(&resource_link.uri, path_style) { + Ok(uri) => Self::Mention { + uri, + content: String::new(), + }, + Err(err) => { + log::error!("Failed to parse mention link: {}", err); + Self::Text(format!("[{}]({})", resource_link.name, resource_link.uri)) + } + } + } + acp::ContentBlock::Resource(resource) => match resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(resource) => { + match MentionUri::parse(&resource.uri, path_style) { + Ok(uri) => Self::Mention { + uri, + content: resource.text, + }, + Err(err) => { + log::error!("Failed to parse mention link: {}", err); + Self::Text( + MarkdownCodeBlock { + tag: &resource.uri, + text: &resource.text, + } + .to_string(), + ) + } + } + } + acp::EmbeddedResourceResource::BlobResourceContents(_) => { + // TODO + Self::Text("[blob]".to_string()) + } + other => { + log::warn!("Unexpected content type: {:?}", other); + Self::Text("[unknown]".to_string()) + } + }, + other => { + log::warn!("Unexpected content type: {:?}", other); + Self::Text("[unknown]".to_string()) + } + } + } +} + +impl From for acp::ContentBlock { + fn from(content: UserMessageContent) -> Self { + match content { + UserMessageContent::Text(text) => text.into(), + UserMessageContent::Image(image) => { + acp::ContentBlock::Image(acp::ImageContent::new(image.source, "image/png")) + } + UserMessageContent::Mention { uri, content } => acp::ContentBlock::Resource( + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(content, uri.to_uri().to_string()), + )), + ), + } + } +} + +fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { + LanguageModelImage { + source: image_content.data.into(), + size: None, } } diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs deleted file mode 100644 index 2139f232e3..0000000000 --- a/crates/agent/src/thread_store.rs +++ /dev/null @@ -1,1287 +0,0 @@ -use crate::{ - context_server_tool::ContextServerTool, - thread::{ - DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId, - }, -}; -use agent_settings::{AgentProfileId, CompletionMode}; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolId, ToolWorkingSet}; -use chrono::{DateTime, Utc}; -use collections::HashMap; -use context_server::ContextServerId; -use fs::{Fs, RemoveOptions}; -use futures::{ - FutureExt as _, StreamExt as _, - channel::{mpsc, oneshot}, - future::{self, BoxFuture, Shared}, -}; -use gpui::{ - App, BackgroundExecutor, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, - Subscription, Task, Window, prelude::*, -}; -use indoc::indoc; -use language_model::{LanguageModelToolResultContent, LanguageModelToolUseId, Role, TokenUsage}; -use project::context_server_store::{ContextServerStatus, ContextServerStore}; -use project::{Project, ProjectItem, ProjectPath, Worktree}; -use prompt_store::{ - ProjectContext, PromptBuilder, PromptId, PromptStore, PromptsUpdatedEvent, RulesFileContext, - UserRulesContext, WorktreeContext, -}; -use serde::{Deserialize, Serialize}; -use sqlez::{ - bindable::{Bind, Column}, - connection::Connection, - statement::Statement, -}; -use std::{ - cell::{Ref, RefCell}, - path::{Path, PathBuf}, - rc::Rc, - sync::{Arc, LazyLock, Mutex}, -}; -use util::{ResultExt as _, rel_path::RelPath}; - -use zed_env_vars::ZED_STATELESS; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum DataType { - #[serde(rename = "json")] - Json, - #[serde(rename = "zstd")] - Zstd, -} - -impl Bind for DataType { - fn bind(&self, statement: &Statement, start_index: i32) -> Result { - let value = match self { - DataType::Json => "json", - DataType::Zstd => "zstd", - }; - value.bind(statement, start_index) - } -} - -impl Column for DataType { - fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { - let (value, next_index) = String::column(statement, start_index)?; - let data_type = match value.as_str() { - "json" => DataType::Json, - "zstd" => DataType::Zstd, - _ => anyhow::bail!("Unknown data type: {}", value), - }; - Ok((data_type, next_index)) - } -} - -static RULES_FILE_NAMES: LazyLock<[&RelPath; 9]> = LazyLock::new(|| { - [ - RelPath::unix(".rules").unwrap(), - RelPath::unix(".cursorrules").unwrap(), - RelPath::unix(".windsurfrules").unwrap(), - RelPath::unix(".clinerules").unwrap(), - RelPath::unix(".github/copilot-instructions.md").unwrap(), - RelPath::unix("CLAUDE.md").unwrap(), - RelPath::unix("AGENT.md").unwrap(), - RelPath::unix("AGENTS.md").unwrap(), - RelPath::unix("GEMINI.md").unwrap(), - ] -}); - -pub fn init(fs: Arc, cx: &mut App) { - ThreadsDatabase::init(fs, cx); -} - -/// A system prompt shared by all threads created by this ThreadStore -#[derive(Clone, Default)] -pub struct SharedProjectContext(Rc>>); - -impl SharedProjectContext { - pub fn borrow(&self) -> Ref<'_, Option> { - self.0.borrow() - } -} - -pub type TextThreadStore = assistant_context::ContextStore; - -pub struct ThreadStore { - project: Entity, - tools: Entity, - prompt_builder: Arc, - prompt_store: Option>, - context_server_tool_ids: HashMap>, - threads: Vec, - project_context: SharedProjectContext, - reload_system_prompt_tx: mpsc::Sender<()>, - _reload_system_prompt_task: Task<()>, - _subscriptions: Vec, -} - -pub struct RulesLoadingError { - pub message: SharedString, -} - -impl EventEmitter for ThreadStore {} - -impl ThreadStore { - pub fn load( - project: Entity, - tools: Entity, - prompt_store: Option>, - prompt_builder: Arc, - cx: &mut App, - ) -> Task>> { - cx.spawn(async move |cx| { - let (thread_store, ready_rx) = cx.update(|cx| { - let mut option_ready_rx = None; - let thread_store = cx.new(|cx| { - let (thread_store, ready_rx) = - Self::new(project, tools, prompt_builder, prompt_store, cx); - option_ready_rx = Some(ready_rx); - thread_store - }); - (thread_store, option_ready_rx.take().unwrap()) - })?; - ready_rx.await?; - Ok(thread_store) - }) - } - - fn new( - project: Entity, - tools: Entity, - prompt_builder: Arc, - prompt_store: Option>, - cx: &mut Context, - ) -> (Self, oneshot::Receiver<()>) { - let mut subscriptions = vec![cx.subscribe(&project, Self::handle_project_event)]; - - if let Some(prompt_store) = prompt_store.as_ref() { - subscriptions.push(cx.subscribe( - prompt_store, - |this, _prompt_store, PromptsUpdatedEvent, _cx| { - this.enqueue_system_prompt_reload(); - }, - )) - } - - // This channel and task prevent concurrent and redundant loading of the system prompt. - let (reload_system_prompt_tx, mut reload_system_prompt_rx) = mpsc::channel(1); - let (ready_tx, ready_rx) = oneshot::channel(); - let mut ready_tx = Some(ready_tx); - let reload_system_prompt_task = cx.spawn({ - let prompt_store = prompt_store.clone(); - async move |thread_store, cx| { - loop { - let Some(reload_task) = thread_store - .update(cx, |thread_store, cx| { - thread_store.reload_system_prompt(prompt_store.clone(), cx) - }) - .ok() - else { - return; - }; - reload_task.await; - if let Some(ready_tx) = ready_tx.take() { - ready_tx.send(()).ok(); - } - reload_system_prompt_rx.next().await; - } - } - }); - - let this = Self { - project, - tools, - prompt_builder, - prompt_store, - context_server_tool_ids: HashMap::default(), - threads: Vec::new(), - project_context: SharedProjectContext::default(), - reload_system_prompt_tx, - _reload_system_prompt_task: reload_system_prompt_task, - _subscriptions: subscriptions, - }; - this.register_context_server_handlers(cx); - this.reload(cx).detach_and_log_err(cx); - (this, ready_rx) - } - - #[cfg(any(test, feature = "test-support"))] - pub fn fake(project: Entity, cx: &mut App) -> Self { - Self { - project, - tools: cx.new(|_| ToolWorkingSet::default()), - prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()), - prompt_store: None, - context_server_tool_ids: HashMap::default(), - threads: Vec::new(), - project_context: SharedProjectContext::default(), - reload_system_prompt_tx: mpsc::channel(0).0, - _reload_system_prompt_task: Task::ready(()), - _subscriptions: vec![], - } - } - - fn handle_project_event( - &mut self, - _project: Entity, - event: &project::Event, - _cx: &mut Context, - ) { - match event { - project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { - self.enqueue_system_prompt_reload(); - } - project::Event::WorktreeUpdatedEntries(_, items) => { - if items - .iter() - .any(|(path, _, _)| RULES_FILE_NAMES.iter().any(|name| path.as_ref() == *name)) - { - self.enqueue_system_prompt_reload(); - } - } - _ => {} - } - } - - fn enqueue_system_prompt_reload(&mut self) { - self.reload_system_prompt_tx.try_send(()).ok(); - } - - // Note that this should only be called from `reload_system_prompt_task`. - fn reload_system_prompt( - &self, - prompt_store: Option>, - cx: &mut Context, - ) -> Task<()> { - let worktrees = self - .project - .read(cx) - .visible_worktrees(cx) - .collect::>(); - let worktree_tasks = worktrees - .into_iter() - .map(|worktree| { - Self::load_worktree_info_for_system_prompt(worktree, self.project.clone(), cx) - }) - .collect::>(); - let default_user_rules_task = match prompt_store { - None => Task::ready(vec![]), - Some(prompt_store) => prompt_store.read_with(cx, |prompt_store, cx| { - let prompts = prompt_store.default_prompt_metadata(); - let load_tasks = prompts.into_iter().map(|prompt_metadata| { - let contents = prompt_store.load(prompt_metadata.id, cx); - async move { (contents.await, prompt_metadata) } - }); - cx.background_spawn(future::join_all(load_tasks)) - }), - }; - - cx.spawn(async move |this, cx| { - let (worktrees, default_user_rules) = - future::join(future::join_all(worktree_tasks), default_user_rules_task).await; - - let worktrees = worktrees - .into_iter() - .map(|(worktree, rules_error)| { - if let Some(rules_error) = rules_error { - this.update(cx, |_, cx| cx.emit(rules_error)).ok(); - } - worktree - }) - .collect::>(); - - let default_user_rules = default_user_rules - .into_iter() - .flat_map(|(contents, prompt_metadata)| match contents { - Ok(contents) => Some(UserRulesContext { - uuid: match prompt_metadata.id { - PromptId::User { uuid } => uuid, - PromptId::EditWorkflow => return None, - }, - title: prompt_metadata.title.map(|title| title.to_string()), - contents, - }), - Err(err) => { - this.update(cx, |_, cx| { - cx.emit(RulesLoadingError { - message: format!("{err:?}").into(), - }); - }) - .ok(); - None - } - }) - .collect::>(); - - this.update(cx, |this, _cx| { - *this.project_context.0.borrow_mut() = - Some(ProjectContext::new(worktrees, default_user_rules)); - }) - .ok(); - }) - } - - fn load_worktree_info_for_system_prompt( - worktree: Entity, - project: Entity, - cx: &mut App, - ) -> Task<(WorktreeContext, Option)> { - let tree = worktree.read(cx); - let root_name = tree.root_name_str().into(); - let abs_path = tree.abs_path(); - - let mut context = WorktreeContext { - root_name, - abs_path, - rules_file: None, - }; - - let rules_task = Self::load_worktree_rules_file(worktree, project, cx); - let Some(rules_task) = rules_task else { - return Task::ready((context, None)); - }; - - cx.spawn(async move |_| { - let (rules_file, rules_file_error) = match rules_task.await { - Ok(rules_file) => (Some(rules_file), None), - Err(err) => ( - None, - Some(RulesLoadingError { - message: format!("{err}").into(), - }), - ), - }; - context.rules_file = rules_file; - (context, rules_file_error) - }) - } - - fn load_worktree_rules_file( - worktree: Entity, - project: Entity, - cx: &mut App, - ) -> Option>> { - let worktree = worktree.read(cx); - let worktree_id = worktree.id(); - let selected_rules_file = RULES_FILE_NAMES - .into_iter() - .filter_map(|name| { - worktree - .entry_for_path(name) - .filter(|entry| entry.is_file()) - .map(|entry| entry.path.clone()) - }) - .next(); - - // Note that Cline supports `.clinerules` being a directory, but that is not currently - // supported. This doesn't seem to occur often in GitHub repositories. - selected_rules_file.map(|path_in_worktree| { - let project_path = ProjectPath { - worktree_id, - path: path_in_worktree.clone(), - }; - let buffer_task = - project.update(cx, |project, cx| project.open_buffer(project_path, cx)); - let rope_task = cx.spawn(async move |cx| { - buffer_task.await?.read_with(cx, |buffer, cx| { - let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?; - anyhow::Ok((project_entry_id, buffer.as_rope().clone())) - })? - }); - // Build a string from the rope on a background thread. - cx.background_spawn(async move { - let (project_entry_id, rope) = rope_task.await?; - anyhow::Ok(RulesFileContext { - path_in_worktree, - text: rope.to_string().trim().to_string(), - project_entry_id: project_entry_id.to_usize(), - }) - }) - }) - } - - pub fn prompt_store(&self) -> &Option> { - &self.prompt_store - } - - pub fn tools(&self) -> Entity { - self.tools.clone() - } - - /// Returns the number of threads. - pub fn thread_count(&self) -> usize { - self.threads.len() - } - - pub fn reverse_chronological_threads(&self) -> impl Iterator { - // ordering is from "ORDER BY" in `list_threads` - self.threads.iter() - } - - pub fn create_thread(&mut self, cx: &mut Context) -> Entity { - cx.new(|cx| { - Thread::new( - self.project.clone(), - self.tools.clone(), - self.prompt_builder.clone(), - self.project_context.clone(), - cx, - ) - }) - } - - pub fn create_thread_from_serialized( - &mut self, - serialized: SerializedThread, - cx: &mut Context, - ) -> Entity { - cx.new(|cx| { - Thread::deserialize( - ThreadId::new(), - serialized, - self.project.clone(), - self.tools.clone(), - self.prompt_builder.clone(), - self.project_context.clone(), - None, - cx, - ) - }) - } - - pub fn open_thread( - &self, - id: &ThreadId, - window: &mut Window, - cx: &mut Context, - ) -> Task>> { - let id = id.clone(); - let database_future = ThreadsDatabase::global_future(cx); - let this = cx.weak_entity(); - window.spawn(cx, async move |cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - let thread = database - .try_find_thread(id.clone()) - .await? - .with_context(|| format!("no thread found with ID: {id:?}"))?; - - let thread = this.update_in(cx, |this, window, cx| { - cx.new(|cx| { - Thread::deserialize( - id.clone(), - thread, - this.project.clone(), - this.tools.clone(), - this.prompt_builder.clone(), - this.project_context.clone(), - Some(window), - cx, - ) - }) - })?; - - Ok(thread) - }) - } - - pub fn save_thread(&self, thread: &Entity, cx: &mut Context) -> Task> { - let (metadata, serialized_thread) = - thread.update(cx, |thread, cx| (thread.id().clone(), thread.serialize(cx))); - - let database_future = ThreadsDatabase::global_future(cx); - cx.spawn(async move |this, cx| { - let serialized_thread = serialized_thread.await?; - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.save_thread(metadata, serialized_thread).await?; - - this.update(cx, |this, cx| this.reload(cx))?.await - }) - } - - pub fn delete_thread(&mut self, id: &ThreadId, cx: &mut Context) -> Task> { - let id = id.clone(); - let database_future = ThreadsDatabase::global_future(cx); - cx.spawn(async move |this, cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.delete_thread(id.clone()).await?; - - this.update(cx, |this, cx| { - this.threads.retain(|thread| thread.id != id); - cx.notify(); - }) - }) - } - - pub fn reload(&self, cx: &mut Context) -> Task> { - let database_future = ThreadsDatabase::global_future(cx); - cx.spawn(async move |this, cx| { - let threads = database_future - .await - .map_err(|err| anyhow!(err))? - .list_threads() - .await?; - - this.update(cx, |this, cx| { - this.threads = threads; - cx.notify(); - }) - }) - } - - fn register_context_server_handlers(&self, cx: &mut Context) { - let context_server_store = self.project.read(cx).context_server_store(); - cx.subscribe(&context_server_store, Self::handle_context_server_event) - .detach(); - - // Check for any servers that were already running before the handler was registered - for server in context_server_store.read(cx).running_servers() { - self.load_context_server_tools(server.id(), context_server_store.clone(), cx); - } - } - - fn handle_context_server_event( - &mut self, - context_server_store: Entity, - event: &project::context_server_store::Event, - cx: &mut Context, - ) { - let tool_working_set = self.tools.clone(); - match event { - project::context_server_store::Event::ServerStatusChanged { server_id, status } => { - match status { - ContextServerStatus::Starting => {} - ContextServerStatus::Running => { - self.load_context_server_tools(server_id.clone(), context_server_store, cx); - } - ContextServerStatus::Stopped | ContextServerStatus::Error(_) => { - if let Some(tool_ids) = self.context_server_tool_ids.remove(server_id) { - tool_working_set.update(cx, |tool_working_set, cx| { - tool_working_set.remove(&tool_ids, cx); - }); - } - } - } - } - } - } - - fn load_context_server_tools( - &self, - server_id: ContextServerId, - context_server_store: Entity, - cx: &mut Context, - ) { - let Some(server) = context_server_store.read(cx).get_running_server(&server_id) else { - return; - }; - let tool_working_set = self.tools.clone(); - cx.spawn(async move |this, cx| { - let Some(protocol) = server.client() else { - return; - }; - - if protocol.capable(context_server::protocol::ServerCapability::Tools) - && let Some(response) = protocol - .request::(()) - .await - .log_err() - { - let tool_ids = tool_working_set - .update(cx, |tool_working_set, cx| { - tool_working_set.extend( - response.tools.into_iter().map(|tool| { - Arc::new(ContextServerTool::new( - context_server_store.clone(), - server.id(), - tool, - )) as Arc - }), - cx, - ) - }) - .log_err(); - - if let Some(tool_ids) = tool_ids { - this.update(cx, |this, _| { - this.context_server_tool_ids.insert(server_id, tool_ids); - }) - .log_err(); - } - } - }) - .detach(); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SerializedThreadMetadata { - pub id: ThreadId, - pub summary: SharedString, - pub updated_at: DateTime, -} - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct SerializedThread { - pub version: String, - pub summary: SharedString, - pub updated_at: DateTime, - pub messages: Vec, - #[serde(default)] - pub initial_project_snapshot: Option>, - #[serde(default)] - pub cumulative_token_usage: TokenUsage, - #[serde(default)] - pub request_token_usage: Vec, - #[serde(default)] - pub detailed_summary_state: DetailedSummaryState, - #[serde(default)] - pub exceeded_window_error: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub completion_mode: Option, - #[serde(default)] - pub tool_use_limit_reached: bool, - #[serde(default)] - pub profile: Option, -} - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct SerializedLanguageModel { - pub provider: String, - pub model: String, -} - -impl SerializedThread { - pub const VERSION: &'static str = "0.2.0"; - - pub fn from_json(json: &[u8]) -> Result { - let saved_thread_json = serde_json::from_slice::(json)?; - match saved_thread_json.get("version") { - Some(serde_json::Value::String(version)) => match version.as_str() { - SerializedThreadV0_1_0::VERSION => { - let saved_thread = - serde_json::from_value::(saved_thread_json)?; - Ok(saved_thread.upgrade()) - } - SerializedThread::VERSION => Ok(serde_json::from_value::( - saved_thread_json, - )?), - _ => anyhow::bail!("unrecognized serialized thread version: {version:?}"), - }, - None => { - let saved_thread = - serde_json::from_value::(saved_thread_json)?; - Ok(saved_thread.upgrade()) - } - version => anyhow::bail!("unrecognized serialized thread version: {version:?}"), - } - } -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct SerializedThreadV0_1_0( - // The structure did not change, so we are reusing the latest SerializedThread. - // When making the next version, make sure this points to SerializedThreadV0_2_0 - SerializedThread, -); - -impl SerializedThreadV0_1_0 { - pub const VERSION: &'static str = "0.1.0"; - - pub fn upgrade(self) -> SerializedThread { - debug_assert_eq!(SerializedThread::VERSION, "0.2.0"); - - let mut messages: Vec = Vec::with_capacity(self.0.messages.len()); - - for message in self.0.messages { - if message.role == Role::User - && !message.tool_results.is_empty() - && let Some(last_message) = messages.last_mut() - { - debug_assert!(last_message.role == Role::Assistant); - - last_message.tool_results = message.tool_results; - continue; - } - - messages.push(message); - } - - SerializedThread { - messages, - version: SerializedThread::VERSION.to_string(), - ..self.0 - } - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct SerializedMessage { - pub id: MessageId, - pub role: Role, - #[serde(default)] - pub segments: Vec, - #[serde(default)] - pub tool_uses: Vec, - #[serde(default)] - pub tool_results: Vec, - #[serde(default)] - pub context: String, - #[serde(default)] - pub creases: Vec, - #[serde(default)] - pub is_hidden: bool, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type")] -pub enum SerializedMessageSegment { - #[serde(rename = "text")] - Text { - text: String, - }, - #[serde(rename = "thinking")] - Thinking { - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - signature: Option, - }, - RedactedThinking { - data: String, - }, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct SerializedToolUse { - pub id: LanguageModelToolUseId, - pub name: SharedString, - pub input: serde_json::Value, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct SerializedToolResult { - pub tool_use_id: LanguageModelToolUseId, - pub is_error: bool, - pub content: LanguageModelToolResultContent, - pub output: Option, -} - -#[derive(Serialize, Deserialize)] -struct LegacySerializedThread { - pub summary: SharedString, - pub updated_at: DateTime, - pub messages: Vec, - #[serde(default)] - pub initial_project_snapshot: Option>, -} - -impl LegacySerializedThread { - pub fn upgrade(self) -> SerializedThread { - SerializedThread { - version: SerializedThread::VERSION.to_string(), - summary: self.summary, - updated_at: self.updated_at, - messages: self.messages.into_iter().map(|msg| msg.upgrade()).collect(), - initial_project_snapshot: self.initial_project_snapshot, - cumulative_token_usage: TokenUsage::default(), - request_token_usage: Vec::new(), - detailed_summary_state: DetailedSummaryState::default(), - exceeded_window_error: None, - model: None, - completion_mode: None, - tool_use_limit_reached: false, - profile: None, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -struct LegacySerializedMessage { - pub id: MessageId, - pub role: Role, - pub text: String, - #[serde(default)] - pub tool_uses: Vec, - #[serde(default)] - pub tool_results: Vec, -} - -impl LegacySerializedMessage { - fn upgrade(self) -> SerializedMessage { - SerializedMessage { - id: self.id, - role: self.role, - segments: vec![SerializedMessageSegment::Text { text: self.text }], - tool_uses: self.tool_uses, - tool_results: self.tool_results, - context: String::new(), - creases: Vec::new(), - is_hidden: false, - } - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct SerializedCrease { - pub start: usize, - pub end: usize, - pub icon_path: SharedString, - pub label: SharedString, -} - -struct GlobalThreadsDatabase( - Shared, Arc>>>, -); - -impl Global for GlobalThreadsDatabase {} - -pub(crate) struct ThreadsDatabase { - executor: BackgroundExecutor, - connection: Arc>, -} - -impl ThreadsDatabase { - fn connection(&self) -> Arc> { - self.connection.clone() - } - - const COMPRESSION_LEVEL: i32 = 3; -} - -impl Bind for ThreadId { - fn bind(&self, statement: &Statement, start_index: i32) -> Result { - self.to_string().bind(statement, start_index) - } -} - -impl Column for ThreadId { - fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { - let (id_str, next_index) = String::column(statement, start_index)?; - Ok((ThreadId::from(id_str.as_str()), next_index)) - } -} - -impl ThreadsDatabase { - fn global_future( - cx: &mut App, - ) -> Shared, Arc>>> { - GlobalThreadsDatabase::global(cx).0.clone() - } - - fn init(fs: Arc, cx: &mut App) { - let executor = cx.background_executor().clone(); - let database_future = executor - .spawn({ - let executor = executor.clone(); - let threads_dir = paths::data_dir().join("threads"); - async move { ThreadsDatabase::new(fs, threads_dir, executor).await } - }) - .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new))) - .boxed() - .shared(); - - cx.set_global(GlobalThreadsDatabase(database_future)); - } - - pub async fn new( - fs: Arc, - threads_dir: PathBuf, - executor: BackgroundExecutor, - ) -> Result { - fs.create_dir(&threads_dir).await?; - - let sqlite_path = threads_dir.join("threads.db"); - let mdb_path = threads_dir.join("threads-db.1.mdb"); - - let needs_migration_from_heed = fs.is_file(&mdb_path).await; - - let connection = if *ZED_STATELESS { - Connection::open_memory(Some("THREAD_FALLBACK_DB")) - } else if cfg!(any(feature = "test-support", test)) { - // rust stores the name of the test on the current thread. - // We use this to automatically create a database that will - // be shared within the test (for the test_retrieve_old_thread) - // but not with concurrent tests. - let thread = std::thread::current(); - let test_name = thread.name(); - Connection::open_memory(Some(&format!( - "THREAD_FALLBACK_{}", - test_name.unwrap_or_default() - ))) - } else { - Connection::open_file(&sqlite_path.to_string_lossy()) - }; - - connection.exec(indoc! {" - CREATE TABLE IF NOT EXISTS threads ( - id TEXT PRIMARY KEY, - summary TEXT NOT NULL, - updated_at TEXT NOT NULL, - data_type TEXT NOT NULL, - data BLOB NOT NULL - ) - "})?() - .map_err(|e| anyhow!("Failed to create threads table: {}", e))?; - - let db = Self { - executor: executor.clone(), - connection: Arc::new(Mutex::new(connection)), - }; - - if needs_migration_from_heed { - let db_connection = db.connection(); - let executor_clone = executor.clone(); - executor - .spawn(async move { - log::info!("Starting threads.db migration"); - Self::migrate_from_heed(&mdb_path, db_connection, executor_clone)?; - fs.remove_dir( - &mdb_path, - RemoveOptions { - recursive: true, - ignore_if_not_exists: true, - }, - ) - .await?; - log::info!("threads.db migrated to sqlite"); - Ok::<(), anyhow::Error>(()) - }) - .detach(); - } - - Ok(db) - } - - // Remove this migration after 2025-09-01 - fn migrate_from_heed( - mdb_path: &Path, - connection: Arc>, - _executor: BackgroundExecutor, - ) -> Result<()> { - use heed::types::SerdeBincode; - struct SerializedThreadHeed(SerializedThread); - - impl heed::BytesEncode<'_> for SerializedThreadHeed { - type EItem = SerializedThreadHeed; - - fn bytes_encode( - item: &Self::EItem, - ) -> Result, heed::BoxedError> { - serde_json::to_vec(&item.0) - .map(std::borrow::Cow::Owned) - .map_err(Into::into) - } - } - - impl<'a> heed::BytesDecode<'a> for SerializedThreadHeed { - type DItem = SerializedThreadHeed; - - fn bytes_decode(bytes: &'a [u8]) -> Result { - SerializedThread::from_json(bytes) - .map(SerializedThreadHeed) - .map_err(Into::into) - } - } - - const ONE_GB_IN_BYTES: usize = 1024 * 1024 * 1024; - - let env = unsafe { - heed::EnvOpenOptions::new() - .map_size(ONE_GB_IN_BYTES) - .max_dbs(1) - .open(mdb_path)? - }; - - let txn = env.write_txn()?; - let threads: heed::Database, SerializedThreadHeed> = env - .open_database(&txn, Some("threads"))? - .ok_or_else(|| anyhow!("threads database not found"))?; - - for result in threads.iter(&txn)? { - let (thread_id, thread_heed) = result?; - Self::save_thread_sync(&connection, thread_id, thread_heed.0)?; - } - - Ok(()) - } - - fn save_thread_sync( - connection: &Arc>, - id: ThreadId, - thread: SerializedThread, - ) -> Result<()> { - let json_data = serde_json::to_string(&thread)?; - let summary = thread.summary.to_string(); - let updated_at = thread.updated_at.to_rfc3339(); - - let connection = connection.lock().unwrap(); - - let compressed = zstd::encode_all(json_data.as_bytes(), Self::COMPRESSION_LEVEL)?; - let data_type = DataType::Zstd; - let data = compressed; - - let mut insert = connection.exec_bound::<(ThreadId, String, String, DataType, Vec)>(indoc! {" - INSERT OR REPLACE INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?) - "})?; - - insert((id, summary, updated_at, data_type, data))?; - - Ok(()) - } - - pub fn list_threads(&self) -> Task>> { - let connection = self.connection.clone(); - - self.executor.spawn(async move { - let connection = connection.lock().unwrap(); - let mut select = - connection.select_bound::<(), (ThreadId, String, String)>(indoc! {" - SELECT id, summary, updated_at FROM threads ORDER BY updated_at DESC - "})?; - - let rows = select(())?; - let mut threads = Vec::new(); - - for (id, summary, updated_at) in rows { - threads.push(SerializedThreadMetadata { - id, - summary: summary.into(), - updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), - }); - } - - Ok(threads) - }) - } - - pub fn try_find_thread(&self, id: ThreadId) -> Task>> { - let connection = self.connection.clone(); - - self.executor.spawn(async move { - let connection = connection.lock().unwrap(); - let mut select = connection.select_bound::)>(indoc! {" - SELECT data_type, data FROM threads WHERE id = ? LIMIT 1 - "})?; - - let rows = select(id)?; - if let Some((data_type, data)) = rows.into_iter().next() { - let json_data = match data_type { - DataType::Zstd => { - let decompressed = zstd::decode_all(&data[..])?; - String::from_utf8(decompressed)? - } - DataType::Json => String::from_utf8(data)?, - }; - - let thread = SerializedThread::from_json(json_data.as_bytes())?; - Ok(Some(thread)) - } else { - Ok(None) - } - }) - } - - pub fn save_thread(&self, id: ThreadId, thread: SerializedThread) -> Task> { - let connection = self.connection.clone(); - - self.executor - .spawn(async move { Self::save_thread_sync(&connection, id, thread) }) - } - - pub fn delete_thread(&self, id: ThreadId) -> Task> { - let connection = self.connection.clone(); - - self.executor.spawn(async move { - let connection = connection.lock().unwrap(); - - let mut delete = connection.exec_bound::(indoc! {" - DELETE FROM threads WHERE id = ? - "})?; - - delete(id)?; - - Ok(()) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::thread::{DetailedSummaryState, MessageId}; - use chrono::Utc; - use language_model::{Role, TokenUsage}; - use pretty_assertions::assert_eq; - - #[test] - fn test_legacy_serialized_thread_upgrade() { - let updated_at = Utc::now(); - let legacy_thread = LegacySerializedThread { - summary: "Test conversation".into(), - updated_at, - messages: vec![LegacySerializedMessage { - id: MessageId(1), - role: Role::User, - text: "Hello, world!".to_string(), - tool_uses: vec![], - tool_results: vec![], - }], - initial_project_snapshot: None, - }; - - let upgraded = legacy_thread.upgrade(); - - assert_eq!( - upgraded, - SerializedThread { - summary: "Test conversation".into(), - updated_at, - messages: vec![SerializedMessage { - id: MessageId(1), - role: Role::User, - segments: vec![SerializedMessageSegment::Text { - text: "Hello, world!".to_string() - }], - tool_uses: vec![], - tool_results: vec![], - context: "".to_string(), - creases: vec![], - is_hidden: false - }], - version: SerializedThread::VERSION.to_string(), - initial_project_snapshot: None, - cumulative_token_usage: TokenUsage::default(), - request_token_usage: vec![], - detailed_summary_state: DetailedSummaryState::default(), - exceeded_window_error: None, - model: None, - completion_mode: None, - tool_use_limit_reached: false, - profile: None - } - ) - } - - #[test] - fn test_serialized_threadv0_1_0_upgrade() { - let updated_at = Utc::now(); - let thread_v0_1_0 = SerializedThreadV0_1_0(SerializedThread { - summary: "Test conversation".into(), - updated_at, - messages: vec![ - SerializedMessage { - id: MessageId(1), - role: Role::User, - segments: vec![SerializedMessageSegment::Text { - text: "Use tool_1".to_string(), - }], - tool_uses: vec![], - tool_results: vec![], - context: "".to_string(), - creases: vec![], - is_hidden: false, - }, - SerializedMessage { - id: MessageId(2), - role: Role::Assistant, - segments: vec![SerializedMessageSegment::Text { - text: "I want to use a tool".to_string(), - }], - tool_uses: vec![SerializedToolUse { - id: "abc".into(), - name: "tool_1".into(), - input: serde_json::Value::Null, - }], - tool_results: vec![], - context: "".to_string(), - creases: vec![], - is_hidden: false, - }, - SerializedMessage { - id: MessageId(1), - role: Role::User, - segments: vec![SerializedMessageSegment::Text { - text: "Here is the tool result".to_string(), - }], - tool_uses: vec![], - tool_results: vec![SerializedToolResult { - tool_use_id: "abc".into(), - is_error: false, - content: LanguageModelToolResultContent::Text("abcdef".into()), - output: Some(serde_json::Value::Null), - }], - context: "".to_string(), - creases: vec![], - is_hidden: false, - }, - ], - version: SerializedThreadV0_1_0::VERSION.to_string(), - initial_project_snapshot: None, - cumulative_token_usage: TokenUsage::default(), - request_token_usage: vec![], - detailed_summary_state: DetailedSummaryState::default(), - exceeded_window_error: None, - model: None, - completion_mode: None, - tool_use_limit_reached: false, - profile: None, - }); - let upgraded = thread_v0_1_0.upgrade(); - - assert_eq!( - upgraded, - SerializedThread { - summary: "Test conversation".into(), - updated_at, - messages: vec![ - SerializedMessage { - id: MessageId(1), - role: Role::User, - segments: vec![SerializedMessageSegment::Text { - text: "Use tool_1".to_string() - }], - tool_uses: vec![], - tool_results: vec![], - context: "".to_string(), - creases: vec![], - is_hidden: false - }, - SerializedMessage { - id: MessageId(2), - role: Role::Assistant, - segments: vec![SerializedMessageSegment::Text { - text: "I want to use a tool".to_string(), - }], - tool_uses: vec![SerializedToolUse { - id: "abc".into(), - name: "tool_1".into(), - input: serde_json::Value::Null, - }], - tool_results: vec![SerializedToolResult { - tool_use_id: "abc".into(), - is_error: false, - content: LanguageModelToolResultContent::Text("abcdef".into()), - output: Some(serde_json::Value::Null), - }], - context: "".to_string(), - creases: vec![], - is_hidden: false, - }, - ], - version: SerializedThread::VERSION.to_string(), - initial_project_snapshot: None, - cumulative_token_usage: TokenUsage::default(), - request_token_usage: vec![], - detailed_summary_state: DetailedSummaryState::default(), - exceeded_window_error: None, - model: None, - completion_mode: None, - tool_use_limit_reached: false, - profile: None - } - ) - } -} diff --git a/crates/agent/src/tool_use.rs b/crates/agent/src/tool_use.rs deleted file mode 100644 index 962dca591f..0000000000 --- a/crates/agent/src/tool_use.rs +++ /dev/null @@ -1,575 +0,0 @@ -use crate::{ - thread::{MessageId, PromptId, ThreadId}, - thread_store::SerializedMessage, -}; -use agent_settings::CompletionMode; -use anyhow::Result; -use assistant_tool::{ - AnyToolCard, Tool, ToolResultContent, ToolResultOutput, ToolUseStatus, ToolWorkingSet, -}; -use collections::HashMap; -use futures::{FutureExt as _, future::Shared}; -use gpui::{App, Entity, SharedString, Task, Window}; -use icons::IconName; -use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelExt, LanguageModelRequest, - LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, - LanguageModelToolUseId, Role, -}; -use project::Project; -use std::sync::Arc; -use util::truncate_lines_to_byte_limit; - -#[derive(Debug)] -pub struct ToolUse { - pub id: LanguageModelToolUseId, - pub name: SharedString, - pub ui_text: SharedString, - pub status: ToolUseStatus, - pub input: serde_json::Value, - pub icon: icons::IconName, - pub needs_confirmation: bool, -} - -pub struct ToolUseState { - tools: Entity, - tool_uses_by_assistant_message: HashMap>, - tool_results: HashMap, - pending_tool_uses_by_id: HashMap, - tool_result_cards: HashMap, - tool_use_metadata_by_id: HashMap, -} - -impl ToolUseState { - pub fn new(tools: Entity) -> Self { - Self { - tools, - tool_uses_by_assistant_message: HashMap::default(), - tool_results: HashMap::default(), - pending_tool_uses_by_id: HashMap::default(), - tool_result_cards: HashMap::default(), - tool_use_metadata_by_id: HashMap::default(), - } - } - - /// Constructs a [`ToolUseState`] from the given list of [`SerializedMessage`]s. - /// - /// Accepts a function to filter the tools that should be used to populate the state. - /// - /// If `window` is `None` (e.g., when in headless mode or when running evals), - /// tool cards won't be deserialized - pub fn from_serialized_messages( - tools: Entity, - messages: &[SerializedMessage], - project: Entity, - window: Option<&mut Window>, // None in headless mode - cx: &mut App, - ) -> Self { - let mut this = Self::new(tools); - let mut tool_names_by_id = HashMap::default(); - let mut window = window; - - for message in messages { - match message.role { - Role::Assistant => { - if !message.tool_uses.is_empty() { - let tool_uses = message - .tool_uses - .iter() - .map(|tool_use| LanguageModelToolUse { - id: tool_use.id.clone(), - name: tool_use.name.clone().into(), - raw_input: tool_use.input.to_string(), - input: tool_use.input.clone(), - is_input_complete: true, - }) - .collect::>(); - - tool_names_by_id.extend( - tool_uses - .iter() - .map(|tool_use| (tool_use.id.clone(), tool_use.name.clone())), - ); - - this.tool_uses_by_assistant_message - .insert(message.id, tool_uses); - - for tool_result in &message.tool_results { - let tool_use_id = tool_result.tool_use_id.clone(); - let Some(tool_use) = tool_names_by_id.get(&tool_use_id) else { - log::warn!("no tool name found for tool use: {tool_use_id:?}"); - continue; - }; - - this.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name: tool_use.clone(), - is_error: tool_result.is_error, - content: tool_result.content.clone(), - output: tool_result.output.clone(), - }, - ); - - if let Some(window) = &mut window - && let Some(tool) = this.tools.read(cx).tool(tool_use, cx) - && let Some(output) = tool_result.output.clone() - && let Some(card) = - tool.deserialize_card(output, project.clone(), window, cx) - { - this.tool_result_cards.insert(tool_use_id, card); - } - } - } - } - Role::System | Role::User => {} - } - } - - this - } - - pub fn cancel_pending(&mut self) -> Vec { - let mut canceled_tool_uses = Vec::new(); - self.pending_tool_uses_by_id - .retain(|tool_use_id, tool_use| { - if matches!(tool_use.status, PendingToolUseStatus::Error { .. }) { - return true; - } - - let content = "Tool canceled by user".into(); - self.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name: tool_use.name.clone(), - content, - output: None, - is_error: true, - }, - ); - canceled_tool_uses.push(tool_use.clone()); - false - }); - canceled_tool_uses - } - - pub fn pending_tool_uses(&self) -> Vec<&PendingToolUse> { - self.pending_tool_uses_by_id.values().collect() - } - - pub fn tool_uses_for_message( - &self, - id: MessageId, - project: &Entity, - cx: &App, - ) -> Vec { - let Some(tool_uses_for_message) = &self.tool_uses_by_assistant_message.get(&id) else { - return Vec::new(); - }; - - let mut tool_uses = Vec::new(); - - for tool_use in tool_uses_for_message.iter() { - let tool_result = self.tool_results.get(&tool_use.id); - - let status = (|| { - if let Some(tool_result) = tool_result { - let content = tool_result - .content - .to_str() - .map(|str| str.to_owned().into()) - .unwrap_or_default(); - - return if tool_result.is_error { - ToolUseStatus::Error(content) - } else { - ToolUseStatus::Finished(content) - }; - } - - if let Some(pending_tool_use) = self.pending_tool_uses_by_id.get(&tool_use.id) { - match pending_tool_use.status { - PendingToolUseStatus::Idle => ToolUseStatus::Pending, - PendingToolUseStatus::NeedsConfirmation { .. } => { - ToolUseStatus::NeedsConfirmation - } - PendingToolUseStatus::Running { .. } => ToolUseStatus::Running, - PendingToolUseStatus::Error(ref err) => { - ToolUseStatus::Error(err.clone().into()) - } - PendingToolUseStatus::InputStillStreaming => { - ToolUseStatus::InputStillStreaming - } - } - } else { - ToolUseStatus::Pending - } - })(); - - let (icon, needs_confirmation) = - if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) { - ( - tool.icon(), - tool.needs_confirmation(&tool_use.input, project, cx), - ) - } else { - (IconName::Cog, false) - }; - - tool_uses.push(ToolUse { - id: tool_use.id.clone(), - name: tool_use.name.clone().into(), - ui_text: self.tool_ui_label( - &tool_use.name, - &tool_use.input, - tool_use.is_input_complete, - cx, - ), - input: tool_use.input.clone(), - status, - icon, - needs_confirmation, - }) - } - - tool_uses - } - - pub fn tool_ui_label( - &self, - tool_name: &str, - input: &serde_json::Value, - is_input_complete: bool, - cx: &App, - ) -> SharedString { - if let Some(tool) = self.tools.read(cx).tool(tool_name, cx) { - if is_input_complete { - tool.ui_text(input).into() - } else { - tool.still_streaming_ui_text(input).into() - } - } else { - format!("Unknown tool {tool_name:?}").into() - } - } - - pub fn tool_results_for_message( - &self, - assistant_message_id: MessageId, - ) -> Vec<&LanguageModelToolResult> { - let Some(tool_uses) = self - .tool_uses_by_assistant_message - .get(&assistant_message_id) - else { - return Vec::new(); - }; - - tool_uses - .iter() - .filter_map(|tool_use| self.tool_results.get(&tool_use.id)) - .collect() - } - - pub fn message_has_tool_results(&self, assistant_message_id: MessageId) -> bool { - self.tool_uses_by_assistant_message - .get(&assistant_message_id) - .is_some_and(|results| !results.is_empty()) - } - - pub fn tool_result( - &self, - tool_use_id: &LanguageModelToolUseId, - ) -> Option<&LanguageModelToolResult> { - self.tool_results.get(tool_use_id) - } - - pub fn tool_result_card(&self, tool_use_id: &LanguageModelToolUseId) -> Option<&AnyToolCard> { - self.tool_result_cards.get(tool_use_id) - } - - pub fn insert_tool_result_card( - &mut self, - tool_use_id: LanguageModelToolUseId, - card: AnyToolCard, - ) { - self.tool_result_cards.insert(tool_use_id, card); - } - - pub fn request_tool_use( - &mut self, - assistant_message_id: MessageId, - tool_use: LanguageModelToolUse, - metadata: ToolUseMetadata, - cx: &App, - ) -> Arc { - let tool_uses = self - .tool_uses_by_assistant_message - .entry(assistant_message_id) - .or_default(); - - let mut existing_tool_use_found = false; - - for existing_tool_use in tool_uses.iter_mut() { - if existing_tool_use.id == tool_use.id { - *existing_tool_use = tool_use.clone(); - existing_tool_use_found = true; - } - } - - if !existing_tool_use_found { - tool_uses.push(tool_use.clone()); - } - - let status = if tool_use.is_input_complete { - self.tool_use_metadata_by_id - .insert(tool_use.id.clone(), metadata); - - PendingToolUseStatus::Idle - } else { - PendingToolUseStatus::InputStillStreaming - }; - - let ui_text: Arc = self - .tool_ui_label( - &tool_use.name, - &tool_use.input, - tool_use.is_input_complete, - cx, - ) - .into(); - - let may_perform_edits = self - .tools - .read(cx) - .tool(&tool_use.name, cx) - .is_some_and(|tool| tool.may_perform_edits()); - - self.pending_tool_uses_by_id.insert( - tool_use.id.clone(), - PendingToolUse { - assistant_message_id, - id: tool_use.id, - name: tool_use.name.clone(), - ui_text: ui_text.clone(), - input: tool_use.input, - may_perform_edits, - status, - }, - ); - - ui_text - } - - pub fn run_pending_tool( - &mut self, - tool_use_id: LanguageModelToolUseId, - ui_text: SharedString, - task: Task<()>, - ) { - if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { - tool_use.ui_text = ui_text.into(); - tool_use.status = PendingToolUseStatus::Running { - _task: task.shared(), - }; - } - } - - pub fn confirm_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - ui_text: impl Into>, - input: serde_json::Value, - request: Arc, - tool: Arc, - ) { - if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { - let ui_text = ui_text.into(); - tool_use.ui_text = ui_text.clone(); - let confirmation = Confirmation { - tool_use_id, - input, - request, - tool, - ui_text, - }; - tool_use.status = PendingToolUseStatus::NeedsConfirmation(Arc::new(confirmation)); - } - } - - pub fn insert_tool_output( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - output: Result, - configured_model: Option<&ConfiguredModel>, - completion_mode: CompletionMode, - ) -> Option { - let metadata = self.tool_use_metadata_by_id.remove(&tool_use_id); - - telemetry::event!( - "Agent Tool Finished", - model = metadata - .as_ref() - .map(|metadata| metadata.model.telemetry_id()), - model_provider = metadata - .as_ref() - .map(|metadata| metadata.model.provider_id().to_string()), - thread_id = metadata.as_ref().map(|metadata| metadata.thread_id.clone()), - prompt_id = metadata.as_ref().map(|metadata| metadata.prompt_id.clone()), - tool_name, - success = output.is_ok() - ); - - match output { - Ok(output) => { - let tool_result = output.content; - const BYTES_PER_TOKEN_ESTIMATE: usize = 3; - - let old_use = self.pending_tool_uses_by_id.remove(&tool_use_id); - - // Protect from overly large output - let tool_output_limit = configured_model - .map(|model| { - model.model.max_token_count_for_mode(completion_mode.into()) as usize - * BYTES_PER_TOKEN_ESTIMATE - }) - .unwrap_or(usize::MAX); - - let content = match tool_result { - ToolResultContent::Text(text) => { - let text = if text.len() < tool_output_limit { - text - } else { - let truncated = truncate_lines_to_byte_limit(&text, tool_output_limit); - format!( - "Tool result too long. The first {} bytes:\n\n{}", - truncated.len(), - truncated - ) - }; - LanguageModelToolResultContent::Text(text.into()) - } - ToolResultContent::Image(language_model_image) => { - if language_model_image.estimate_tokens() < tool_output_limit { - LanguageModelToolResultContent::Image(language_model_image) - } else { - self.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name, - content: "Tool responded with an image that would exceeded the remaining tokens".into(), - is_error: true, - output: None, - }, - ); - - return old_use; - } - } - }; - - self.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name, - content, - is_error: false, - output: output.output, - }, - ); - - old_use - } - Err(err) => { - self.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name, - content: LanguageModelToolResultContent::Text(err.to_string().into()), - is_error: true, - output: None, - }, - ); - - if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { - tool_use.status = PendingToolUseStatus::Error(err.to_string().into()); - } - - self.pending_tool_uses_by_id.get(&tool_use_id).cloned() - } - } - } - - pub fn has_tool_results(&self, assistant_message_id: MessageId) -> bool { - self.tool_uses_by_assistant_message - .contains_key(&assistant_message_id) - } - - pub fn tool_results( - &self, - assistant_message_id: MessageId, - ) -> impl Iterator)> { - self.tool_uses_by_assistant_message - .get(&assistant_message_id) - .into_iter() - .flatten() - .map(|tool_use| (tool_use, self.tool_results.get(&tool_use.id))) - } -} - -#[derive(Debug, Clone)] -pub struct PendingToolUse { - pub id: LanguageModelToolUseId, - /// The ID of the Assistant message in which the tool use was requested. - #[allow(unused)] - pub assistant_message_id: MessageId, - pub name: Arc, - pub ui_text: Arc, - pub input: serde_json::Value, - pub status: PendingToolUseStatus, - pub may_perform_edits: bool, -} - -#[derive(Debug, Clone)] -pub struct Confirmation { - pub tool_use_id: LanguageModelToolUseId, - pub input: serde_json::Value, - pub ui_text: Arc, - pub request: Arc, - pub tool: Arc, -} - -#[derive(Debug, Clone)] -pub enum PendingToolUseStatus { - InputStillStreaming, - Idle, - NeedsConfirmation(Arc), - Running { _task: Shared> }, - Error(#[allow(unused)] Arc), -} - -impl PendingToolUseStatus { - pub fn is_idle(&self) -> bool { - matches!(self, PendingToolUseStatus::Idle) - } - - pub fn is_error(&self) -> bool { - matches!(self, PendingToolUseStatus::Error(_)) - } - - pub fn needs_confirmation(&self) -> bool { - matches!(self, PendingToolUseStatus::NeedsConfirmation { .. }) - } -} - -#[derive(Clone)] -pub struct ToolUseMetadata { - pub model: Arc, - pub thread_id: ThreadId, - pub prompt_id: PromptId, -} diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs new file mode 100644 index 0000000000..62a52998a7 --- /dev/null +++ b/crates/agent/src/tools.rs @@ -0,0 +1,98 @@ +mod context_server_registry; +mod copy_path_tool; +mod create_directory_tool; +mod delete_path_tool; +mod diagnostics_tool; +mod edit_file_tool; + +mod fetch_tool; +mod find_path_tool; +mod grep_tool; +mod list_directory_tool; +mod move_path_tool; +mod now_tool; +mod open_tool; +mod read_file_tool; + +mod terminal_tool; +mod thinking_tool; +mod web_search_tool; + +use crate::AgentTool; +use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat}; + +pub use context_server_registry::*; +pub use copy_path_tool::*; +pub use create_directory_tool::*; +pub use delete_path_tool::*; +pub use diagnostics_tool::*; +pub use edit_file_tool::*; + +pub use fetch_tool::*; +pub use find_path_tool::*; +pub use grep_tool::*; +pub use list_directory_tool::*; +pub use move_path_tool::*; +pub use now_tool::*; +pub use open_tool::*; +pub use read_file_tool::*; + +pub use terminal_tool::*; +pub use thinking_tool::*; +pub use web_search_tool::*; + +macro_rules! tools { + ($($tool:ty),* $(,)?) => { + /// A list of all built-in tool names + pub fn supported_built_in_tool_names(provider: Option) -> impl Iterator { + [ + $( + (if let Some(provider) = provider.as_ref() { + <$tool>::supports_provider(provider) + } else { + true + }) + .then(|| <$tool>::name().to_string()), + )* + ] + .into_iter() + .flatten() + } + + /// A list of all built-in tools + pub fn built_in_tools() -> impl Iterator { + fn language_model_tool() -> LanguageModelRequestTool { + LanguageModelRequestTool { + name: T::name().to_string(), + description: T::description().to_string(), + input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), + } + } + [ + $( + language_model_tool::<$tool>(), + )* + ] + .into_iter() + } + }; +} + +tools! { + CopyPathTool, + CreateDirectoryTool, + DeletePathTool, + DiagnosticsTool, + EditFileTool, + FetchTool, + FindPathTool, + GrepTool, + ListDirectoryTool, + MovePathTool, + NowTool, + OpenTool, + ReadFileTool, + TerminalTool, + ThinkingTool, + WebSearchTool, +} diff --git a/crates/agent2/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs similarity index 95% rename from crates/agent2/src/tools/context_server_registry.rs rename to crates/agent/src/tools/context_server_registry.rs index 46fa029804..03a0ef84e7 100644 --- a/crates/agent2/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -32,6 +32,17 @@ impl ContextServerRegistry { this } + pub fn tools_for_server( + &self, + server_id: &ContextServerId, + ) -> impl Iterator> { + self.registered_servers + .get(server_id) + .map(|server| server.tools.values()) + .into_iter() + .flatten() + } + pub fn servers( &self, ) -> impl Iterator< @@ -154,7 +165,7 @@ impl AnyAgentTool for ContextServerTool { format: language_model::LanguageModelToolSchemaFormat, ) -> Result { let mut schema = self.tool.input_schema.clone(); - assistant_tool::adapt_schema_to_format(&mut schema, format)?; + language_model::tool_schema::adapt_schema_to_format(&mut schema, format)?; Ok(match schema { serde_json::Value::Null => { serde_json::json!({ "type": "object", "properties": [] }) diff --git a/crates/agent2/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs similarity index 100% rename from crates/agent2/src/tools/copy_path_tool.rs rename to crates/agent/src/tools/copy_path_tool.rs diff --git a/crates/agent2/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs similarity index 100% rename from crates/agent2/src/tools/create_directory_tool.rs rename to crates/agent/src/tools/create_directory_tool.rs diff --git a/crates/agent2/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs similarity index 100% rename from crates/agent2/src/tools/delete_path_tool.rs rename to crates/agent/src/tools/delete_path_tool.rs diff --git a/crates/agent2/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs similarity index 100% rename from crates/agent2/src/tools/diagnostics_tool.rs rename to crates/agent/src/tools/diagnostics_tool.rs diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs similarity index 77% rename from crates/agent2/src/tools/edit_file_tool.rs rename to crates/agent/src/tools/edit_file_tool.rs index 7c51df0fae..0ab99426e2 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1,8 +1,10 @@ -use crate::{AgentTool, Thread, ToolCallEventStream}; +use crate::{ + AgentTool, Templates, Thread, ToolCallEventStream, + edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}, +}; use acp_thread::Diff; use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; use anyhow::{Context as _, Result, anyhow}; -use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}; use cloud_llm_client::CompletionIntent; use collections::HashSet; use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; @@ -34,7 +36,7 @@ const DEFAULT_UI_TEXT: &str = "Editing file"; /// /// 2. Verify the directory path is correct (only applicable when creating new files): /// - Use the `list_directory` tool to verify the parent directory exists and is the correct location -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct EditFileToolInput { /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit. /// @@ -75,7 +77,7 @@ pub struct EditFileToolInput { pub mode: EditFileMode, } -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] struct EditFileToolPartialInput { #[serde(default)] path: String, @@ -123,6 +125,7 @@ pub struct EditFileTool { thread: WeakEntity, language_registry: Arc, project: Entity, + templates: Arc, } impl EditFileTool { @@ -130,11 +133,13 @@ impl EditFileTool { project: Entity, thread: WeakEntity, language_registry: Arc, + templates: Arc, ) -> Self { Self { project, thread, language_registry, + templates, } } @@ -268,14 +273,9 @@ impl AgentTool for EditFileTool { }; let abs_path = project.read(cx).absolute_path(&project_path, cx); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path, - line: None, - meta: None, - }]), - ..Default::default() - }); + event_stream.update_fields( + ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]), + ); } let authorize = self.authorize(&input, &event_stream, cx); @@ -294,8 +294,7 @@ impl AgentTool for EditFileTool { model, project.clone(), action_log.clone(), - // TODO: move edit agent to this crate so we can use our templates - assistant_tools::templates::Templates::new(), + self.templates.clone(), edit_format, ); @@ -305,6 +304,40 @@ impl AgentTool for EditFileTool { })? .await?; + // Check if the file has been modified since the agent last read it + if let Some(abs_path) = abs_path.as_ref() { + let (last_read_mtime, current_mtime, is_dirty) = self.thread.update(cx, |thread, cx| { + let last_read = thread.file_read_times.get(abs_path).copied(); + let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime()); + let dirty = buffer.read(cx).is_dirty(); + (last_read, current, dirty) + })?; + + // Check for unsaved changes first - these indicate modifications we don't know about + if is_dirty { + anyhow::bail!( + "This file cannot be written to because it has unsaved changes. \ + Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \ + Ask the user to save that buffer's changes and to inform you when it's ok to proceed." + ); + } + + // Check if the file was modified on disk since we last read it + if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) { + // MTime can be unreliable for comparisons, so our newtype intentionally + // doesn't support comparing them. If the mtime at all different + // (which could be because of a modification or because e.g. system clock changed), + // we pessimistically assume it was modified. + if current != last_read { + anyhow::bail!( + "The file {} has been modified since you last read it. \ + Please read the file again to get the current state before editing it.", + input.path.display() + ); + } + } + } + let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; event_stream.update_diff(diff.clone()); let _finalize_diff = util::defer({ @@ -351,10 +384,7 @@ impl AgentTool for EditFileTool { range.start.to_point(&buffer.snapshot()).row }).ok(); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![ToolCallLocation { path: abs_path, line, meta: None }]), - ..Default::default() - }); + event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)])); } emitted_location = true; } @@ -417,6 +447,17 @@ impl AgentTool for EditFileTool { log.buffer_edited(buffer.clone(), cx); })?; + // Update the recorded read time after a successful edit so consecutive edits work + if let Some(abs_path) = abs_path.as_ref() { + if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| { + buffer.file().and_then(|file| file.disk_state().mtime()) + })? { + self.thread.update(cx, |thread, _| { + thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime); + })?; + } + } + let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; let (new_text, unified_diff) = cx .background_spawn({ @@ -558,7 +599,6 @@ fn resolve_path( mod tests { use super::*; use crate::{ContextServerRegistry, Templates}; - use client::TelemetrySettings; use fs::Fs; use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; @@ -599,6 +639,7 @@ mod tests { project, thread.downgrade(), language_registry, + Templates::new(), )) .run(input, ToolCallEventStream::test().0, cx) }) @@ -790,7 +831,7 @@ mod tests { store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On); settings.project.all_languages.defaults.formatter = - Some(language::language_settings::SelectedFormatter::Auto); + Some(language::language_settings::FormatterList::default()); }); }); }); @@ -807,6 +848,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry.clone(), + Templates::new(), )) .run(input, ToolCallEventStream::test().0, cx) }); @@ -865,6 +907,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry, + Templates::new(), )) .run(input, ToolCallEventStream::test().0, cx) }); @@ -951,6 +994,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry.clone(), + Templates::new(), )) .run(input, ToolCallEventStream::test().0, cx) }); @@ -1005,6 +1049,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry, + Templates::new(), )) .run(input, ToolCallEventStream::test().0, cx) }); @@ -1057,6 +1102,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry, + Templates::new(), )); fs.insert_tree("/root", json!({})).await; @@ -1197,6 +1243,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry, + Templates::new(), )); // Test global config paths - these should require confirmation if they exist and are outside the project @@ -1309,6 +1356,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry, + Templates::new(), )); // Test files in different worktrees @@ -1393,6 +1441,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry, + Templates::new(), )); // Test edge cases @@ -1482,6 +1531,7 @@ mod tests { project.clone(), thread.downgrade(), language_registry, + Templates::new(), )); // Test different EditFileMode values @@ -1566,6 +1616,7 @@ mod tests { project, thread.downgrade(), language_registry, + Templates::new(), )); cx.update(|cx| { @@ -1653,6 +1704,7 @@ mod tests { project.clone(), thread.downgrade(), languages.clone(), + Templates::new(), )); let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let edit = cx.update(|cx| { @@ -1682,6 +1734,7 @@ mod tests { project.clone(), thread.downgrade(), languages.clone(), + Templates::new(), )); let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let edit = cx.update(|cx| { @@ -1709,6 +1762,7 @@ mod tests { project.clone(), thread.downgrade(), languages.clone(), + Templates::new(), )); let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let edit = cx.update(|cx| { @@ -1731,14 +1785,426 @@ mod tests { } } + #[gpui::test] + async fn test_file_read_times_tracking(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + // Initially, file_read_times should be empty + let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty()); + assert!(is_empty, "file_read_times should start empty"); + + // Create read tool + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + + // Read the file to record the read time + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Verify that file_read_times now contains an entry for the file + let has_entry = thread.read_with(cx, |thread, _| { + thread.file_read_times.len() == 1 + && thread + .file_read_times + .keys() + .any(|path| path.ends_with("test.txt")) + }); + assert!( + has_entry, + "file_read_times should contain an entry after reading the file" + ); + + // Read the file again - should update the entry + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Should still have exactly one entry + let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1); + assert!( + has_one_entry, + "file_read_times should still have one entry after re-reading" + ); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); }); } + + #[gpui::test] + async fn test_consecutive_edits_work(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + Templates::new(), + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // First edit should work + let edit_result = { + let edit_task = cx.update(|cx| { + edit_tool.clone().run( + EditFileToolInput { + display_description: "First edit".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + }, + ToolCallEventStream::test().0, + cx, + ) + }); + + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk( + "original contentmodified content" + .to_string(), + ); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!( + edit_result.is_ok(), + "First edit should succeed, got error: {:?}", + edit_result.as_ref().err() + ); + + // Second edit should also work because the edit updated the recorded read time + let edit_result = { + let edit_task = cx.update(|cx| { + edit_tool.clone().run( + EditFileToolInput { + display_description: "Second edit".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + }, + ToolCallEventStream::test().0, + cx, + ) + }); + + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk( + "modified contentfurther modified content".to_string(), + ); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!( + edit_result.is_ok(), + "Second consecutive edit should succeed, got error: {:?}", + edit_result.as_ref().err() + ); + } + + #[gpui::test] + async fn test_external_modification_detected(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + Templates::new(), + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Simulate external modification - advance time and save file + cx.background_executor + .advance_clock(std::time::Duration::from_secs(2)); + fs.save( + path!("/root/test.txt").as_ref(), + &"externally modified content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + // Reload the buffer to pick up the new mtime + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + buffer + .update(cx, |buffer, cx| buffer.reload(cx)) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + // Try to edit - should fail because file was modified externally + let result = cx + .update(|cx| { + edit_tool.clone().run( + EditFileToolInput { + display_description: "Edit after external change".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + assert!( + result.is_err(), + "Edit should fail after external modification" + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("has been modified since you last read it"), + "Error should mention file modification, got: {}", + error_msg + ); + } + + #[gpui::test] + async fn test_dirty_buffer_detected(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + Templates::new(), + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Open the buffer and make it dirty by editing without saving + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + + // Make an in-memory edit to the buffer (making it dirty) + buffer.update(cx, |buffer, cx| { + let end_point = buffer.max_point(); + buffer.edit([(end_point..end_point, " added text")], None, cx); + }); + + // Verify buffer is dirty + let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty()); + assert!(is_dirty, "Buffer should be dirty after in-memory edit"); + + // Try to edit - should fail because buffer has unsaved changes + let result = cx + .update(|cx| { + edit_tool.clone().run( + EditFileToolInput { + display_description: "Edit with dirty buffer".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + assert!(result.is_err(), "Edit should fail when buffer is dirty"); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("cannot be written to because it has unsaved changes"), + "Error should mention unsaved changes, got: {}", + error_msg + ); + } } diff --git a/crates/agent2/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs similarity index 100% rename from crates/agent2/src/tools/fetch_tool.rs rename to crates/agent/src/tools/fetch_tool.rs diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs similarity index 82% rename from crates/agent2/src/tools/find_path_tool.rs rename to crates/agent/src/tools/find_path_tool.rs index 59f203cec9..2a33b14b4c 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -118,33 +118,29 @@ impl AgentTool for FindPathTool { let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len()) ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())]; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(if paginated_matches.is_empty() { - "No matches".into() - } else if paginated_matches.len() == 1 { - "1 match".into() - } else { - format!("{} matches", paginated_matches.len()) - }), - content: Some( - paginated_matches - .iter() - .map(|path| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: format!("file://{}", path.display()), - name: path.to_string_lossy().into(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + event_stream.update_fields( + acp::ToolCallUpdateFields::new() + .title(if paginated_matches.is_empty() { + "No matches".into() + } else if paginated_matches.len() == 1 { + "1 match".into() + } else { + format!("{} matches", paginated_matches.len()) + }) + .content( + paginated_matches + .iter() + .map(|path| { + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + path.to_string_lossy(), + format!("file://{}", path.display()), + )), + )) + }) + .collect::>(), + ), + ); Ok(FindPathToolOutput { offset: input.offset, @@ -177,7 +173,7 @@ fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task + /// If the project has the following root directories: + /// + /// - /a/b/backend + /// - /c/d/frontend + /// + /// Use "backend/**/*.rs" to search only Rust files in the backend root directory. + /// Use "frontend/src/**/*.ts" to search TypeScript files only in the frontend root directory (sub-directory "src"). + /// Use "**/*.rs" to search Rust files across all root directories. + /// pub include_pattern: Option, /// Optional starting position for paginated results (0-based). /// When not provided, starts from the beginning. @@ -132,8 +145,7 @@ impl AgentTool for GrepTool { let exclude_patterns = global_settings .file_scan_exclusions .sources() - .iter() - .chain(global_settings.private_files.sources().iter()); + .chain(global_settings.private_files.sources()); match PathMatcher::new(exclude_patterns, path_style) { Ok(matcher) => matcher, @@ -310,7 +322,6 @@ mod tests { use super::*; use gpui::{TestAppContext, UpdateGlobal}; - use language::{Language, LanguageConfig, LanguageMatcher}; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; @@ -552,7 +563,7 @@ mod tests { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| { - project.languages().add(rust_lang().into()) + project.languages().add(language::rust_lang()) }); project @@ -778,27 +789,9 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); }); } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../../languages/src/rust/outline.scm")) - .unwrap() - } - #[gpui::test] async fn test_grep_security_boundaries(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent2/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs similarity index 99% rename from crates/agent2/src/tools/list_directory_tool.rs rename to crates/agent/src/tools/list_directory_tool.rs index cd8b46ddeb..b7ceba5abf 100644 --- a/crates/agent2/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -223,8 +223,6 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); }); } diff --git a/crates/agent2/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs similarity index 100% rename from crates/agent2/src/tools/move_path_tool.rs rename to crates/agent/src/tools/move_path_tool.rs diff --git a/crates/agent2/src/tools/now_tool.rs b/crates/agent/src/tools/now_tool.rs similarity index 100% rename from crates/agent2/src/tools/now_tool.rs rename to crates/agent/src/tools/now_tool.rs diff --git a/crates/agent2/src/tools/open_tool.rs b/crates/agent/src/tools/open_tool.rs similarity index 98% rename from crates/agent2/src/tools/open_tool.rs rename to crates/agent/src/tools/open_tool.rs index b98ae9af3b..8826d1529c 100644 --- a/crates/agent2/src/tools/open_tool.rs +++ b/crates/agent/src/tools/open_tool.rs @@ -163,8 +163,6 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); }); } } diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs similarity index 82% rename from crates/agent2/src/tools/read_file_tool.rs rename to crates/agent/src/tools/read_file_tool.rs index ce8dcba102..acfd4a1674 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -1,8 +1,7 @@ use action_log::ActionLog; use agent_client_protocol::{self as acp, ToolCallUpdateFields}; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::outline; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, Entity, SharedString, Task, WeakEntity}; use indoc::formatdoc; use language::Point; use language_model::{LanguageModelImage, LanguageModelToolResultContent}; @@ -13,11 +12,14 @@ use settings::Settings; use std::sync::Arc; use util::markdown::MarkdownCodeBlock; -use crate::{AgentTool, ToolCallEventStream}; +use crate::{AgentTool, Thread, ToolCallEventStream, outline}; /// Reads the content of the given file in the project. /// /// - Never attempt to read a path that hasn't been previously mentioned. +/// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content. +/// This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line. +/// Do NOT retry reading the same file without line numbers if you receive an outline. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ReadFileToolInput { /// The relative path of the file to read. @@ -43,13 +45,19 @@ pub struct ReadFileToolInput { } pub struct ReadFileTool { + thread: WeakEntity, project: Entity, action_log: Entity, } impl ReadFileTool { - pub fn new(project: Entity, action_log: Entity) -> Self { + pub fn new( + thread: WeakEntity, + project: Entity, + action_log: Entity, + ) -> Self { Self { + thread, project, action_log, } @@ -145,14 +153,10 @@ impl AgentTool for ReadFileTool { let file_path = input.path.clone(); - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: input.start_line.map(|line| line.saturating_sub(1)), - meta: None, - }]), - ..Default::default() - }); + event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ + acp::ToolCallLocation::new(&abs_path) + .line(input.start_line.map(|line| line.saturating_sub(1))), + ])); if image_store::is_image_file(&self.project, &project_path, cx) { return cx.spawn(async move |cx| { @@ -196,6 +200,17 @@ impl AgentTool for ReadFileTool { anyhow::bail!("{file_path} not found"); } + // Record the file read time and mtime + if let Some(mtime) = buffer.read_with(cx, |buffer, _| { + buffer.file().and_then(|file| file.disk_state().mtime()) + })? { + self.thread + .update(cx, |thread, _| { + thread.file_read_times.insert(abs_path.to_path_buf(), mtime); + }) + .ok(); + } + let mut anchor = None; // Check if specific line ranges are provided @@ -238,16 +253,15 @@ impl AgentTool for ReadFileTool { if buffer_content.is_outline { Ok(formatdoc! {" - This file was too big to read all at once. + SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers. + + IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline. + Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters. {} - Using the line numbers in this outline, you can call this tool again - while specifying the start_line and end_line fields to see the - implementations of symbols in the outline. - - Alternatively, you can fall back to the `grep` tool (if available) - to search the file for specific content.", buffer_content.text + NEXT STEPS: To read a specific symbol's implementation, call read_file with the same path plus start_line and end_line from the outline above. + For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text } .into()) } else { @@ -259,7 +273,9 @@ impl AgentTool for ReadFileTool { project.set_agent_location( Some(AgentLocation { buffer: buffer.downgrade(), - position: anchor.unwrap_or(text::Anchor::MIN), + position: anchor.unwrap_or_else(|| { + text::Anchor::min_for_buffer(buffer.read(cx).remote_id()) + }), }), cx, ); @@ -269,12 +285,9 @@ impl AgentTool for ReadFileTool { text, } .to_string(); - event_stream.update_fields(ToolCallUpdateFields { - content: Some(vec![acp::ToolCallContent::Content { - content: markdown.into(), - }]), - ..Default::default() - }) + event_stream.update_fields(ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new(markdown)), + ])); } })?; @@ -286,11 +299,14 @@ impl AgentTool for ReadFileTool { #[cfg(test)] mod test { use super::*; + use crate::{ContextServerRegistry, Templates, Thread}; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; - use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; + use language_model::fake_provider::FakeLanguageModel; use project::{FakeFs, Project}; + use prompt_store::ProjectContext; use serde_json::json; use settings::SettingsStore; + use std::sync::Arc; use util::path; #[gpui::test] @@ -301,7 +317,20 @@ mod test { fs.insert_tree(path!("/root"), json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let (event_stream, _) = ToolCallEventStream::test(); let result = cx @@ -334,7 +363,20 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -362,9 +404,22 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(Arc::new(rust_lang())); + language_registry.add(language::rust_lang()); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -379,7 +434,7 @@ mod test { let content = result.to_str().unwrap(); assert_eq!( - content.lines().skip(4).take(6).collect::>(), + content.lines().skip(7).take(6).collect::>(), vec![ "struct Test0 [L1-4]", " a [L2]", @@ -414,7 +469,7 @@ mod test { pretty_assertions::assert_eq!( content .lines() - .skip(4) + .skip(7) .take(expected_content.len()) .collect::>(), expected_content @@ -436,7 +491,20 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -464,7 +532,20 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); // start_line of 0 should be treated as 1 let result = cx @@ -510,54 +591,9 @@ mod test { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); }); } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query( - r#" - (line_comment) @annotation - - (struct_item - "struct" @context - name: (_) @name) @item - (enum_item - "enum" @context - name: (_) @name) @item - (enum_variant - name: (_) @name) @item - (field_declaration - name: (_) @name) @item - (impl_item - "impl" @context - trait: (_)? @name - "for"? @context - type: (_) @name - body: (_ "{" (_)* "}")) @item - (function_item - "fn" @context - name: (_) @name) @item - (mod_item - "mod" @context - name: (_) @name) @item - "#, - ) - .unwrap() - } - #[gpui::test] async fn test_read_file_security(cx: &mut TestAppContext) { init_test(cx); @@ -610,7 +646,20 @@ mod test { let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); // Reading a file outside the project worktree should fail let result = cx @@ -824,7 +873,24 @@ mod test { .await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone())); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log.clone(), + )); // Test reading allowed files in worktree1 let result = cx diff --git a/crates/agent2/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs similarity index 83% rename from crates/agent2/src/tools/terminal_tool.rs rename to crates/agent/src/tools/terminal_tool.rs index 6d30c19152..f3302fb189 100644 --- a/crates/agent2/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -1,6 +1,7 @@ use agent_client_protocol as acp; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; +use futures::FutureExt as _; +use gpui::{App, AppContext, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,6 +9,7 @@ use std::{ path::{Path, PathBuf}, rc::Rc, sync::Arc, + time::Duration, }; use util::markdown::MarkdownInlineCode; @@ -25,13 +27,17 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// /// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. /// +/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs. +/// /// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. - command: String, + pub command: String, /// Working directory for the command. This must be one of the root directories of the project. - cd: String, + pub cd: String, + /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. + pub timeout_ms: Option, } pub struct TerminalTool { @@ -112,12 +118,30 @@ impl AgentTool for TerminalTool { .await?; let terminal_id = terminal.id(cx)?; - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]), - ..Default::default() - }); + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)), + ])); + + let timeout = input.timeout_ms.map(Duration::from_millis); + + let exit_status = match timeout { + Some(timeout) => { + let wait_for_exit = terminal.wait_for_exit(cx)?; + let timeout_task = cx.background_spawn(async move { + smol::Timer::after(timeout).await; + }); + + futures::select! { + status = wait_for_exit.clone().fuse() => status, + _ = timeout_task.fuse() => { + terminal.kill(cx)?; + wait_for_exit.await + } + } + } + None => terminal.wait_for_exit(cx)?.await, + }; - let exit_status = terminal.wait_for_exit(cx)?.await; let output = terminal.current_output(cx)?; Ok(process_content(output, &input.command, exit_status)) diff --git a/crates/agent2/src/tools/thinking_tool.rs b/crates/agent/src/tools/thinking_tool.rs similarity index 89% rename from crates/agent2/src/tools/thinking_tool.rs rename to crates/agent/src/tools/thinking_tool.rs index 0a68f7545f..96024326f6 100644 --- a/crates/agent2/src/tools/thinking_tool.rs +++ b/crates/agent/src/tools/thinking_tool.rs @@ -43,10 +43,8 @@ impl AgentTool for ThinkingTool { event_stream: ToolCallEventStream, _cx: &mut App, ) -> Task> { - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![input.content.into()]), - ..Default::default() - }); + event_stream + .update_fields(acp::ToolCallUpdateFields::new().content(vec![input.content.into()])); Task::ready(Ok("Finished thinking.".to_string())) } } diff --git a/crates/agent2/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs similarity index 72% rename from crates/agent2/src/tools/web_search_tool.rs rename to crates/agent/src/tools/web_search_tool.rs index b65c89167d..eb4ebacea2 100644 --- a/crates/agent2/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -57,7 +57,7 @@ impl AgentTool for WebSearchTool { } /// We currently only support Zed Cloud as a provider. - fn supported_provider(&self, provider: &LanguageModelProviderId) -> bool { + fn supports_provider(provider: &LanguageModelProviderId) -> bool { provider == &ZED_CLOUD_PROVIDER_ID } @@ -76,10 +76,8 @@ impl AgentTool for WebSearchTool { let response = match search_task.await { Ok(response) => response, Err(err) => { - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some("Web Search Failed".to_string()), - ..Default::default() - }); + event_stream + .update_fields(acp::ToolCallUpdateFields::new().title("Web Search Failed")); return Err(err); } }; @@ -107,26 +105,23 @@ fn emit_update(response: &WebSearchResponse, event_stream: &ToolCallEventStream) } else { format!("{} results", response.results.len()) }; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(format!("Searched the web: {result_text}")), - content: Some( - response - .results - .iter() - .map(|result| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: result.title.clone(), - uri: result.url.clone(), - title: Some(result.title.clone()), - description: Some(result.text.clone()), - mime_type: None, - annotations: None, - size: None, - meta: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + event_stream.update_fields( + acp::ToolCallUpdateFields::new() + .title(format!("Searched the web: {result_text}")) + .content( + response + .results + .iter() + .map(|result| { + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(result.title.clone(), result.url.clone()) + .title(result.title.clone()) + .description(result.text.clone()), + ), + )) + }) + .collect::>(), + ), + ); } diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml deleted file mode 100644 index b712bed258..0000000000 --- a/crates/agent2/Cargo.toml +++ /dev/null @@ -1,102 +0,0 @@ -[package] -name = "agent2" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lib] -path = "src/agent2.rs" - -[features] -test-support = ["db/test-support"] -e2e = [] - -[lints] -workspace = true - -[dependencies] -acp_thread.workspace = true -action_log.workspace = true -agent.workspace = true -agent-client-protocol.workspace = true -agent_servers.workspace = true -agent_settings.workspace = true -anyhow.workspace = true -assistant_context.workspace = true -assistant_tool.workspace = true -assistant_tools.workspace = true -chrono.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -collections.workspace = true -context_server.workspace = true -db.workspace = true -fs.workspace = true -futures.workspace = true -git.workspace = true -gpui.workspace = true -handlebars = { workspace = true, features = ["rust-embed"] } -html_to_markdown.workspace = true -http_client.workspace = true -indoc.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true -language_models.workspace = true -log.workspace = true -open.workspace = true -parking_lot.workspace = true -paths.workspace = true -project.workspace = true -prompt_store.workspace = true -rust-embed.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -smol.workspace = true -sqlez.workspace = true -task.workspace = true -telemetry.workspace = true -terminal.workspace = true -thiserror.workspace = true -text.workspace = true -ui.workspace = true -util.workspace = true -uuid.workspace = true -watch.workspace = true -web_search.workspace = true -workspace-hack.workspace = true -zed_env_vars.workspace = true -zstd.workspace = true - -[dev-dependencies] -agent = { workspace = true, "features" = ["test-support"] } -agent_servers = { workspace = true, "features" = ["test-support"] } -assistant_context = { workspace = true, "features" = ["test-support"] } -ctor.workspace = true -client = { workspace = true, "features" = ["test-support"] } -clock = { workspace = true, "features" = ["test-support"] } -context_server = { workspace = true, "features" = ["test-support"] } -db = { workspace = true, "features" = ["test-support"] } -editor = { workspace = true, "features" = ["test-support"] } -env_logger.workspace = true -fs = { workspace = true, "features" = ["test-support"] } -git = { workspace = true, "features" = ["test-support"] } -gpui = { workspace = true, "features" = ["test-support"] } -gpui_tokio.workspace = true -language = { workspace = true, "features" = ["test-support"] } -language_model = { workspace = true, "features" = ["test-support"] } -lsp = { workspace = true, "features" = ["test-support"] } -pretty_assertions.workspace = true -project = { workspace = true, "features" = ["test-support"] } -reqwest_client.workspace = true -settings = { workspace = true, "features" = ["test-support"] } -tempfile.workspace = true -terminal = { workspace = true, "features" = ["test-support"] } -theme = { workspace = true, "features" = ["test-support"] } -tree-sitter-rust.workspace = true -unindent = { workspace = true } -worktree = { workspace = true, "features" = ["test-support"] } -zlog.workspace = true diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs deleted file mode 100644 index fe47c66fea..0000000000 --- a/crates/agent2/src/agent.rs +++ /dev/null @@ -1,1588 +0,0 @@ -use crate::{ - ContextServerRegistry, Thread, ThreadEvent, ThreadsDatabase, ToolCallAuthorization, - UserMessageContent, templates::Templates, -}; -use crate::{HistoryStore, TerminalHandle, ThreadEnvironment, TitleUpdated, TokenUsageUpdated}; -use acp_thread::{AcpThread, AgentModelSelector}; -use action_log::ActionLog; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; -use collections::{HashSet, IndexMap}; -use fs::Fs; -use futures::channel::{mpsc, oneshot}; -use futures::future::Shared; -use futures::{StreamExt, future}; -use gpui::{ - App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, -}; -use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; -use project::{Project, ProjectItem, ProjectPath, Worktree}; -use prompt_store::{ - ProjectContext, PromptId, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, -}; -use settings::{LanguageModelSelection, update_settings_file}; -use std::any::Any; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::sync::Arc; -use util::ResultExt; -use util::rel_path::RelPath; - -const RULES_FILE_NAMES: [&str; 9] = [ - ".rules", - ".cursorrules", - ".windsurfrules", - ".clinerules", - ".github/copilot-instructions.md", - "CLAUDE.md", - "AGENT.md", - "AGENTS.md", - "GEMINI.md", -]; - -pub struct RulesLoadingError { - pub message: SharedString, -} - -/// Holds both the internal Thread and the AcpThread for a session -struct Session { - /// The internal thread that processes messages - thread: Entity, - /// The ACP thread that handles protocol communication - acp_thread: WeakEntity, - pending_save: Task<()>, - _subscriptions: Vec, -} - -pub struct LanguageModels { - /// Access language model by ID - models: HashMap>, - /// Cached list for returning language model information - model_list: acp_thread::AgentModelList, - refresh_models_rx: watch::Receiver<()>, - refresh_models_tx: watch::Sender<()>, - _authenticate_all_providers_task: Task<()>, -} - -impl LanguageModels { - fn new(cx: &mut App) -> Self { - let (refresh_models_tx, refresh_models_rx) = watch::channel(()); - - let mut this = Self { - models: HashMap::default(), - model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()), - refresh_models_rx, - refresh_models_tx, - _authenticate_all_providers_task: Self::authenticate_all_language_model_providers(cx), - }; - this.refresh_list(cx); - this - } - - fn refresh_list(&mut self, cx: &App) { - let providers = LanguageModelRegistry::global(cx) - .read(cx) - .providers() - .into_iter() - .filter(|provider| provider.is_authenticated(cx)) - .collect::>(); - - let mut language_model_list = IndexMap::default(); - let mut recommended_models = HashSet::default(); - - let mut recommended = Vec::new(); - for provider in &providers { - for model in provider.recommended_models(cx) { - recommended_models.insert((model.provider_id(), model.id())); - recommended.push(Self::map_language_model_to_info(&model, provider)); - } - } - if !recommended.is_empty() { - language_model_list.insert( - acp_thread::AgentModelGroupName("Recommended".into()), - recommended, - ); - } - - let mut models = HashMap::default(); - for provider in providers { - let mut provider_models = Vec::new(); - for model in provider.provided_models(cx) { - let model_info = Self::map_language_model_to_info(&model, &provider); - let model_id = model_info.id.clone(); - if !recommended_models.contains(&(model.provider_id(), model.id())) { - provider_models.push(model_info); - } - models.insert(model_id, model); - } - if !provider_models.is_empty() { - language_model_list.insert( - acp_thread::AgentModelGroupName(provider.name().0.clone()), - provider_models, - ); - } - } - - self.models = models; - self.model_list = acp_thread::AgentModelList::Grouped(language_model_list); - self.refresh_models_tx.send(()).ok(); - } - - fn watch(&self) -> watch::Receiver<()> { - self.refresh_models_rx.clone() - } - - pub fn model_from_id(&self, model_id: &acp::ModelId) -> Option> { - self.models.get(model_id).cloned() - } - - fn map_language_model_to_info( - model: &Arc, - provider: &Arc, - ) -> acp_thread::AgentModelInfo { - acp_thread::AgentModelInfo { - id: Self::model_id(model), - name: model.name().0, - description: None, - icon: Some(provider.icon()), - } - } - - fn model_id(model: &Arc) -> acp::ModelId { - acp::ModelId(format!("{}/{}", model.provider_id().0, model.id().0).into()) - } - - fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> { - let authenticate_all_providers = LanguageModelRegistry::global(cx) - .read(cx) - .providers() - .iter() - .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) - .collect::>(); - - cx.background_spawn(async move { - for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { - if let Err(err) = authenticate_task.await { - match err { - language_model::AuthenticateError::CredentialsNotFound => { - // Since we're authenticating these providers in the - // background for the purposes of populating the - // language selector, we don't care about providers - // where the credentials are not found. - } - language_model::AuthenticateError::ConnectionRefused => { - // Not logging connection refused errors as they are mostly from LM Studio's noisy auth failures. - // LM Studio only has one auth method (endpoint call) which fails for users who haven't enabled it. - // TODO: Better manage LM Studio auth logic to avoid these noisy failures. - } - _ => { - // Some providers have noisy failure states that we - // don't want to spam the logs with every time the - // language model selector is initialized. - // - // Ideally these should have more clear failure modes - // that we know are safe to ignore here, like what we do - // with `CredentialsNotFound` above. - match provider_id.0.as_ref() { - "lmstudio" | "ollama" => { - // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". - // - // These fail noisily, so we don't log them. - } - "copilot_chat" => { - // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. - } - _ => { - log::error!( - "Failed to authenticate provider: {}: {err}", - provider_name.0 - ); - } - } - } - } - } - } - }) - } -} - -pub struct NativeAgent { - /// Session ID -> Session mapping - sessions: HashMap, - history: Entity, - /// Shared project context for all threads - project_context: Entity, - project_context_needs_refresh: watch::Sender<()>, - _maintain_project_context: Task>, - context_server_registry: Entity, - /// Shared templates for all threads - templates: Arc, - /// Cached model information - models: LanguageModels, - project: Entity, - prompt_store: Option>, - fs: Arc, - _subscriptions: Vec, -} - -impl NativeAgent { - pub async fn new( - project: Entity, - history: Entity, - templates: Arc, - prompt_store: Option>, - fs: Arc, - cx: &mut AsyncApp, - ) -> Result> { - log::debug!("Creating new NativeAgent"); - - let project_context = cx - .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))? - .await; - - cx.new(|cx| { - let mut subscriptions = vec![ - cx.subscribe(&project, Self::handle_project_event), - cx.subscribe( - &LanguageModelRegistry::global(cx), - Self::handle_models_updated_event, - ), - ]; - if let Some(prompt_store) = prompt_store.as_ref() { - subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) - } - - let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = - watch::channel(()); - Self { - sessions: HashMap::new(), - history, - project_context: cx.new(|_| project_context), - project_context_needs_refresh: project_context_needs_refresh_tx, - _maintain_project_context: cx.spawn(async move |this, cx| { - Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await - }), - context_server_registry: cx.new(|cx| { - ContextServerRegistry::new(project.read(cx).context_server_store(), cx) - }), - templates, - models: LanguageModels::new(cx), - project, - prompt_store, - fs, - _subscriptions: subscriptions, - } - }) - } - - fn register_session( - &mut self, - thread_handle: Entity, - cx: &mut Context, - ) -> Entity { - let connection = Rc::new(NativeAgentConnection(cx.entity())); - - let thread = thread_handle.read(cx); - let session_id = thread.id().clone(); - let title = thread.title(); - let project = thread.project.clone(); - let action_log = thread.action_log.clone(); - let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone(); - let acp_thread = cx.new(|cx| { - acp_thread::AcpThread::new( - title, - connection, - project.clone(), - action_log.clone(), - session_id.clone(), - prompt_capabilities_rx, - cx, - ) - }); - - let registry = LanguageModelRegistry::read_global(cx); - let summarization_model = registry.thread_summary_model().map(|c| c.model); - - thread_handle.update(cx, |thread, cx| { - thread.set_summarization_model(summarization_model, cx); - thread.add_default_tools( - Rc::new(AcpThreadEnvironment { - acp_thread: acp_thread.downgrade(), - }) as _, - cx, - ) - }); - - let subscriptions = vec![ - cx.observe_release(&acp_thread, |this, acp_thread, _cx| { - this.sessions.remove(acp_thread.session_id()); - }), - cx.subscribe(&thread_handle, Self::handle_thread_title_updated), - cx.subscribe(&thread_handle, Self::handle_thread_token_usage_updated), - cx.observe(&thread_handle, move |this, thread, cx| { - this.save_thread(thread, cx) - }), - ]; - - self.sessions.insert( - session_id, - Session { - thread: thread_handle, - acp_thread: acp_thread.downgrade(), - _subscriptions: subscriptions, - pending_save: Task::ready(()), - }, - ); - acp_thread - } - - pub fn models(&self) -> &LanguageModels { - &self.models - } - - async fn maintain_project_context( - this: WeakEntity, - mut needs_refresh: watch::Receiver<()>, - cx: &mut AsyncApp, - ) -> Result<()> { - while needs_refresh.changed().await.is_ok() { - let project_context = this - .update(cx, |this, cx| { - Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx) - })? - .await; - this.update(cx, |this, cx| { - this.project_context = cx.new(|_| project_context); - })?; - } - - Ok(()) - } - - fn build_project_context( - project: &Entity, - prompt_store: Option<&Entity>, - cx: &mut App, - ) -> Task { - let worktrees = project.read(cx).visible_worktrees(cx).collect::>(); - let worktree_tasks = worktrees - .into_iter() - .map(|worktree| { - Self::load_worktree_info_for_system_prompt(worktree, project.clone(), cx) - }) - .collect::>(); - let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() { - prompt_store.read_with(cx, |prompt_store, cx| { - let prompts = prompt_store.default_prompt_metadata(); - let load_tasks = prompts.into_iter().map(|prompt_metadata| { - let contents = prompt_store.load(prompt_metadata.id, cx); - async move { (contents.await, prompt_metadata) } - }); - cx.background_spawn(future::join_all(load_tasks)) - }) - } else { - Task::ready(vec![]) - }; - - cx.spawn(async move |_cx| { - let (worktrees, default_user_rules) = - future::join(future::join_all(worktree_tasks), default_user_rules_task).await; - - let worktrees = worktrees - .into_iter() - .map(|(worktree, _rules_error)| { - // TODO: show error message - // if let Some(rules_error) = rules_error { - // this.update(cx, |_, cx| cx.emit(rules_error)).ok(); - // } - worktree - }) - .collect::>(); - - let default_user_rules = default_user_rules - .into_iter() - .flat_map(|(contents, prompt_metadata)| match contents { - Ok(contents) => Some(UserRulesContext { - uuid: match prompt_metadata.id { - PromptId::User { uuid } => uuid, - PromptId::EditWorkflow => return None, - }, - title: prompt_metadata.title.map(|title| title.to_string()), - contents, - }), - Err(_err) => { - // TODO: show error message - // this.update(cx, |_, cx| { - // cx.emit(RulesLoadingError { - // message: format!("{err:?}").into(), - // }); - // }) - // .ok(); - None - } - }) - .collect::>(); - - ProjectContext::new(worktrees, default_user_rules) - }) - } - - fn load_worktree_info_for_system_prompt( - worktree: Entity, - project: Entity, - cx: &mut App, - ) -> Task<(WorktreeContext, Option)> { - let tree = worktree.read(cx); - let root_name = tree.root_name_str().into(); - let abs_path = tree.abs_path(); - - let mut context = WorktreeContext { - root_name, - abs_path, - rules_file: None, - }; - - let rules_task = Self::load_worktree_rules_file(worktree, project, cx); - let Some(rules_task) = rules_task else { - return Task::ready((context, None)); - }; - - cx.spawn(async move |_| { - let (rules_file, rules_file_error) = match rules_task.await { - Ok(rules_file) => (Some(rules_file), None), - Err(err) => ( - None, - Some(RulesLoadingError { - message: format!("{err}").into(), - }), - ), - }; - context.rules_file = rules_file; - (context, rules_file_error) - }) - } - - fn load_worktree_rules_file( - worktree: Entity, - project: Entity, - cx: &mut App, - ) -> Option>> { - let worktree = worktree.read(cx); - let worktree_id = worktree.id(); - let selected_rules_file = RULES_FILE_NAMES - .into_iter() - .filter_map(|name| { - worktree - .entry_for_path(RelPath::unix(name).unwrap()) - .filter(|entry| entry.is_file()) - .map(|entry| entry.path.clone()) - }) - .next(); - - // Note that Cline supports `.clinerules` being a directory, but that is not currently - // supported. This doesn't seem to occur often in GitHub repositories. - selected_rules_file.map(|path_in_worktree| { - let project_path = ProjectPath { - worktree_id, - path: path_in_worktree.clone(), - }; - let buffer_task = - project.update(cx, |project, cx| project.open_buffer(project_path, cx)); - let rope_task = cx.spawn(async move |cx| { - buffer_task.await?.read_with(cx, |buffer, cx| { - let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?; - anyhow::Ok((project_entry_id, buffer.as_rope().clone())) - })? - }); - // Build a string from the rope on a background thread. - cx.background_spawn(async move { - let (project_entry_id, rope) = rope_task.await?; - anyhow::Ok(RulesFileContext { - path_in_worktree, - text: rope.to_string().trim().to_string(), - project_entry_id: project_entry_id.to_usize(), - }) - }) - }) - } - - fn handle_thread_title_updated( - &mut self, - thread: Entity, - _: &TitleUpdated, - cx: &mut Context, - ) { - let session_id = thread.read(cx).id(); - let Some(session) = self.sessions.get(session_id) else { - return; - }; - let thread = thread.downgrade(); - let acp_thread = session.acp_thread.clone(); - cx.spawn(async move |_, cx| { - let title = thread.read_with(cx, |thread, _| thread.title())?; - let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?; - task.await - }) - .detach_and_log_err(cx); - } - - fn handle_thread_token_usage_updated( - &mut self, - thread: Entity, - usage: &TokenUsageUpdated, - cx: &mut Context, - ) { - let Some(session) = self.sessions.get(thread.read(cx).id()) else { - return; - }; - session - .acp_thread - .update(cx, |acp_thread, cx| { - acp_thread.update_token_usage(usage.0.clone(), cx); - }) - .ok(); - } - - fn handle_project_event( - &mut self, - _project: Entity, - event: &project::Event, - _cx: &mut Context, - ) { - match event { - project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { - self.project_context_needs_refresh.send(()).ok(); - } - project::Event::WorktreeUpdatedEntries(_, items) => { - if items.iter().any(|(path, _, _)| { - RULES_FILE_NAMES - .iter() - .any(|name| path.as_ref() == RelPath::unix(name).unwrap()) - }) { - self.project_context_needs_refresh.send(()).ok(); - } - } - _ => {} - } - } - - fn handle_prompts_updated_event( - &mut self, - _prompt_store: Entity, - _event: &prompt_store::PromptsUpdatedEvent, - _cx: &mut Context, - ) { - self.project_context_needs_refresh.send(()).ok(); - } - - fn handle_models_updated_event( - &mut self, - _registry: Entity, - _event: &language_model::Event, - cx: &mut Context, - ) { - self.models.refresh_list(cx); - - let registry = LanguageModelRegistry::read_global(cx); - let default_model = registry.default_model().map(|m| m.model); - let summarization_model = registry.thread_summary_model().map(|m| m.model); - - for session in self.sessions.values_mut() { - session.thread.update(cx, |thread, cx| { - if thread.model().is_none() - && let Some(model) = default_model.clone() - { - thread.set_model(model, cx); - cx.notify(); - } - thread.set_summarization_model(summarization_model.clone(), cx); - }); - } - } - - pub fn open_thread( - &mut self, - id: acp::SessionId, - cx: &mut Context, - ) -> Task>> { - let database_future = ThreadsDatabase::connect(cx); - cx.spawn(async move |this, cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - let db_thread = database - .load_thread(id.clone()) - .await? - .with_context(|| format!("no thread found with ID: {id:?}"))?; - - let thread = this.update(cx, |this, cx| { - let action_log = cx.new(|_cx| ActionLog::new(this.project.clone())); - cx.new(|cx| { - Thread::from_db( - id.clone(), - db_thread, - this.project.clone(), - this.project_context.clone(), - this.context_server_registry.clone(), - action_log.clone(), - this.templates.clone(), - cx, - ) - }) - })?; - let acp_thread = - this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?; - let events = thread.update(cx, |thread, cx| thread.replay(cx))?; - cx.update(|cx| { - NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) - })? - .await?; - Ok(acp_thread) - }) - } - - pub fn thread_summary( - &mut self, - id: acp::SessionId, - cx: &mut Context, - ) -> Task> { - let thread = self.open_thread(id.clone(), cx); - cx.spawn(async move |this, cx| { - let acp_thread = thread.await?; - let result = this - .update(cx, |this, cx| { - this.sessions - .get(&id) - .unwrap() - .thread - .update(cx, |thread, cx| thread.summary(cx)) - })? - .await?; - drop(acp_thread); - Ok(result) - }) - } - - fn save_thread(&mut self, thread: Entity, cx: &mut Context) { - if thread.read(cx).is_empty() { - return; - } - - let database_future = ThreadsDatabase::connect(cx); - let (id, db_thread) = - thread.update(cx, |thread, cx| (thread.id().clone(), thread.to_db(cx))); - let Some(session) = self.sessions.get_mut(&id) else { - return; - }; - let history = self.history.clone(); - session.pending_save = cx.spawn(async move |_, cx| { - let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else { - return; - }; - let db_thread = db_thread.await; - database.save_thread(id, db_thread).await.log_err(); - history.update(cx, |history, cx| history.reload(cx)).ok(); - }); - } -} - -/// Wrapper struct that implements the AgentConnection trait -#[derive(Clone)] -pub struct NativeAgentConnection(pub Entity); - -impl NativeAgentConnection { - pub fn thread(&self, session_id: &acp::SessionId, cx: &App) -> Option> { - self.0 - .read(cx) - .sessions - .get(session_id) - .map(|session| session.thread.clone()) - } - - fn run_turn( - &self, - session_id: acp::SessionId, - cx: &mut App, - f: impl 'static - + FnOnce(Entity, &mut App) -> Result>>, - ) -> Task> { - let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| { - agent - .sessions - .get_mut(&session_id) - .map(|s| (s.thread.clone(), s.acp_thread.clone())) - }) else { - return Task::ready(Err(anyhow!("Session not found"))); - }; - log::debug!("Found session for: {}", session_id); - - let response_stream = match f(thread, cx) { - Ok(stream) => stream, - Err(err) => return Task::ready(Err(err)), - }; - Self::handle_thread_events(response_stream, acp_thread, cx) - } - - fn handle_thread_events( - mut events: mpsc::UnboundedReceiver>, - acp_thread: WeakEntity, - cx: &App, - ) -> Task> { - cx.spawn(async move |cx| { - // Handle response stream and forward to session.acp_thread - while let Some(result) = events.next().await { - match result { - Ok(event) => { - log::trace!("Received completion event: {:?}", event); - - match event { - ThreadEvent::UserMessage(message) => { - acp_thread.update(cx, |thread, cx| { - for content in message.content { - thread.push_user_content_block( - Some(message.id.clone()), - content.into(), - cx, - ); - } - })?; - } - ThreadEvent::AgentText(text) => { - acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - false, - cx, - ) - })?; - } - ThreadEvent::AgentThinking(text) => { - acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - true, - cx, - ) - })?; - } - ThreadEvent::ToolCallAuthorization(ToolCallAuthorization { - tool_call, - options, - response, - }) => { - let outcome_task = acp_thread.update(cx, |thread, cx| { - thread.request_tool_call_authorization( - tool_call, options, true, cx, - ) - })??; - cx.background_spawn(async move { - if let acp::RequestPermissionOutcome::Selected { option_id } = - outcome_task.await - { - response - .send(option_id) - .map(|_| anyhow!("authorization receiver was dropped")) - .log_err(); - } - }) - .detach(); - } - ThreadEvent::ToolCall(tool_call) => { - acp_thread.update(cx, |thread, cx| { - thread.upsert_tool_call(tool_call, cx) - })??; - } - ThreadEvent::ToolCallUpdate(update) => { - acp_thread.update(cx, |thread, cx| { - thread.update_tool_call(update, cx) - })??; - } - ThreadEvent::Retry(status) => { - acp_thread.update(cx, |thread, cx| { - thread.update_retry_status(status, cx) - })?; - } - ThreadEvent::Stop(stop_reason) => { - log::debug!("Assistant message complete: {:?}", stop_reason); - return Ok(acp::PromptResponse { - stop_reason, - meta: None, - }); - } - } - } - Err(e) => { - log::error!("Error in model response stream: {:?}", e); - return Err(e); - } - } - } - - log::debug!("Response stream completed"); - anyhow::Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) - }) - } -} - -struct NativeAgentModelSelector { - session_id: acp::SessionId, - connection: NativeAgentConnection, -} - -impl acp_thread::AgentModelSelector for NativeAgentModelSelector { - fn list_models(&self, cx: &mut App) -> Task> { - log::debug!("NativeAgentConnection::list_models called"); - let list = self.connection.0.read(cx).models.model_list.clone(); - Task::ready(if list.is_empty() { - Err(anyhow::anyhow!("No models available")) - } else { - Ok(list) - }) - } - - fn select_model(&self, model_id: acp::ModelId, cx: &mut App) -> Task> { - log::debug!( - "Setting model for session {}: {}", - self.session_id, - model_id - ); - let Some(thread) = self - .connection - .0 - .read(cx) - .sessions - .get(&self.session_id) - .map(|session| session.thread.clone()) - else { - return Task::ready(Err(anyhow!("Session not found"))); - }; - - let Some(model) = self.connection.0.read(cx).models.model_from_id(&model_id) else { - return Task::ready(Err(anyhow!("Invalid model ID {}", model_id))); - }; - - thread.update(cx, |thread, cx| { - thread.set_model(model.clone(), cx); - }); - - update_settings_file( - self.connection.0.read(cx).fs.clone(), - cx, - move |settings, _cx| { - let provider = model.provider_id().0.to_string(); - let model = model.id().0.to_string(); - settings - .agent - .get_or_insert_default() - .set_model(LanguageModelSelection { - provider: provider.into(), - model, - }); - }, - ); - - Task::ready(Ok(())) - } - - fn selected_model(&self, cx: &mut App) -> Task> { - let Some(thread) = self - .connection - .0 - .read(cx) - .sessions - .get(&self.session_id) - .map(|session| session.thread.clone()) - else { - return Task::ready(Err(anyhow!("Session not found"))); - }; - let Some(model) = thread.read(cx).model() else { - return Task::ready(Err(anyhow!("Model not found"))); - }; - let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id()) - else { - return Task::ready(Err(anyhow!("Provider not found"))); - }; - Task::ready(Ok(LanguageModels::map_language_model_to_info( - model, &provider, - ))) - } - - fn watch(&self, cx: &mut App) -> Option> { - Some(self.connection.0.read(cx).models.watch()) - } -} - -impl acp_thread::AgentConnection for NativeAgentConnection { - fn new_thread( - self: Rc, - project: Entity, - cwd: &Path, - cx: &mut App, - ) -> Task>> { - let agent = self.0.clone(); - log::debug!("Creating new thread for project at: {:?}", cwd); - - cx.spawn(async move |cx| { - log::debug!("Starting thread creation in async context"); - - // Create Thread - let thread = agent.update( - cx, - |agent, cx: &mut gpui::Context| -> Result<_> { - // Fetch default model from registry settings - let registry = LanguageModelRegistry::read_global(cx); - // Log available models for debugging - let available_count = registry.available_models(cx).count(); - log::debug!("Total available models: {}", available_count); - - let default_model = registry.default_model().and_then(|default_model| { - agent - .models - .model_from_id(&LanguageModels::model_id(&default_model.model)) - }); - Ok(cx.new(|cx| { - Thread::new( - project.clone(), - agent.project_context.clone(), - agent.context_server_registry.clone(), - agent.templates.clone(), - default_model, - cx, - ) - })) - }, - )??; - agent.update(cx, |agent, cx| agent.register_session(thread, cx)) - }) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] // No auth for in-process - } - - fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut App) -> Task> { - Task::ready(Ok(())) - } - - fn model_selector(&self, session_id: &acp::SessionId) -> Option> { - Some(Rc::new(NativeAgentModelSelector { - session_id: session_id.clone(), - connection: self.clone(), - }) as Rc) - } - - fn prompt( - &self, - id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let id = id.expect("UserMessageId is required"); - let session_id = params.session_id.clone(); - log::info!("Received prompt request for session: {}", session_id); - log::debug!("Prompt blocks count: {}", params.prompt.len()); - - self.run_turn(session_id, cx, |thread, cx| { - let content: Vec = params - .prompt - .into_iter() - .map(Into::into) - .collect::>(); - log::debug!("Converted prompt to message: {} chars", content.len()); - log::debug!("Message id: {:?}", id); - log::debug!("Message content: {:?}", content); - - thread.update(cx, |thread, cx| thread.send(id, content, cx)) - }) - } - - fn resume( - &self, - session_id: &acp::SessionId, - _cx: &App, - ) -> Option> { - Some(Rc::new(NativeAgentSessionResume { - connection: self.clone(), - session_id: session_id.clone(), - }) as _) - } - - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { - log::info!("Cancelling on session: {}", session_id); - self.0.update(cx, |agent, cx| { - if let Some(agent) = agent.sessions.get(session_id) { - agent.thread.update(cx, |thread, cx| thread.cancel(cx)); - } - }); - } - - fn truncate( - &self, - session_id: &agent_client_protocol::SessionId, - cx: &App, - ) -> Option> { - self.0.read_with(cx, |agent, _cx| { - agent.sessions.get(session_id).map(|session| { - Rc::new(NativeAgentSessionTruncate { - thread: session.thread.clone(), - acp_thread: session.acp_thread.clone(), - }) as _ - }) - }) - } - - fn set_title( - &self, - session_id: &acp::SessionId, - _cx: &App, - ) -> Option> { - Some(Rc::new(NativeAgentSessionSetTitle { - connection: self.clone(), - session_id: session_id.clone(), - }) as _) - } - - fn telemetry(&self) -> Option> { - Some(Rc::new(self.clone()) as Rc) - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -impl acp_thread::AgentTelemetry for NativeAgentConnection { - fn agent_name(&self) -> String { - "Zed".into() - } - - fn thread_data( - &self, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - let Some(session) = self.0.read(cx).sessions.get(session_id) else { - return Task::ready(Err(anyhow!("Session not found"))); - }; - - let task = session.thread.read(cx).to_db(cx); - cx.background_spawn(async move { - serde_json::to_value(task.await).context("Failed to serialize thread") - }) - } -} - -struct NativeAgentSessionTruncate { - thread: Entity, - acp_thread: WeakEntity, -} - -impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate { - fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task> { - match self.thread.update(cx, |thread, cx| { - thread.truncate(message_id.clone(), cx)?; - Ok(thread.latest_token_usage()) - }) { - Ok(usage) => { - self.acp_thread - .update(cx, |thread, cx| { - thread.update_token_usage(usage, cx); - }) - .ok(); - Task::ready(Ok(())) - } - Err(error) => Task::ready(Err(error)), - } - } -} - -struct NativeAgentSessionResume { - connection: NativeAgentConnection, - session_id: acp::SessionId, -} - -impl acp_thread::AgentSessionResume for NativeAgentSessionResume { - fn run(&self, cx: &mut App) -> Task> { - self.connection - .run_turn(self.session_id.clone(), cx, |thread, cx| { - thread.update(cx, |thread, cx| thread.resume(cx)) - }) - } -} - -struct NativeAgentSessionSetTitle { - connection: NativeAgentConnection, - session_id: acp::SessionId, -} - -impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle { - fn run(&self, title: SharedString, cx: &mut App) -> Task> { - let Some(session) = self.connection.0.read(cx).sessions.get(&self.session_id) else { - return Task::ready(Err(anyhow!("session not found"))); - }; - let thread = session.thread.clone(); - thread.update(cx, |thread, cx| thread.set_title(title, cx)); - Task::ready(Ok(())) - } -} - -pub struct AcpThreadEnvironment { - acp_thread: WeakEntity, -} - -impl ThreadEnvironment for AcpThreadEnvironment { - fn create_terminal( - &self, - command: String, - cwd: Option, - output_byte_limit: Option, - cx: &mut AsyncApp, - ) -> Task>> { - let task = self.acp_thread.update(cx, |thread, cx| { - thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx) - }); - - let acp_thread = self.acp_thread.clone(); - cx.spawn(async move |cx| { - let terminal = task?.await?; - - let (drop_tx, drop_rx) = oneshot::channel(); - let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone())?; - - cx.spawn(async move |cx| { - drop_rx.await.ok(); - acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx)) - }) - .detach(); - - let handle = AcpTerminalHandle { - terminal, - _drop_tx: Some(drop_tx), - }; - - Ok(Rc::new(handle) as _) - }) - } -} - -pub struct AcpTerminalHandle { - terminal: Entity, - _drop_tx: Option>, -} - -impl TerminalHandle for AcpTerminalHandle { - fn id(&self, cx: &AsyncApp) -> Result { - self.terminal.read_with(cx, |term, _cx| term.id().clone()) - } - - fn wait_for_exit(&self, cx: &AsyncApp) -> Result>> { - self.terminal - .read_with(cx, |term, _cx| term.wait_for_exit()) - } - - fn current_output(&self, cx: &AsyncApp) -> Result { - self.terminal - .read_with(cx, |term, cx| term.current_output(cx)) - } -} - -#[cfg(test)] -mod tests { - use crate::HistoryEntryId; - - use super::*; - use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; - use fs::FakeFs; - use gpui::TestAppContext; - use indoc::formatdoc; - use language_model::fake_provider::FakeLanguageModel; - use serde_json::json; - use settings::SettingsStore; - use util::{path, rel_path::rel_path}; - - #[gpui::test] - async fn test_maintaining_project_context(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/", - json!({ - "a": {} - }), - ) - .await; - let project = Project::test(fs.clone(), [], cx).await; - let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); - let agent = NativeAgent::new( - project.clone(), - history_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); - agent.read_with(cx, |agent, cx| { - assert_eq!(agent.project_context.read(cx).worktrees, vec![]) - }); - - let worktree = project - .update(cx, |project, cx| project.create_worktree("/a", true, cx)) - .await - .unwrap(); - cx.run_until_parked(); - agent.read_with(cx, |agent, cx| { - assert_eq!( - agent.project_context.read(cx).worktrees, - vec![WorktreeContext { - root_name: "a".into(), - abs_path: Path::new("/a").into(), - rules_file: None - }] - ) - }); - - // Creating `/a/.rules` updates the project context. - fs.insert_file("/a/.rules", Vec::new()).await; - cx.run_until_parked(); - agent.read_with(cx, |agent, cx| { - let rules_entry = worktree - .read(cx) - .entry_for_path(rel_path(".rules")) - .unwrap(); - assert_eq!( - agent.project_context.read(cx).worktrees, - vec![WorktreeContext { - root_name: "a".into(), - abs_path: Path::new("/a").into(), - rules_file: Some(RulesFileContext { - path_in_worktree: rel_path(".rules").into(), - text: "".into(), - project_entry_id: rules_entry.id.to_usize() - }) - }] - ) - }); - } - - #[gpui::test] - async fn test_listing_models(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/", json!({ "a": {} })).await; - let project = Project::test(fs.clone(), [], cx).await; - let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); - let connection = NativeAgentConnection( - NativeAgent::new( - project.clone(), - history_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(), - ); - - // Create a thread/session - let acp_thread = cx - .update(|cx| { - Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx) - }) - .await - .unwrap(); - - let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); - - let models = cx - .update(|cx| { - connection - .model_selector(&session_id) - .unwrap() - .list_models(cx) - }) - .await - .unwrap(); - - let acp_thread::AgentModelList::Grouped(models) = models else { - panic!("Unexpected model group"); - }; - assert_eq!( - models, - IndexMap::from_iter([( - AgentModelGroupName("Fake".into()), - vec![AgentModelInfo { - id: acp::ModelId("fake/fake".into()), - name: "Fake".into(), - description: None, - icon: Some(ui::IconName::ZedAssistant), - }] - )]) - ); - } - - #[gpui::test] - async fn test_model_selection_persists_to_settings(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.create_dir(paths::settings_file().parent().unwrap()) - .await - .unwrap(); - fs.insert_file( - paths::settings_file(), - json!({ - "agent": { - "default_model": { - "provider": "foo", - "model": "bar" - } - } - }) - .to_string() - .into_bytes(), - ) - .await; - let project = Project::test(fs.clone(), [], cx).await; - - let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); - - // Create the agent and connection - let agent = NativeAgent::new( - project.clone(), - history_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); - let connection = NativeAgentConnection(agent.clone()); - - // Create a thread/session - let acp_thread = cx - .update(|cx| { - Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx) - }) - .await - .unwrap(); - - let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); - - // Select a model - let selector = connection.model_selector(&session_id).unwrap(); - let model_id = acp::ModelId("fake/fake".into()); - cx.update(|cx| selector.select_model(model_id.clone(), cx)) - .await - .unwrap(); - - // Verify the thread has the selected model - agent.read_with(cx, |agent, _| { - let session = agent.sessions.get(&session_id).unwrap(); - session.thread.read_with(cx, |thread, _| { - assert_eq!(thread.model().unwrap().id().0, "fake"); - }); - }); - - cx.run_until_parked(); - - // Verify settings file was updated - let settings_content = fs.load(paths::settings_file()).await.unwrap(); - let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap(); - - // Check that the agent settings contain the selected model - assert_eq!( - settings_json["agent"]["default_model"]["model"], - json!("fake") - ); - assert_eq!( - settings_json["agent"]["default_model"]["provider"], - json!("fake") - ); - } - - #[gpui::test] - #[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows - async fn test_save_load_thread(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/", - json!({ - "a": { - "b.md": "Lorem" - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; - let context_store = cx.new(|cx| assistant_context::ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); - let agent = NativeAgent::new( - project.clone(), - history_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); - let connection = Rc::new(NativeAgentConnection(agent.clone())); - - let acp_thread = cx - .update(|cx| { - connection - .clone() - .new_thread(project.clone(), Path::new(""), cx) - }) - .await - .unwrap(); - let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); - let thread = agent.read_with(cx, |agent, _| { - agent.sessions.get(&session_id).unwrap().thread.clone() - }); - - // Ensure empty threads are not saved, even if they get mutated. - let model = Arc::new(FakeLanguageModel::default()); - let summary_model = Arc::new(FakeLanguageModel::default()); - thread.update(cx, |thread, cx| { - thread.set_model(model.clone(), cx); - thread.set_summarization_model(Some(summary_model.clone()), cx); - }); - cx.run_until_parked(); - assert_eq!(history_entries(&history_store, cx), vec![]); - - let send = acp_thread.update(cx, |thread, cx| { - thread.send( - vec![ - "What does ".into(), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: "b.md".into(), - uri: MentionUri::File { - abs_path: path!("/a/b.md").into(), - } - .to_uri() - .to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - " mean?".into(), - ], - cx, - ) - }); - let send = cx.foreground_executor().spawn(send); - cx.run_until_parked(); - - model.send_last_completion_stream_text_chunk("Lorem."); - model.end_last_completion_stream(); - cx.run_until_parked(); - summary_model.send_last_completion_stream_text_chunk("Explaining /a/b.md"); - summary_model.end_last_completion_stream(); - - send.await.unwrap(); - let uri = MentionUri::File { - abs_path: path!("/a/b.md").into(), - } - .to_uri(); - acp_thread.read_with(cx, |thread, cx| { - assert_eq!( - thread.to_markdown(cx), - formatdoc! {" - ## User - - What does [@b.md]({uri}) mean? - - ## Assistant - - Lorem. - - "} - ) - }); - - cx.run_until_parked(); - - // Drop the ACP thread, which should cause the session to be dropped as well. - cx.update(|_| { - drop(thread); - drop(acp_thread); - }); - agent.read_with(cx, |agent, _| { - assert_eq!(agent.sessions.keys().cloned().collect::>(), []); - }); - - // Ensure the thread can be reloaded from disk. - assert_eq!( - history_entries(&history_store, cx), - vec![( - HistoryEntryId::AcpThread(session_id.clone()), - "Explaining /a/b.md".into() - )] - ); - let acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) - .await - .unwrap(); - acp_thread.read_with(cx, |thread, cx| { - assert_eq!( - thread.to_markdown(cx), - formatdoc! {" - ## User - - What does [@b.md]({uri}) mean? - - ## Assistant - - Lorem. - - "} - ) - }); - } - - fn history_entries( - history: &Entity, - cx: &mut TestAppContext, - ) -> Vec<(HistoryEntryId, String)> { - history.read_with(cx, |history, _| { - history - .entries() - .map(|e| (e.id(), e.title().to_string())) - .collect::>() - }) - } - - fn init_test(cx: &mut TestAppContext) { - env_logger::try_init().ok(); - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - Project::init_settings(cx); - agent_settings::init(cx); - language::init(cx); - LanguageModelRegistry::test(cx); - }); - } -} diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs deleted file mode 100644 index 1fc9c1cb95..0000000000 --- a/crates/agent2/src/agent2.rs +++ /dev/null @@ -1,19 +0,0 @@ -mod agent; -mod db; -mod history_store; -mod native_agent_server; -mod templates; -mod thread; -mod tool_schema; -mod tools; - -#[cfg(test)] -mod tests; - -pub use agent::*; -pub use db::*; -pub use history_store::*; -pub use native_agent_server::NativeAgentServer; -pub use templates::*; -pub use thread::*; -pub use tools::*; diff --git a/crates/agent2/src/history_store.rs b/crates/agent2/src/history_store.rs deleted file mode 100644 index ff6caacc78..0000000000 --- a/crates/agent2/src/history_store.rs +++ /dev/null @@ -1,357 +0,0 @@ -use crate::{DbThreadMetadata, ThreadsDatabase}; -use acp_thread::MentionUri; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; -use assistant_context::{AssistantContext, SavedContextMetadata}; -use chrono::{DateTime, Utc}; -use db::kvp::KEY_VALUE_STORE; -use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::*}; -use itertools::Itertools; -use paths::contexts_dir; -use serde::{Deserialize, Serialize}; -use std::{collections::VecDeque, path::Path, sync::Arc, time::Duration}; -use ui::ElementId; -use util::ResultExt as _; - -const MAX_RECENTLY_OPENED_ENTRIES: usize = 6; -const RECENTLY_OPENED_THREADS_KEY: &str = "recent-agent-threads"; -const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50); - -const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); - -#[derive(Clone, Debug)] -pub enum HistoryEntry { - AcpThread(DbThreadMetadata), - TextThread(SavedContextMetadata), -} - -impl HistoryEntry { - pub fn updated_at(&self) -> DateTime { - match self { - HistoryEntry::AcpThread(thread) => thread.updated_at, - HistoryEntry::TextThread(context) => context.mtime.to_utc(), - } - } - - pub fn id(&self) -> HistoryEntryId { - match self { - HistoryEntry::AcpThread(thread) => HistoryEntryId::AcpThread(thread.id.clone()), - HistoryEntry::TextThread(context) => HistoryEntryId::TextThread(context.path.clone()), - } - } - - pub fn mention_uri(&self) -> MentionUri { - match self { - HistoryEntry::AcpThread(thread) => MentionUri::Thread { - id: thread.id.clone(), - name: thread.title.to_string(), - }, - HistoryEntry::TextThread(context) => MentionUri::TextThread { - path: context.path.as_ref().to_owned(), - name: context.title.to_string(), - }, - } - } - - pub fn title(&self) -> &SharedString { - match self { - HistoryEntry::AcpThread(thread) if thread.title.is_empty() => DEFAULT_TITLE, - HistoryEntry::AcpThread(thread) => &thread.title, - HistoryEntry::TextThread(context) => &context.title, - } - } -} - -/// Generic identifier for a history entry. -#[derive(Clone, PartialEq, Eq, Debug, Hash)] -pub enum HistoryEntryId { - AcpThread(acp::SessionId), - TextThread(Arc), -} - -impl Into for HistoryEntryId { - fn into(self) -> ElementId { - match self { - HistoryEntryId::AcpThread(session_id) => ElementId::Name(session_id.0.into()), - HistoryEntryId::TextThread(path) => ElementId::Path(path), - } - } -} - -#[derive(Serialize, Deserialize, Debug)] -enum SerializedRecentOpen { - AcpThread(String), - TextThread(String), -} - -pub struct HistoryStore { - threads: Vec, - entries: Vec, - context_store: Entity, - recently_opened_entries: VecDeque, - _subscriptions: Vec, - _save_recently_opened_entries_task: Task<()>, -} - -impl HistoryStore { - pub fn new( - context_store: Entity, - cx: &mut Context, - ) -> Self { - let subscriptions = vec![cx.observe(&context_store, |this, _, cx| this.update_entries(cx))]; - - cx.spawn(async move |this, cx| { - let entries = Self::load_recently_opened_entries(cx).await; - this.update(cx, |this, cx| { - if let Some(entries) = entries.log_err() { - this.recently_opened_entries = entries; - } - - this.reload(cx); - }) - .ok(); - }) - .detach(); - - Self { - context_store, - recently_opened_entries: VecDeque::default(), - threads: Vec::default(), - entries: Vec::default(), - _subscriptions: subscriptions, - _save_recently_opened_entries_task: Task::ready(()), - } - } - - pub fn thread_from_session_id(&self, session_id: &acp::SessionId) -> Option<&DbThreadMetadata> { - self.threads.iter().find(|thread| &thread.id == session_id) - } - - pub fn delete_thread( - &mut self, - id: acp::SessionId, - cx: &mut Context, - ) -> Task> { - let database_future = ThreadsDatabase::connect(cx); - cx.spawn(async move |this, cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.delete_thread(id.clone()).await?; - this.update(cx, |this, cx| this.reload(cx)) - }) - } - - pub fn delete_text_thread( - &mut self, - path: Arc, - cx: &mut Context, - ) -> Task> { - self.context_store.update(cx, |context_store, cx| { - context_store.delete_local_context(path, cx) - }) - } - - pub fn load_text_thread( - &self, - path: Arc, - cx: &mut Context, - ) -> Task>> { - self.context_store.update(cx, |context_store, cx| { - context_store.open_local_context(path, cx) - }) - } - - pub fn reload(&self, cx: &mut Context) { - let database_future = ThreadsDatabase::connect(cx); - cx.spawn(async move |this, cx| { - let threads = database_future - .await - .map_err(|err| anyhow!(err))? - .list_threads() - .await?; - - this.update(cx, |this, cx| { - if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES { - for thread in threads - .iter() - .take(MAX_RECENTLY_OPENED_ENTRIES - this.recently_opened_entries.len()) - .rev() - { - this.push_recently_opened_entry( - HistoryEntryId::AcpThread(thread.id.clone()), - cx, - ) - } - } - this.threads = threads; - this.update_entries(cx); - }) - }) - .detach_and_log_err(cx); - } - - fn update_entries(&mut self, cx: &mut Context) { - #[cfg(debug_assertions)] - if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() { - return; - } - let mut history_entries = Vec::new(); - history_entries.extend(self.threads.iter().cloned().map(HistoryEntry::AcpThread)); - history_entries.extend( - self.context_store - .read(cx) - .unordered_contexts() - .cloned() - .map(HistoryEntry::TextThread), - ); - - history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at())); - self.entries = history_entries; - cx.notify() - } - - pub fn is_empty(&self, _cx: &App) -> bool { - self.entries.is_empty() - } - - pub fn recently_opened_entries(&self, cx: &App) -> Vec { - #[cfg(debug_assertions)] - if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() { - return Vec::new(); - } - - let thread_entries = self.threads.iter().flat_map(|thread| { - self.recently_opened_entries - .iter() - .enumerate() - .flat_map(|(index, entry)| match entry { - HistoryEntryId::AcpThread(id) if &thread.id == id => { - Some((index, HistoryEntry::AcpThread(thread.clone()))) - } - _ => None, - }) - }); - - let context_entries = - self.context_store - .read(cx) - .unordered_contexts() - .flat_map(|context| { - self.recently_opened_entries - .iter() - .enumerate() - .flat_map(|(index, entry)| match entry { - HistoryEntryId::TextThread(path) if &context.path == path => { - Some((index, HistoryEntry::TextThread(context.clone()))) - } - _ => None, - }) - }); - - thread_entries - .chain(context_entries) - // optimization to halt iteration early - .take(self.recently_opened_entries.len()) - .sorted_unstable_by_key(|(index, _)| *index) - .map(|(_, entry)| entry) - .collect() - } - - fn save_recently_opened_entries(&mut self, cx: &mut Context) { - let serialized_entries = self - .recently_opened_entries - .iter() - .filter_map(|entry| match entry { - HistoryEntryId::TextThread(path) => path.file_name().map(|file| { - SerializedRecentOpen::TextThread(file.to_string_lossy().into_owned()) - }), - HistoryEntryId::AcpThread(id) => { - Some(SerializedRecentOpen::AcpThread(id.to_string())) - } - }) - .collect::>(); - - self._save_recently_opened_entries_task = cx.spawn(async move |_, cx| { - let content = serde_json::to_string(&serialized_entries).unwrap(); - cx.background_executor() - .timer(SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE) - .await; - - if cfg!(any(feature = "test-support", test)) { - return; - } - KEY_VALUE_STORE - .write_kvp(RECENTLY_OPENED_THREADS_KEY.to_owned(), content) - .await - .log_err(); - }); - } - - fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { - cx.background_spawn(async move { - if cfg!(any(feature = "test-support", test)) { - anyhow::bail!("history store does not persist in tests"); - } - let json = KEY_VALUE_STORE - .read_kvp(RECENTLY_OPENED_THREADS_KEY)? - .unwrap_or("[]".to_string()); - let entries = serde_json::from_str::>(&json) - .context("deserializing persisted agent panel navigation history")? - .into_iter() - .take(MAX_RECENTLY_OPENED_ENTRIES) - .flat_map(|entry| match entry { - SerializedRecentOpen::AcpThread(id) => Some(HistoryEntryId::AcpThread( - acp::SessionId(id.as_str().into()), - )), - SerializedRecentOpen::TextThread(file_name) => Some( - HistoryEntryId::TextThread(contexts_dir().join(file_name).into()), - ), - }) - .collect(); - Ok(entries) - }) - } - - pub fn push_recently_opened_entry(&mut self, entry: HistoryEntryId, cx: &mut Context) { - self.recently_opened_entries - .retain(|old_entry| old_entry != &entry); - self.recently_opened_entries.push_front(entry); - self.recently_opened_entries - .truncate(MAX_RECENTLY_OPENED_ENTRIES); - self.save_recently_opened_entries(cx); - } - - pub fn remove_recently_opened_thread(&mut self, id: acp::SessionId, cx: &mut Context) { - self.recently_opened_entries.retain( - |entry| !matches!(entry, HistoryEntryId::AcpThread(thread_id) if thread_id == &id), - ); - self.save_recently_opened_entries(cx); - } - - pub fn replace_recently_opened_text_thread( - &mut self, - old_path: &Path, - new_path: &Arc, - cx: &mut Context, - ) { - for entry in &mut self.recently_opened_entries { - match entry { - HistoryEntryId::TextThread(path) if path.as_ref() == old_path => { - *entry = HistoryEntryId::TextThread(new_path.clone()); - break; - } - _ => {} - } - } - self.save_recently_opened_entries(cx); - } - - pub fn remove_recently_opened_entry(&mut self, entry: &HistoryEntryId, cx: &mut Context) { - self.recently_opened_entries - .retain(|old_entry| old_entry != entry); - self.save_recently_opened_entries(cx); - } - - pub fn entries(&self) -> impl Iterator { - self.entries.iter().cloned() - } -} diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs deleted file mode 100644 index 7a99142283..0000000000 --- a/crates/agent2/src/thread.rs +++ /dev/null @@ -1,2642 +0,0 @@ -use crate::{ - ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, - DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, - ListDirectoryTool, MovePathTool, NowTool, OpenTool, ReadFileTool, SystemPromptTemplate, - Template, Templates, TerminalTool, ThinkingTool, WebSearchTool, -}; -use acp_thread::{MentionUri, UserMessageId}; -use action_log::ActionLog; -use agent::thread::{GitState, ProjectSnapshot, WorktreeSnapshot}; -use agent_client_protocol as acp; -use agent_settings::{ - AgentProfileId, AgentProfileSettings, AgentSettings, CompletionMode, - SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, -}; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::adapt_schema_to_format; -use chrono::{DateTime, Utc}; -use client::{ModelRequestUsage, RequestUsage}; -use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; -use collections::{HashMap, HashSet, IndexMap}; -use fs::Fs; -use futures::{ - FutureExt, - channel::{mpsc, oneshot}, - future::Shared, - stream::FuturesUnordered, -}; -use git::repository::DiffType; -use gpui::{ - App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, -}; -use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelExt, - LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, - LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, - LanguageModelToolUseId, Role, SelectedModel, StopReason, TokenUsage, -}; -use project::{ - Project, - git_store::{GitStore, RepositoryState}, -}; -use prompt_store::ProjectContext; -use schemars::{JsonSchema, Schema}; -use serde::{Deserialize, Serialize}; -use settings::{Settings, update_settings_file}; -use smol::stream::StreamExt; -use std::{ - collections::BTreeMap, - ops::RangeInclusive, - path::Path, - rc::Rc, - sync::Arc, - time::{Duration, Instant}, -}; -use std::{fmt::Write, path::PathBuf}; -use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock}; -use uuid::Uuid; - -const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; -pub const MAX_TOOL_NAME_LENGTH: usize = 64; - -/// The ID of the user prompt that initiated a request. -/// -/// This equates to the user physically submitting a message to the model (e.g., by pressing the Enter key). -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] -pub struct PromptId(Arc); - -impl PromptId { - pub fn new() -> Self { - Self(Uuid::new_v4().to_string().into()) - } -} - -impl std::fmt::Display for PromptId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -pub(crate) const MAX_RETRY_ATTEMPTS: u8 = 4; -pub(crate) const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); - -#[derive(Debug, Clone)] -enum RetryStrategy { - ExponentialBackoff { - initial_delay: Duration, - max_attempts: u8, - }, - Fixed { - delay: Duration, - max_attempts: u8, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum Message { - User(UserMessage), - Agent(AgentMessage), - Resume, -} - -impl Message { - pub fn as_agent_message(&self) -> Option<&AgentMessage> { - match self { - Message::Agent(agent_message) => Some(agent_message), - _ => None, - } - } - - pub fn to_request(&self) -> Vec { - match self { - Message::User(message) => vec![message.to_request()], - Message::Agent(message) => message.to_request(), - Message::Resume => vec![LanguageModelRequestMessage { - role: Role::User, - content: vec!["Continue where you left off".into()], - cache: false, - }], - } - } - - pub fn to_markdown(&self) -> String { - match self { - Message::User(message) => message.to_markdown(), - Message::Agent(message) => message.to_markdown(), - Message::Resume => "[resume]\n".into(), - } - } - - pub fn role(&self) -> Role { - match self { - Message::User(_) | Message::Resume => Role::User, - Message::Agent(_) => Role::Assistant, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct UserMessage { - pub id: UserMessageId, - pub content: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum UserMessageContent { - Text(String), - Mention { uri: MentionUri, content: String }, - Image(LanguageModelImage), -} - -impl UserMessage { - pub fn to_markdown(&self) -> String { - let mut markdown = String::from("## User\n\n"); - - for content in &self.content { - match content { - UserMessageContent::Text(text) => { - markdown.push_str(text); - markdown.push('\n'); - } - UserMessageContent::Image(_) => { - markdown.push_str("\n"); - } - UserMessageContent::Mention { uri, content } => { - if !content.is_empty() { - let _ = writeln!(&mut markdown, "{}\n\n{}", uri.as_link(), content); - } else { - let _ = writeln!(&mut markdown, "{}", uri.as_link()); - } - } - } - } - - markdown - } - - fn to_request(&self) -> LanguageModelRequestMessage { - let mut message = LanguageModelRequestMessage { - role: Role::User, - content: Vec::with_capacity(self.content.len()), - cache: false, - }; - - const OPEN_CONTEXT: &str = "\n\ - The following items were attached by the user. \ - They are up-to-date and don't need to be re-read.\n\n"; - - const OPEN_FILES_TAG: &str = ""; - const OPEN_DIRECTORIES_TAG: &str = ""; - const OPEN_SYMBOLS_TAG: &str = ""; - const OPEN_SELECTIONS_TAG: &str = ""; - const OPEN_THREADS_TAG: &str = ""; - const OPEN_FETCH_TAG: &str = ""; - const OPEN_RULES_TAG: &str = - "\nThe user has specified the following rules that should be applied:\n"; - - let mut file_context = OPEN_FILES_TAG.to_string(); - let mut directory_context = OPEN_DIRECTORIES_TAG.to_string(); - let mut symbol_context = OPEN_SYMBOLS_TAG.to_string(); - let mut selection_context = OPEN_SELECTIONS_TAG.to_string(); - let mut thread_context = OPEN_THREADS_TAG.to_string(); - let mut fetch_context = OPEN_FETCH_TAG.to_string(); - let mut rules_context = OPEN_RULES_TAG.to_string(); - - for chunk in &self.content { - let chunk = match chunk { - UserMessageContent::Text(text) => { - language_model::MessageContent::Text(text.clone()) - } - UserMessageContent::Image(value) => { - language_model::MessageContent::Image(value.clone()) - } - UserMessageContent::Mention { uri, content } => { - match uri { - MentionUri::File { abs_path } => { - write!( - &mut file_context, - "\n{}", - MarkdownCodeBlock { - tag: &codeblock_tag(abs_path, None), - text: &content.to_string(), - } - ) - .ok(); - } - MentionUri::PastedImage => { - debug_panic!("pasted image URI should not be used in mention content") - } - MentionUri::Directory { .. } => { - write!(&mut directory_context, "\n{}\n", content).ok(); - } - MentionUri::Symbol { - abs_path: path, - line_range, - .. - } => { - write!( - &mut symbol_context, - "\n{}", - MarkdownCodeBlock { - tag: &codeblock_tag(path, Some(line_range)), - text: content - } - ) - .ok(); - } - MentionUri::Selection { - abs_path: path, - line_range, - .. - } => { - write!( - &mut selection_context, - "\n{}", - MarkdownCodeBlock { - tag: &codeblock_tag( - path.as_deref().unwrap_or("Untitled".as_ref()), - Some(line_range) - ), - text: content - } - ) - .ok(); - } - MentionUri::Thread { .. } => { - write!(&mut thread_context, "\n{}\n", content).ok(); - } - MentionUri::TextThread { .. } => { - write!(&mut thread_context, "\n{}\n", content).ok(); - } - MentionUri::Rule { .. } => { - write!( - &mut rules_context, - "\n{}", - MarkdownCodeBlock { - tag: "", - text: content - } - ) - .ok(); - } - MentionUri::Fetch { url } => { - write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok(); - } - } - - language_model::MessageContent::Text(uri.as_link().to_string()) - } - }; - - message.content.push(chunk); - } - - let len_before_context = message.content.len(); - - if file_context.len() > OPEN_FILES_TAG.len() { - file_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(file_context)); - } - - if directory_context.len() > OPEN_DIRECTORIES_TAG.len() { - directory_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(directory_context)); - } - - if symbol_context.len() > OPEN_SYMBOLS_TAG.len() { - symbol_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(symbol_context)); - } - - if selection_context.len() > OPEN_SELECTIONS_TAG.len() { - selection_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(selection_context)); - } - - if thread_context.len() > OPEN_THREADS_TAG.len() { - thread_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(thread_context)); - } - - if fetch_context.len() > OPEN_FETCH_TAG.len() { - fetch_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(fetch_context)); - } - - if rules_context.len() > OPEN_RULES_TAG.len() { - rules_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(rules_context)); - } - - if message.content.len() > len_before_context { - message.content.insert( - len_before_context, - language_model::MessageContent::Text(OPEN_CONTEXT.into()), - ); - message - .content - .push(language_model::MessageContent::Text("".into())); - } - - message - } -} - -fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive>) -> String { - let mut result = String::new(); - - if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { - let _ = write!(result, "{} ", extension); - } - - let _ = write!(result, "{}", full_path.display()); - - if let Some(range) = line_range { - if range.start() == range.end() { - let _ = write!(result, ":{}", range.start() + 1); - } else { - let _ = write!(result, ":{}-{}", range.start() + 1, range.end() + 1); - } - } - - result -} - -impl AgentMessage { - pub fn to_markdown(&self) -> String { - let mut markdown = String::from("## Assistant\n\n"); - - for content in &self.content { - match content { - AgentMessageContent::Text(text) => { - markdown.push_str(text); - markdown.push('\n'); - } - AgentMessageContent::Thinking { text, .. } => { - markdown.push_str(""); - markdown.push_str(text); - markdown.push_str("\n"); - } - AgentMessageContent::RedactedThinking(_) => { - markdown.push_str("\n") - } - AgentMessageContent::ToolUse(tool_use) => { - markdown.push_str(&format!( - "**Tool Use**: {} (ID: {})\n", - tool_use.name, tool_use.id - )); - markdown.push_str(&format!( - "{}\n", - MarkdownCodeBlock { - tag: "json", - text: &format!("{:#}", tool_use.input) - } - )); - } - } - } - - for tool_result in self.tool_results.values() { - markdown.push_str(&format!( - "**Tool Result**: {} (ID: {})\n\n", - tool_result.tool_name, tool_result.tool_use_id - )); - if tool_result.is_error { - markdown.push_str("**ERROR:**\n"); - } - - match &tool_result.content { - LanguageModelToolResultContent::Text(text) => { - writeln!(markdown, "{text}\n").ok(); - } - LanguageModelToolResultContent::Image(_) => { - writeln!(markdown, "\n").ok(); - } - } - - if let Some(output) = tool_result.output.as_ref() { - writeln!( - markdown, - "**Debug Output**:\n\n```json\n{}\n```\n", - serde_json::to_string_pretty(output).unwrap() - ) - .unwrap(); - } - } - - markdown - } - - pub fn to_request(&self) -> Vec { - let mut assistant_message = LanguageModelRequestMessage { - role: Role::Assistant, - content: Vec::with_capacity(self.content.len()), - cache: false, - }; - for chunk in &self.content { - match chunk { - AgentMessageContent::Text(text) => { - assistant_message - .content - .push(language_model::MessageContent::Text(text.clone())); - } - AgentMessageContent::Thinking { text, signature } => { - assistant_message - .content - .push(language_model::MessageContent::Thinking { - text: text.clone(), - signature: signature.clone(), - }); - } - AgentMessageContent::RedactedThinking(value) => { - assistant_message.content.push( - language_model::MessageContent::RedactedThinking(value.clone()), - ); - } - AgentMessageContent::ToolUse(tool_use) => { - if self.tool_results.contains_key(&tool_use.id) { - assistant_message - .content - .push(language_model::MessageContent::ToolUse(tool_use.clone())); - } - } - }; - } - - let mut user_message = LanguageModelRequestMessage { - role: Role::User, - content: Vec::new(), - cache: false, - }; - - for tool_result in self.tool_results.values() { - let mut tool_result = tool_result.clone(); - // Surprisingly, the API fails if we return an empty string here. - // It thinks we are sending a tool use without a tool result. - if tool_result.content.is_empty() { - tool_result.content = "".into(); - } - user_message - .content - .push(language_model::MessageContent::ToolResult(tool_result)); - } - - let mut messages = Vec::new(); - if !assistant_message.content.is_empty() { - messages.push(assistant_message); - } - if !user_message.content.is_empty() { - messages.push(user_message); - } - messages - } -} - -#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentMessage { - pub content: Vec, - pub tool_results: IndexMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum AgentMessageContent { - Text(String), - Thinking { - text: String, - signature: Option, - }, - RedactedThinking(String), - ToolUse(LanguageModelToolUse), -} - -pub trait TerminalHandle { - fn id(&self, cx: &AsyncApp) -> Result; - fn current_output(&self, cx: &AsyncApp) -> Result; - fn wait_for_exit(&self, cx: &AsyncApp) -> Result>>; -} - -pub trait ThreadEnvironment { - fn create_terminal( - &self, - command: String, - cwd: Option, - output_byte_limit: Option, - cx: &mut AsyncApp, - ) -> Task>>; -} - -#[derive(Debug)] -pub enum ThreadEvent { - UserMessage(UserMessage), - AgentText(String), - AgentThinking(String), - ToolCall(acp::ToolCall), - ToolCallUpdate(acp_thread::ToolCallUpdate), - ToolCallAuthorization(ToolCallAuthorization), - Retry(acp_thread::RetryStatus), - Stop(acp::StopReason), -} - -#[derive(Debug)] -pub struct NewTerminal { - pub command: String, - pub output_byte_limit: Option, - pub cwd: Option, - pub response: oneshot::Sender>>, -} - -#[derive(Debug)] -pub struct ToolCallAuthorization { - pub tool_call: acp::ToolCallUpdate, - pub options: Vec, - pub response: oneshot::Sender, -} - -#[derive(Debug, thiserror::Error)] -enum CompletionError { - #[error("max tokens")] - MaxTokens, - #[error("refusal")] - Refusal, - #[error(transparent)] - Other(#[from] anyhow::Error), -} - -pub struct Thread { - id: acp::SessionId, - prompt_id: PromptId, - updated_at: DateTime, - title: Option, - pending_title_generation: Option>, - summary: Option, - messages: Vec, - completion_mode: CompletionMode, - /// Holds the task that handles agent interaction until the end of the turn. - /// Survives across multiple requests as the model performs tool calls and - /// we run tools, report their results. - running_turn: Option, - pending_message: Option, - tools: BTreeMap>, - tool_use_limit_reached: bool, - request_token_usage: HashMap, - #[allow(unused)] - cumulative_token_usage: TokenUsage, - #[allow(unused)] - initial_project_snapshot: Shared>>>, - context_server_registry: Entity, - profile_id: AgentProfileId, - project_context: Entity, - templates: Arc, - model: Option>, - summarization_model: Option>, - prompt_capabilities_tx: watch::Sender, - pub(crate) prompt_capabilities_rx: watch::Receiver, - pub(crate) project: Entity, - pub(crate) action_log: Entity, -} - -impl Thread { - fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities { - let image = model.map_or(true, |model| model.supports_images()); - acp::PromptCapabilities { - meta: None, - image, - audio: false, - embedded_context: true, - } - } - - pub fn new( - project: Entity, - project_context: Entity, - context_server_registry: Entity, - templates: Arc, - model: Option>, - cx: &mut Context, - ) -> Self { - let profile_id = AgentSettings::get_global(cx).default_profile.clone(); - let action_log = cx.new(|_cx| ActionLog::new(project.clone())); - let (prompt_capabilities_tx, prompt_capabilities_rx) = - watch::channel(Self::prompt_capabilities(model.as_deref())); - Self { - id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), - prompt_id: PromptId::new(), - updated_at: Utc::now(), - title: None, - pending_title_generation: None, - summary: None, - messages: Vec::new(), - completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, - running_turn: None, - pending_message: None, - tools: BTreeMap::default(), - tool_use_limit_reached: false, - request_token_usage: HashMap::default(), - cumulative_token_usage: TokenUsage::default(), - initial_project_snapshot: { - let project_snapshot = Self::project_snapshot(project.clone(), cx); - cx.foreground_executor() - .spawn(async move { Some(project_snapshot.await) }) - .shared() - }, - context_server_registry, - profile_id, - project_context, - templates, - model, - summarization_model: None, - prompt_capabilities_tx, - prompt_capabilities_rx, - project, - action_log, - } - } - - pub fn id(&self) -> &acp::SessionId { - &self.id - } - - pub fn replay( - &mut self, - cx: &mut Context, - ) -> mpsc::UnboundedReceiver> { - let (tx, rx) = mpsc::unbounded(); - let stream = ThreadEventStream(tx); - for message in &self.messages { - match message { - Message::User(user_message) => stream.send_user_message(user_message), - Message::Agent(assistant_message) => { - for content in &assistant_message.content { - match content { - AgentMessageContent::Text(text) => stream.send_text(text), - AgentMessageContent::Thinking { text, .. } => { - stream.send_thinking(text) - } - AgentMessageContent::RedactedThinking(_) => {} - AgentMessageContent::ToolUse(tool_use) => { - self.replay_tool_call( - tool_use, - assistant_message.tool_results.get(&tool_use.id), - &stream, - cx, - ); - } - } - } - } - Message::Resume => {} - } - } - rx - } - - fn replay_tool_call( - &self, - tool_use: &LanguageModelToolUse, - tool_result: Option<&LanguageModelToolResult>, - stream: &ThreadEventStream, - cx: &mut Context, - ) { - let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| { - self.context_server_registry - .read(cx) - .servers() - .find_map(|(_, tools)| { - if let Some(tool) = tools.get(tool_use.name.as_ref()) { - Some(tool.clone()) - } else { - None - } - }) - }); - - let Some(tool) = tool else { - stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCall(acp::ToolCall { - meta: None, - id: acp::ToolCallId(tool_use.id.to_string().into()), - title: tool_use.name.to_string(), - kind: acp::ToolKind::Other, - status: acp::ToolCallStatus::Failed, - content: Vec::new(), - locations: Vec::new(), - raw_input: Some(tool_use.input.clone()), - raw_output: None, - }))) - .ok(); - return; - }; - - let title = tool.initial_title(tool_use.input.clone(), cx); - let kind = tool.kind(); - stream.send_tool_call(&tool_use.id, title, kind, tool_use.input.clone()); - - let output = tool_result - .as_ref() - .and_then(|result| result.output.clone()); - if let Some(output) = output.clone() { - let tool_event_stream = ToolCallEventStream::new( - tool_use.id.clone(), - stream.clone(), - Some(self.project.read(cx).fs().clone()), - ); - tool.replay(tool_use.input.clone(), output, tool_event_stream, cx) - .log_err(); - } - - stream.update_tool_call_fields( - &tool_use.id, - acp::ToolCallUpdateFields { - status: Some( - tool_result - .as_ref() - .map_or(acp::ToolCallStatus::Failed, |result| { - if result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - } - }), - ), - raw_output: output, - ..Default::default() - }, - ); - } - - pub fn from_db( - id: acp::SessionId, - db_thread: DbThread, - project: Entity, - project_context: Entity, - context_server_registry: Entity, - action_log: Entity, - templates: Arc, - cx: &mut Context, - ) -> Self { - let profile_id = db_thread - .profile - .unwrap_or_else(|| AgentSettings::get_global(cx).default_profile.clone()); - let model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - db_thread - .model - .and_then(|model| { - let model = SelectedModel { - provider: model.provider.clone().into(), - model: model.model.into(), - }; - registry.select_model(&model, cx) - }) - .or_else(|| registry.default_model()) - .map(|model| model.model) - }); - let (prompt_capabilities_tx, prompt_capabilities_rx) = - watch::channel(Self::prompt_capabilities(model.as_deref())); - - Self { - id, - prompt_id: PromptId::new(), - title: if db_thread.title.is_empty() { - None - } else { - Some(db_thread.title.clone()) - }, - pending_title_generation: None, - summary: db_thread.detailed_summary, - messages: db_thread.messages, - completion_mode: db_thread.completion_mode.unwrap_or_default(), - running_turn: None, - pending_message: None, - tools: BTreeMap::default(), - tool_use_limit_reached: false, - request_token_usage: db_thread.request_token_usage.clone(), - cumulative_token_usage: db_thread.cumulative_token_usage, - initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), - context_server_registry, - profile_id, - project_context, - templates, - model, - summarization_model: None, - project, - action_log, - updated_at: db_thread.updated_at, - prompt_capabilities_tx, - prompt_capabilities_rx, - } - } - - pub fn to_db(&self, cx: &App) -> Task { - let initial_project_snapshot = self.initial_project_snapshot.clone(); - let mut thread = DbThread { - title: self.title(), - messages: self.messages.clone(), - updated_at: self.updated_at, - detailed_summary: self.summary.clone(), - initial_project_snapshot: None, - cumulative_token_usage: self.cumulative_token_usage, - request_token_usage: self.request_token_usage.clone(), - model: self.model.as_ref().map(|model| DbLanguageModel { - provider: model.provider_id().to_string(), - model: model.name().0.to_string(), - }), - completion_mode: Some(self.completion_mode), - profile: Some(self.profile_id.clone()), - }; - - cx.background_spawn(async move { - let initial_project_snapshot = initial_project_snapshot.await; - thread.initial_project_snapshot = initial_project_snapshot; - thread - }) - } - - /// Create a snapshot of the current project state including git information and unsaved buffers. - fn project_snapshot( - project: Entity, - cx: &mut Context, - ) -> Task> { - let git_store = project.read(cx).git_store().clone(); - let worktree_snapshots: Vec<_> = project - .read(cx) - .visible_worktrees(cx) - .map(|worktree| Self::worktree_snapshot(worktree, git_store.clone(), cx)) - .collect(); - - cx.spawn(async move |_, _| { - let worktree_snapshots = futures::future::join_all(worktree_snapshots).await; - - Arc::new(ProjectSnapshot { - worktree_snapshots, - timestamp: Utc::now(), - }) - }) - } - - fn worktree_snapshot( - worktree: Entity, - git_store: Entity, - cx: &App, - ) -> Task { - cx.spawn(async move |cx| { - // Get worktree path and snapshot - let worktree_info = cx.update(|app_cx| { - let worktree = worktree.read(app_cx); - let path = worktree.abs_path().to_string_lossy().into_owned(); - let snapshot = worktree.snapshot(); - (path, snapshot) - }); - - let Ok((worktree_path, _snapshot)) = worktree_info else { - return WorktreeSnapshot { - worktree_path: String::new(), - git_state: None, - }; - }; - - let git_state = git_store - .update(cx, |git_store, cx| { - git_store - .repositories() - .values() - .find(|repo| { - repo.read(cx) - .abs_path_to_repo_path(&worktree.read(cx).abs_path()) - .is_some() - }) - .cloned() - }) - .ok() - .flatten() - .map(|repo| { - repo.update(cx, |repo, _| { - let current_branch = - repo.branch.as_ref().map(|branch| branch.name().to_owned()); - repo.send_job(None, |state, _| async move { - let RepositoryState::Local { backend, .. } = state else { - return GitState { - remote_url: None, - head_sha: None, - current_branch, - diff: None, - }; - }; - - let remote_url = backend.remote_url("origin"); - let head_sha = backend.head_sha().await; - let diff = backend.diff(DiffType::HeadToWorktree).await.ok(); - - GitState { - remote_url, - head_sha, - current_branch, - diff, - } - }) - }) - }); - - let git_state = match git_state { - Some(git_state) => match git_state.ok() { - Some(git_state) => git_state.await.ok(), - None => None, - }, - None => None, - }; - - WorktreeSnapshot { - worktree_path, - git_state, - } - }) - } - - pub fn project_context(&self) -> &Entity { - &self.project_context - } - - pub fn project(&self) -> &Entity { - &self.project - } - - pub fn action_log(&self) -> &Entity { - &self.action_log - } - - pub fn is_empty(&self) -> bool { - self.messages.is_empty() && self.title.is_none() - } - - pub fn model(&self) -> Option<&Arc> { - self.model.as_ref() - } - - pub fn set_model(&mut self, model: Arc, cx: &mut Context) { - let old_usage = self.latest_token_usage(); - self.model = Some(model); - let new_caps = Self::prompt_capabilities(self.model.as_deref()); - let new_usage = self.latest_token_usage(); - if old_usage != new_usage { - cx.emit(TokenUsageUpdated(new_usage)); - } - self.prompt_capabilities_tx.send(new_caps).log_err(); - cx.notify() - } - - pub fn summarization_model(&self) -> Option<&Arc> { - self.summarization_model.as_ref() - } - - pub fn set_summarization_model( - &mut self, - model: Option>, - cx: &mut Context, - ) { - self.summarization_model = model; - cx.notify() - } - - pub fn completion_mode(&self) -> CompletionMode { - self.completion_mode - } - - pub fn set_completion_mode(&mut self, mode: CompletionMode, cx: &mut Context) { - let old_usage = self.latest_token_usage(); - self.completion_mode = mode; - let new_usage = self.latest_token_usage(); - if old_usage != new_usage { - cx.emit(TokenUsageUpdated(new_usage)); - } - cx.notify() - } - - #[cfg(any(test, feature = "test-support"))] - pub fn last_message(&self) -> Option { - if let Some(message) = self.pending_message.clone() { - Some(Message::Agent(message)) - } else { - self.messages.last().cloned() - } - } - - pub fn add_default_tools( - &mut self, - environment: Rc, - cx: &mut Context, - ) { - let language_registry = self.project.read(cx).languages().clone(); - self.add_tool(CopyPathTool::new(self.project.clone())); - self.add_tool(CreateDirectoryTool::new(self.project.clone())); - self.add_tool(DeletePathTool::new( - self.project.clone(), - self.action_log.clone(), - )); - self.add_tool(DiagnosticsTool::new(self.project.clone())); - self.add_tool(EditFileTool::new( - self.project.clone(), - cx.weak_entity(), - language_registry, - )); - self.add_tool(FetchTool::new(self.project.read(cx).client().http_client())); - self.add_tool(FindPathTool::new(self.project.clone())); - self.add_tool(GrepTool::new(self.project.clone())); - self.add_tool(ListDirectoryTool::new(self.project.clone())); - self.add_tool(MovePathTool::new(self.project.clone())); - self.add_tool(NowTool); - self.add_tool(OpenTool::new(self.project.clone())); - self.add_tool(ReadFileTool::new( - self.project.clone(), - self.action_log.clone(), - )); - self.add_tool(TerminalTool::new(self.project.clone(), environment)); - self.add_tool(ThinkingTool); - self.add_tool(WebSearchTool); - } - - pub fn add_tool(&mut self, tool: T) { - self.tools.insert(T::name().into(), tool.erase()); - } - - pub fn remove_tool(&mut self, name: &str) -> bool { - self.tools.remove(name).is_some() - } - - pub fn profile(&self) -> &AgentProfileId { - &self.profile_id - } - - pub fn set_profile(&mut self, profile_id: AgentProfileId) { - self.profile_id = profile_id; - } - - pub fn cancel(&mut self, cx: &mut Context) { - if let Some(running_turn) = self.running_turn.take() { - running_turn.cancel(); - } - self.flush_pending_message(cx); - } - - fn update_token_usage(&mut self, update: language_model::TokenUsage, cx: &mut Context) { - let Some(last_user_message) = self.last_user_message() else { - return; - }; - - self.request_token_usage - .insert(last_user_message.id.clone(), update); - cx.emit(TokenUsageUpdated(self.latest_token_usage())); - cx.notify(); - } - - pub fn truncate(&mut self, message_id: UserMessageId, cx: &mut Context) -> Result<()> { - self.cancel(cx); - let Some(position) = self.messages.iter().position( - |msg| matches!(msg, Message::User(UserMessage { id, .. }) if id == &message_id), - ) else { - return Err(anyhow!("Message not found")); - }; - - for message in self.messages.drain(position..) { - match message { - Message::User(message) => { - self.request_token_usage.remove(&message.id); - } - Message::Agent(_) | Message::Resume => {} - } - } - self.summary = None; - cx.notify(); - Ok(()) - } - - pub fn latest_token_usage(&self) -> Option { - let last_user_message = self.last_user_message()?; - let tokens = self.request_token_usage.get(&last_user_message.id)?; - let model = self.model.clone()?; - - Some(acp_thread::TokenUsage { - max_tokens: model.max_token_count_for_mode(self.completion_mode.into()), - used_tokens: tokens.total_tokens(), - }) - } - - pub fn resume( - &mut self, - cx: &mut Context, - ) -> Result>> { - self.messages.push(Message::Resume); - cx.notify(); - - log::debug!("Total messages in thread: {}", self.messages.len()); - self.run_turn(cx) - } - - /// Sending a message results in the model streaming a response, which could include tool calls. - /// After calling tools, the model will stops and waits for any outstanding tool calls to be completed and their results sent. - /// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn. - pub fn send( - &mut self, - id: UserMessageId, - content: impl IntoIterator, - cx: &mut Context, - ) -> Result>> - where - T: Into, - { - let model = self.model().context("No language model configured")?; - - log::info!("Thread::send called with model: {}", model.name().0); - self.advance_prompt_id(); - - let content = content.into_iter().map(Into::into).collect::>(); - log::debug!("Thread::send content: {:?}", content); - - self.messages - .push(Message::User(UserMessage { id, content })); - cx.notify(); - - log::debug!("Total messages in thread: {}", self.messages.len()); - self.run_turn(cx) - } - - fn run_turn( - &mut self, - cx: &mut Context, - ) -> Result>> { - self.cancel(cx); - - let model = self.model.clone().context("No language model configured")?; - let profile = AgentSettings::get_global(cx) - .profiles - .get(&self.profile_id) - .context("Profile not found")?; - let (events_tx, events_rx) = mpsc::unbounded::>(); - let event_stream = ThreadEventStream(events_tx); - let message_ix = self.messages.len().saturating_sub(1); - self.tool_use_limit_reached = false; - self.summary = None; - self.running_turn = Some(RunningTurn { - event_stream: event_stream.clone(), - tools: self.enabled_tools(profile, &model, cx), - _task: cx.spawn(async move |this, cx| { - log::debug!("Starting agent turn execution"); - - let turn_result = Self::run_turn_internal(&this, model, &event_stream, cx).await; - _ = this.update(cx, |this, cx| this.flush_pending_message(cx)); - - match turn_result { - Ok(()) => { - log::debug!("Turn execution completed"); - event_stream.send_stop(acp::StopReason::EndTurn); - } - Err(error) => { - log::error!("Turn execution failed: {:?}", error); - match error.downcast::() { - Ok(CompletionError::Refusal) => { - event_stream.send_stop(acp::StopReason::Refusal); - _ = this.update(cx, |this, _| this.messages.truncate(message_ix)); - } - Ok(CompletionError::MaxTokens) => { - event_stream.send_stop(acp::StopReason::MaxTokens); - } - Ok(CompletionError::Other(error)) | Err(error) => { - event_stream.send_error(error); - } - } - } - } - - _ = this.update(cx, |this, _| this.running_turn.take()); - }), - }); - Ok(events_rx) - } - - async fn run_turn_internal( - this: &WeakEntity, - model: Arc, - event_stream: &ThreadEventStream, - cx: &mut AsyncApp, - ) -> Result<()> { - let mut attempt = 0; - let mut intent = CompletionIntent::UserPrompt; - loop { - let request = - this.update(cx, |this, cx| this.build_completion_request(intent, cx))??; - - telemetry::event!( - "Agent Thread Completion", - thread_id = this.read_with(cx, |this, _| this.id.to_string())?, - prompt_id = this.read_with(cx, |this, _| this.prompt_id.to_string())?, - model = model.telemetry_id(), - model_provider = model.provider_id().to_string(), - attempt - ); - - log::debug!("Calling model.stream_completion, attempt {}", attempt); - let mut events = model - .stream_completion(request, cx) - .await - .map_err(|error| anyhow!(error))?; - let mut tool_results = FuturesUnordered::new(); - let mut error = None; - while let Some(event) = events.next().await { - log::trace!("Received completion event: {:?}", event); - match event { - Ok(event) => { - tool_results.extend(this.update(cx, |this, cx| { - this.handle_completion_event(event, event_stream, cx) - })??); - } - Err(err) => { - error = Some(err); - break; - } - } - } - - let end_turn = tool_results.is_empty(); - while let Some(tool_result) = tool_results.next().await { - log::debug!("Tool finished {:?}", tool_result); - - event_stream.update_tool_call_fields( - &tool_result.tool_use_id, - acp::ToolCallUpdateFields { - status: Some(if tool_result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - }), - raw_output: tool_result.output.clone(), - ..Default::default() - }, - ); - this.update(cx, |this, _cx| { - this.pending_message() - .tool_results - .insert(tool_result.tool_use_id.clone(), tool_result); - })?; - } - - this.update(cx, |this, cx| { - this.flush_pending_message(cx); - if this.title.is_none() && this.pending_title_generation.is_none() { - this.generate_title(cx); - } - })?; - - if let Some(error) = error { - attempt += 1; - let retry = - this.update(cx, |this, _| this.handle_completion_error(error, attempt))??; - let timer = cx.background_executor().timer(retry.duration); - event_stream.send_retry(retry); - timer.await; - this.update(cx, |this, _cx| { - if let Some(Message::Agent(message)) = this.messages.last() { - if message.tool_results.is_empty() { - intent = CompletionIntent::UserPrompt; - this.messages.push(Message::Resume); - } - } - })?; - } else if this.read_with(cx, |this, _| this.tool_use_limit_reached)? { - return Err(language_model::ToolUseLimitReachedError.into()); - } else if end_turn { - return Ok(()); - } else { - intent = CompletionIntent::ToolResults; - attempt = 0; - } - } - } - - fn handle_completion_error( - &mut self, - error: LanguageModelCompletionError, - attempt: u8, - ) -> Result { - if self.completion_mode == CompletionMode::Normal { - return Err(anyhow!(error)); - } - - let Some(strategy) = Self::retry_strategy_for(&error) else { - return Err(anyhow!(error)); - }; - - let max_attempts = match &strategy { - RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, - RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, - }; - - if attempt > max_attempts { - return Err(anyhow!(error)); - } - - let delay = match &strategy { - RetryStrategy::ExponentialBackoff { initial_delay, .. } => { - let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); - Duration::from_secs(delay_secs) - } - RetryStrategy::Fixed { delay, .. } => *delay, - }; - log::debug!("Retry attempt {attempt} with delay {delay:?}"); - - Ok(acp_thread::RetryStatus { - last_error: error.to_string().into(), - attempt: attempt as usize, - max_attempts: max_attempts as usize, - started_at: Instant::now(), - duration: delay, - }) - } - - /// A helper method that's called on every streamed completion event. - /// Returns an optional tool result task, which the main agentic loop will - /// send back to the model when it resolves. - fn handle_completion_event( - &mut self, - event: LanguageModelCompletionEvent, - event_stream: &ThreadEventStream, - cx: &mut Context, - ) -> Result>> { - log::trace!("Handling streamed completion event: {:?}", event); - use LanguageModelCompletionEvent::*; - - match event { - StartMessage { .. } => { - self.flush_pending_message(cx); - self.pending_message = Some(AgentMessage::default()); - } - Text(new_text) => self.handle_text_event(new_text, event_stream, cx), - Thinking { text, signature } => { - self.handle_thinking_event(text, signature, event_stream, cx) - } - RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx), - ToolUse(tool_use) => { - return Ok(self.handle_tool_use_event(tool_use, event_stream, cx)); - } - ToolUseJsonParseError { - id, - tool_name, - raw_input, - json_parse_error, - } => { - return Ok(Some(Task::ready( - self.handle_tool_use_json_parse_error_event( - id, - tool_name, - raw_input, - json_parse_error, - ), - ))); - } - UsageUpdate(usage) => { - telemetry::event!( - "Agent Thread Completion Usage Updated", - thread_id = self.id.to_string(), - prompt_id = self.prompt_id.to_string(), - model = self.model.as_ref().map(|m| m.telemetry_id()), - model_provider = self.model.as_ref().map(|m| m.provider_id().to_string()), - input_tokens = usage.input_tokens, - output_tokens = usage.output_tokens, - cache_creation_input_tokens = usage.cache_creation_input_tokens, - cache_read_input_tokens = usage.cache_read_input_tokens, - ); - self.update_token_usage(usage, cx); - } - StatusUpdate(CompletionRequestStatus::UsageUpdated { amount, limit }) => { - self.update_model_request_usage(amount, limit, cx); - } - StatusUpdate( - CompletionRequestStatus::Started - | CompletionRequestStatus::Queued { .. } - | CompletionRequestStatus::Failed { .. }, - ) => {} - StatusUpdate(CompletionRequestStatus::ToolUseLimitReached) => { - self.tool_use_limit_reached = true; - } - Stop(StopReason::Refusal) => return Err(CompletionError::Refusal.into()), - Stop(StopReason::MaxTokens) => return Err(CompletionError::MaxTokens.into()), - Stop(StopReason::ToolUse | StopReason::EndTurn) => {} - } - - Ok(None) - } - - fn handle_text_event( - &mut self, - new_text: String, - event_stream: &ThreadEventStream, - cx: &mut Context, - ) { - event_stream.send_text(&new_text); - - let last_message = self.pending_message(); - if let Some(AgentMessageContent::Text(text)) = last_message.content.last_mut() { - text.push_str(&new_text); - } else { - last_message - .content - .push(AgentMessageContent::Text(new_text)); - } - - cx.notify(); - } - - fn handle_thinking_event( - &mut self, - new_text: String, - new_signature: Option, - event_stream: &ThreadEventStream, - cx: &mut Context, - ) { - event_stream.send_thinking(&new_text); - - let last_message = self.pending_message(); - if let Some(AgentMessageContent::Thinking { text, signature }) = - last_message.content.last_mut() - { - text.push_str(&new_text); - *signature = new_signature.or(signature.take()); - } else { - last_message.content.push(AgentMessageContent::Thinking { - text: new_text, - signature: new_signature, - }); - } - - cx.notify(); - } - - fn handle_redacted_thinking_event(&mut self, data: String, cx: &mut Context) { - let last_message = self.pending_message(); - last_message - .content - .push(AgentMessageContent::RedactedThinking(data)); - cx.notify(); - } - - fn handle_tool_use_event( - &mut self, - tool_use: LanguageModelToolUse, - event_stream: &ThreadEventStream, - cx: &mut Context, - ) -> Option> { - cx.notify(); - - let tool = self.tool(tool_use.name.as_ref()); - let mut title = SharedString::from(&tool_use.name); - let mut kind = acp::ToolKind::Other; - if let Some(tool) = tool.as_ref() { - title = tool.initial_title(tool_use.input.clone(), cx); - kind = tool.kind(); - } - - // Ensure the last message ends in the current tool use - let last_message = self.pending_message(); - let push_new_tool_use = last_message.content.last_mut().is_none_or(|content| { - if let AgentMessageContent::ToolUse(last_tool_use) = content { - if last_tool_use.id == tool_use.id { - *last_tool_use = tool_use.clone(); - false - } else { - true - } - } else { - true - } - }); - - if push_new_tool_use { - event_stream.send_tool_call(&tool_use.id, title, kind, tool_use.input.clone()); - last_message - .content - .push(AgentMessageContent::ToolUse(tool_use.clone())); - } else { - event_stream.update_tool_call_fields( - &tool_use.id, - acp::ToolCallUpdateFields { - title: Some(title.into()), - kind: Some(kind), - raw_input: Some(tool_use.input.clone()), - ..Default::default() - }, - ); - } - - if !tool_use.is_input_complete { - return None; - } - - let Some(tool) = tool else { - let content = format!("No tool named {} exists", tool_use.name); - return Some(Task::ready(LanguageModelToolResult { - content: LanguageModelToolResultContent::Text(Arc::from(content)), - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - output: None, - })); - }; - - let fs = self.project.read(cx).fs().clone(); - let tool_event_stream = - ToolCallEventStream::new(tool_use.id.clone(), event_stream.clone(), Some(fs)); - tool_event_stream.update_fields(acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }); - let supports_images = self.model().is_some_and(|model| model.supports_images()); - let tool_result = tool.run(tool_use.input, tool_event_stream, cx); - log::debug!("Running tool {}", tool_use.name); - Some(cx.foreground_executor().spawn(async move { - let tool_result = tool_result.await.and_then(|output| { - if let LanguageModelToolResultContent::Image(_) = &output.llm_output - && !supports_images - { - return Err(anyhow!( - "Attempted to read an image, but this model doesn't support it.", - )); - } - Ok(output) - }); - - match tool_result { - Ok(output) => LanguageModelToolResult { - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: false, - content: output.llm_output, - output: Some(output.raw_output), - }, - Err(error) => LanguageModelToolResult { - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), - output: Some(error.to_string().into()), - }, - } - })) - } - - fn handle_tool_use_json_parse_error_event( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - raw_input: Arc, - json_parse_error: String, - ) -> LanguageModelToolResult { - let tool_output = format!("Error parsing input JSON: {json_parse_error}"); - LanguageModelToolResult { - tool_use_id, - tool_name, - is_error: true, - content: LanguageModelToolResultContent::Text(tool_output.into()), - output: Some(serde_json::Value::String(raw_input.to_string())), - } - } - - fn update_model_request_usage(&self, amount: usize, limit: UsageLimit, cx: &mut Context) { - self.project - .read(cx) - .user_store() - .update(cx, |user_store, cx| { - user_store.update_model_request_usage( - ModelRequestUsage(RequestUsage { - amount: amount as i32, - limit, - }), - cx, - ) - }); - } - - pub fn title(&self) -> SharedString { - self.title.clone().unwrap_or("New Thread".into()) - } - - pub fn summary(&mut self, cx: &mut Context) -> Task> { - if let Some(summary) = self.summary.as_ref() { - return Task::ready(Ok(summary.clone())); - } - let Some(model) = self.summarization_model.clone() else { - return Task::ready(Err(anyhow!("No summarization model available"))); - }; - let mut request = LanguageModelRequest { - intent: Some(CompletionIntent::ThreadContextSummarization), - temperature: AgentSettings::temperature_for_model(&model, cx), - ..Default::default() - }; - - for message in &self.messages { - request.messages.extend(message.to_request()); - } - - request.messages.push(LanguageModelRequestMessage { - role: Role::User, - content: vec![SUMMARIZE_THREAD_DETAILED_PROMPT.into()], - cache: false, - }); - cx.spawn(async move |this, cx| { - let mut summary = String::new(); - let mut messages = model.stream_completion(request, cx).await?; - while let Some(event) = messages.next().await { - let event = event?; - let text = match event { - LanguageModelCompletionEvent::Text(text) => text, - LanguageModelCompletionEvent::StatusUpdate( - CompletionRequestStatus::UsageUpdated { amount, limit }, - ) => { - this.update(cx, |thread, cx| { - thread.update_model_request_usage(amount, limit, cx); - })?; - continue; - } - _ => continue, - }; - - let mut lines = text.lines(); - summary.extend(lines.next()); - } - - log::debug!("Setting summary: {}", summary); - let summary = SharedString::from(summary); - - this.update(cx, |this, cx| { - this.summary = Some(summary.clone()); - cx.notify() - })?; - - Ok(summary) - }) - } - - fn generate_title(&mut self, cx: &mut Context) { - let Some(model) = self.summarization_model.clone() else { - return; - }; - - log::debug!( - "Generating title with model: {:?}", - self.summarization_model.as_ref().map(|model| model.name()) - ); - let mut request = LanguageModelRequest { - intent: Some(CompletionIntent::ThreadSummarization), - temperature: AgentSettings::temperature_for_model(&model, cx), - ..Default::default() - }; - - for message in &self.messages { - request.messages.extend(message.to_request()); - } - - request.messages.push(LanguageModelRequestMessage { - role: Role::User, - content: vec![SUMMARIZE_THREAD_PROMPT.into()], - cache: false, - }); - self.pending_title_generation = Some(cx.spawn(async move |this, cx| { - let mut title = String::new(); - - let generate = async { - let mut messages = model.stream_completion(request, cx).await?; - while let Some(event) = messages.next().await { - let event = event?; - let text = match event { - LanguageModelCompletionEvent::Text(text) => text, - LanguageModelCompletionEvent::StatusUpdate( - CompletionRequestStatus::UsageUpdated { amount, limit }, - ) => { - this.update(cx, |thread, cx| { - thread.update_model_request_usage(amount, limit, cx); - })?; - continue; - } - _ => continue, - }; - - let mut lines = text.lines(); - title.extend(lines.next()); - - // Stop if the LLM generated multiple lines. - if lines.next().is_some() { - break; - } - } - anyhow::Ok(()) - }; - - if generate.await.context("failed to generate title").is_ok() { - _ = this.update(cx, |this, cx| this.set_title(title.into(), cx)); - } - _ = this.update(cx, |this, _| this.pending_title_generation = None); - })); - } - - pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { - self.pending_title_generation = None; - if Some(&title) != self.title.as_ref() { - self.title = Some(title); - cx.emit(TitleUpdated); - cx.notify(); - } - } - - fn last_user_message(&self) -> Option<&UserMessage> { - self.messages - .iter() - .rev() - .find_map(|message| match message { - Message::User(user_message) => Some(user_message), - Message::Agent(_) => None, - Message::Resume => None, - }) - } - - fn pending_message(&mut self) -> &mut AgentMessage { - self.pending_message.get_or_insert_default() - } - - fn flush_pending_message(&mut self, cx: &mut Context) { - let Some(mut message) = self.pending_message.take() else { - return; - }; - - if message.content.is_empty() { - return; - } - - for content in &message.content { - let AgentMessageContent::ToolUse(tool_use) = content else { - continue; - }; - - if !message.tool_results.contains_key(&tool_use.id) { - message.tool_results.insert( - tool_use.id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use.id.clone(), - tool_name: tool_use.name.clone(), - is_error: true, - content: LanguageModelToolResultContent::Text(TOOL_CANCELED_MESSAGE.into()), - output: None, - }, - ); - } - } - - self.messages.push(Message::Agent(message)); - self.updated_at = Utc::now(); - self.summary = None; - cx.notify() - } - - pub(crate) fn build_completion_request( - &self, - completion_intent: CompletionIntent, - cx: &App, - ) -> Result { - let model = self.model().context("No language model configured")?; - let tools = if let Some(turn) = self.running_turn.as_ref() { - turn.tools - .iter() - .filter_map(|(tool_name, tool)| { - log::trace!("Including tool: {}", tool_name); - Some(LanguageModelRequestTool { - name: tool_name.to_string(), - description: tool.description().to_string(), - input_schema: tool.input_schema(model.tool_input_format()).log_err()?, - }) - }) - .collect::>() - } else { - Vec::new() - }; - - log::debug!("Building completion request"); - log::debug!("Completion intent: {:?}", completion_intent); - log::debug!("Completion mode: {:?}", self.completion_mode); - - let messages = self.build_request_messages(cx); - log::debug!("Request will include {} messages", messages.len()); - log::debug!("Request includes {} tools", tools.len()); - - let request = LanguageModelRequest { - thread_id: Some(self.id.to_string()), - prompt_id: Some(self.prompt_id.to_string()), - intent: Some(completion_intent), - mode: Some(self.completion_mode.into()), - messages, - tools, - tool_choice: None, - stop: Vec::new(), - temperature: AgentSettings::temperature_for_model(model, cx), - thinking_allowed: true, - }; - - log::debug!("Completion request built successfully"); - Ok(request) - } - - fn enabled_tools( - &self, - profile: &AgentProfileSettings, - model: &Arc, - cx: &App, - ) -> BTreeMap> { - fn truncate(tool_name: &SharedString) -> SharedString { - if tool_name.len() > MAX_TOOL_NAME_LENGTH { - let mut truncated = tool_name.to_string(); - truncated.truncate(MAX_TOOL_NAME_LENGTH); - truncated.into() - } else { - tool_name.clone() - } - } - - let mut tools = self - .tools - .iter() - .filter_map(|(tool_name, tool)| { - if tool.supported_provider(&model.provider_id()) - && profile.is_tool_enabled(tool_name) - { - Some((truncate(tool_name), tool.clone())) - } else { - None - } - }) - .collect::>(); - - let mut context_server_tools = Vec::new(); - let mut seen_tools = tools.keys().cloned().collect::>(); - let mut duplicate_tool_names = HashSet::default(); - for (server_id, server_tools) in self.context_server_registry.read(cx).servers() { - for (tool_name, tool) in server_tools { - if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) { - let tool_name = truncate(tool_name); - if !seen_tools.insert(tool_name.clone()) { - duplicate_tool_names.insert(tool_name.clone()); - } - context_server_tools.push((server_id.clone(), tool_name, tool.clone())); - } - } - } - - // When there are duplicate tool names, disambiguate by prefixing them - // with the server ID. In the rare case there isn't enough space for the - // disambiguated tool name, keep only the last tool with this name. - for (server_id, tool_name, tool) in context_server_tools { - if duplicate_tool_names.contains(&tool_name) { - let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); - if available >= 2 { - let mut disambiguated = server_id.0.to_string(); - disambiguated.truncate(available - 1); - disambiguated.push('_'); - disambiguated.push_str(&tool_name); - tools.insert(disambiguated.into(), tool.clone()); - } else { - tools.insert(tool_name, tool.clone()); - } - } else { - tools.insert(tool_name, tool.clone()); - } - } - - tools - } - - fn tool(&self, name: &str) -> Option> { - self.running_turn.as_ref()?.tools.get(name).cloned() - } - - fn build_request_messages(&self, cx: &App) -> Vec { - log::trace!( - "Building request messages from {} thread messages", - self.messages.len() - ); - - let system_prompt = SystemPromptTemplate { - project: self.project_context.read(cx), - available_tools: self.tools.keys().cloned().collect(), - } - .render(&self.templates) - .context("failed to build system prompt") - .expect("Invalid template"); - let mut messages = vec![LanguageModelRequestMessage { - role: Role::System, - content: vec![system_prompt.into()], - cache: false, - }]; - for message in &self.messages { - messages.extend(message.to_request()); - } - - if let Some(last_message) = messages.last_mut() { - last_message.cache = true; - } - - if let Some(message) = self.pending_message.as_ref() { - messages.extend(message.to_request()); - } - - messages - } - - pub fn to_markdown(&self) -> String { - let mut markdown = String::new(); - for (ix, message) in self.messages.iter().enumerate() { - if ix > 0 { - markdown.push('\n'); - } - markdown.push_str(&message.to_markdown()); - } - - if let Some(message) = self.pending_message.as_ref() { - markdown.push('\n'); - markdown.push_str(&message.to_markdown()); - } - - markdown - } - - fn advance_prompt_id(&mut self) { - self.prompt_id = PromptId::new(); - } - - fn retry_strategy_for(error: &LanguageModelCompletionError) -> Option { - use LanguageModelCompletionError::*; - use http_client::StatusCode; - - // General strategy here: - // - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all. - // - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), retry up to 4 times with exponential backoff. - // - If it's an issue that *might* be fixed by retrying (e.g. internal server error), retry up to 3 times. - match error { - HttpResponseError { - status_code: StatusCode::TOO_MANY_REQUESTS, - .. - } => Some(RetryStrategy::ExponentialBackoff { - initial_delay: BASE_RETRY_DELAY, - max_attempts: MAX_RETRY_ATTEMPTS, - }), - ServerOverloaded { retry_after, .. } | RateLimitExceeded { retry_after, .. } => { - Some(RetryStrategy::Fixed { - delay: retry_after.unwrap_or(BASE_RETRY_DELAY), - max_attempts: MAX_RETRY_ATTEMPTS, - }) - } - UpstreamProviderError { - status, - retry_after, - .. - } => match *status { - StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => { - Some(RetryStrategy::Fixed { - delay: retry_after.unwrap_or(BASE_RETRY_DELAY), - max_attempts: MAX_RETRY_ATTEMPTS, - }) - } - StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed { - delay: retry_after.unwrap_or(BASE_RETRY_DELAY), - // Internal Server Error could be anything, retry up to 3 times. - max_attempts: 3, - }), - status => { - // There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"), - // but we frequently get them in practice. See https://http.dev/529 - if status.as_u16() == 529 { - Some(RetryStrategy::Fixed { - delay: retry_after.unwrap_or(BASE_RETRY_DELAY), - max_attempts: MAX_RETRY_ATTEMPTS, - }) - } else { - Some(RetryStrategy::Fixed { - delay: retry_after.unwrap_or(BASE_RETRY_DELAY), - max_attempts: 2, - }) - } - } - }, - ApiInternalServerError { .. } => Some(RetryStrategy::Fixed { - delay: BASE_RETRY_DELAY, - max_attempts: 3, - }), - ApiReadResponseError { .. } - | HttpSend { .. } - | DeserializeResponse { .. } - | BadRequestFormat { .. } => Some(RetryStrategy::Fixed { - delay: BASE_RETRY_DELAY, - max_attempts: 3, - }), - // Retrying these errors definitely shouldn't help. - HttpResponseError { - status_code: - StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED, - .. - } - | AuthenticationError { .. } - | PermissionError { .. } - | NoApiKey { .. } - | ApiEndpointNotFound { .. } - | PromptTooLarge { .. } => None, - // These errors might be transient, so retry them - SerializeRequest { .. } | BuildRequestBody { .. } => Some(RetryStrategy::Fixed { - delay: BASE_RETRY_DELAY, - max_attempts: 1, - }), - // Retry all other 4xx and 5xx errors once. - HttpResponseError { status_code, .. } - if status_code.is_client_error() || status_code.is_server_error() => - { - Some(RetryStrategy::Fixed { - delay: BASE_RETRY_DELAY, - max_attempts: 3, - }) - } - Other(err) - if err.is::() - || err.is::() => - { - // Retrying won't help for Payment Required or Model Request Limit errors (where - // the user must upgrade to usage-based billing to get more requests, or else wait - // for a significant amount of time for the request limit to reset). - None - } - // Conservatively assume that any other errors are non-retryable - HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed { - delay: BASE_RETRY_DELAY, - max_attempts: 2, - }), - } - } -} - -struct RunningTurn { - /// Holds the task that handles agent interaction until the end of the turn. - /// Survives across multiple requests as the model performs tool calls and - /// we run tools, report their results. - _task: Task<()>, - /// The current event stream for the running turn. Used to report a final - /// cancellation event if we cancel the turn. - event_stream: ThreadEventStream, - /// The tools that were enabled for this turn. - tools: BTreeMap>, -} - -impl RunningTurn { - fn cancel(self) { - log::debug!("Cancelling in progress turn"); - self.event_stream.send_canceled(); - } -} - -pub struct TokenUsageUpdated(pub Option); - -impl EventEmitter for Thread {} - -pub struct TitleUpdated; - -impl EventEmitter for Thread {} - -pub trait AgentTool -where - Self: 'static + Sized, -{ - type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema; - type Output: for<'de> Deserialize<'de> + Serialize + Into; - - fn name() -> &'static str; - - fn description(&self) -> SharedString { - let schema = schemars::schema_for!(Self::Input); - SharedString::new( - schema - .get("description") - .and_then(|description| description.as_str()) - .unwrap_or_default(), - ) - } - - fn kind() -> acp::ToolKind; - - /// The initial tool title to display. Can be updated during the tool run. - fn initial_title( - &self, - input: Result, - cx: &mut App, - ) -> SharedString; - - /// Returns the JSON schema that describes the tool's input. - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Schema { - crate::tool_schema::root_schema_for::(format) - } - - /// Some tools rely on a provider for the underlying billing or other reasons. - /// Allow the tool to check if they are compatible, or should be filtered out. - fn supported_provider(&self, _provider: &LanguageModelProviderId) -> bool { - true - } - - /// Runs the tool with the provided input. - fn run( - self: Arc, - input: Self::Input, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task>; - - /// Emits events for a previous execution of the tool. - fn replay( - &self, - _input: Self::Input, - _output: Self::Output, - _event_stream: ToolCallEventStream, - _cx: &mut App, - ) -> Result<()> { - Ok(()) - } - - fn erase(self) -> Arc { - Arc::new(Erased(Arc::new(self))) - } -} - -pub struct Erased(T); - -pub struct AgentToolOutput { - pub llm_output: LanguageModelToolResultContent, - pub raw_output: serde_json::Value, -} - -pub trait AnyAgentTool { - fn name(&self) -> SharedString; - fn description(&self) -> SharedString; - fn kind(&self) -> acp::ToolKind; - fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString; - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; - fn supported_provider(&self, _provider: &LanguageModelProviderId) -> bool { - true - } - fn run( - self: Arc, - input: serde_json::Value, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task>; - fn replay( - &self, - input: serde_json::Value, - output: serde_json::Value, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Result<()>; -} - -impl AnyAgentTool for Erased> -where - T: AgentTool, -{ - fn name(&self) -> SharedString { - T::name().into() - } - - fn description(&self) -> SharedString { - self.0.description() - } - - fn kind(&self) -> agent_client_protocol::ToolKind { - T::kind() - } - - fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString { - let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input); - self.0.initial_title(parsed_input, _cx) - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - let mut json = serde_json::to_value(self.0.input_schema(format))?; - adapt_schema_to_format(&mut json, format)?; - Ok(json) - } - - fn supported_provider(&self, provider: &LanguageModelProviderId) -> bool { - self.0.supported_provider(provider) - } - - fn run( - self: Arc, - input: serde_json::Value, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - cx.spawn(async move |cx| { - let input = serde_json::from_value(input)?; - let output = cx - .update(|cx| self.0.clone().run(input, event_stream, cx))? - .await?; - let raw_output = serde_json::to_value(&output)?; - Ok(AgentToolOutput { - llm_output: output.into(), - raw_output, - }) - }) - } - - fn replay( - &self, - input: serde_json::Value, - output: serde_json::Value, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Result<()> { - let input = serde_json::from_value(input)?; - let output = serde_json::from_value(output)?; - self.0.replay(input, output, event_stream, cx) - } -} - -#[derive(Clone)] -struct ThreadEventStream(mpsc::UnboundedSender>); - -impl ThreadEventStream { - fn send_user_message(&self, message: &UserMessage) { - self.0 - .unbounded_send(Ok(ThreadEvent::UserMessage(message.clone()))) - .ok(); - } - - fn send_text(&self, text: &str) { - self.0 - .unbounded_send(Ok(ThreadEvent::AgentText(text.to_string()))) - .ok(); - } - - fn send_thinking(&self, text: &str) { - self.0 - .unbounded_send(Ok(ThreadEvent::AgentThinking(text.to_string()))) - .ok(); - } - - fn send_tool_call( - &self, - id: &LanguageModelToolUseId, - title: SharedString, - kind: acp::ToolKind, - input: serde_json::Value, - ) { - self.0 - .unbounded_send(Ok(ThreadEvent::ToolCall(Self::initial_tool_call( - id, - title.to_string(), - kind, - input, - )))) - .ok(); - } - - fn initial_tool_call( - id: &LanguageModelToolUseId, - title: String, - kind: acp::ToolKind, - input: serde_json::Value, - ) -> acp::ToolCall { - acp::ToolCall { - meta: None, - id: acp::ToolCallId(id.to_string().into()), - title, - kind, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(input), - raw_output: None, - } - } - - fn update_tool_call_fields( - &self, - tool_use_id: &LanguageModelToolUseId, - fields: acp::ToolCallUpdateFields, - ) { - self.0 - .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( - acp::ToolCallUpdate { - meta: None, - id: acp::ToolCallId(tool_use_id.to_string().into()), - fields, - } - .into(), - ))) - .ok(); - } - - fn send_retry(&self, status: acp_thread::RetryStatus) { - self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); - } - - fn send_stop(&self, reason: acp::StopReason) { - self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok(); - } - - fn send_canceled(&self) { - self.0 - .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled))) - .ok(); - } - - fn send_error(&self, error: impl Into) { - self.0.unbounded_send(Err(error.into())).ok(); - } -} - -#[derive(Clone)] -pub struct ToolCallEventStream { - tool_use_id: LanguageModelToolUseId, - stream: ThreadEventStream, - fs: Option>, -} - -impl ToolCallEventStream { - #[cfg(test)] - pub fn test() -> (Self, ToolCallEventStreamReceiver) { - let (events_tx, events_rx) = mpsc::unbounded::>(); - - let stream = ToolCallEventStream::new("test_id".into(), ThreadEventStream(events_tx), None); - - (stream, ToolCallEventStreamReceiver(events_rx)) - } - - fn new( - tool_use_id: LanguageModelToolUseId, - stream: ThreadEventStream, - fs: Option>, - ) -> Self { - Self { - tool_use_id, - stream, - fs, - } - } - - pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) { - self.stream - .update_tool_call_fields(&self.tool_use_id, fields); - } - - pub fn update_diff(&self, diff: Entity) { - self.stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( - acp_thread::ToolCallUpdateDiff { - id: acp::ToolCallId(self.tool_use_id.to_string().into()), - diff, - } - .into(), - ))) - .ok(); - } - - pub fn authorize(&self, title: impl Into, cx: &mut App) -> Task> { - if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { - return Task::ready(Ok(())); - } - - let (response_tx, response_rx) = oneshot::channel(); - self.stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate { - meta: None, - id: acp::ToolCallId(self.tool_use_id.to_string().into()), - fields: acp::ToolCallUpdateFields { - title: Some(title.into()), - ..Default::default() - }, - }, - options: vec![ - acp::PermissionOption { - id: acp::PermissionOptionId("always_allow".into()), - name: "Always Allow".into(), - kind: acp::PermissionOptionKind::AllowAlways, - meta: None, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("allow".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("deny".into()), - name: "Deny".into(), - kind: acp::PermissionOptionKind::RejectOnce, - meta: None, - }, - ], - response: response_tx, - }, - ))) - .ok(); - let fs = self.fs.clone(); - cx.spawn(async move |cx| match response_rx.await?.0.as_ref() { - "always_allow" => { - if let Some(fs) = fs.clone() { - cx.update(|cx| { - update_settings_file(fs, cx, |settings, _| { - settings - .agent - .get_or_insert_default() - .set_always_allow_tool_actions(true); - }); - })?; - } - - Ok(()) - } - "allow" => Ok(()), - _ => Err(anyhow!("Permission to run tool denied by user")), - }) - } -} - -#[cfg(test)] -pub struct ToolCallEventStreamReceiver(mpsc::UnboundedReceiver>); - -#[cfg(test)] -impl ToolCallEventStreamReceiver { - pub async fn expect_authorization(&mut self) -> ToolCallAuthorization { - let event = self.0.next().await; - if let Some(Ok(ThreadEvent::ToolCallAuthorization(auth))) = event { - auth - } else { - panic!("Expected ToolCallAuthorization but got: {:?}", event); - } - } - - pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields { - let event = self.0.next().await; - if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( - update, - )))) = event - { - update.fields - } else { - panic!("Expected update fields but got: {:?}", event); - } - } - - pub async fn expect_diff(&mut self) -> Entity { - let event = self.0.next().await; - if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff( - update, - )))) = event - { - update.diff - } else { - panic!("Expected diff but got: {:?}", event); - } - } - - pub async fn expect_terminal(&mut self) -> Entity { - let event = self.0.next().await; - if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal( - update, - )))) = event - { - update.terminal - } else { - panic!("Expected terminal but got: {:?}", event); - } - } -} - -#[cfg(test)] -impl std::ops::Deref for ToolCallEventStreamReceiver { - type Target = mpsc::UnboundedReceiver>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[cfg(test)] -impl std::ops::DerefMut for ToolCallEventStreamReceiver { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl From<&str> for UserMessageContent { - fn from(text: &str) -> Self { - Self::Text(text.into()) - } -} - -impl From for UserMessageContent { - fn from(value: acp::ContentBlock) -> Self { - match value { - acp::ContentBlock::Text(text_content) => Self::Text(text_content.text), - acp::ContentBlock::Image(image_content) => Self::Image(convert_image(image_content)), - acp::ContentBlock::Audio(_) => { - // TODO - Self::Text("[audio]".to_string()) - } - acp::ContentBlock::ResourceLink(resource_link) => { - match MentionUri::parse(&resource_link.uri) { - Ok(uri) => Self::Mention { - uri, - content: String::new(), - }, - Err(err) => { - log::error!("Failed to parse mention link: {}", err); - Self::Text(format!("[{}]({})", resource_link.name, resource_link.uri)) - } - } - } - acp::ContentBlock::Resource(resource) => match resource.resource { - acp::EmbeddedResourceResource::TextResourceContents(resource) => { - match MentionUri::parse(&resource.uri) { - Ok(uri) => Self::Mention { - uri, - content: resource.text, - }, - Err(err) => { - log::error!("Failed to parse mention link: {}", err); - Self::Text( - MarkdownCodeBlock { - tag: &resource.uri, - text: &resource.text, - } - .to_string(), - ) - } - } - } - acp::EmbeddedResourceResource::BlobResourceContents(_) => { - // TODO - Self::Text("[blob]".to_string()) - } - }, - } - } -} - -impl From for acp::ContentBlock { - fn from(content: UserMessageContent) -> Self { - match content { - UserMessageContent::Text(text) => acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - UserMessageContent::Image(image) => acp::ContentBlock::Image(acp::ImageContent { - data: image.source.to_string(), - mime_type: "image/png".to_string(), - meta: None, - annotations: None, - uri: None, - }), - UserMessageContent::Mention { uri, content } => { - acp::ContentBlock::Resource(acp::EmbeddedResource { - meta: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - meta: None, - mime_type: None, - text: content, - uri: uri.to_uri().to_string(), - }, - ), - annotations: None, - }) - } - } - } -} - -fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { - LanguageModelImage { - source: image_content.data.into(), - // TODO: make this optional? - size: gpui::Size::new(0.into(), 0.into()), - } -} diff --git a/crates/agent2/src/tool_schema.rs b/crates/agent2/src/tool_schema.rs deleted file mode 100644 index f608336b41..0000000000 --- a/crates/agent2/src/tool_schema.rs +++ /dev/null @@ -1,43 +0,0 @@ -use language_model::LanguageModelToolSchemaFormat; -use schemars::{ - JsonSchema, Schema, - generate::SchemaSettings, - transform::{Transform, transform_subschemas}, -}; - -pub(crate) fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { - let mut generator = match format { - LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), - LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() - .with(|settings| { - settings.meta_schema = None; - settings.inline_subschemas = true; - }) - .with_transform(ToJsonSchemaSubsetTransform) - .into_generator(), - }; - generator.root_schema_for::() -} - -#[derive(Debug, Clone)] -struct ToJsonSchemaSubsetTransform; - -impl Transform for ToJsonSchemaSubsetTransform { - fn transform(&mut self, schema: &mut Schema) { - // Ensure that the type field is not an array, this happens when we use - // Option, the type will be [T, "null"]. - if let Some(type_field) = schema.get_mut("type") - && let Some(types) = type_field.as_array() - && let Some(first_type) = types.first() - { - *type_field = first_type.clone(); - } - - // oneOf is not supported, use anyOf instead - if let Some(one_of) = schema.remove("oneOf") { - schema.insert("anyOf".to_string(), one_of); - } - - transform_subschemas(self, schema); - } -} diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs deleted file mode 100644 index bcca7eecd1..0000000000 --- a/crates/agent2/src/tools.rs +++ /dev/null @@ -1,60 +0,0 @@ -mod context_server_registry; -mod copy_path_tool; -mod create_directory_tool; -mod delete_path_tool; -mod diagnostics_tool; -mod edit_file_tool; -mod fetch_tool; -mod find_path_tool; -mod grep_tool; -mod list_directory_tool; -mod move_path_tool; -mod now_tool; -mod open_tool; -mod read_file_tool; -mod terminal_tool; -mod thinking_tool; -mod web_search_tool; - -/// A list of all built in tool names, for use in deduplicating MCP tool names -pub fn default_tool_names() -> impl Iterator { - [ - CopyPathTool::name(), - CreateDirectoryTool::name(), - DeletePathTool::name(), - DiagnosticsTool::name(), - EditFileTool::name(), - FetchTool::name(), - FindPathTool::name(), - GrepTool::name(), - ListDirectoryTool::name(), - MovePathTool::name(), - NowTool::name(), - OpenTool::name(), - ReadFileTool::name(), - TerminalTool::name(), - ThinkingTool::name(), - WebSearchTool::name(), - ] - .into_iter() -} - -pub use context_server_registry::*; -pub use copy_path_tool::*; -pub use create_directory_tool::*; -pub use delete_path_tool::*; -pub use diagnostics_tool::*; -pub use edit_file_tool::*; -pub use fetch_tool::*; -pub use find_path_tool::*; -pub use grep_tool::*; -pub use list_directory_tool::*; -pub use move_path_tool::*; -pub use now_tool::*; -pub use open_tool::*; -pub use read_file_tool::*; -pub use terminal_tool::*; -pub use thinking_tool::*; -pub use web_search_tool::*; - -use crate::AgentTool; diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index bdf1b72fdc..9a04fb763d 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -21,7 +21,6 @@ acp_tools.workspace = true acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true -agent_settings.workspace = true anyhow.workspace = true async-trait.workspace = true client.workspace = true @@ -33,11 +32,11 @@ gpui.workspace = true gpui_tokio = { workspace = true, optional = true } http_client.workspace = true indoc.workspace = true -language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true project.workspace = true +release_channel.workspace = true reqwest_client = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true @@ -51,7 +50,6 @@ terminal.workspace = true uuid.workspace = true util.workspace = true watch.workspace = true -workspace-hack.workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 57ddfcf9dc..e99855fe8a 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,7 +9,8 @@ use futures::io::BufReader; use project::Project; use project::agent_server_store::AgentServerCommand; use serde::Deserialize; -use task::Shell; +use settings::Settings as _; +use task::ShellBuilder; use util::ResultExt as _; use std::path::PathBuf; @@ -22,7 +23,7 @@ use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntit use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; -use terminal::terminal_settings::{AlternateScroll, CursorShape}; +use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings}; #[derive(Debug, Error)] #[error("Unsupported version")] @@ -30,16 +31,18 @@ pub struct UnsupportedVersion; pub struct AcpConnection { server_name: SharedString, + telemetry_id: SharedString, connection: Rc, sessions: Rc>>, auth_methods: Vec, agent_capabilities: acp::AgentCapabilities, default_mode: Option, + default_model: Option, root_dir: PathBuf, // NB: Don't move this into the wait_task, since we need to ensure the process is // killed on drop (setting kill_on_drop on the command seems to not always work). child: smol::process::Child, - _io_task: Task>, + _io_task: Task>, _wait_task: Task>, _stderr_task: Task>, } @@ -56,6 +59,7 @@ pub async fn connect( command: AgentServerCommand, root_dir: &Path, default_mode: Option, + default_model: Option, is_remote: bool, cx: &mut AsyncApp, ) -> Result> { @@ -64,6 +68,7 @@ pub async fn connect( command.clone(), root_dir, default_mode, + default_model, is_remote, cx, ) @@ -71,7 +76,7 @@ pub async fn connect( Ok(Rc::new(conn) as _) } -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; +const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1; impl AcpConnection { pub async fn stdio( @@ -79,12 +84,15 @@ impl AcpConnection { command: AgentServerCommand, root_dir: &Path, default_mode: Option, + default_model: Option, is_remote: bool, cx: &mut AsyncApp, ) -> Result { - let mut child = util::command::new_smol_command(&command.path); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let mut child = + builder.build_command(Some(command.path.display().to_string()), &command.args); child - .args(command.args.iter().map(|arg| arg.as_str())) .envs(command.env.iter().flatten()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -97,7 +105,7 @@ impl AcpConnection { let stdout = child.stdout.take().context("Failed to take stdout")?; let stdin = child.stdin.take().context("Failed to take stdin")?; let stderr = child.stderr.take().context("Failed to take stderr")?; - log::info!( + log::debug!( "Spawning external agent server: {:?}, {:?}", command.path, command.args @@ -106,6 +114,14 @@ impl AcpConnection { let sessions = Rc::new(RefCell::new(HashMap::default())); + let (release_channel, version) = cx.update(|cx| { + ( + release_channel::ReleaseChannel::try_global(cx) + .map(|release_channel| release_channel.display_name()), + release_channel::AppVersion::global(cx).to_string(), + ) + })?; + let client = ClientDelegate { sessions: sessions.clone(), cx: cx.clone(), @@ -125,7 +141,7 @@ impl AcpConnection { while let Ok(n) = stderr.read_line(&mut line).await && n > 0 { - log::warn!("agent stderr: {}", &line); + log::warn!("agent stderr: {}", line.trim()); line.clear(); } Ok(()) @@ -159,33 +175,48 @@ impl AcpConnection { })?; let response = connection - .initialize(acp::InitializeRequest { - protocol_version: acp::VERSION, - client_capabilities: acp::ClientCapabilities { - fs: acp::FileSystemCapability { - read_text_file: true, - write_text_file: true, - meta: None, - }, - terminal: true, - meta: None, - }, - meta: None, - }) + .initialize( + acp::InitializeRequest::new(acp::ProtocolVersion::V1) + .client_capabilities( + acp::ClientCapabilities::new() + .fs(acp::FileSystemCapability::new() + .read_text_file(true) + .write_text_file(true)) + .terminal(true) + // Experimental: Allow for rendering terminal output from the agents + .meta(acp::Meta::from_iter([ + ("terminal_output".into(), true.into()), + ("terminal-auth".into(), true.into()), + ])), + ) + .client_info( + acp::Implementation::new("zed", version) + .title(release_channel.map(ToOwned::to_owned)), + ), + ) .await?; if response.protocol_version < MINIMUM_SUPPORTED_VERSION { return Err(UnsupportedVersion.into()); } + let telemetry_id = response + .agent_info + // Use the one the agent provides if we have one + .map(|info| info.name.into()) + // Otherwise, just use the name + .unwrap_or_else(|| server_name.clone()); + Ok(Self { auth_methods: response.auth_methods, root_dir: root_dir.to_owned(), connection, server_name, + telemetry_id, sessions, agent_capabilities: response.agent_capabilities, default_mode, + default_model, _io_task: io_task, _wait_task: wait_task, _stderr_task: stderr_task, @@ -210,6 +241,10 @@ impl Drop for AcpConnection { } impl AgentConnection for AcpConnection { + fn telemetry_id(&self) -> SharedString { + self.telemetry_id.clone() + } + fn new_thread( self: Rc, project: Entity, @@ -220,6 +255,7 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let sessions = self.sessions.clone(); let default_mode = self.default_mode.clone(); + let default_model = self.default_model.clone(); let cwd = cwd.to_path_buf(); let context_server_store = project.read(cx).context_server_store().read(cx); let mcp_servers = if project.read(cx).is_local() { @@ -228,23 +264,37 @@ impl AgentConnection for AcpConnection { .iter() .filter_map(|id| { let configuration = context_server_store.configuration_for_server(id)?; - let command = configuration.command(); - Some(acp::McpServer::Stdio { - name: id.0.to_string(), - command: command.path.clone(), - args: command.args.clone(), - env: if let Some(env) = command.env.as_ref() { - env.iter() - .map(|(name, value)| acp::EnvVariable { - name: name.clone(), - value: value.clone(), - meta: None, - }) - .collect() - } else { - vec![] - }, - }) + match &*configuration { + project::context_server_store::ContextServerConfiguration::Custom { + command, + .. + } + | project::context_server_store::ContextServerConfiguration::Extension { + command, + .. + } => Some(acp::McpServer::Stdio( + acp::McpServerStdio::new(id.0.to_string(), &command.path) + .args(command.args.clone()) + .env(if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable::new(name, value)) + .collect() + } else { + vec![] + }), + )), + project::context_server_store::ContextServerConfiguration::Http { + url, + headers, + } => Some(acp::McpServer::Http( + acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers( + headers + .iter() + .map(|(name, value)| acp::HttpHeader::new(name, value)) + .collect(), + ), + )), + } }) .collect() } else { @@ -256,13 +306,13 @@ impl AgentConnection for AcpConnection { cx.spawn(async move |cx| { let response = conn - .new_session(acp::NewSessionRequest { mcp_servers, cwd, meta: None }) + .new_session(acp::NewSessionRequest::new(cwd).mcp_servers(mcp_servers)) .await .map_err(|err| { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + if err.code == acp::ErrorCode::AuthRequired { let mut error = AuthRequired::new(); - if err.message != acp::ErrorCode::AUTH_REQUIRED.message { + if err.message != acp::ErrorCode::AuthRequired.to_string() { error = error.with_description(err.message); } @@ -287,12 +337,9 @@ impl AgentConnection for AcpConnection { let default_mode = default_mode.clone(); let session_id = response.session_id.clone(); let modes = modes.clone(); + let conn = conn.clone(); async move |_| { - let result = conn.set_session_mode(acp::SetSessionModeRequest { - session_id, - mode_id: default_mode, - meta: None, - }) + let result = conn.set_session_mode(acp::SetSessionModeRequest::new(session_id, default_mode)) .await.log_err(); if result.is_none() { @@ -321,6 +368,49 @@ impl AgentConnection for AcpConnection { } } + if let Some(default_model) = default_model { + if let Some(models) = models.as_ref() { + let mut models_ref = models.borrow_mut(); + let has_model = models_ref.available_models.iter().any(|model| model.model_id == default_model); + + if has_model { + let initial_model_id = models_ref.current_model_id.clone(); + + cx.spawn({ + let default_model = default_model.clone(); + let session_id = response.session_id.clone(); + let models = models.clone(); + let conn = conn.clone(); + async move |_| { + let result = conn.set_session_model(acp::SetSessionModelRequest::new(session_id, default_model)) + .await.log_err(); + + if result.is_none() { + models.borrow_mut().current_model_id = initial_model_id; + } + } + }).detach(); + + models_ref.current_model_id = default_model; + } else { + let available_models = models_ref + .available_models + .iter() + .map(|model| format!("- `{}`: {}", model.model_id, model.name)) + .collect::>() + .join("\n"); + + log::warn!( + "`{default_model}` is not a valid {name} model. Available options:\n{available_models}", + ); + } + } else { + log::warn!( + "`{name}` does not support model selection, but `default_model` was set in settings.", + ); + } + } + let session_id = response.session_id; let action_log = cx.new(|_| ActionLog::new(project.clone()))?; let thread = cx.new(|cx| { @@ -356,12 +446,8 @@ impl AgentConnection for AcpConnection { fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { let conn = self.connection.clone(); cx.foreground_executor().spawn(async move { - conn.authenticate(acp::AuthenticateRequest { - method_id: method_id.clone(), - meta: None, - }) - .await?; - + conn.authenticate(acp::AuthenticateRequest::new(method_id)) + .await?; Ok(()) }) } @@ -388,11 +474,11 @@ impl AgentConnection for AcpConnection { match result { Ok(response) => Ok(response), Err(err) => { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + if err.code == acp::ErrorCode::AuthRequired { return Err(anyhow!(acp::Error::auth_required())); } - if err.code != ErrorCode::INTERNAL_ERROR.code { + if err.code != ErrorCode::InternalError { anyhow::bail!(err) } @@ -415,10 +501,7 @@ impl AgentConnection for AcpConnection { && (details.contains("This operation was aborted") || details.contains("The user aborted a request")) { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Cancelled, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)) } else { Err(anyhow!(details)) } @@ -435,10 +518,7 @@ impl AgentConnection for AcpConnection { session.suppress_abort_err = true; } let conn = self.connection.clone(); - let params = acp::CancelNotification { - session_id: session_id.clone(), - meta: None, - }; + let params = acp::CancelNotification::new(session_id.clone()); cx.foreground_executor() .spawn(async move { conn.cancel(params).await }) .detach(); @@ -519,11 +599,7 @@ impl acp_thread::AgentSessionModes for AcpSessionModes { let state = self.state.clone(); cx.foreground_executor().spawn(async move { let result = connection - .set_session_mode(acp::SetSessionModeRequest { - session_id, - mode_id, - meta: None, - }) + .set_session_mode(acp::SetSessionModeRequest::new(session_id, mode_id)) .await; if result.is_err() { @@ -582,11 +658,7 @@ impl acp_thread::AgentModelSelector for AcpModelSelector { let state = self.state.clone(); cx.foreground_executor().spawn(async move { let result = connection - .set_session_model(acp::SetSessionModelRequest { - session_id, - model_id, - meta: None, - }) + .set_session_model(acp::SetSessionModelRequest::new(session_id, model_id)) .await; if result.is_err() { @@ -648,10 +720,7 @@ impl acp::Client for ClientDelegate { let outcome = task.await; - Ok(acp::RequestPermissionResponse { - outcome, - meta: None, - }) + Ok(acp::RequestPermissionResponse::new(outcome)) } async fn write_text_file( @@ -683,10 +752,7 @@ impl acp::Client for ClientDelegate { let content = task.await?; - Ok(acp::ReadTextFileResponse { - content, - meta: None, - }) + Ok(acp::ReadTextFileResponse::new(content)) } async fn session_notification( @@ -698,7 +764,11 @@ impl acp::Client for ClientDelegate { .get(¬ification.session_id) .context("Failed to get session")?; - if let acp::SessionUpdate::CurrentModeUpdate { current_mode_id } = ¬ification.update { + if let acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate { + current_mode_id, + .. + }) = ¬ification.update + { if let Some(session_modes) = &session.session_modes { session_modes.borrow_mut().current_mode_id = current_mode_id.clone(); } else { @@ -717,7 +787,7 @@ impl acp::Client for ClientDelegate { if let Some(terminal_info) = meta.get("terminal_info") { if let Some(id_str) = terminal_info.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); + let terminal_id = acp::TerminalId::new(id_str); let cwd = terminal_info .get("cwd") .and_then(|v| v.as_str().map(PathBuf::from)); @@ -733,7 +803,7 @@ impl acp::Client for ClientDelegate { let lower = cx.new(|cx| builder.subscribe(cx)); thread.on_terminal_provider_event( TerminalProviderEvent::Created { - terminal_id: terminal_id.clone(), + terminal_id, label: tc.title.clone(), cwd, output_byte_limit: None, @@ -758,15 +828,12 @@ impl acp::Client for ClientDelegate { if let Some(meta) = &tcu.meta { if let Some(term_out) = meta.get("terminal_output") { if let Some(id_str) = term_out.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); + let terminal_id = acp::TerminalId::new(id_str); if let Some(s) = term_out.get("data").and_then(|v| v.as_str()) { let data = s.as_bytes().to_vec(); let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { thread.on_terminal_provider_event( - TerminalProviderEvent::Output { - terminal_id: terminal_id.clone(), - data, - }, + TerminalProviderEvent::Output { terminal_id, data }, cx, ); }); @@ -777,21 +844,24 @@ impl acp::Client for ClientDelegate { // terminal_exit if let Some(term_exit) = meta.get("terminal_exit") { if let Some(id_str) = term_exit.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); - let status = acp::TerminalExitStatus { - exit_code: term_exit - .get("exit_code") - .and_then(|v| v.as_u64()) - .map(|i| i as u32), - signal: term_exit - .get("signal") - .and_then(|v| v.as_str().map(|s| s.to_string())), - meta: None, - }; + let terminal_id = acp::TerminalId::new(id_str); + let status = acp::TerminalExitStatus::new() + .exit_code( + term_exit + .get("exit_code") + .and_then(|v| v.as_u64()) + .map(|i| i as u32), + ) + .signal( + term_exit + .get("signal") + .and_then(|v| v.as_str().map(|s| s.to_string())), + ); + let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { - terminal_id: terminal_id.clone(), + terminal_id, status, }, cx, @@ -812,52 +882,23 @@ impl acp::Client for ClientDelegate { let thread = self.session_thread(&args.session_id)?; let project = thread.read_with(&self.cx, |thread, _cx| thread.project().clone())?; - let mut env = if let Some(dir) = &args.cwd { - project - .update(&mut self.cx.clone(), |project, cx| { - project.directory_environment(&task::Shell::System, dir.clone().into(), cx) - })? - .await - .unwrap_or_default() - } else { - Default::default() - }; - for var in args.env { - env.insert(var.name, var.value); - } - - // Use remote shell or default system shell, as appropriate - let shell = project - .update(&mut self.cx.clone(), |project, cx| { - project - .remote_client() - .and_then(|r| r.read(cx).default_system_shell()) - .map(Shell::Program) - })? - .unwrap_or(task::Shell::System); - let (task_command, task_args) = task::ShellBuilder::new(&shell) - .redirect_stdin_to_dev_null() - .build(Some(args.command.clone()), &args.args); - - let terminal_entity = project - .update(&mut self.cx.clone(), |project, cx| { - project.create_terminal_task( - task::SpawnInTerminal { - command: Some(task_command), - args: task_args, - cwd: args.cwd.clone(), - env, - ..Default::default() - }, - cx, - ) - })? - .await?; + let terminal_entity = acp_thread::create_terminal_entity( + args.command.clone(), + &args.args, + args.env + .into_iter() + .map(|env| (env.name, env.value)) + .collect(), + args.cwd.clone(), + &project, + &mut self.cx.clone(), + ) + .await?; // Register with renderer let terminal_entity = thread.update(&mut self.cx.clone(), |thread, cx| { thread.register_terminal_created( - acp::TerminalId(uuid::Uuid::new_v4().to_string().into()), + acp::TerminalId::new(uuid::Uuid::new_v4().to_string()), format!("{} {}", args.command, args.args.join(" ")), args.cwd.clone(), args.output_byte_limit, @@ -867,10 +908,7 @@ impl acp::Client for ClientDelegate { })?; let terminal_id = terminal_entity.read_with(&self.cx, |terminal, _| terminal.id().clone())?; - Ok(acp::CreateTerminalResponse { - terminal_id, - meta: None, - }) + Ok(acp::CreateTerminalResponse::new(terminal_id)) } async fn kill_terminal_command( @@ -931,10 +969,7 @@ impl acp::Client for ClientDelegate { })?? .await; - Ok(acp::WaitForTerminalExitResponse { - exit_status, - meta: None, - }) + Ok(acp::WaitForTerminalExitResponse::new(exit_status)) } } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index b44c2123fb..46e8508e44 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -56,7 +56,6 @@ impl AgentServerDelegate { pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; fn name(&self) -> SharedString; - fn telemetry_id(&self) -> &'static str; fn default_mode(&self, _cx: &mut App) -> Option { None } @@ -68,6 +67,18 @@ pub trait AgentServer: Send { ) { } + fn default_model(&self, _cx: &mut App) -> Option { + None + } + + fn set_default_model( + &self, + _model_id: Option, + _fs: Arc, + _cx: &mut App, + ) { + } + fn connect( &self, root_dir: Option<&Path>, diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index b84a386679..e67ddd5c06 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -22,10 +22,6 @@ pub struct AgentServerLoginCommand { } impl AgentServer for ClaudeCode { - fn telemetry_id(&self) -> &'static str { - "claude-code" - } - fn name(&self) -> SharedString { "Claude Code".into() } @@ -41,7 +37,7 @@ impl AgentServer for ClaudeCode { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -55,6 +51,27 @@ impl AgentServer for ClaudeCode { }); } + fn default_model(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).claude.clone() + }); + + settings + .as_ref() + .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) + } + + fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { + update_settings_file(fs, cx, |settings, _| { + settings + .agent_servers + .get_or_insert_default() + .claude + .get_or_insert_default() + .default_model = model_id.map(|m| m.to_string()) + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -67,6 +84,7 @@ impl AgentServer for ClaudeCode { let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); + let default_model = self.default_model(cx); cx.spawn(async move |cx| { let (command, root_dir, login) = store @@ -88,6 +106,7 @@ impl AgentServer for ClaudeCode { command, root_dir.as_ref(), default_mode, + default_model, is_remote, cx, ) diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index 0a19cfd032..c2b308e48b 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -1,11 +1,16 @@ use std::rc::Rc; +use std::sync::Arc; use std::{any::Any, path::Path}; -use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; +use agent_client_protocol as acp; use anyhow::{Context as _, Result}; -use gpui::{App, SharedString, Task}; -use project::agent_server_store::CODEX_NAME; +use fs::Fs; +use gpui::{App, AppContext as _, SharedString, Task}; +use project::agent_server_store::{AllAgentServersSettings, CODEX_NAME}; +use settings::{SettingsStore, update_settings_file}; + +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; #[derive(Clone)] pub struct Codex; @@ -18,10 +23,6 @@ pub(crate) mod tests { } impl AgentServer for Codex { - fn telemetry_id(&self) -> &'static str { - "codex" - } - fn name(&self) -> SharedString { "Codex".into() } @@ -30,6 +31,48 @@ impl AgentServer for Codex { ui::IconName::AiOpenAi } + fn default_mode(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).codex.clone() + }); + + settings + .as_ref() + .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) + } + + fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { + update_settings_file(fs, cx, |settings, _| { + settings + .agent_servers + .get_or_insert_default() + .codex + .get_or_insert_default() + .default_mode = mode_id.map(|m| m.to_string()) + }); + } + + fn default_model(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).codex.clone() + }); + + settings + .as_ref() + .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) + } + + fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { + update_settings_file(fs, cx, |settings, _| { + settings + .agent_servers + .get_or_insert_default() + .codex + .get_or_insert_default() + .default_model = model_id.map(|m| m.to_string()) + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -42,6 +85,7 @@ impl AgentServer for Codex { let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); + let default_model = self.default_model(cx); cx.spawn(async move |cx| { let (command, root_dir, login) = store @@ -53,8 +97,6 @@ impl AgentServer for Codex { root_dir.as_deref(), extra_env, delegate.status_tx, - // For now, report that there are no updates. - // (A future PR will use the GitHub Releases API to fetch them.) delegate.new_version_available, &mut cx.to_async(), )) @@ -66,6 +108,7 @@ impl AgentServer for Codex { command, root_dir.as_ref(), default_mode, + default_model, is_remote, cx, ) diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 406a189651..6b981ce8b8 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,4 +1,4 @@ -use crate::{AgentServerDelegate, load_proxy_env}; +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; @@ -20,11 +20,7 @@ impl CustomAgentServer { } } -impl crate::AgentServer for CustomAgentServer { - fn telemetry_id(&self) -> &'static str { - "custom" - } - +impl AgentServer for CustomAgentServer { fn name(&self) -> SharedString { self.name.clone() } @@ -44,19 +40,64 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { let name = self.name(); update_settings_file(fs, cx, move |settings, _| { - settings + let settings = settings .agent_servers .get_or_insert_default() .custom - .get_mut(&name) - .unwrap() - .default_mode = mode_id.map(|m| m.to_string()) + .entry(name.clone()) + .or_insert_with(|| settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + }); + + match settings { + settings::CustomAgentServerSettings::Custom { default_mode, .. } + | settings::CustomAgentServerSettings::Extension { default_mode, .. } => { + *default_mode = mode_id.map(|m| m.to_string()); + } + } + }); + } + + fn default_model(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .custom + .get(&self.name()) + .cloned() + }); + + settings + .as_ref() + .and_then(|s| s.default_model().map(acp::ModelId::new)) + } + + fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { + let name = self.name(); + update_settings_file(fs, cx, move |settings, _| { + let settings = settings + .agent_servers + .get_or_insert_default() + .custom + .entry(name.clone()) + .or_insert_with(|| settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + }); + + match settings { + settings::CustomAgentServerSettings::Custom { default_model, .. } + | settings::CustomAgentServerSettings::Extension { default_model, .. } => { + *default_model = model_id.map(|m| m.to_string()); + } + } }); } @@ -70,9 +111,9 @@ impl crate::AgentServer for CustomAgentServer { let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let default_mode = self.default_mode(cx); + let default_model = self.default_model(cx); let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); - cx.spawn(async move |cx| { let (command, root_dir, login) = store .update(cx, |store, cx| { @@ -95,6 +136,7 @@ impl crate::AgentServer for CustomAgentServer { command, root_dir.as_ref(), default_mode, + default_model, is_remote, cx, ) diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 60480caa54..9db7535b5e 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -6,7 +6,9 @@ use gpui::{AppContext, Entity, TestAppContext}; use indoc::indoc; #[cfg(test)] use project::agent_server_store::BuiltinAgentServerSettings; -use project::{FakeFs, Project, agent_server_store::AllAgentServersSettings}; +use project::{FakeFs, Project}; +#[cfg(test)] +use settings::Settings; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -80,26 +82,9 @@ where .update(cx, |thread, cx| { thread.send( vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "Read the file ".into(), - annotations: None, - meta: None, - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: "foo.rs".into(), - name: "foo.rs".into(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - acp::ContentBlock::Text(acp::TextContent { - text: " and tell me what the content of the println! is".into(), - annotations: None, - meta: None, - }), + "Read the file ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new("foo.rs", "foo.rs")), + " and tell me what the content of the println! is".into(), ], cx, ) @@ -427,7 +412,7 @@ macro_rules! common_e2e_tests { async fn tool_call_with_permission(cx: &mut ::gpui::TestAppContext) { $crate::e2e_tests::test_tool_call_with_permission( $server, - ::agent_client_protocol::PermissionOptionId($allow_option_id.into()), + ::agent_client_protocol::PermissionOptionId::new($allow_option_id), cx, ) .await; @@ -452,35 +437,29 @@ pub use common_e2e_tests; // Helpers pub async fn init_test(cx: &mut TestAppContext) -> Arc { - use settings::Settings; - env_logger::try_init().ok(); cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - Project::init_settings(cx); - language::init(cx); gpui_tokio::init(cx); let http_client = reqwest_client::ReqwestClient::user_agent("agent tests").unwrap(); cx.set_http_client(Arc::new(http_client)); - client::init_settings(cx); let client = client::Client::production(cx); let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); language_model::init(client.clone(), cx); language_models::init(user_store, client, cx); - agent_settings::init(cx); - AllAgentServersSettings::register(cx); #[cfg(test)] - AllAgentServersSettings::override_global( - AllAgentServersSettings { + project::agent_server_store::AllAgentServersSettings::override_global( + project::agent_server_store::AllAgentServersSettings { claude: Some(BuiltinAgentServerSettings { path: Some("claude-code-acp".into()), args: None, env: None, ignore_system_version: None, default_mode: None, + default_model: None, }), gemini: Some(crate::gemini::tests::local_command().into()), codex: Some(BuiltinAgentServerSettings { @@ -489,6 +468,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { env: None, ignore_system_version: None, default_mode: None, + default_model: None, }), custom: collections::HashMap::default(), }, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 8004f5caec..5fea74746a 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -12,10 +12,6 @@ use project::agent_server_store::GEMINI_NAME; pub struct Gemini; impl AgentServer for Gemini { - fn telemetry_id(&self) -> &'static str { - "gemini-cli" - } - fn name(&self) -> SharedString { "Gemini CLI".into() } @@ -36,6 +32,7 @@ impl AgentServer for Gemini { let store = delegate.store.downgrade(); let mut extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); + let default_model = self.default_model(cx); cx.spawn(async move |cx| { extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); @@ -67,6 +64,7 @@ impl AgentServer for Gemini { command, root_dir.as_ref(), default_mode, + default_model, is_remote, cx, ) diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index a8b457a9dd..8ddcac24fe 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -24,7 +24,6 @@ schemars.workspace = true serde.workspace = true settings.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] fs.workspace = true diff --git a/crates/agent_settings/src/agent_profile.rs b/crates/agent_settings/src/agent_profile.rs index 999ddc8083..aff666e011 100644 --- a/crates/agent_settings/src/agent_profile.rs +++ b/crates/agent_settings/src/agent_profile.rs @@ -6,8 +6,8 @@ use convert_case::{Case, Casing as _}; use fs::Fs; use gpui::{App, SharedString}; use settings::{ - AgentProfileContent, ContextServerPresetContent, Settings as _, SettingsContent, - update_settings_file, + AgentProfileContent, ContextServerPresetContent, LanguageModelSelection, Settings as _, + SettingsContent, update_settings_file, }; use util::ResultExt as _; @@ -53,19 +53,30 @@ impl AgentProfile { let base_profile = base_profile_id.and_then(|id| AgentSettings::get_global(cx).profiles.get(&id).cloned()); + // Copy toggles from the base profile so the new profile starts with familiar defaults. + let tools = base_profile + .as_ref() + .map(|profile| profile.tools.clone()) + .unwrap_or_default(); + let enable_all_context_servers = base_profile + .as_ref() + .map(|profile| profile.enable_all_context_servers) + .unwrap_or_default(); + let context_servers = base_profile + .as_ref() + .map(|profile| profile.context_servers.clone()) + .unwrap_or_default(); + // Preserve the base profile's model preference when cloning into a new profile. + let default_model = base_profile + .as_ref() + .and_then(|profile| profile.default_model.clone()); + let profile_settings = AgentProfileSettings { name: name.into(), - tools: base_profile - .as_ref() - .map(|profile| profile.tools.clone()) - .unwrap_or_default(), - enable_all_context_servers: base_profile - .as_ref() - .map(|profile| profile.enable_all_context_servers) - .unwrap_or_default(), - context_servers: base_profile - .map(|profile| profile.context_servers) - .unwrap_or_default(), + tools, + enable_all_context_servers, + context_servers, + default_model, }; update_settings_file(fs, cx, { @@ -96,6 +107,8 @@ pub struct AgentProfileSettings { pub tools: IndexMap, bool>, pub enable_all_context_servers: bool, pub context_servers: IndexMap, ContextServerPreset>, + /// Default language model to apply when this profile becomes active. + pub default_model: Option, } impl AgentProfileSettings { @@ -144,6 +157,7 @@ impl AgentProfileSettings { ) }) .collect(), + default_model: self.default_model.clone(), }, ); @@ -153,15 +167,23 @@ impl AgentProfileSettings { impl From for AgentProfileSettings { fn from(content: AgentProfileContent) -> Self { + let AgentProfileContent { + name, + tools, + enable_all_context_servers, + context_servers, + default_model, + } = content; + Self { - name: content.name.into(), - tools: content.tools, - enable_all_context_servers: content.enable_all_context_servers.unwrap_or_default(), - context_servers: content - .context_servers + name: name.into(), + tools, + enable_all_context_servers: enable_all_context_servers.unwrap_or_default(), + context_servers: context_servers .into_iter() .map(|(server_id, preset)| (server_id, preset.into())) .collect(), + default_model, } } } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index d862cacee1..25ca5c78d6 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -9,30 +9,27 @@ use project::DisableAiSettings; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ - DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, - NotifyWhenAgentWaiting, Settings, SettingsContent, + DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, + NotifyWhenAgentWaiting, RegisterSetting, Settings, }; pub use crate::agent_profile::*; -pub const SUMMARIZE_THREAD_PROMPT: &str = - include_str!("../../agent/src/prompts/summarize_thread_prompt.txt"); +pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt"); pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str = - include_str!("../../agent/src/prompts/summarize_thread_detailed_prompt.txt"); + include_str!("prompts/summarize_thread_detailed_prompt.txt"); -pub fn init(cx: &mut App) { - AgentSettings::register(cx); -} - -#[derive(Clone, Debug)] +#[derive(Clone, Debug, RegisterSetting)] pub struct AgentSettings { pub enabled: bool, pub button: bool, pub dock: DockPosition, + pub agents_panel_dock: DockSide, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, pub inline_assistant_model: Option, + pub inline_assistant_use_streaming_tools: bool, pub commit_message_model: Option, pub thread_summary_model: Option, pub inline_alternatives: Vec, @@ -42,7 +39,6 @@ pub struct AgentSettings { pub always_allow_tool_actions: bool, pub notify_when_agent_waiting: NotifyWhenAgentWaiting, pub play_sound_when_agent_done: bool, - pub stream_edits: bool, pub single_file_review: bool, pub model_parameters: Vec, pub preferred_completion_mode: CompletionMode, @@ -151,16 +147,20 @@ impl Default for AgentProfileId { } impl Settings for AgentSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let agent = content.agent.clone().unwrap(); Self { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), + agents_panel_dock: agent.agents_panel_dock.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), default_model: Some(agent.default_model.unwrap()), inline_assistant_model: agent.inline_assistant_model, + inline_assistant_use_streaming_tools: agent + .inline_assistant_use_streaming_tools + .unwrap_or(true), commit_message_model: agent.commit_message_model, thread_summary_model: agent.thread_summary_model, inline_alternatives: agent.inline_alternatives.unwrap_or_default(), @@ -175,7 +175,6 @@ impl Settings for AgentSettings { always_allow_tool_actions: agent.always_allow_tool_actions.unwrap(), notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(), play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(), - stream_edits: agent.stream_edits.unwrap(), single_file_review: agent.single_file_review.unwrap(), model_parameters: agent.model_parameters, preferred_completion_mode: agent.preferred_completion_mode.unwrap().into(), @@ -186,14 +185,4 @@ impl Settings for AgentSettings { message_editor_min_lines: agent.message_editor_min_lines.unwrap(), } } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut SettingsContent) { - if let Some(b) = vscode - .read_value("chat.agent.enabled") - .and_then(|b| b.as_bool()) - { - current.agent.get_or_insert_default().enabled = Some(b); - current.agent.get_or_insert_default().button = Some(b); - } - } } diff --git a/crates/agent/src/prompts/summarize_thread_detailed_prompt.txt b/crates/agent_settings/src/prompts/summarize_thread_detailed_prompt.txt similarity index 100% rename from crates/agent/src/prompts/summarize_thread_detailed_prompt.txt rename to crates/agent_settings/src/prompts/summarize_thread_detailed_prompt.txt diff --git a/crates/agent/src/prompts/summarize_thread_prompt.txt b/crates/agent_settings/src/prompts/summarize_thread_prompt.txt similarity index 100% rename from crates/agent/src/prompts/summarize_thread_prompt.txt rename to crates/agent_settings/src/prompts/summarize_thread_prompt.txt diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 47d9f6d6a2..38580b4d2c 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,23 +13,22 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = ["gpui/test-support", "language/test-support"] +test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support"] +unit-eval = [] [dependencies] acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true agent.workspace = true -agent2.workspace = true agent_servers.workspace = true agent_settings.workspace = true ai_onboarding.workspace = true anyhow.workspace = true arrayvec.workspace = true -assistant_context.workspace = true +assistant_text_thread.workspace = true assistant_slash_command.workspace = true assistant_slash_commands.workspace = true -assistant_tool.workspace = true audio.workspace = true buffer_diff.workspace = true chrono.workspace = true @@ -41,6 +40,7 @@ component.workspace = true context_server.workspace = true db.workspace = true editor.workspace = true +eval_utils = { workspace = true, optional = true } extension.workspace = true extension_host.workspace = true feature_flags.workspace = true @@ -49,6 +49,7 @@ fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +gpui_tokio.workspace = true html_to_markdown.workspace = true http_client.workspace = true indoc.workspace = true @@ -71,6 +72,7 @@ postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true +rand.workspace = true release_channel.workspace = true rope.workspace = true rules_library.workspace = true @@ -84,7 +86,6 @@ smol.workspace = true streaming_diff.workspace = true task.workspace = true telemetry.workspace = true -telemetry_events.workspace = true terminal.workspace = true terminal_view.workspace = true text.workspace = true @@ -94,22 +95,24 @@ time_format.workspace = true ui.workspace = true ui_input.workspace = true url.workspace = true -urlencoding.workspace = true util.workspace = true +uuid.workspace = true watch.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true +image.workspace = true +async-fs.workspace = true +reqwest_client = { workspace = true, optional = true } [dev-dependencies] acp_thread = { workspace = true, features = ["test-support"] } agent = { workspace = true, features = ["test-support"] } -agent2 = { workspace = true, features = ["test-support"] } -assistant_context = { workspace = true, features = ["test-support"] } -assistant_tools.workspace = true +assistant_text_thread = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } +clock.workspace = true db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +eval_utils.workspace = true gpui = { workspace = true, "features" = ["test-support"] } indoc.workspace = true language = { workspace = true, "features" = ["test-support"] } @@ -117,6 +120,7 @@ languages = { workspace = true, features = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } -rand.workspace = true +semver.workspace = true +reqwest_client.workspace = true tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index 2e15cd424d..7a740c2dc4 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -1,4 +1,3 @@ -mod completion_provider; mod entry_view_state; mod message_editor; mod mode_selector; diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 340b7f27e9..feae74a86b 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,10 +1,10 @@ use std::{cell::RefCell, ops::Range, rc::Rc}; use acp_thread::{AcpThread, AgentThreadEntry}; +use agent::HistoryStore; use agent_client_protocol::{self as acp, ToolCallId}; -use agent2::HistoryStore; use collections::HashMap; -use editor::{Editor, EditorMode, MinimapVisibility}; +use editor::{Editor, EditorMode, MinimapVisibility, SizingBehavior}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window, @@ -22,7 +22,7 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; pub struct EntryViewState { workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, entries: Vec, @@ -34,7 +34,7 @@ pub struct EntryViewState { impl EntryViewState { pub fn new( workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, @@ -328,7 +328,7 @@ impl Entry { fn create_terminal( workspace: WeakEntity, - project: Entity, + project: WeakEntity, terminal: Entity, window: &mut Window, cx: &mut App, @@ -336,9 +336,9 @@ fn create_terminal( cx.new(|cx| { let mut view = TerminalView::new( terminal.read(cx).inner().clone(), - workspace.clone(), + workspace, None, - project.downgrade(), + project, window, cx, ); @@ -357,7 +357,7 @@ fn create_editor_diff( EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, - sized_by_content: true, + sizing_behavior: SizingBehavior::SizeByContent, }, diff.read(cx).multibuffer().clone(), None, @@ -399,22 +399,20 @@ mod tests { use std::{path::Path, rc::Rc}; use acp_thread::{AgentConnection, StubAgentConnection}; + use agent::HistoryStore; use agent_client_protocol as acp; - use agent_settings::AgentSettings; - use agent2::HistoryStore; - use assistant_context::ContextStore; + use assistant_text_thread::TextThreadStore; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; - use editor::{EditorSettings, RowInfo}; + use editor::RowInfo; use fs::FakeFs; - use gpui::{AppContext as _, SemanticVersion, TestAppContext}; + use gpui::{AppContext as _, TestAppContext}; use crate::acp::entry_view_state::EntryViewState; use multi_buffer::MultiBufferRow; use pretty_assertions::assert_matches; use project::Project; use serde_json::json; - use settings::{Settings as _, SettingsStore}; - use theme::ThemeSettings; + use settings::SettingsStore; use util::path; use workspace::Workspace; @@ -434,24 +432,11 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let tool_call = acp::ToolCall { - id: acp::ToolCallId("tool".into()), - title: "Tool call".into(), - kind: acp::ToolKind::Other, - status: acp::ToolCallStatus::InProgress, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/hello.txt".into(), - old_text: Some("hi world".into()), - new_text: "hello world".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; + let tool_call = acp::ToolCall::new("tool", "Tool call") + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/hello.txt", "hello world").old_text("hi world"), + )]); let connection = Rc::new(StubAgentConnection::new()); let thread = cx .update(|_, cx| { @@ -467,13 +452,13 @@ mod tests { connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx) }); - let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let view_state = cx.new(|_cx| { EntryViewState::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store, None, Default::default(), @@ -540,13 +525,8 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - AgentSettings::register(cx); - workspace::init_settings(cx); - ThemeSettings::register(cx); - release_channel::init(SemanticVersion::default(), cx); - EditorSettings::register(cx); + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); } } diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index b752151757..5e9c55cc56 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1,64 +1,45 @@ use crate::{ - acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion}, - context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content}, + ChatWithFollow, + completion_provider::{ + PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, + PromptContextType, SlashCommandCompletion, + }, + mention_set::{ + Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, + }, }; -use acp_thread::{MentionUri, selection_name}; +use acp_thread::MentionUri; +use agent::HistoryStore; use agent_client_protocol as acp; -use agent_servers::{AgentServer, AgentServerDelegate}; -use agent2::HistoryStore; use anyhow::{Result, anyhow}; -use assistant_slash_commands::codeblock_fence_for_path; -use assistant_tool::outline; -use collections::{HashMap, HashSet}; +use collections::HashSet; use editor::{ - Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, - EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, InlayId, - MultiBuffer, ToOffset, - actions::Paste, - display_map::{Crease, CreaseId, FoldId, Inlay}, -}; -use futures::{ - FutureExt as _, - future::{Shared, join_all}, + Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, + EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, + MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu, + scroll::Autoscroll, }; +use futures::{FutureExt as _, future::join_all}; use gpui::{ - Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, SharedString, - Subscription, Task, TextStyle, WeakEntity, pulsating_between, + AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, + KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, Language, language_settings::InlayHintKind}; -use language_model::LanguageModelImage; -use postage::stream::Stream as _; -use project::{ - CompletionIntent, InlayHint, InlayHintLabel, Project, ProjectItem, ProjectPath, Worktree, -}; -use prompt_store::{PromptId, PromptStore}; +use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; +use prompt_store::PromptStore; use rope::Point; use settings::Settings; -use std::{ - cell::RefCell, - ffi::OsStr, - fmt::Write, - ops::{Range, RangeInclusive}, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, - time::Duration, -}; -use text::OffsetRangeExt; +use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc}; use theme::ThemeSettings; -use ui::{ButtonLike, TintColor, Toggleable, prelude::*}; -use util::{ResultExt, debug_panic, rel_path::RelPath}; -use workspace::{Workspace, notifications::NotifyResultExt as _}; +use ui::prelude::*; +use util::{ResultExt, debug_panic}; +use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::Chat; pub struct MessageEditor { - mention_set: MentionSet, + mention_set: Entity, editor: Entity, - project: Entity, workspace: WeakEntity, - history_store: Entity, - prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, agent_name: SharedString, @@ -76,12 +57,47 @@ pub enum MessageEditorEvent { impl EventEmitter for MessageEditor {} -const COMMAND_HINT_INLAY_ID: u32 = 0; +const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0); + +impl PromptCompletionProviderDelegate for Entity { + fn supports_images(&self, cx: &App) -> bool { + self.read(cx).prompt_capabilities.borrow().image + } + + fn supported_modes(&self, cx: &App) -> Vec { + let mut supported = vec![PromptContextType::File, PromptContextType::Symbol]; + if self.read(cx).prompt_capabilities.borrow().embedded_context { + supported.extend(&[ + PromptContextType::Thread, + PromptContextType::Fetch, + PromptContextType::Rules, + ]); + } + supported + } + + fn available_commands(&self, cx: &App) -> Vec { + self.read(cx) + .available_commands + .borrow() + .iter() + .map(|cmd| crate::completion_provider::AvailableCommand { + name: cmd.name.clone().into(), + description: cmd.description.clone().into(), + requires_argument: cmd.input.is_some(), + }) + .collect() + } + + fn confirm_command(&self, cx: &mut App) { + self.update(cx, |this, cx| this.send(cx)); + } +} impl MessageEditor { pub fn new( workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, @@ -99,15 +115,7 @@ impl MessageEditor { }, None, ); - let completion_provider = Rc::new(ContextPickerCompletionProvider::new( - cx.weak_entity(), - workspace.clone(), - history_store.clone(), - prompt_store.clone(), - prompt_capabilities.clone(), - available_commands.clone(), - )); - let mention_set = MentionSet::default(); + let editor = cx.new(|cx| { let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); @@ -115,9 +123,9 @@ impl MessageEditor { let mut editor = Editor::new(mode, buffer, None, window, cx); editor.set_placeholder_text(placeholder, window, cx); editor.set_show_indent_guides(false, cx); + editor.set_show_completions_on_input(Some(true)); editor.set_soft_wrap(); editor.set_use_modal_editing(true); - editor.set_completion_provider(Some(completion_provider.clone())); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, max_entries_visible: 12, @@ -126,6 +134,19 @@ impl MessageEditor { editor.register_addon(MessageEditorAddon::new()); editor }); + let mention_set = + cx.new(|_cx| MentionSet::new(project, history_store.clone(), prompt_store.clone())); + let completion_provider = Rc::new(PromptCompletionProvider::new( + cx.entity(), + editor.downgrade(), + mention_set.clone(), + history_store.clone(), + prompt_store.clone(), + workspace.clone(), + )); + editor.update(cx, |editor, _cx| { + editor.set_completion_provider(Some(completion_provider.clone())) + }); cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| { cx.emit(MessageEditorEvent::Focus) @@ -141,16 +162,22 @@ impl MessageEditor { subscriptions.push(cx.subscribe_in(&editor, window, { move |this, editor, event, window, cx| { - if let EditorEvent::Edited { .. } = event { - let snapshot = editor.update(cx, |editor, cx| { + if let EditorEvent::Edited { .. } = event + && !editor.read(cx).read_only(cx) + { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + this.mention_set + .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot)); + let new_hints = this - .command_hint(editor.buffer(), cx) + .command_hint(snapshot.buffer()) .into_iter() .collect::>(); let has_new_hint = !new_hints.is_empty(); editor.splice_inlays( if has_hint { - &[InlayId::Hint(COMMAND_HINT_INLAY_ID)] + &[COMMAND_HINT_INLAY_ID] } else { &[] }, @@ -158,11 +185,7 @@ impl MessageEditor { cx, ); has_hint = has_new_hint; - - editor.snapshot(window, cx) }); - this.mention_set.remove_invalid(snapshot); - cx.notify(); } } @@ -170,11 +193,8 @@ impl MessageEditor { Self { editor, - project, mention_set, workspace, - history_store, - prompt_store, prompt_capabilities, available_commands, agent_name, @@ -183,13 +203,12 @@ impl MessageEditor { } } - fn command_hint(&self, buffer: &Entity, cx: &App) -> Option { + fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option { let available_commands = self.available_commands.borrow(); if available_commands.is_empty() { return None; } - let snapshot = buffer.read(cx).snapshot(cx); let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?; if parsed_command.argument.is_some() { return None; @@ -200,10 +219,15 @@ impl MessageEditor { .iter() .find(|command| command.name == command_name)?; - let acp::AvailableCommandInput::Unstructured { mut hint } = - available_command.input.clone()?; + let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput { + mut hint, + .. + }) = available_command.input.clone()? + else { + return None; + }; - let mut hint_pos = parsed_command.source_range.end + 1; + let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize; if hint_pos > snapshot.len() { hint_pos = snapshot.len(); hint.insert(0, ' '); @@ -228,12 +252,23 @@ impl MessageEditor { pub fn insert_thread_summary( &mut self, - thread: agent2::DbThreadMetadata, + thread: agent::DbThreadMetadata, window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let uri = MentionUri::Thread { + id: thread.id.clone(), + name: thread.title.to_string(), + }; + let content = format!("{}\n", uri.as_link()); + + let content_len = content.len() - 1; + let start = self.editor.update(cx, |editor, cx| { - editor.set_text(format!("{}\n", thread.title), window, cx); + editor.set_text(content, window, cx); editor .buffer() .read(cx) @@ -242,18 +277,23 @@ impl MessageEditor { .text_anchor }); - self.confirm_mention_completion( - thread.title.clone(), - start, - thread.title.len(), - MentionUri::Thread { - id: thread.id.clone(), - name: thread.title.to_string(), - }, - window, - cx, - ) - .detach(); + let supports_images = self.prompt_capabilities.borrow().image; + + self.mention_set + .update(cx, |mention_set, cx| { + mention_set.confirm_mention_completion( + thread.title, + start, + content_len, + uri, + supports_images, + self.editor.clone(), + &workspace, + window, + cx, + ) + }) + .detach(); } #[cfg(test)] @@ -261,391 +301,22 @@ impl MessageEditor { &self.editor } - #[cfg(test)] - pub(crate) fn mention_set(&mut self) -> &mut MentionSet { - &mut self.mention_set - } - pub fn is_empty(&self, cx: &App) -> bool { self.editor.read(cx).is_empty(cx) } - pub fn mentions(&self) -> HashSet { - self.mention_set - .mentions - .values() - .map(|(uri, _)| uri.clone()) - .collect() - } - - pub fn confirm_mention_completion( - &mut self, - crease_text: SharedString, - start: text::Anchor, - content_len: usize, - mention_uri: MentionUri, - window: &mut Window, - cx: &mut Context, - ) -> Task<()> { - let snapshot = self - .editor - .update(cx, |editor, cx| editor.snapshot(window, cx)); - let Some((excerpt_id, _, _)) = snapshot.buffer_snapshot().as_singleton() else { - return Task::ready(()); - }; - let Some(start_anchor) = snapshot - .buffer_snapshot() - .anchor_in_excerpt(*excerpt_id, start) - else { - return Task::ready(()); - }; - let end_anchor = snapshot - .buffer_snapshot() - .anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1); - - let crease = if let MentionUri::File { abs_path } = &mention_uri - && let Some(extension) = abs_path.extension() - && let Some(extension) = extension.to_str() - && Img::extensions().contains(&extension) - && !extension.contains("svg") - { - let Some(project_path) = self - .project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - log::error!("project path not found"); - return Task::ready(()); - }; - let image = self - .project - .update(cx, |project, cx| project.open_image(project_path, cx)); - let image = cx - .spawn(async move |_, cx| { - let image = image.await.map_err(|e| e.to_string())?; - let image = image - .update(cx, |image, _| image.image.clone()) - .map_err(|e| e.to_string())?; - Ok(image) - }) - .shared(); - insert_crease_for_mention( - *excerpt_id, - start, - content_len, - mention_uri.name().into(), - IconName::Image.path().into(), - Some(image), - self.editor.clone(), - window, - cx, - ) - } else { - insert_crease_for_mention( - *excerpt_id, - start, - content_len, - crease_text, - mention_uri.icon_path(cx), - None, - self.editor.clone(), - window, - cx, - ) - }; - let Some((crease_id, tx)) = crease else { - return Task::ready(()); - }; - - let task = match mention_uri.clone() { - MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, cx), - MentionUri::Directory { .. } => Task::ready(Ok(Mention::UriOnly)), - MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx), - MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx), - MentionUri::File { abs_path } => self.confirm_mention_for_file(abs_path, cx), - MentionUri::Symbol { - abs_path, - line_range, - .. - } => self.confirm_mention_for_symbol(abs_path, line_range, cx), - MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx), - MentionUri::PastedImage => { - debug_panic!("pasted image URI should not be included in completions"); - Task::ready(Err(anyhow!( - "pasted imaged URI should not be included in completions" - ))) - } - MentionUri::Selection { .. } => { - // Handled elsewhere - debug_panic!("unexpected selection URI"); - Task::ready(Err(anyhow!("unexpected selection URI"))) - } - }; - let task = cx - .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) - .shared(); - self.mention_set - .mentions - .insert(crease_id, (mention_uri, task.clone())); - - // Notify the user if we failed to load the mentioned context - cx.spawn_in(window, async move |this, cx| { - let result = task.await.notify_async_err(cx); - drop(tx); - if result.is_none() { - this.update(cx, |this, cx| { - this.editor.update(cx, |editor, cx| { - // Remove mention - editor.edit([(start_anchor..end_anchor, "")], cx); - }); - this.mention_set.mentions.remove(&crease_id); - }) - .ok(); - } - }) - } - - fn confirm_mention_for_file( - &mut self, - abs_path: PathBuf, - cx: &mut Context, - ) -> Task> { - let Some(project_path) = self - .project + pub fn is_completions_menu_visible(&self, cx: &App) -> bool { + self.editor .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - return Task::ready(Err(anyhow!("project path not found"))); - }; - let extension = abs_path - .extension() - .and_then(OsStr::to_str) - .unwrap_or_default(); - - if Img::extensions().contains(&extension) && !extension.contains("svg") { - if !self.prompt_capabilities.borrow().image { - return Task::ready(Err(anyhow!("This model does not support images yet"))); - } - let task = self - .project - .update(cx, |project, cx| project.open_image(project_path, cx)); - return cx.spawn(async move |_, cx| { - let image = task.await?; - let image = image.update(cx, |image, _| image.image.clone())?; - let format = image.format; - let image = cx - .update(|cx| LanguageModelImage::from_image(image, cx))? - .await; - if let Some(image) = image { - Ok(Mention::Image(MentionImage { - data: image.source, - format, - })) - } else { - Err(anyhow!("Failed to convert image")) - } - }); - } - - let buffer = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)); - cx.spawn(async move |_, cx| { - let buffer = buffer.await?; - let buffer_content = outline::get_buffer_content_or_outline( - buffer.clone(), - Some(&abs_path.to_string_lossy()), - &cx, - ) - .await?; - - Ok(Mention::Text { - content: buffer_content.text, - tracked_buffers: vec![buffer], - }) - }) + .context_menu() + .borrow() + .as_ref() + .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()) } - fn confirm_mention_for_fetch( - &mut self, - url: url::Url, - cx: &mut Context, - ) -> Task> { - let http_client = match self - .workspace - .update(cx, |workspace, _| workspace.client().http_client()) - { - Ok(http_client) => http_client, - Err(e) => return Task::ready(Err(e)), - }; - cx.background_executor().spawn(async move { - let content = fetch_url_content(http_client, url.to_string()).await?; - Ok(Mention::Text { - content, - tracked_buffers: Vec::new(), - }) - }) - } - - fn confirm_mention_for_symbol( - &mut self, - abs_path: PathBuf, - line_range: RangeInclusive, - cx: &mut Context, - ) -> Task> { - let Some(project_path) = self - .project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - return Task::ready(Err(anyhow!("project path not found"))); - }; - let buffer = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)); - cx.spawn(async move |_, cx| { - let buffer = buffer.await?; - let mention = buffer.update(cx, |buffer, cx| { - let start = Point::new(*line_range.start(), 0).min(buffer.max_point()); - let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point()); - let content = buffer.text_for_range(start..end).collect(); - Mention::Text { - content, - tracked_buffers: vec![cx.entity()], - } - })?; - anyhow::Ok(mention) - }) - } - - fn confirm_mention_for_rule( - &mut self, - id: PromptId, - cx: &mut Context, - ) -> Task> { - let Some(prompt_store) = self.prompt_store.clone() else { - return Task::ready(Err(anyhow!("missing prompt store"))); - }; - let prompt = prompt_store.read(cx).load(id, cx); - cx.spawn(async move |_, _| { - let prompt = prompt.await?; - Ok(Mention::Text { - content: prompt, - tracked_buffers: Vec::new(), - }) - }) - } - - pub fn confirm_mention_for_selection( - &mut self, - source_range: Range, - selections: Vec<(Entity, Range, Range)>, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); - let Some((&excerpt_id, _, _)) = snapshot.as_singleton() else { - return; - }; - let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, source_range.start) else { - return; - }; - - let offset = start.to_offset(&snapshot); - - for (buffer, selection_range, range_to_fold) in selections { - let range = snapshot.anchor_after(offset + range_to_fold.start) - ..snapshot.anchor_after(offset + range_to_fold.end); - - let abs_path = buffer - .read(cx) - .project_path(cx) - .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx)); - let snapshot = buffer.read(cx).snapshot(); - - let text = snapshot - .text_for_range(selection_range.clone()) - .collect::(); - let point_range = selection_range.to_point(&snapshot); - let line_range = point_range.start.row..=point_range.end.row; - - let uri = MentionUri::Selection { - abs_path: abs_path.clone(), - line_range: line_range.clone(), - }; - let crease = crate::context_picker::crease_for_mention( - selection_name(abs_path.as_deref(), &line_range).into(), - uri.icon_path(cx), - range, - self.editor.downgrade(), - ); - - let crease_id = self.editor.update(cx, |editor, cx| { - let crease_ids = editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases(vec![crease], false, window, cx); - crease_ids.first().copied().unwrap() - }); - - self.mention_set.mentions.insert( - crease_id, - ( - uri, - Task::ready(Ok(Mention::Text { - content: text, - tracked_buffers: vec![buffer], - })) - .shared(), - ), - ); - } - } - - fn confirm_mention_for_thread( - &mut self, - id: acp::SessionId, - cx: &mut Context, - ) -> Task> { - let server = Rc::new(agent2::NativeAgentServer::new( - self.project.read(cx).fs().clone(), - self.history_store.clone(), - )); - let delegate = AgentServerDelegate::new( - self.project.read(cx).agent_server_store().clone(), - self.project.clone(), - None, - None, - ); - let connection = server.connect(None, delegate, cx); - cx.spawn(async move |_, cx| { - let (agent, _) = connection.await?; - let agent = agent.downcast::().unwrap(); - let summary = agent - .0 - .update(cx, |agent, cx| agent.thread_summary(id, cx))? - .await?; - anyhow::Ok(Mention::Text { - content: summary.to_string(), - tracked_buffers: Vec::new(), - }) - }) - } - - fn confirm_mention_for_text_thread( - &mut self, - path: PathBuf, - cx: &mut Context, - ) -> Task> { - let context = self.history_store.update(cx, |text_thread_store, cx| { - text_thread_store.load_text_thread(path.as_path().into(), cx) - }); - cx.spawn(async move |_, cx| { - let context = context.await?; - let xml = context.update(cx, |context, cx| context.to_xml(cx))?; - Ok(Mention::Text { - content: xml, - tracked_buffers: Vec::new(), - }) - }) + #[cfg(test)] + pub fn mention_set(&self) -> &Entity { + &self.mention_set } fn validate_slash_commands( @@ -695,20 +366,21 @@ impl MessageEditor { return Task::ready(Err(err)); } - let contents = self.mention_set.contents( - &self.prompt_capabilities.borrow(), - full_mention_content, - self.project.clone(), - cx, - ); + let contents = self + .mention_set + .update(cx, |store, cx| store.contents(full_mention_content, cx)); let editor = self.editor.clone(); + let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context; cx.spawn(async move |_, cx| { let contents = contents.await?; let mut all_tracked_buffers = Vec::new(); let result = editor.update(cx, |editor, cx| { - let mut ix = 0; + let (mut ix, _) = text + .char_indices() + .find(|(_, c)| !c.is_whitespace()) + .unwrap_or((0, '\0')); let mut chunks: Vec = Vec::new(); let text = editor.text(cx); editor.display_map.update(cx, |map, cx| { @@ -719,17 +391,8 @@ impl MessageEditor { }; let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot()); - if crease_range.start > ix { - //todo(): Custom slash command ContentBlock? - // let chunk = if prevent_slash_commands - // && ix == 0 - // && parse_slash_command(&text[ix..]).is_some() - // { - // format!(" {}", &text[ix..crease_range.start]).into() - // } else { - // text[ix..crease_range.start].into() - // }; - let chunk = text[ix..crease_range.start].into(); + if crease_range.start.0 > ix { + let chunk = text[ix..crease_range.start.0].into(); chunks.push(chunk); } let chunk = match mention { @@ -738,21 +401,28 @@ impl MessageEditor { tracked_buffers, } => { all_tracked_buffers.extend(tracked_buffers.iter().cloned()); - acp::ContentBlock::Resource(acp::EmbeddedResource { - annotations: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - mime_type: None, - text: content.clone(), - uri: uri.to_uri().to_string(), - meta: None, - }, - ), - meta: None, - }) + if supports_embedded_context { + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new( + content.clone(), + uri.to_uri().to_string(), + ), + ), + )) + } else { + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )) + } } - Mention::Image(mention_image) => { - let uri = match uri { + Mention::Image(mention_image) => acp::ContentBlock::Image( + acp::ImageContent::new( + mention_image.data.clone(), + mention_image.format.mime_type(), + ) + .uri(match uri { MentionUri::File { .. } => Some(uri.to_uri().to_string()), MentionUri::PastedImage => None, other => { @@ -762,42 +432,17 @@ impl MessageEditor { ); None } - }; - acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data: mention_image.data.to_string(), - mime_type: mention_image.format.mime_type().into(), - uri, - meta: None, - }) - } - Mention::UriOnly => { - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: uri.name(), - uri: uri.to_uri().to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }) - } + }), + ), + Mention::Link => acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()), + ), }; chunks.push(chunk); - ix = crease_range.end; + ix = crease_range.end.0; } if ix < text.len() { - //todo(): Custom slash command ContentBlock? - // let last_chunk = if prevent_slash_commands - // && ix == 0 - // && parse_slash_command(&text[ix..]).is_some() - // { - // format!(" {}", text[ix..].trim_end()) - // } else { - // text[ix..].trim_end().to_owned() - // }; let last_chunk = text[ix..].trim_end().to_owned(); if !last_chunk.is_empty() { chunks.push(last_chunk.into()); @@ -814,132 +459,234 @@ impl MessageEditor { self.editor.update(cx, |editor, cx| { editor.clear(window, cx); editor.remove_creases( - self.mention_set - .mentions - .drain() - .map(|(crease_id, _)| crease_id), + self.mention_set.update(cx, |mention_set, _cx| { + mention_set + .clear() + .map(|(crease_id, _)| crease_id) + .collect::>() + }), cx, ) }); } - fn send(&mut self, _: &Chat, _: &mut Window, cx: &mut Context) { + pub fn send(&mut self, cx: &mut Context) { if self.is_empty(cx) { return; } + self.editor.update(cx, |editor, cx| { + editor.clear_inlay_hints(cx); + }); cx.emit(MessageEditorEvent::Send) } + pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context) { + let editor = self.editor.clone(); + + cx.spawn_in(window, async move |_, cx| { + editor + .update_in(cx, |editor, window, cx| { + let menu_is_open = + editor.context_menu().borrow().as_ref().is_some_and(|menu| { + matches!(menu, CodeContextMenu::Completions(_)) && menu.visible() + }); + + let has_at_sign = { + let snapshot = editor.display_snapshot(cx); + let cursor = editor.selections.newest::(&snapshot).head(); + let offset = cursor.to_offset(&snapshot); + if offset.0 > 0 { + snapshot + .buffer_snapshot() + .reversed_chars_at(offset) + .next() + .map(|sign| sign == '@') + .unwrap_or(false) + } else { + false + } + }; + + if menu_is_open && has_at_sign { + return; + } + + editor.insert("@", window, cx); + editor.show_completions(&editor::actions::ShowCompletions, window, cx); + }) + .log_err(); + }) + .detach(); + } + + fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context) { + self.send(cx); + } + + fn chat_with_follow( + &mut self, + _: &ChatWithFollow, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace + .update(cx, |this, cx| { + this.follow(CollaboratorId::Agent, window, cx) + }) + .log_err(); + + self.send(cx); + } + fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context) { cx.emit(MessageEditorEvent::Cancel) } fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - if !self.prompt_capabilities.borrow().image { - return; - } - - let images = cx + let editor_clipboard_selections = cx .read_from_clipboard() - .map(|item| { - item.into_entries() - .filter_map(|entry| { - if let ClipboardEntry::Image(image) = entry { - Some(image) - } else { - None - } - }) - .collect::>() - }) - .unwrap_or_default(); - - if images.is_empty() { - return; - } - cx.stop_propagation(); - - let replacement_text = MentionUri::PastedImage.as_link().to_string(); - for image in images { - let (excerpt_id, text_anchor, multibuffer_anchor) = - self.editor.update(cx, |message_editor, cx| { - let snapshot = message_editor.snapshot(window, cx); - let (excerpt_id, _, buffer_snapshot) = - snapshot.buffer_snapshot().as_singleton().unwrap(); - - let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len()); - let multibuffer_anchor = snapshot - .buffer_snapshot() - .anchor_in_excerpt(*excerpt_id, text_anchor); - message_editor.edit( - [( - multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), - format!("{replacement_text} "), - )], - cx, - ); - (*excerpt_id, text_anchor, multibuffer_anchor) - }); - - let content_len = replacement_text.len(); - let Some(start_anchor) = multibuffer_anchor else { - continue; - }; - let end_anchor = self.editor.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) - }); - let image = Arc::new(image); - let Some((crease_id, tx)) = insert_crease_for_mention( - excerpt_id, - text_anchor, - content_len, - MentionUri::PastedImage.name().into(), - IconName::Image.path().into(), - Some(Task::ready(Ok(image.clone())).shared()), - self.editor.clone(), - window, - cx, - ) else { - continue; - }; - let task = cx - .spawn_in(window, { - async move |_, cx| { - let format = image.format; - let image = cx - .update(|_, cx| LanguageModelImage::from_image(image, cx)) - .map_err(|e| e.to_string())? - .await; - drop(tx); - if let Some(image) = image { - Ok(Mention::Image(MentionImage { - data: image.source, - format, - })) - } else { - Err("Failed to convert image".into()) - } - } - }) - .shared(); - - self.mention_set - .mentions - .insert(crease_id, (MentionUri::PastedImage, task.clone())); - - cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_none() { - this.update(cx, |this, cx| { - this.editor.update(cx, |editor, cx| { - editor.edit([(start_anchor..end_anchor, "")], cx); - }); - this.mention_set.mentions.remove(&crease_id); - }) - .ok(); + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + ClipboardEntry::String(text) => { + text.metadata_json::>() } - }) - .detach(); + _ => None, + }); + + let has_file_context = editor_clipboard_selections + .as_ref() + .is_some_and(|selections| { + selections + .iter() + .any(|sel| sel.file_path.is_some() && sel.line_range.is_some()) + }); + + if has_file_context { + if let Some((workspace, selections)) = + self.workspace.upgrade().zip(editor_clipboard_selections) + { + let Some(first_selection) = selections.first() else { + return; + }; + if let Some(file_path) = &first_selection.file_path { + // In case someone pastes selections from another window + // with a different project, we don't want to insert the + // crease (containing the absolute path) since the agent + // cannot access files outside the project. + let is_in_project = workspace + .read(cx) + .project() + .read(cx) + .project_path_for_absolute_path(file_path, cx) + .is_some(); + if !is_in_project { + return; + } + } + + cx.stop_propagation(); + let insertion_target = self + .editor + .read(cx) + .selections + .newest_anchor() + .start + .text_anchor; + + let project = workspace.read(cx).project().clone(); + for selection in selections { + if let (Some(file_path), Some(line_range)) = + (selection.file_path, selection.line_range) + { + let crease_text = + acp_thread::selection_name(Some(file_path.as_ref()), &line_range); + + let mention_uri = MentionUri::Selection { + abs_path: Some(file_path.clone()), + line_range: line_range.clone(), + }; + + let mention_text = mention_uri.as_link().to_string(); + let (excerpt_id, text_anchor, content_len) = + self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx); + let snapshot = buffer.snapshot(cx); + let (excerpt_id, _, buffer_snapshot) = + snapshot.as_singleton().unwrap(); + let text_anchor = insertion_target.bias_left(&buffer_snapshot); + + editor.insert(&mention_text, window, cx); + editor.insert(" ", window, cx); + + (*excerpt_id, text_anchor, mention_text.len()) + }); + + let Some((crease_id, tx)) = insert_crease_for_mention( + excerpt_id, + text_anchor, + content_len, + crease_text.into(), + mention_uri.icon_path(cx), + None, + self.editor.clone(), + window, + cx, + ) else { + continue; + }; + drop(tx); + + let mention_task = cx + .spawn({ + let project = project.clone(); + async move |_, cx| { + let project_path = project + .update(cx, |project, cx| { + project.project_path_for_absolute_path(&file_path, cx) + }) + .map_err(|e| e.to_string())? + .ok_or_else(|| "project path not found".to_string())?; + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path, cx) + }) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + + buffer + .update(cx, |buffer, cx| { + let start = Point::new(*line_range.start(), 0) + .min(buffer.max_point()); + let end = Point::new(*line_range.end() + 1, 0) + .min(buffer.max_point()); + let content = + buffer.text_for_range(start..end).collect(); + Mention::Text { + content, + tracked_buffers: vec![cx.entity()], + } + }) + .map_err(|e| e.to_string()) + } + }) + .shared(); + + self.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task) + }); + } + } + return; + } + } + + if self.prompt_capabilities.borrow().image + && let Some(task) = + paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) + { + task.detach(); } } @@ -950,26 +697,29 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) { - let path_style = self.project.read(cx).path_style(cx); + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let project = workspace.read(cx).project().clone(); + let path_style = project.read(cx).path_style(cx); let buffer = self.editor.read(cx).buffer().clone(); let Some(buffer) = buffer.read(cx).as_singleton() else { return; }; let mut tasks = Vec::new(); for path in paths { - let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else { + let Some(entry) = project.read(cx).entry_for_path(&path, cx) else { continue; }; - let Some(worktree) = self.project.read(cx).worktree_for_id(path.worktree_id, cx) else { + let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else { continue; }; let abs_path = worktree.read(cx).absolutize(&path.path); - let (file_name, _) = - crate::context_picker::file_context_picker::extract_file_name_and_directory( - &path.path, - worktree.read(cx).root_name(), - path_style, - ); + let (file_name, _) = crate::completion_provider::extract_file_name_and_directory( + &path.path, + worktree.read(cx).root_name(), + path_style, + ); let uri = if entry.is_dir() { MentionUri::Directory { abs_path } @@ -991,14 +741,20 @@ impl MessageEditor { cx, ); }); - tasks.push(self.confirm_mention_completion( - file_name, - anchor, - content_len, - uri, - window, - cx, - )); + let supports_images = self.prompt_capabilities.borrow().image; + tasks.push(self.mention_set.update(cx, |mention_set, cx| { + mention_set.confirm_mention_completion( + file_name, + anchor, + content_len, + uri, + supports_images, + self.editor.clone(), + &workspace, + window, + cx, + ) + })); } cx.spawn(async move |_, _| { join_all(tasks).await; @@ -1016,22 +772,27 @@ impl MessageEditor { let cursor_anchor = editor.selections.newest_anchor().head(); let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx)); let anchor = buffer.update(cx, |buffer, _cx| { - buffer.anchor_before(cursor_offset.min(buffer.len())) + buffer.anchor_before(cursor_offset.0.min(buffer.len())) }); let Some(workspace) = self.workspace.upgrade() else { return; }; - let Some(completion) = ContextPickerCompletionProvider::completion_for_action( - ContextPickerAction::AddSelections, - anchor..anchor, - cx.weak_entity(), - &workspace, - cx, - ) else { + let Some(completion) = + PromptCompletionProvider::>::completion_for_action( + PromptContextAction::AddSelections, + anchor..anchor, + self.editor.downgrade(), + self.mention_set.downgrade(), + &workspace, + cx, + ) + else { return; }; + self.editor.update(cx, |message_editor, cx| { message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx); + message_editor.request_autoscroll(Autoscroll::fit(), cx); }); if let Some(confirm) = completion.confirm { confirm(CompletionIntent::Complete, window, cx); @@ -1058,8 +819,13 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + self.clear(window, cx); + let path_style = workspace.read(cx).project().read(cx).path_style(cx); let mut text = String::new(); let mut mentions = Vec::new(); @@ -1072,7 +838,8 @@ impl MessageEditor { resource: acp::EmbeddedResourceResource::TextResourceContents(resource), .. }) => { - let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() else { + let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err() + else { continue; }; let start = text.len(); @@ -1088,22 +855,23 @@ impl MessageEditor { )); } acp::ContentBlock::ResourceLink(resource) => { - if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() { + if let Some(mention_uri) = + MentionUri::parse(&resource.uri, path_style).log_err() + { let start = text.len(); write!(&mut text, "{}", mention_uri.as_link()).ok(); let end = text.len(); - mentions.push((start..end, mention_uri, Mention::UriOnly)); + mentions.push((start..end, mention_uri, Mention::Link)); } } acp::ContentBlock::Image(acp::ImageContent { uri, data, mime_type, - annotations: _, - meta: _, + .. }) => { let mention_uri = if let Some(uri) = uri { - MentionUri::parse(&uri) + MentionUri::parse(&uri, path_style) } else { Ok(MentionUri::PastedImage) }; @@ -1126,7 +894,7 @@ impl MessageEditor { }), )); } - acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {} + _ => {} } } @@ -1136,7 +904,7 @@ impl MessageEditor { }); for (range, mention_uri, mention) in mentions { - let anchor = snapshot.anchor_before(range.start); + let anchor = snapshot.anchor_before(MultiBufferOffset(range.start)); let Some((crease_id, tx)) = insert_crease_for_mention( anchor.excerpt_id, anchor.text_anchor, @@ -1152,10 +920,13 @@ impl MessageEditor { }; drop(tx); - self.mention_set.mentions.insert( - crease_id, - (mention_uri.clone(), Task::ready(Ok(mention)).shared()), - ); + self.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention( + crease_id, + mention_uri.clone(), + Task::ready(Ok(mention)).shared(), + ) + }); } cx.notify(); } @@ -1164,6 +935,17 @@ impl MessageEditor { self.editor.read(cx).text(cx) } + pub fn set_placeholder_text( + &mut self, + placeholder: &str, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.set_placeholder_text(placeholder, window, cx); + }); + } + #[cfg(test)] pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { @@ -1172,111 +954,6 @@ impl MessageEditor { } } -fn full_mention_for_directory( - project: &Entity, - abs_path: &Path, - cx: &mut App, -) -> Task> { - fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc, String)> { - let mut files = Vec::new(); - - for entry in worktree.child_entries(path) { - if entry.is_dir() { - files.extend(collect_files_in_path(worktree, &entry.path)); - } else if entry.is_file() { - files.push(( - entry.path.clone(), - worktree - .full_path(&entry.path) - .to_string_lossy() - .to_string(), - )); - } - } - - files - } - - let Some(project_path) = project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - return Task::ready(Err(anyhow!("project path not found"))); - }; - let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else { - return Task::ready(Err(anyhow!("project entry not found"))); - }; - let directory_path = entry.path.clone(); - let worktree_id = project_path.worktree_id; - let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else { - return Task::ready(Err(anyhow!("worktree not found"))); - }; - let project = project.clone(); - cx.spawn(async move |cx| { - let file_paths = worktree.read_with(cx, |worktree, _cx| { - collect_files_in_path(worktree, &directory_path) - })?; - let descendants_future = cx.update(|cx| { - join_all(file_paths.into_iter().map(|(worktree_path, full_path)| { - let rel_path = worktree_path - .strip_prefix(&directory_path) - .log_err() - .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into()); - - let open_task = project.update(cx, |project, cx| { - project.buffer_store().update(cx, |buffer_store, cx| { - let project_path = ProjectPath { - worktree_id, - path: worktree_path, - }; - buffer_store.open_buffer(project_path, cx) - }) - }); - - cx.spawn(async move |cx| { - let buffer = open_task.await.log_err()?; - let buffer_content = outline::get_buffer_content_or_outline( - buffer.clone(), - Some(&full_path), - &cx, - ) - .await - .ok()?; - - Some((rel_path, full_path, buffer_content.text, buffer)) - }) - })) - })?; - - let contents = cx - .background_spawn(async move { - let (contents, tracked_buffers) = descendants_future - .await - .into_iter() - .flatten() - .map(|(rel_path, full_path, rope, buffer)| { - ((rel_path, full_path, rope), buffer) - }) - .unzip(); - Mention::Text { - content: render_directory_contents(contents), - tracked_buffers, - } - }) - .await; - anyhow::Ok(contents) - }) -} - -fn render_directory_contents(entries: Vec<(Arc, String, String)>) -> String { - let mut output = String::new(); - for (_relative_path, full_path, content) in entries { - let fence = codeblock_fence_for_path(Some(&full_path), None); - write!(output, "\n{fence}\n{content}\n```").unwrap(); - } - output -} - impl Focusable for MessageEditor { fn focus_handle(&self, cx: &App) -> FocusHandle { self.editor.focus_handle(cx) @@ -1287,7 +964,8 @@ impl Render for MessageEditor { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .key_context("MessageEditor") - .on_action(cx.listener(Self::send)) + .on_action(cx.listener(Self::chat)) + .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) .capture_action(cx.listener(Self::paste)) .flex_1() @@ -1319,244 +997,6 @@ impl Render for MessageEditor { } } -pub(crate) fn insert_crease_for_mention( - excerpt_id: ExcerptId, - anchor: text::Anchor, - content_len: usize, - crease_label: SharedString, - crease_icon: SharedString, - // abs_path: Option>, - image: Option, String>>>>, - editor: Entity, - window: &mut Window, - cx: &mut App, -) -> Option<(CreaseId, postage::barrier::Sender)> { - let (tx, rx) = postage::barrier::channel(); - - let crease_id = editor.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - - let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?; - - let start = start.bias_right(&snapshot); - let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); - - let placeholder = FoldPlaceholder { - render: render_mention_fold_button( - crease_label, - crease_icon, - start..end, - rx, - image, - cx.weak_entity(), - cx, - ), - merge_adjacent: false, - ..Default::default() - }; - - let crease = Crease::Inline { - range: start..end, - placeholder, - render_toggle: None, - render_trailer: None, - metadata: None, - }; - - let ids = editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases(vec![crease], false, window, cx); - - Some(ids[0]) - })?; - - Some((crease_id, tx)) -} - -fn render_mention_fold_button( - label: SharedString, - icon: SharedString, - range: Range, - mut loading_finished: postage::barrier::Receiver, - image_task: Option, String>>>>, - editor: WeakEntity, - cx: &mut App, -) -> Arc, &mut App) -> AnyElement> { - let loading = cx.new(|cx| { - let loading = cx.spawn(async move |this, cx| { - loading_finished.recv().await; - this.update(cx, |this: &mut LoadingContext, cx| { - this.loading = None; - cx.notify(); - }) - .ok(); - }); - LoadingContext { - id: cx.entity_id(), - label, - icon, - range, - editor, - loading: Some(loading), - image: image_task.clone(), - } - }); - Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element()) -} - -struct LoadingContext { - id: EntityId, - label: SharedString, - icon: SharedString, - range: Range, - editor: WeakEntity, - loading: Option>, - image: Option, String>>>>, -} - -impl Render for LoadingContext { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_in_text_selection = self - .editor - .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx)) - .unwrap_or_default(); - ButtonLike::new(("loading-context", self.id)) - .style(ButtonStyle::Filled) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_in_text_selection) - .when_some(self.image.clone(), |el, image_task| { - el.hoverable_tooltip(move |_, cx| { - let image = image_task.peek().cloned().transpose().ok().flatten(); - let image_task = image_task.clone(); - cx.new::(|cx| ImageHover { - image, - _task: cx.spawn(async move |this, cx| { - if let Ok(image) = image_task.clone().await { - this.update(cx, |this, cx| { - if this.image.replace(image).is_none() { - cx.notify(); - } - }) - .ok(); - } - }), - }) - .into() - }) - }) - .child( - h_flex() - .gap_1() - .child( - Icon::from_path(self.icon.clone()) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(self.label.clone()) - .size(LabelSize::Small) - .buffer_font(cx) - .single_line(), - ) - .map(|el| { - if self.loading.is_some() { - el.with_animation( - "loading-context-crease", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.opacity(delta), - ) - .into_any() - } else { - el.into_any() - } - }), - ) - } -} - -struct ImageHover { - image: Option>, - _task: Task<()>, -} - -impl Render for ImageHover { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - if let Some(image) = self.image.clone() { - gpui::img(image).max_w_96().max_h_96().into_any_element() - } else { - gpui::Empty.into_any_element() - } - } -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum Mention { - Text { - content: String, - tracked_buffers: Vec>, - }, - Image(MentionImage), - UriOnly, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MentionImage { - pub data: SharedString, - pub format: ImageFormat, -} - -#[derive(Default)] -pub struct MentionSet { - mentions: HashMap>>)>, -} - -impl MentionSet { - fn contents( - &self, - prompt_capabilities: &acp::PromptCapabilities, - full_mention_content: bool, - project: Entity, - cx: &mut App, - ) -> Task>> { - if !prompt_capabilities.embedded_context { - let mentions = self - .mentions - .iter() - .map(|(crease_id, (uri, _))| (*crease_id, (uri.clone(), Mention::UriOnly))) - .collect(); - - return Task::ready(Ok(mentions)); - } - - let mentions = self.mentions.clone(); - cx.spawn(async move |cx| { - let mut contents = HashMap::default(); - for (crease_id, (mention_uri, task)) in mentions { - let content = if full_mention_content - && let MentionUri::Directory { abs_path } = &mention_uri - { - cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))? - .await? - } else { - task.await.map_err(|e| anyhow!("{e}"))? - }; - - contents.insert(crease_id, (mention_uri, content)); - } - Ok(contents) - }) - } - - fn remove_invalid(&mut self, snapshot: EditorSnapshot) { - for (crease_id, crease) in snapshot.crease_snapshot.creases() { - if !crease.range().start.is_valid(&snapshot.buffer_snapshot()) { - self.mentions.remove(&crease_id); - } - } - } -} - pub struct MessageEditorAddon {} impl MessageEditorAddon { @@ -1587,16 +1027,16 @@ mod tests { use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc}; use acp_thread::MentionUri; + use agent::{HistoryStore, outline}; use agent_client_protocol as acp; - use agent2::HistoryStore; - use assistant_context::ContextStore; - use assistant_tool::outline; - use editor::{AnchorRangeExt as _, Editor, EditorMode}; + use assistant_text_thread::TextThreadStore; + use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset}; use fs::FakeFs; use futures::StreamExt as _; use gpui::{ AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext, }; + use language_model::LanguageModelRegistry; use lsp::{CompletionContext, CompletionTriggerKind}; use project::{CompletionIntent, Project, ProjectPath}; use serde_json::json; @@ -1621,14 +1061,14 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -1684,13 +1124,10 @@ mod tests { editor.update_in(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); - let start = snapshot - .anchor_in_excerpt(excerpt_id, completion.replace_range.start) + let range = snapshot + .anchor_range_in_excerpt(excerpt_id, completion.replace_range) .unwrap(); - let end = snapshot - .anchor_in_excerpt(excerpt_id, completion.replace_range.end) - .unwrap(); - editor.edit([(start..end, completion.new_text)], cx); + editor.edit([(range, completion.new_text)], cx); (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx); }); @@ -1729,8 +1166,8 @@ mod tests { .await; let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); // Start with no available commands - simulating Claude which doesn't support slash commands let available_commands = Rc::new(RefCell::new(vec![])); @@ -1742,7 +1179,7 @@ mod tests { cx.new(|cx| { MessageEditor::new( workspace_handle.clone(), - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -1776,12 +1213,7 @@ mod tests { assert!(error_message.contains("Available commands: none")); // Now simulate Claude providing its list of available commands (which doesn't include file) - available_commands.replace(vec![acp::AvailableCommand { - name: "help".to_string(), - description: "Get help".to_string(), - input: None, - meta: None, - }]); + available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]); // Test that unsupported slash commands trigger an error when we have a list of available commands editor.update_in(cx, |editor, window, cx| { @@ -1881,10 +1313,8 @@ mod tests { let app_state = cx.update(AppState::test); cx.update(|cx| { - language::init(cx); editor::init(cx); workspace::init(app_state.clone(), cx); - Project::init_settings(cx); }); let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; @@ -1893,24 +1323,16 @@ mod tests { let mut cx = VisualTestContext::from_window(*window, cx); - let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ - acp::AvailableCommand { - name: "quick-math".to_string(), - description: "2 + 2 = 4 - 1 = 3".to_string(), - input: None, - meta: None, - }, - acp::AvailableCommand { - name: "say-hello".to_string(), - description: "Say hello to whoever you want".to_string(), - input: Some(acp::AvailableCommandInput::Unstructured { - hint: "".to_string(), - }), - meta: None, - }, + acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), + acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input( + acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new( + "", + )), + ), ])); let editor = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -1918,7 +1340,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -2011,21 +1433,11 @@ mod tests { editor.update_in(&mut cx, |editor, _window, cx| { assert_eq!(editor.text(cx), "/say-hello "); assert_eq!(editor.display_text(cx), "/say-hello "); - assert!(editor.has_visible_completions_menu()); - - assert_eq!( - current_completion_labels_with_documentation(editor), - &[("say-hello".into(), "Say hello to whoever you want".into())] - ); + assert!(!editor.has_visible_completions_menu()); }); cx.simulate_input("GPT5"); - editor.update_in(&mut cx, |editor, window, cx| { - assert!(editor.has_visible_completions_menu()); - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - cx.run_until_parked(); editor.update_in(&mut cx, |editor, window, cx| { @@ -2034,7 +1446,7 @@ mod tests { assert!(!editor.has_visible_completions_menu()); // Delete argument - for _ in 0..4 { + for _ in 0..5 { editor.backspace(&editor::actions::Backspace, window, cx); } }); @@ -2042,13 +1454,12 @@ mod tests { cx.run_until_parked(); editor.update_in(&mut cx, |editor, window, cx| { - assert_eq!(editor.text(cx), "/say-hello "); + assert_eq!(editor.text(cx), "/say-hello"); // Hint is visible because argument was deleted assert_eq!(editor.display_text(cx), "/say-hello "); // Delete last command letter editor.backspace(&editor::actions::Backspace, window, cx); - editor.backspace(&editor::actions::Backspace, window, cx); }); cx.run_until_parked(); @@ -2068,10 +1479,8 @@ mod tests { 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 @@ -2122,7 +1531,7 @@ mod tests { rel_path("b/eight.txt"), ]; - let slash = PathStyle::local().separator(); + let slash = PathStyle::local().primary_separator(); let mut opened_editors = Vec::new(); for path in paths { @@ -2144,8 +1553,8 @@ mod tests { opened_editors.push(buffer); } - let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -2153,7 +1562,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -2192,21 +1601,23 @@ mod tests { assert_eq!( current_completion_labels(editor), &[ - format!("eight.txt dir{slash}b{slash}"), - format!("seven.txt dir{slash}b{slash}"), - format!("six.txt dir{slash}b{slash}"), - format!("five.txt dir{slash}b{slash}"), + format!("eight.txt b{slash}"), + format!("seven.txt b{slash}"), + format!("six.txt b{slash}"), + format!("five.txt b{slash}"), + "Files & Directories".into(), + "Symbols".into() ] ); editor.set_text("", window, cx); }); - prompt_capabilities.replace(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }); + prompt_capabilities.replace( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ); cx.simulate_input("Lorem "); @@ -2223,10 +1634,10 @@ mod tests { assert_eq!( current_completion_labels(editor), &[ - format!("eight.txt dir{slash}b{slash}"), - format!("seven.txt dir{slash}b{slash}"), - format!("six.txt dir{slash}b{slash}"), - format!("five.txt dir{slash}b{slash}"), + format!("eight.txt b{slash}"), + format!("seven.txt b{slash}"), + format!("six.txt b{slash}"), + format!("five.txt b{slash}"), "Files & Directories".into(), "Symbols".into(), "Threads".into(), @@ -2259,7 +1670,7 @@ mod tests { assert!(editor.has_visible_completions_menu()); assert_eq!( current_completion_labels(editor), - vec![format!("one.txt dir{slash}a{slash}")] + vec![format!("one.txt a{slash}")] ); }); @@ -2280,21 +1691,11 @@ mod tests { assert_eq!(fold_ranges(editor, cx).len(), 1); }); - let all_prompt_capabilities = acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }; - let contents = message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .unwrap() @@ -2306,28 +1707,10 @@ mod tests { panic!("Unexpected mentions"); }; pretty_assertions::assert_eq!(content, "1"); - pretty_assertions::assert_eq!(uri, &url_one.parse::().unwrap()); - } - - let contents = message_editor - .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &acp::PromptCapabilities::default(), - false, - project.clone(), - cx, - ) - }) - .await - .unwrap() - .into_values() - .collect::>(); - - { - let [(uri, Mention::UriOnly)] = contents.as_slice() else { - panic!("Unexpected mentions"); - }; - pretty_assertions::assert_eq!(uri, &url_one.parse::().unwrap()); + pretty_assertions::assert_eq!( + uri, + &MentionUri::parse(&url_one, PathStyle::local()).unwrap() + ); } cx.simulate_input(" "); @@ -2365,12 +1748,9 @@ mod tests { let contents = message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .unwrap() @@ -2388,7 +1768,10 @@ mod tests { panic!("Unexpected mentions"); }; pretty_assertions::assert_eq!(content, "8"); - pretty_assertions::assert_eq!(uri, &url_eight.parse::().unwrap()); + pretty_assertions::assert_eq!( + uri, + &MentionUri::parse(&url_eight, PathStyle::local()).unwrap() + ); } editor.update(&mut cx, |editor, cx| { @@ -2473,7 +1856,7 @@ mod tests { format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ") ); assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), &["MySymbol"]); + assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]); }); editor.update_in(&mut cx, |editor, window, cx| { @@ -2488,12 +1871,9 @@ mod tests { let contents = message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .unwrap() @@ -2529,7 +1909,7 @@ mod tests { format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri()) ); assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), &[format!("x.png dir{slash}")]); + assert_eq!(current_completion_labels(editor), &["x.png "]); }); editor.update_in(&mut cx, |editor, window, cx| { @@ -2539,12 +1919,9 @@ mod tests { // Getting the message contents fails message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .expect_err("Should fail to load x.png"); @@ -2571,7 +1948,7 @@ mod tests { format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri()) ); assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), &[format!("x.png dir{slash}")]); + assert_eq!(current_completion_labels(editor), &["x.png "]); }); editor.update_in(&mut cx, |editor, window, cx| { @@ -2595,12 +1972,9 @@ mod tests { // Now getting the contents succeeds, because the invalid mention was removed let contents = message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .unwrap(); @@ -2612,7 +1986,7 @@ mod tests { editor.display_map.update(cx, |display_map, cx| { display_map .snapshot(cx) - .folds_in_range(0..snapshot.len()) + .folds_in_range(MultiBufferOffset(0)..snapshot.len()) .map(|fold| fold.range.to_point(&snapshot)) .collect() }) @@ -2643,13 +2017,14 @@ mod tests { } #[gpui::test] - async fn test_large_file_mention_uses_outline(cx: &mut TestAppContext) { + async fn test_large_file_mention_fallback(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); // Create a large file that exceeds AUTO_OUTLINE_SIZE - const LINE: &str = "fn example_function() { /* some code */ }\n"; + // Using plain text without a configured language, so no outline is available + const LINE: &str = "This is a line of text in the file\n"; let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len())); assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE); @@ -2660,8 +2035,8 @@ mod tests { fs.insert_tree( "/project", json!({ - "large_file.rs": large_content.clone(), - "small_file.rs": small_content, + "large_file.txt": large_content.clone(), + "small_file.txt": small_content, }), ) .await; @@ -2671,14 +2046,14 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { let editor = MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -2693,11 +2068,9 @@ mod tests { cx, ); // Enable embedded context so files are actually included - editor.prompt_capabilities.replace(acp::PromptCapabilities { - embedded_context: true, - meta: None, - ..Default::default() - }); + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)); editor }) }); @@ -2707,20 +2080,31 @@ mod tests { let large_file_abs_path = project.read_with(cx, |project, cx| { let worktree = project.worktrees(cx).next().unwrap(); let worktree_root = worktree.read(cx).abs_path(); - worktree_root.join("large_file.rs") + worktree_root.join("large_file.txt") }); let large_file_task = message_editor.update(cx, |editor, cx| { - editor.confirm_mention_for_file(large_file_abs_path, cx) + editor.mention_set().update(cx, |set, cx| { + set.confirm_mention_for_file(large_file_abs_path, true, cx) + }) }); let large_file_mention = large_file_task.await.unwrap(); match large_file_mention { Mention::Text { content, .. } => { - // Should contain outline header for large files - assert!(content.contains("File outline for")); - assert!(content.contains("file too large to show full content")); - // Should not contain the full repeated content - assert!(!content.contains(&LINE.repeat(100))); + // Should contain some of the content but not all of it + assert!( + content.contains(LINE), + "Should contain some of the file content" + ); + assert!( + !content.contains(&LINE.repeat(100)), + "Should not contain the full file" + ); + // Should be much smaller than original + assert!( + content.len() < large_content.len() / 10, + "Should be significantly truncated" + ); } _ => panic!("Expected Text mention for large file"), } @@ -2730,21 +2114,419 @@ mod tests { let small_file_abs_path = project.read_with(cx, |project, cx| { let worktree = project.worktrees(cx).next().unwrap(); let worktree_root = worktree.read(cx).abs_path(); - worktree_root.join("small_file.rs") + worktree_root.join("small_file.txt") }); let small_file_task = message_editor.update(cx, |editor, cx| { - editor.confirm_mention_for_file(small_file_abs_path, cx) + editor.mention_set().update(cx, |set, cx| { + set.confirm_mention_for_file(small_file_abs_path, true, cx) + }) }); let small_file_mention = small_file_task.await.unwrap(); match small_file_mention { Mention::Text { content, .. } => { - // Should contain the actual content + // Should contain the full actual content assert_eq!(content, small_content); - // Should not contain outline header - assert!(!content.contains("File outline for")); } _ => panic!("Expected Text mention for small file"), } } + + #[gpui::test] + async fn test_insert_thread_summary(cx: &mut TestAppContext) { + init_test(cx); + cx.update(LanguageModelRegistry::test); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file": ""})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Create a thread metadata to insert as summary + let thread_metadata = agent::DbThreadMetadata { + id: acp::SessionId::new("thread-123"), + title: "Previous Conversation".into(), + updated_at: chrono::Utc::now(), + }; + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + let mut editor = MessageEditor::new( + workspace.downgrade(), + project.downgrade(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ); + editor.insert_thread_summary(thread_metadata.clone(), window, cx); + editor + }) + }); + + // Construct expected values for verification + let expected_uri = MentionUri::Thread { + id: thread_metadata.id.clone(), + name: thread_metadata.title.to_string(), + }; + let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri()); + + message_editor.read_with(cx, |editor, cx| { + let text = editor.text(cx); + + assert!( + text.contains(&expected_link), + "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}", + expected_link, + text + ); + + let mentions = editor.mention_set().read(cx).mentions(); + assert_eq!( + mentions.len(), + 1, + "Expected exactly one mention after inserting thread summary" + ); + + assert!( + mentions.contains(&expected_uri), + "Expected mentions to contain the thread URI" + ); + }); + } + + #[gpui::test] + async fn test_whitespace_trimming(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file.rs": "fn main() {}"})) + .await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace.downgrade(), + project.downgrade(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ) + }) + }); + let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone()); + + cx.run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(" \u{A0}してhello world ", window, cx); + }); + + let (content, _) = message_editor + .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .await + .unwrap(); + + assert_eq!(content, vec!["してhello world".into()]); + } + + #[gpui::test] + async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + let file_content = "fn main() { println!(\"Hello, world!\"); }\n"; + + fs.insert_tree( + "/project", + json!({ + "src": { + "main.rs": file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window); + let editor = message_editor.read(cx).editor().clone(); + (message_editor, editor) + }); + + cx.simulate_input("What is in @file main"); + + editor.update_in(cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + assert_eq!(editor.text(cx), "What is in @file main"); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + let content = message_editor + .update(cx, |editor, cx| editor.contents(false, cx)) + .await + .unwrap() + .0; + + let main_rs_uri = if cfg!(windows) { + "file:///C:/project/src/main.rs" + } else { + "file:///project/src/main.rs" + }; + + // When embedded context is `false` we should get a resource link + pretty_assertions::assert_eq!( + content, + vec![ + "What is in ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri)) + ] + ); + + message_editor.update(cx, |editor, _cx| { + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)) + }); + + let content = message_editor + .update(cx, |editor, cx| editor.contents(false, cx)) + .await + .unwrap() + .0; + + // When embedded context is `true` we should get a resource + pretty_assertions::assert_eq!( + content, + vec![ + "What is in ".into(), + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(file_content, main_rs_uri) + ) + )) + ] + ); + } + + #[gpui::test] + async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(app_state.clone(), cx); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "test.txt": "line1\nline2\nline3\nline4\nline5\n", + }), + ) + .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, cx); + + // Open a regular editor with the created file, and select a portion of + // the text that will be used for the selections that are meant to be + // inserted in the agent panel. + let editor = workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: rel_path("test.txt").into(), + }, + None, + false, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]); + }); + }); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Create a new `MessageEditor`. The `EditorMode::full()` has to be used + // to ensure we have a fixed viewport, so we can eventually actually + // place the cursor outside of the visible area. + let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::full(), + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + + message_editor + }); + + message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + // Update the Agent Panel's Message Editor text to have 100 + // lines, ensuring that the cursor is set at line 90 and that we + // then scroll all the way to the top, so the cursor's position + // remains off screen. + let mut lines = String::new(); + for _ in 1..=100 { + lines.push_str(&"Another line in the agent panel's message editor\n"); + } + editor.set_text(lines.as_str(), window, cx); + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]); + }); + editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx); + }); + }); + + cx.run_until_parked(); + + // Before proceeding, let's assert that the cursor is indeed off screen, + // otherwise the rest of the test doesn't make sense. + message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let cursor_row = editor.selections.newest::(&snapshot).head().row; + let scroll_top = snapshot.scroll_position().y as u32; + let visible_lines = editor.visible_line_count().unwrap() as u32; + let visible_range = scroll_top..(scroll_top + visible_lines); + + assert!(!visible_range.contains(&cursor_row)); + }) + }); + + // Now let's insert the selection in the Agent Panel's editor and + // confirm that, after the insertion, the cursor is now in the visible + // range. + message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.insert_selections(window, cx); + }); + + cx.run_until_parked(); + + message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let cursor_row = editor.selections.newest::(&snapshot).head().row; + let scroll_top = snapshot.scroll_position().y as u32; + let visible_lines = editor.visible_line_count().unwrap() as u32; + let visible_range = scroll_top..(scroll_top + visible_lines); + + assert!(visible_range.contains(&cursor_row)); + }) + }); + } } diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs index 4108741266..1f50ce7432 100644 --- a/crates/agent_ui/src/acp/mode_selector.rs +++ b/crates/agent_ui/src/acp/mode_selector.rs @@ -1,15 +1,17 @@ use acp_thread::AgentSessionModes; use agent_client_protocol as acp; use agent_servers::AgentServer; +use agent_settings::AgentSettings; use fs::Fs; use gpui::{Context, Entity, FocusHandle, WeakEntity, Window, prelude::*}; +use settings::Settings as _; use std::{rc::Rc, sync::Arc}; use ui::{ Button, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*, }; -use crate::{CycleModeSelector, ToggleProfileSelector}; +use crate::{CycleModeSelector, ToggleProfileSelector, ui::HoldForDefault}; pub struct ModeSelector { connection: Rc, @@ -54,6 +56,10 @@ impl ModeSelector { self.set_mode(all_modes[next_index].id.clone(), cx); } + pub fn mode(&self) -> acp::SessionModeId { + self.connection.current_mode() + } + pub fn set_mode(&mut self, mode: acp::SessionModeId, cx: &mut Context) { let task = self.connection.set_mode(mode, cx); self.setting_mode = true; @@ -84,6 +90,14 @@ impl ModeSelector { let current_mode = self.connection.current_mode(); let default_mode = self.agent_server.default_mode(cx); + let settings = AgentSettings::get_global(cx); + let side = match settings.dock { + settings::DockPosition::Left => DocumentationSide::Right, + settings::DockPosition::Bottom | settings::DockPosition::Right => { + DocumentationSide::Left + } + }; + for mode in all_modes { let is_selected = &mode.id == ¤t_mode; let is_default = Some(&mode.id) == default_mode.as_ref(); @@ -91,39 +105,14 @@ impl ModeSelector { .toggleable(IconPosition::End, is_selected); let entry = if let Some(description) = &mode.description { - entry.documentation_aside(DocumentationSide::Left, DocumentationEdge::Bottom, { + entry.documentation_aside(side, DocumentationEdge::Bottom, { let description = description.clone(); - move |cx| { + move |_| { v_flex() .gap_1() .child(Label::new(description.clone())) - .child( - h_flex() - .pt_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .gap_0p5() - .text_sm() - .text_color(Color::Muted.color(cx)) - .child("Hold") - .child(h_flex().flex_shrink_0().children( - ui::render_modifiers( - &gpui::Modifiers::secondary_key(), - PlatformStyle::platform(), - None, - Some(ui::TextSize::Default.rems(cx).into()), - true, - ), - )) - .child(div().map(|this| { - if is_default { - this.child("to also unset as default") - } else { - this.child("to also set as default") - } - })), - ) + .child(HoldForDefault::new(is_default)) .into_any_element() } }) @@ -172,13 +161,18 @@ impl Render for ModeSelector { .map(|mode| mode.name.clone()) .unwrap_or_else(|| "Unknown".into()); - let this = cx.entity(); + let this = cx.weak_entity(); + + let icon = if self.menu_handle.is_deployed() { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; let trigger_button = Button::new("mode-selector-trigger", current_mode_name) .label_size(LabelSize::Small) - .style(ButtonStyle::Subtle) .color(Color::Muted) - .icon(IconName::ChevronDown) + .icon(icon) .icon_size(IconSize::XSmall) .icon_position(IconPosition::End) .icon_color(Color::Muted) @@ -189,7 +183,7 @@ impl Render for ModeSelector { trigger_button, Tooltip::element({ let focus_handle = self.focus_handle.clone(); - move |window, cx| { + move |_window, cx| { v_flex() .gap_1() .child( @@ -200,10 +194,9 @@ impl Render for ModeSelector { .border_b_1() .border_color(cx.theme().colors().border_variant) .child(Label::new("Cycle Through Modes")) - .children(KeyBinding::for_action_in( + .child(KeyBinding::for_action_in( &CycleModeSelector, &focus_handle, - window, cx, )), ) @@ -212,10 +205,9 @@ impl Render for ModeSelector { .gap_2() .justify_between() .child(Label::new("Toggle Mode Menu")) - .children(KeyBinding::for_action_in( + .child(KeyBinding::for_action_in( &ToggleProfileSelector, &focus_handle, - window, cx, )), ) @@ -230,7 +222,8 @@ impl Render for ModeSelector { y: px(-2.0), }) .menu(move |window, cx| { - Some(this.update(cx, |this, cx| this.build_context_menu(window, cx))) + this.update(cx, |this, cx| this.build_context_menu(window, cx)) + .ok() }) } } diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 381bdb01ed..f9710ad9b3 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -1,27 +1,38 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector}; +use agent_servers::AgentServer; use anyhow::Result; use collections::IndexMap; +use fs::Fs; use futures::FutureExt; use fuzzy::{StringMatchCandidate, match_strings}; -use gpui::{Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, Task, WeakEntity}; +use gpui::{ + Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity, +}; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; use ui::{ - AnyElement, App, Context, DocumentationAside, DocumentationEdge, DocumentationSide, - IntoElement, ListItem, ListItemSpacing, SharedString, Window, prelude::*, rems, + DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, KeyBinding, ListItem, + ListItemSpacing, prelude::*, }; use util::ResultExt; +use zed_actions::agent::OpenSettings; + +use crate::ui::HoldForDefault; pub type AcpModelSelector = Picker; pub fn acp_model_selector( selector: Rc, + agent_server: Rc, + fs: Arc, + focus_handle: FocusHandle, window: &mut Window, cx: &mut Context, ) -> AcpModelSelector { - let delegate = AcpModelPickerDelegate::new(selector, window, cx); + let delegate = + AcpModelPickerDelegate::new(selector, agent_server, fs, focus_handle, window, cx); Picker::list(delegate, window, cx) .show_scrollbar(true) .width(rems(20.)) @@ -35,17 +46,23 @@ enum AcpModelPickerEntry { pub struct AcpModelPickerDelegate { selector: Rc, + agent_server: Rc, + fs: Arc, filtered_entries: Vec, models: Option, selected_index: usize, - selected_description: Option<(usize, SharedString)>, + selected_description: Option<(usize, SharedString, bool)>, selected_model: Option, _refresh_models_task: Task<()>, + focus_handle: FocusHandle, } impl AcpModelPickerDelegate { fn new( selector: Rc, + agent_server: Rc, + fs: Arc, + focus_handle: FocusHandle, window: &mut Window, cx: &mut Context, ) -> Self { @@ -86,12 +103,15 @@ impl AcpModelPickerDelegate { Self { selector, + agent_server, + fs, filtered_entries: Vec::new(), models: None, selected_model: None, selected_index: 0, selected_description: None, _refresh_models_task: refresh_models_task, + focus_handle, } } @@ -181,6 +201,21 @@ impl PickerDelegate for AcpModelPickerDelegate { if let Some(AcpModelPickerEntry::Model(model_info)) = self.filtered_entries.get(self.selected_index) { + if window.modifiers().secondary() { + let default_model = self.agent_server.default_model(cx); + let is_default = default_model.as_ref() == Some(&model_info.id); + + self.agent_server.set_default_model( + if is_default { + None + } else { + Some(model_info.id.clone()) + }, + self.fs.clone(), + cx, + ); + } + self.selector .select_model(model_info.id.clone(), cx) .detach_and_log_err(cx); @@ -225,6 +260,8 @@ impl PickerDelegate for AcpModelPickerDelegate { ), AcpModelPickerEntry::Model(model_info) => { let is_selected = Some(model_info) == self.selected_model.as_ref(); + let default_model = self.agent_server.default_model(cx); + let is_default = default_model.as_ref() == Some(&model_info.id); let model_icon_color = if is_selected { Color::Accent @@ -239,8 +276,8 @@ impl PickerDelegate for AcpModelPickerDelegate { this .on_hover(cx.listener(move |menu, hovered, _, cx| { if *hovered { - menu.delegate.selected_description = Some((ix, description.clone())); - } else if matches!(menu.delegate.selected_description, Some((id, _)) if id == ix) { + menu.delegate.selected_description = Some((ix, description.clone(), is_default)); + } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) { menu.delegate.selected_description = None; } cx.notify(); @@ -251,17 +288,17 @@ impl PickerDelegate for AcpModelPickerDelegate { .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .start_slot::(model_info.icon.map(|icon| { - Icon::new(icon) - .color(model_icon_color) - .size(IconSize::Small) - })) .child( h_flex() .w_full() - .pl_0p5() .gap_1p5() - .w(px(240.)) + .when_some(model_info.icon, |this, icon| { + this.child( + Icon::new(icon) + .color(model_icon_color) + .size(IconSize::Small) + ) + }) .child(Label::new(model_info.name.clone()).truncate()), ) .end_slot(div().pr_3().when(is_selected, |this| { @@ -278,49 +315,62 @@ impl PickerDelegate for AcpModelPickerDelegate { } } - fn render_footer( - &self, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - Some( - h_flex() - .w_full() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .p_1() - .gap_4() - .justify_between() - .child( - Button::new("configure", "Configure") - .icon(IconName::Settings) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(|_, window, cx| { - window.dispatch_action( - zed_actions::agent::OpenSettings.boxed_clone(), - cx, - ); - }), - ) - .into_any(), - ) - } - fn documentation_aside( &self, _window: &mut Window, _cx: &mut Context>, ) -> Option { - self.selected_description.as_ref().map(|(_, description)| { - let description = description.clone(); - DocumentationAside::new( - DocumentationSide::Left, - DocumentationEdge::Bottom, - Rc::new(move |_| Label::new(description.clone()).into_any_element()), - ) - }) + self.selected_description + .as_ref() + .map(|(_, description, is_default)| { + let description = description.clone(); + let is_default = *is_default; + + DocumentationAside::new( + DocumentationSide::Left, + DocumentationEdge::Top, + Rc::new(move |_| { + v_flex() + .gap_1() + .child(Label::new(description.clone())) + .child(HoldForDefault::new(is_default)) + .into_any_element() + }), + ) + }) + } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let focus_handle = self.focus_handle.clone(); + + if !self.selector.should_render_footer() { + return None; + } + + Some( + h_flex() + .w_full() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("configure", "Configure") + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(OpenSettings.boxed_clone(), cx); + }), + ) + .into_any(), + ) } } @@ -414,7 +464,7 @@ mod tests { models .into_iter() .map(|model| acp_thread::AgentModelInfo { - id: acp::ModelId(model.to_string().into()), + id: acp::ModelId::new(model.to_string()), name: model.to_string().into(), description: None, icon: None, diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs index 55f530c81b..e2393c11bd 100644 --- a/crates/agent_ui/src/acp/model_selector_popover.rs +++ b/crates/agent_ui/src/acp/model_selector_popover.rs @@ -1,6 +1,9 @@ use std::rc::Rc; +use std::sync::Arc; -use acp_thread::AgentModelSelector; +use acp_thread::{AgentModelInfo, AgentModelSelector}; +use agent_servers::AgentServer; +use fs::Fs; use gpui::{Entity, FocusHandle}; use picker::popover_menu::PickerPopoverMenu; use ui::{ @@ -20,13 +23,25 @@ pub struct AcpModelSelectorPopover { impl AcpModelSelectorPopover { pub(crate) fn new( selector: Rc, + agent_server: Rc, + fs: Arc, menu_handle: PopoverMenuHandle, focus_handle: FocusHandle, window: &mut Window, cx: &mut Context, ) -> Self { + let focus_handle_clone = focus_handle.clone(); Self { - selector: cx.new(move |cx| acp_model_selector(selector, window, cx)), + selector: cx.new(move |cx| { + acp_model_selector( + selector, + agent_server, + fs, + focus_handle_clone.clone(), + window, + cx, + ) + }), menu_handle, focus_handle, } @@ -36,12 +51,8 @@ impl AcpModelSelectorPopover { self.menu_handle.toggle(window, cx); } - pub fn active_model_name(&self, cx: &App) -> Option { - self.selector - .read(cx) - .delegate - .active_model() - .map(|model| model.name.clone()) + pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> { + self.selector.read(cx).delegate.active_model() } } @@ -57,38 +68,28 @@ impl Render for AcpModelSelectorPopover { let focus_handle = self.focus_handle.clone(); - let color = if self.menu_handle.is_deployed() { - Color::Accent + let (color, icon) = if self.menu_handle.is_deployed() { + (Color::Accent, IconName::ChevronUp) } else { - Color::Muted + (Color::Muted, IconName::ChevronDown) }; PickerPopoverMenu::new( self.selector.clone(), ButtonLike::new("active-model") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when_some(model_icon, |this, icon| { this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) }) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child( Label::new(model_name) .color(color) .size(LabelSize::Small) .ml_0p5(), ) - .child( - Icon::new(IconName::ChevronDown) - .color(Color::Muted) - .size(IconSize::XSmall), - ), - move |window, cx| { - Tooltip::for_action_in( - "Change Model", - &ToggleModelSelector, - &focus_handle, - window, - cx, - ) + .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), + move |_window, cx| { + Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) }, gpui::Corner::BottomRight, cx, diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index cd696f33fa..1aa89b35d3 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -1,6 +1,6 @@ use crate::acp::AcpThreadView; -use crate::{AgentPanel, RemoveSelectedThread}; -use agent2::{HistoryEntry, HistoryStore}; +use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; +use agent::{HistoryEntry, HistoryStore}; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; use editor::{Editor, EditorEvent}; use fuzzy::StringMatchCandidate; @@ -12,7 +12,7 @@ use std::{fmt::Display, ops::Range}; use text::Bias; use time::{OffsetDateTime, UtcOffset}; use ui::{ - HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tooltip, WithScrollbar, + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, prelude::*, }; @@ -23,11 +23,9 @@ pub struct AcpThreadHistory { hovered_index: Option, search_editor: Entity, search_query: SharedString, - visible_items: Vec, - local_timezone: UtcOffset, - + confirming_delete_history: bool, _update_task: Task<()>, _subscriptions: Vec, } @@ -62,7 +60,7 @@ impl EventEmitter for AcpThreadHistory {} impl AcpThreadHistory { pub(crate) fn new( - history_store: Entity, + history_store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -101,6 +99,7 @@ impl AcpThreadHistory { ) .unwrap(), search_query: SharedString::default(), + confirming_delete_history: false, _subscriptions: vec![search_editor_subscription, history_store_subscription], _update_task: Task::ready(()), }; @@ -327,13 +326,31 @@ impl AcpThreadHistory { HistoryEntry::AcpThread(thread) => self .history_store .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), - HistoryEntry::TextThread(context) => self.history_store.update(cx, |this, cx| { - this.delete_text_thread(context.path.clone(), cx) + HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { + this.delete_text_thread(text_thread.path.clone(), cx) }), }; task.detach_and_log_err(cx); } + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.history_store.update(cx, |store, cx| { + store.delete_threads(cx).detach_and_log_err(cx) + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + fn render_list_items( &mut self, range: Range, @@ -426,12 +443,13 @@ impl AcpThreadHistory { .shape(IconButtonShape::Square) .icon_size(IconSize::XSmall) .icon_color(Color::Muted) - .tooltip(move |window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) }) - .on_click( - cx.listener(move |this, _, _, cx| this.remove_thread(ix, cx)), - ), + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), ) } else { None @@ -450,34 +468,38 @@ impl Focusable for AcpThreadHistory { impl Render for AcpThreadHistory { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history_store.read(cx).is_empty(cx); + v_flex() .key_context("ThreadHistory") .size_full() + .bg(cx.theme().colors().panel_background) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::remove_selected_thread)) - .when(!self.history_store.read(cx).is_empty(cx), |parent| { - parent.child( - h_flex() - .h(px(41.)) // Match the toolbar perfectly - .w_full() - .py_1() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(self.search_editor.clone()), - ) - }) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) .child({ let view = v_flex() .id("list-container") @@ -485,20 +507,16 @@ impl Render for AcpThreadHistory { .overflow_hidden() .flex_grow(); - if self.history_store.read(cx).is_empty(cx) { - view.justify_center() - .child( - h_flex().w_full().justify_center().child( - Label::new("You don't have any past threads yet.") - .size(LabelSize::Small), - ), - ) - } else if self.search_produced_no_matches() { - view.justify_center().child( - h_flex().w_full().justify_center().child( - Label::new("No threads match your search.").size(LabelSize::Small), - ), + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) } else { view.child( uniform_list( @@ -510,16 +528,74 @@ impl Render for AcpThreadHistory { ) .p_1() .pr_4() - .track_scroll(self.scroll_handle.clone()) + .track_scroll(&self.scroll_handle) .flex_grow(), ) - .vertical_scrollbar_for( - self.scroll_handle.clone(), - window, - cx, - ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) } }) + .when(!has_no_history, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) } } @@ -598,8 +674,8 @@ impl RenderOnce for AcpHistoryEntryElement { .shape(IconButtonShape::Square) .icon_size(IconSize::XSmall) .icon_color(Color::Muted) - .tooltip(move |window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) }) .on_click({ let thread_view = self.thread_view.clone(); @@ -638,12 +714,12 @@ impl RenderOnce for AcpHistoryEntryElement { }); } } - HistoryEntry::TextThread(context) => { + HistoryEntry::TextThread(text_thread) => { if let Some(panel) = workspace.read(cx).panel::(cx) { panel.update(cx, |panel, cx| { panel - .open_saved_prompt_editor( - context.path.clone(), + .open_saved_text_thread( + text_thread.path.clone(), window, cx, ) @@ -675,7 +751,7 @@ impl EntryTimeFormat { timezone, time_format::TimestampFormat::EnhancedAbsolute, ), - EntryTimeFormat::TimeOnly => time_format::format_time(timestamp), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), } } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 7d8fdcb936..6cd2ec2fa3 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -4,12 +4,12 @@ use acp_thread::{ ToolCallStatus, UserMessageId, }; use acp_thread::{AgentConnection, Plan}; -use action_log::ActionLog; +use action_log::{ActionLog, ActionLogTelemetry}; +use agent::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; use agent_client_protocol::{self as acp, PromptCapabilities}; use agent_servers::{AgentServer, AgentServerDelegate}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; -use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; -use anyhow::{Result, anyhow, bail}; +use anyhow::{Result, anyhow}; use arrayvec::ArrayVec; use audio::{Audio, Sound}; use buffer_diff::BufferDiff; @@ -17,7 +17,9 @@ use client::zed_urls; use cloud_llm_client::PlanV1; use collections::{HashMap, HashSet}; use editor::scroll::Autoscroll; -use editor::{Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects}; +use editor::{ + Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior, +}; use file_icons::FileIcons; use fs::Fs; use futures::FutureExt as _; @@ -49,7 +51,7 @@ use ui::{ PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; -use workspace::{CollaboratorId, Workspace}; +use workspace::{CollaboratorId, NewTerminal, Workspace}; use zed_actions::agent::{Chat, ToggleModelSelector}; use zed_actions::assistant::OpenRulesLibrary; @@ -67,8 +69,8 @@ use crate::ui::{ }; use crate::{ AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode, - CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, OpenHistory, RejectAll, - RejectOnce, ToggleBurnMode, ToggleProfileSelector, + CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAgentDiff, OpenHistory, + RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector, }; #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -98,11 +100,11 @@ impl ThreadError { { Self::ModelRequestLimitReached(error.plan) } else if let Some(acp_error) = error.downcast_ref::() - && acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code + && acp_error.code == acp::ErrorCode::AuthRequired { Self::AuthenticationRequired(acp_error.message.clone().into()) } else { - let string = error.to_string(); + let string = format!("{:#}", error); // TODO: we should have Gemini return better errors here. if agent.clone().downcast::().is_some() && string.contains("Could not load the default credentials") @@ -111,20 +113,21 @@ impl ThreadError { { Self::AuthenticationRequired(string.into()) } else { - Self::Other(error.to_string().into()) + Self::Other(string.into()) } } } } -impl ProfileProvider for Entity { +impl ProfileProvider for Entity { fn profile_id(&self, cx: &App) -> AgentProfileId { self.read(cx).profile().clone() } fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) { - self.update(cx, |thread, _cx| { - thread.set_profile(profile_id); + self.update(cx, |thread, cx| { + // Apply the profile and let the thread swap to its default model. + thread.set_profile(profile_id, cx); }); } @@ -167,7 +170,7 @@ impl ThreadFeedbackState { } } let session_id = thread.read(cx).session_id().clone(); - let agent_name = telemetry.agent_name(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); let rating = match feedback { ThreadFeedback::Positive => "positive", @@ -177,9 +180,9 @@ impl ThreadFeedbackState { let thread = task.await?; telemetry::event!( "Agent Thread Rated", + agent = agent_telemetry_id, session_id = session_id, rating = rating, - agent = agent_name, thread = thread ); anyhow::Ok(()) @@ -204,15 +207,15 @@ impl ThreadFeedbackState { self.comments_editor.take(); let session_id = thread.read(cx).session_id().clone(); - let agent_name = telemetry.agent_name(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); cx.background_spawn(async move { let thread = task.await?; telemetry::event!( "Agent Thread Feedback Comments", + agent = agent_telemetry_id, session_id = session_id, comments = comments, - agent = agent_name, thread = thread ); anyhow::Ok(()) @@ -275,6 +278,7 @@ pub struct AcpThreadView { notification_subscriptions: HashMap, Vec>, thread_retry_status: Option, thread_error: Option, + thread_error_markdown: Option>, thread_feedback: ThreadFeedbackState, list_state: ListState, auth_task: Option>, @@ -292,6 +296,8 @@ pub struct AcpThreadView { resume_thread_metadata: Option, _cancel_task: Option>, _subscriptions: [Subscription; 5], + show_codex_windows_warning: bool, + in_flight_prompt: Option>, } enum ThreadState { @@ -327,27 +333,19 @@ impl AcpThreadView { project: Entity, history_store: Entity, prompt_store: Option>, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> Self { let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); - let placeholder = if agent.name() == "Zed Agent" { - format!("Message the {} — @ to include context", agent.name()) - } else if agent.name() == "Claude Code" || !available_commands.borrow().is_empty() { - format!( - "Message {} — @ to include context, / for commands", - agent.name() - ) - } else { - format!("Message {} — @ to include context", agent.name()) - }; + let placeholder = placeholder_text(agent.name().as_ref(), false); let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( workspace.clone(), - project.clone(), + project.downgrade(), history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), @@ -372,7 +370,7 @@ impl AcpThreadView { let entry_view_state = cx.new(|_| { EntryViewState::new( workspace.clone(), - project.clone(), + project.downgrade(), history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), @@ -394,6 +392,10 @@ impl AcpThreadView { ), ]; + let show_codex_windows_warning = cfg!(windows) + && project.read(cx).is_local() + && agent.clone().downcast::().is_some(); + Self { agent: agent.clone(), workspace: workspace.clone(), @@ -404,6 +406,7 @@ impl AcpThreadView { resume_thread.clone(), workspace.clone(), project.clone(), + track_load_event, window, cx, ), @@ -417,6 +420,7 @@ impl AcpThreadView { list_state: list_state, thread_retry_status: None, thread_error: None, + thread_error_markdown: None, thread_feedback: Default::default(), auth_task: None, expanded_tool_calls: HashSet::default(), @@ -436,6 +440,8 @@ impl AcpThreadView { focus_handle: cx.focus_handle(), new_server_version_available: None, resume_thread_metadata: resume_thread, + show_codex_windows_warning, + in_flight_prompt: None, } } @@ -445,6 +451,7 @@ impl AcpThreadView { self.resume_thread_metadata.clone(), self.workspace.clone(), self.project.clone(), + true, window, cx, ); @@ -458,6 +465,7 @@ impl AcpThreadView { resume_thread: Option, workspace: WeakEntity, project: Entity, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> ThreadState { @@ -516,9 +524,13 @@ impl AcpThreadView { } }; + if track_load_event { + telemetry::event!("Agent Thread Started", agent = connection.telemetry_id()); + } + let result = if let Some(native_agent) = connection .clone() - .downcast::() + .downcast::() && let Some(resume) = resume_thread.clone() { cx.update(|_, cx| { @@ -528,14 +540,7 @@ impl AcpThreadView { }) .log_err() } else { - let root_dir = if let Some(acp_agent) = connection - .clone() - .downcast::() - { - acp_agent.root_dir().into() - } else { - root_dir.unwrap_or(paths::home_dir().as_path().into()) - }; + let root_dir = root_dir.unwrap_or(paths::home_dir().as_path().into()); cx.update(|_, cx| { connection .clone() @@ -597,9 +602,13 @@ impl AcpThreadView { .connection() .model_selector(thread.read(cx).session_id()) .map(|selector| { + let agent_server = this.agent.clone(); + let fs = this.project.read(cx).fs().clone(); cx.new(|cx| { AcpModelSelectorPopover::new( selector, + agent_server, + fs, PopoverMenuHandle::default(), this.focus_handle(cx), window, @@ -653,7 +662,6 @@ impl AcpThreadView { mode_selector, _subscriptions: subscriptions, }; - this.message_editor.focus_handle(cx).focus(window); this.profile_selector = this.as_native_thread(cx).map(|thread| { cx.new(|cx| { @@ -666,6 +674,8 @@ impl AcpThreadView { }) }); + this.message_editor.focus_handle(cx).focus(window); + cx.notify(); } Err(err) => { @@ -782,7 +792,8 @@ impl AcpThreadView { 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())) + self.thread_state = + ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into())) } if self.message_editor.focus_handle(cx).is_focused(window) { self.focus_handle.focus(window) @@ -805,6 +816,7 @@ impl AcpThreadView { if should_retry { self.thread_error = None; + self.thread_error_markdown = None; self.reset(window, cx); } } @@ -870,6 +882,7 @@ impl AcpThreadView { cx: &mut Context, ) { self.set_editor_is_expanded(!self.editor_expanded, cx); + cx.stop_propagation(); cx.notify(); } @@ -881,7 +894,7 @@ impl AcpThreadView { EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, - sized_by_content: false, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, }, cx, ) @@ -997,6 +1010,10 @@ impl AcpThreadView { } } + pub fn is_loading(&self) -> bool { + matches!(self.thread_state, ThreadState::Loading { .. }) + } + fn resume_chat(&mut self, cx: &mut Context) { self.thread_error.take(); let Some(thread) = self.thread() else { @@ -1046,32 +1063,36 @@ impl AcpThreadView { }; let connection = thread.read(cx).connection().clone(); - let auth_methods = connection.auth_methods(); - let has_supported_auth = auth_methods.iter().any(|method| { - let id = method.id.0.as_ref(); - id == "claude-login" || id == "spawn-gemini-cli" - }); - let can_login = has_supported_auth || auth_methods.is_empty() || self.login.is_some(); - if !can_login { + let can_login = !connection.auth_methods().is_empty() || self.login.is_some(); + // Does the agent have a specific logout command? Prefer that in case they need to reset internal state. + let logout_supported = text == "/logout" + && self + .available_commands + .borrow() + .iter() + .any(|command| command.name == "logout"); + if can_login && !logout_supported { + self.message_editor + .update(cx, |editor, cx| editor.clear(window, cx)); + + let this = cx.weak_entity(); + let agent = self.agent.clone(); + window.defer(cx, |window, cx| { + Self::handle_auth_required( + this, + AuthRequired { + description: None, + provider_id: None, + }, + agent, + connection, + window, + cx, + ); + }); + cx.notify(); return; - }; - let this = cx.weak_entity(); - let agent = self.agent.clone(); - window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired { - description: None, - provider_id: None, - }, - agent, - connection, - window, - cx, - ); - }); - cx.notify(); - return; + } } self.send_impl(self.message_editor.clone(), window, cx) @@ -1114,8 +1135,6 @@ impl AcpThreadView { message_editor.contents(full_mention_content, cx) }); - let agent_telemetry_id = self.agent.telemetry_id(); - self.thread_error.take(); self.editing_message.take(); self.thread_feedback.clear(); @@ -1123,6 +1142,8 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + let session_id = thread.read(cx).session_id().clone(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let thread = thread.downgrade(); if self.should_be_following { self.workspace @@ -1133,6 +1154,8 @@ impl AcpThreadView { } self.is_loading_contents = true; + let model_id = self.current_model_id(cx); + let mode_id = self.current_mode_id(cx); let guard = cx.new(|_| ()); cx.observe_release(&guard, |this, _guard, cx| { this.is_loading_contents = false; @@ -1148,12 +1171,14 @@ impl AcpThreadView { } this.update_in(cx, |this, window, cx| { + this.in_flight_prompt = Some(contents.clone()); this.set_editor_is_expanded(false, cx); this.scroll_to_bottom(cx); this.message_editor.update(cx, |message_editor, cx| { message_editor.clear(window, cx); }); })?; + let turn_start_time = Instant::now(); let send = thread.update(cx, |thread, cx| { thread.action_log().update(cx, |action_log, cx| { for buffer in tracked_buffers { @@ -1162,11 +1187,34 @@ impl AcpThreadView { }); drop(guard); - telemetry::event!("Agent Message Sent", agent = agent_telemetry_id); + telemetry::event!( + "Agent Message Sent", + agent = agent_telemetry_id, + session = session_id, + model = model_id, + mode = mode_id + ); thread.send(contents, cx) })?; - send.await + let res = send.await; + let turn_time_ms = turn_start_time.elapsed().as_millis(); + let status = if res.is_ok() { + this.update(cx, |this, _| this.in_flight_prompt.take()).ok(); + "success" + } else { + "failure" + }; + telemetry::event!( + "Agent Turn Completed", + agent = agent_telemetry_id, + session = session_id, + model = model_id, + mode = mode_id, + status, + turn_time_ms, + ); + res }); cx.spawn(async move |this, cx| { @@ -1239,23 +1287,40 @@ impl AcpThreadView { }; cx.spawn_in(window, async move |this, cx| { + // Check if there are any edits from prompts before the one being regenerated. + // + // If there are, we keep/accept them since we're not regenerating the prompt that created them. + // + // If editing the prompt that generated the edits, they are auto-rejected + // through the `rewind` function in the `acp_thread`. + let has_earlier_edits = thread.read_with(cx, |thread, _| { + thread + .entries() + .iter() + .take(entry_ix) + .any(|entry| entry.diffs().next().is_some()) + })?; + + if has_earlier_edits { + thread.update(cx, |thread, cx| { + thread.action_log().update(cx, |action_log, cx| { + action_log.keep_all_edits(None, cx); + }); + })?; + } + thread .update(cx, |thread, cx| thread.rewind(user_message_id, cx))? .await?; this.update_in(cx, |this, window, cx| { this.send_impl(message_editor, window, cx); + this.focus_handle(cx).focus(window); })?; anyhow::Ok(()) }) .detach(); } - fn open_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, @@ -1316,6 +1381,7 @@ impl AcpThreadView { fn clear_thread_error(&mut self, cx: &mut Context) { self.thread_error = None; + self.thread_error_markdown = None; cx.notify(); } @@ -1373,7 +1439,7 @@ impl AcpThreadView { AcpThreadEvent::Refusal => { self.thread_retry_status.take(); self.thread_error = Some(ThreadError::Refusal); - let model_or_agent_name = self.get_current_model_name(cx); + let model_or_agent_name = self.current_model_name(cx); let notification_message = format!("{} refused to respond to this request", model_or_agent_name); self.notify_with_sound(¬ification_message, IconName::Warning, window, cx); @@ -1419,21 +1485,18 @@ impl AcpThreadView { .iter() .any(|method| method.id.0.as_ref() == "claude-login") { - available_commands.push(acp::AvailableCommand { - name: "login".to_owned(), - description: "Authenticate".to_owned(), - input: None, - meta: None, - }); - available_commands.push(acp::AvailableCommand { - name: "logout".to_owned(), - description: "Authenticate".to_owned(), - input: None, - meta: None, - }); + available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); + available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); } + let has_commands = !available_commands.is_empty(); self.available_commands.replace(available_commands); + + let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands); + + self.message_editor.update(cx, |editor, cx| { + editor.set_placeholder_text(&new_placeholder, window, cx); + }); } AcpThreadEvent::ModeUpdated(_mode) => { // The connection keeps track of the mode @@ -1458,6 +1521,114 @@ impl AcpThreadView { else { return; }; + let agent_telemetry_id = connection.telemetry_id(); + + // Check for the experimental "terminal-auth" _meta field + let auth_method = connection.auth_methods().iter().find(|m| m.id == method); + + if let Some(auth_method) = auth_method { + if let Some(meta) = &auth_method.meta { + if let Some(terminal_auth) = meta.get("terminal-auth") { + // Extract terminal auth details from meta + if let (Some(command), Some(label)) = ( + terminal_auth.get("command").and_then(|v| v.as_str()), + terminal_auth.get("label").and_then(|v| v.as_str()), + ) { + let args = terminal_auth + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let env = terminal_auth + .get("env") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| { + v.as_str().map(|val| (k.clone(), val.to_string())) + }) + .collect::>() + }) + .unwrap_or_default(); + + // Run SpawnInTerminal in the same dir as the ACP server + let cwd = connection + .clone() + .downcast::() + .map(|acp_conn| acp_conn.root_dir().to_path_buf()); + + // Build SpawnInTerminal from _meta + let login = task::SpawnInTerminal { + id: task::TaskId(format!("external-agent-{}-login", label)), + full_label: label.to_string(), + label: label.to_string(), + command: Some(command.to_string()), + args, + command_label: label.to_string(), + cwd, + env, + use_new_terminal: true, + allow_concurrent_runs: true, + hide: task::HideStrategy::Always, + ..Default::default() + }; + + self.thread_error.take(); + configuration_view.take(); + pending_auth_method.replace(method.clone()); + + if let Some(workspace) = self.workspace.upgrade() { + let project = self.project.clone(); + let authenticate = Self::spawn_external_agent_login( + login, workspace, project, false, true, window, cx, + ); + cx.notify(); + self.auth_task = Some(cx.spawn_in(window, { + async move |this, cx| { + let result = authenticate.await; + + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent_telemetry_id + ), + Err(_) => { + telemetry::event!( + "Authenticate Agent Failed", + agent = agent_telemetry_id, + ) + } + } + + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + if let ThreadState::Unauthenticated { + pending_auth_method, + .. + } = &mut this.thread_state + { + pending_auth_method.take(); + } + this.handle_thread_error(err, cx); + } else { + this.reset(window, cx); + } + this.auth_task.take() + }) + .ok(); + } + })); + } + return; + } + } + } + } if method.0.as_ref() == "gemini-api-key" { let registry = LanguageModelRegistry::global(cx); @@ -1513,6 +1684,7 @@ impl AcpThreadView { None, this.workspace.clone(), this.project.clone(), + true, window, cx, ) @@ -1557,7 +1729,10 @@ impl AcpThreadView { && let Some(login) = self.login.clone() { if let Some(workspace) = self.workspace.upgrade() { - Self::spawn_external_agent_login(login, workspace, false, window, cx) + let project = self.project.clone(); + Self::spawn_external_agent_login( + login, workspace, project, false, false, window, cx, + ) } else { Task::ready(Ok(())) } @@ -1565,59 +1740,77 @@ impl AcpThreadView { connection.authenticate(method, cx) }; cx.notify(); - self.auth_task = - Some(cx.spawn_in(window, { - let agent = self.agent.clone(); - async move |this, cx| { - let result = authenticate.await; + self.auth_task = Some(cx.spawn_in(window, { + async move |this, cx| { + let result = authenticate.await; - match &result { - Ok(_) => telemetry::event!( - "Authenticate Agent Succeeded", - agent = agent.telemetry_id() - ), - Err(_) => { - telemetry::event!( - "Authenticate Agent Failed", - agent = agent.telemetry_id(), - ) - } + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent_telemetry_id + ), + Err(_) => { + telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,) } - - this.update_in(cx, |this, window, cx| { - if let Err(err) = result { - if let ThreadState::Unauthenticated { - pending_auth_method, - .. - } = &mut this.thread_state - { - pending_auth_method.take(); - } - this.handle_thread_error(err, cx); - } else { - this.reset(window, cx); - } - this.auth_task.take() - }) - .ok(); } - })); + + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + if let ThreadState::Unauthenticated { + pending_auth_method, + .. + } = &mut this.thread_state + { + pending_auth_method.take(); + } + this.handle_thread_error(err, cx); + } else { + this.reset(window, cx); + } + this.auth_task.take() + }) + .ok(); + } + })); } fn spawn_external_agent_login( login: task::SpawnInTerminal, workspace: Entity, + project: Entity, previous_attempt: bool, + check_exit_code: bool, window: &mut Window, cx: &mut App, ) -> Task> { let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { return Task::ready(Ok(())); }; - let project = workspace.read(cx).project().clone(); window.spawn(cx, async move |cx| { let mut task = login.clone(); + if let Some(cmd) = &task.command { + // Have "node" command use Zed's managed Node runtime by default + if cmd == "node" { + let resolved_node_runtime = project + .update(cx, |project, cx| { + let agent_server_store = project.agent_server_store().clone(); + agent_server_store.update(cx, |store, cx| { + store.node_runtime().map(|node_runtime| { + cx.background_spawn(async move { + node_runtime.binary_path().await + }) + }) + }) + }); + + if let Ok(Some(resolve_task)) = resolved_node_runtime { + if let Ok(node_path) = resolve_task.await { + task.command = Some(node_path.to_string_lossy().to_string()); + } + } + } + } task.shell = task::Shell::WithArguments { program: task.command.take().expect("login command should be set"), args: std::mem::take(&mut task.args), @@ -1635,44 +1828,65 @@ impl AcpThreadView { })?; let terminal = terminal.await?; - let mut exit_status = terminal - .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? - .fuse(); - let logged_in = cx - .spawn({ - let terminal = terminal.clone(); - async move |cx| { - loop { - cx.background_executor().timer(Duration::from_secs(1)).await; - let content = - terminal.update(cx, |terminal, _cx| terminal.get_content())?; - if content.contains("Login successful") - || content.contains("Type your message") - { - return anyhow::Ok(()); + if check_exit_code { + // For extension-based auth, wait for the process to exit and check exit code + let exit_status = terminal + .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? + .await; + + match exit_status { + Some(status) if status.success() => { + Ok(()) + } + Some(status) => { + Err(anyhow!("Login command failed with exit code: {:?}", status.code())) + } + None => { + Err(anyhow!("Login command terminated without exit status")) + } + } + } else { + // For hardcoded agents (claude-login, gemini-cli): look for specific output + let mut exit_status = terminal + .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? + .fuse(); + + let logged_in = cx + .spawn({ + let terminal = terminal.clone(); + async move |cx| { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + let content = + terminal.update(cx, |terminal, _cx| terminal.get_content())?; + if content.contains("Login successful") + || content.contains("Type your message") + { + return anyhow::Ok(()); + } } } + }) + .fuse(); + futures::pin_mut!(logged_in); + futures::select_biased! { + result = logged_in => { + if let Err(e) = result { + log::error!("{e}"); + return Err(anyhow!("exited before logging in")); + } } - }) - .fuse(); - futures::pin_mut!(logged_in); - futures::select_biased! { - result = logged_in => { - if let Err(e) = result { - log::error!("{e}"); + _ = exit_status => { + if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") { + return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, project.clone(), true, false, window, cx))?.await + } return Err(anyhow!("exited before logging in")); } } - _ = exit_status => { - if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") { - return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, true, window, cx))?.await - } - return Err(anyhow!("exited before logging in")); - } + terminal.update(cx, |terminal, _| terminal.kill_active_task())?; + Ok(()) } - terminal.update(cx, |terminal, _| terminal.kill_active_task())?; - Ok(()) }) } @@ -1687,6 +1901,15 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); + + telemetry::event!( + "Agent Tool Call Authorized", + agent = agent_telemetry_id, + session = thread.read(cx).session_id(), + option = option_kind + ); + thread.update(cx, |thread, cx| { thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); }); @@ -1937,6 +2160,15 @@ impl AcpThreadView { .into_any(), }; + let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { + matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ) + } else { + false + }; + let Some(thread) = self.thread() else { return primary; }; @@ -1945,7 +2177,13 @@ impl AcpThreadView { v_flex() .w_full() .child(primary) - .child(self.render_thread_controls(&thread, cx)) + .map(|this| { + if needs_confirmation { + this.child(self.render_generating(true)) + } else { + this.child(self.render_thread_controls(&thread, cx)) + } + }) .when_some( self.thread_feedback.comments_editor.clone(), |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)), @@ -2148,7 +2386,6 @@ impl AcpThreadView { options, entry_ix, tool_call.id.clone(), - window, cx, )) .into_any(), @@ -2321,7 +2558,7 @@ impl AcpThreadView { acp::ToolKind::Think => IconName::ToolThink, acp::ToolKind::Fetch => IconName::ToolWeb, acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, - acp::ToolKind::Other => IconName::ToolHammer, + acp::ToolKind::Other | _ => IconName::ToolHammer, }) } .size(IconSize::Small) @@ -2549,7 +2786,6 @@ impl AcpThreadView { options: &[acp::PermissionOption], entry_ix: usize, tool_call_id: acp::ToolCallId, - window: &Window, cx: &Context, ) -> Div { let is_first = self.thread().is_some_and(|thread| { @@ -2574,7 +2810,7 @@ impl AcpThreadView { }) .gap_0p5() .children(options.iter().map(move |option| { - let option_id = SharedString::from(option.id.0.clone()); + let option_id = SharedString::from(option.option_id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) .map(|this| { let (this, action) = match option.kind { @@ -2590,7 +2826,7 @@ impl AcpThreadView { this.icon(IconName::Close).icon_color(Color::Error), Some(&RejectOnce as &dyn Action), ), - acp::PermissionOptionKind::RejectAlways => { + acp::PermissionOptionKind::RejectAlways | _ => { (this.icon(IconName::Close).icon_color(Color::Error), None) } }; @@ -2606,7 +2842,7 @@ impl AcpThreadView { seen_kinds.push(option.kind); this.key_binding( - KeyBinding::for_action_in(action, &self.focus_handle, window, cx) + KeyBinding::for_action_in(action, &self.focus_handle, cx) .map(|kb| kb.size(rems_from_px(10.))), ) }) @@ -2615,7 +2851,7 @@ impl AcpThreadView { .label_size(LabelSize::Small) .on_click(cx.listener({ let tool_call_id = tool_call_id.clone(); - let option_id = option.id.clone(); + let option_id = option.option_id.clone(); let option_kind = option.kind; move |this, _, window, cx| { this.authorize_tool_call( @@ -2727,7 +2963,7 @@ impl AcpThreadView { let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); let command_failed = command_finished - && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success())); + && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success())); let time_elapsed = if let Some(output) = output { output.ended_at.duration_since(started_at) @@ -2787,12 +3023,11 @@ impl AcpThreadView { .icon_size(IconSize::Small) .icon_color(Color::Error) .label_size(LabelSize::Small) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( "Stop This Command", None, "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", - window, cx, ) }) @@ -2947,7 +3182,7 @@ impl AcpThreadView { .text_ui_sm(cx) .h_full() .children(terminal_view.map(|terminal_view| { - if terminal_view + let element = if terminal_view .read(cx) .content_mode(window, cx) .is_scrollable() @@ -2955,7 +3190,15 @@ impl AcpThreadView { div().h_72().child(terminal_view).into_any_element() } else { terminal_view.into_any_element() - } + }; + + div() + .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| { + window.dispatch_action(NewThread.boxed_clone(), cx); + cx.stop_propagation(); + })) + .child(element) + .into_any_element() })), ) }) @@ -3093,11 +3336,11 @@ impl AcpThreadView { ) } - fn render_recent_history(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + fn render_recent_history(&self, cx: &mut Context) -> AnyElement { let render_history = self .agent .clone() - .downcast::() + .downcast::() .is_some() && self .history_store @@ -3122,7 +3365,6 @@ impl AcpThreadView { KeyBinding::for_action_in( &OpenHistory, &self.focus_handle(cx), - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))), @@ -3273,7 +3515,9 @@ impl AcpThreadView { (method.id.0.clone(), method.name.clone()) }; - Button::new(SharedString::from(method_id.clone()), name) + let agent_telemetry_id = connection.telemetry_id(); + + Button::new(method_id.clone(), name) .label_size(LabelSize::Small) .map(|this| { if ix == 0 { @@ -3282,16 +3526,22 @@ impl AcpThreadView { this.style(ButtonStyle::Outlined) } }) + .when_some( + method.description.clone(), + |this, description| { + this.tooltip(Tooltip::text(description)) + }, + ) .on_click({ cx.listener(move |this, _, window, cx| { telemetry::event!( "Authenticate Agent Started", - agent = this.agent.telemetry_id(), + agent = agent_telemetry_id, method = method_id ); this.authenticate( - acp::AuthMethodId(method_id.clone()), + acp::AuthMethodId::new(method_id.clone()), window, cx, ) @@ -3402,6 +3652,7 @@ impl AcpThreadView { ) -> Option { let thread = thread_entity.read(cx); let action_log = thread.action_log(); + let telemetry = ActionLogTelemetry::from(thread); let changed_buffers = action_log.read(cx).changed_buffers(cx); let plan = thread.plan(); @@ -3444,12 +3695,12 @@ impl AcpThreadView { &changed_buffers, self.edits_expanded, pending_edits, - window, cx, )) .when(self.edits_expanded, |parent| { parent.child(self.render_edited_files( action_log, + telemetry, &changed_buffers, pending_edits, cx, @@ -3555,48 +3806,64 @@ impl AcpThreadView { })) } - fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context) -> Div { - v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| { - let element = h_flex() - .py_1() - .px_2() - .gap_2() - .justify_between() - .bg(cx.theme().colors().editor_background) - .when(index < plan.entries.len() - 1, |parent| { - parent.border_color(cx.theme().colors().border).border_b_1() - }) - .child( - h_flex() - .id(("plan_entry", index)) - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .text_xs() - .text_color(cx.theme().colors().text_muted) - .child(match entry.status { - acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element(), - acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress) - .size(IconSize::Small) - .color(Color::Accent) - .with_rotate_animation(2) - .into_any_element(), - acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete) - .size(IconSize::Small) - .color(Color::Success) - .into_any_element(), - }) - .child(MarkdownElement::new( - entry.content.clone(), - plan_label_markdown_style(&entry.status, window, cx), - )), - ); + fn render_plan_entries( + &self, + plan: &Plan, + window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + v_flex() + .id("plan_items_list") + .max_h_40() + .overflow_y_scroll() + .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| { + let element = h_flex() + .py_1() + .px_2() + .gap_2() + .justify_between() + .bg(cx.theme().colors().editor_background) + .when(index < plan.entries.len() - 1, |parent| { + parent.border_color(cx.theme().colors().border).border_b_1() + }) + .child( + h_flex() + .id(("plan_entry", index)) + .gap_1p5() + .max_w_full() + .overflow_x_scroll() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(match entry.status { + acp::PlanEntryStatus::InProgress => { + Icon::new(IconName::TodoProgress) + .size(IconSize::Small) + .color(Color::Accent) + .with_rotate_animation(2) + .into_any_element() + } + acp::PlanEntryStatus::Completed => { + Icon::new(IconName::TodoComplete) + .size(IconSize::Small) + .color(Color::Success) + .into_any_element() + } + acp::PlanEntryStatus::Pending | _ => { + Icon::new(IconName::TodoPending) + .size(IconSize::Small) + .color(Color::Muted) + .into_any_element() + } + }) + .child(MarkdownElement::new( + entry.content.clone(), + plan_label_markdown_style(&entry.status, window, cx), + )), + ); - Some(element) - })) + Some(element) + })) + .into_any_element() } fn render_edits_summary( @@ -3604,7 +3871,6 @@ impl AcpThreadView { 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."; @@ -3621,6 +3887,7 @@ impl AcpThreadView { .child( h_flex() .id("edits-container") + .cursor_pointer() .gap_1() .child(Disclosure::new("edits-disclosure", expanded)) .map(|this| { @@ -3680,12 +3947,11 @@ impl AcpThreadView { .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Review Changes", &OpenAgentDiff, &focus_handle, - window, cx, ) } @@ -3703,13 +3969,8 @@ impl AcpThreadView { this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) }) .key_binding( - KeyBinding::for_action_in( - &RejectAll, - &focus_handle.clone(), - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), + KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx) + .map(|kb| kb.size(rems_from_px(10.))), ) .on_click(cx.listener(move |this, _, window, cx| { this.reject_all(&RejectAll, window, cx); @@ -3723,7 +3984,7 @@ impl AcpThreadView { this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) }) .key_binding( - KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx) + KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(10.))), ) .on_click(cx.listener(move |this, _, window, cx| { @@ -3736,150 +3997,181 @@ impl AcpThreadView { fn render_edited_files( &self, action_log: &Entity, + telemetry: ActionLogTelemetry, changed_buffers: &BTreeMap, Entity>, pending_edits: bool, cx: &Context, - ) -> Div { + ) -> impl IntoElement { let editor_bg_color = cx.theme().colors().editor_background; - v_flex().children(changed_buffers.iter().enumerate().flat_map( - |(index, (buffer, _diff))| { - let file = buffer.read(cx).file()?; - let path = file.path(); - let path_style = file.path_style(cx); - let separator = file.path_style(cx).separator(); + v_flex() + .id("edited_files_list") + .max_h_40() + .overflow_y_scroll() + .children( + changed_buffers + .iter() + .enumerate() + .flat_map(|(index, (buffer, _diff))| { + let file = buffer.read(cx).file()?; + let path = file.path(); + let path_style = file.path_style(cx); + let separator = file.path_style(cx).primary_separator(); - let file_path = path.parent().and_then(|parent| { - if parent.is_empty() { - None - } else { - Some( - Label::new(format!("{}{separator}", parent.display(path_style))) - .color(Color::Muted) + let file_path = path.parent().and_then(|parent| { + if parent.is_empty() { + None + } else { + Some( + Label::new(format!( + "{}{separator}", + parent.display(path_style) + )) + .color(Color::Muted) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + } + }); + + let file_name = path.file_name().map(|name| { + Label::new(name.to_string()) .size(LabelSize::XSmall) - .buffer_font(cx), - ) - } - }); + .buffer_font(cx) + .ml_1p5() + }); - let file_name = path.file_name().map(|name| { - Label::new(name.to_string()) - .size(LabelSize::XSmall) - .buffer_font(cx) - }); + let file_icon = FileIcons::get_icon(path.as_std_path(), cx) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) + .unwrap_or_else(|| { + Icon::new(IconName::File) + .color(Color::Muted) + .size(IconSize::Small) + }); - let file_icon = FileIcons::get_icon(path.as_std_path(), cx) - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) - .unwrap_or_else(|| { - 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 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)) - .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() - .relative() - .id(("file-name", index)) - .pr_8() - .gap_1p5() - .w_full() - .overflow_x_scroll() - .child(file_icon) - .child(h_flex().gap_0p5().children(file_name).children(file_path)) + let element = h_flex() + .group("edited-code") + .id(("file-container", index)) + .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( - div() - .absolute() - .h_full() - .w_12() - .top_0() - .bottom_0() - .right_0() - .bg(overlay_gradient), + h_flex() + .id(("file-name-row", index)) + .relative() + .pr_8() + .w_full() + .overflow_x_scroll() + .child( + h_flex() + .id(("file-name-path", index)) + .cursor_pointer() + .pr_0p5() + .gap_0p5() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .rounded_xs() + .child(file_icon) + .children(file_name) + .children(file_path) + .tooltip(Tooltip::text("Go to File")) + .on_click({ + let buffer = buffer.clone(); + cx.listener(move |this, _, window, cx| { + this.open_edited_buffer(&buffer, window, cx); + }) + }), + ) + .child( + div() + .absolute() + .h_full() + .w_12() + .top_0() + .bottom_0() + .right_0() + .bg(overlay_gradient), + ), ) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - 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 + 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(); + let telemetry = telemetry.clone(); + move |_, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log .reject_edits_in_ranges( buffer.clone(), - vec![Anchor::MIN..Anchor::MAX], + vec![Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + )], + Some(telemetry.clone()), 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( + Button::new("keep-file", "Keep") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .on_click({ + let buffer = buffer.clone(); + let action_log = action_log.clone(); + let telemetry = telemetry.clone(); + move |_, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log.keep_edits_in_range( + buffer.clone(), + Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + ), + Some(telemetry.clone()), + cx, + ); + }) + } + }), + ), + ); - Some(element) - }, - )) + Some(element) + }), + ) + .into_any_element() } fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { @@ -3900,8 +4192,10 @@ impl AcpThreadView { .block_mouse_except_scroll(); let enable_editor = match self.thread_state { - ThreadState::Loading { .. } | ThreadState::Ready { .. } => true, - ThreadState::Unauthenticated { .. } | ThreadState::LoadError(..) => false, + ThreadState::Ready { .. } => true, + ThreadState::Loading { .. } + | ThreadState::Unauthenticated { .. } + | ThreadState::LoadError(..) => false, }; v_flex() @@ -3953,18 +4247,21 @@ impl AcpThreadView { .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( expand_tooltip, &ExpandMessageEditor, &focus_handle, - window, cx, ) } }) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(ExpandMessageEditor), cx); + .on_click(cx.listener(|this, _, window, cx| { + this.expand_message_editor( + &ExpandMessageEditor, + window, + cx, + ); })), ), ), @@ -3976,6 +4273,8 @@ impl AcpThreadView { .justify_between() .child( h_flex() + .gap_0p5() + .child(self.render_add_context_button(cx)) .child(self.render_follow_toggle(cx)) .children(self.render_burn_mode_toggle(cx)), ) @@ -3996,12 +4295,12 @@ impl AcpThreadView { pub(crate) fn as_native_connection( &self, cx: &App, - ) -> Option> { + ) -> Option> { let acp_thread = self.thread()?.read(cx); acp_thread.connection().clone().downcast() } - pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { + pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { let acp_thread = self.thread()?.read(cx); self.as_native_connection(cx)? .thread(acp_thread.session_id(), cx) @@ -4081,17 +4380,23 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + let telemetry = ActionLogTelemetry::from(thread.read(cx)); let action_log = thread.read(cx).action_log().clone(); - action_log.update(cx, |action_log, cx| action_log.keep_all_edits(cx)); + action_log.update(cx, |action_log, cx| { + action_log.keep_all_edits(Some(telemetry), cx) + }); } fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context) { let Some(thread) = self.thread() else { return; }; + let telemetry = ActionLogTelemetry::from(thread.read(cx)); let action_log = thread.read(cx).action_log().clone(); action_log - .update(cx, |action_log, cx| action_log.reject_all_edits(cx)) + .update(cx, |action_log, cx| { + action_log.reject_all_edits(Some(telemetry), cx) + }) .detach(); } @@ -4122,7 +4427,7 @@ impl AcpThreadView { self.authorize_tool_call( tool_call.id.clone(), - option.id.clone(), + option.option_id.clone(), option.kind, window, cx, @@ -4183,8 +4488,8 @@ impl AcpThreadView { IconButton::new("stop-generation", IconName::Stop) .icon_color(Color::Error) .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .tooltip(move |window, cx| { - Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx) }) .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) .into_any_element() @@ -4206,7 +4511,7 @@ impl AcpThreadView { this.icon_color(Color::Accent) } }) - .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx)) + .tooltip(move |_window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, cx)) .on_click(cx.listener(|this, _, window, cx| { this.send(window, cx); })) @@ -4267,15 +4572,14 @@ impl AcpThreadView { .icon_color(Color::Muted) .toggle_state(following) .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if following { - Tooltip::for_action(tooltip_label.clone(), &Follow, window, cx) + Tooltip::for_action(tooltip_label.clone(), &Follow, cx) } else { Tooltip::with_meta( tooltip_label.clone(), Some(&Follow), "Track the agent's location as it reads and edits files.", - window, cx, ) } @@ -4285,6 +4589,29 @@ impl AcpThreadView { })) } + fn render_add_context_button(&self, cx: &mut Context) -> impl IntoElement { + let message_editor = self.message_editor.clone(); + let menu_visible = message_editor.read(cx).is_completions_menu_visible(cx); + + IconButton::new("add-context", IconName::AtSign) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .when(!menu_visible, |this| { + this.tooltip(move |_window, cx| { + Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx) + }) + }) + .on_click(cx.listener(move |_this, _, window, cx| { + let message_editor_clone = message_editor.clone(); + + window.defer(cx, move |window, cx| { + message_editor_clone.update(cx, |message_editor, cx| { + message_editor.trigger_completion_menu(window, cx); + }); + }); + })) + } + 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| { @@ -4303,7 +4630,8 @@ impl AcpThreadView { return; }; - if let Some(mention) = MentionUri::parse(&url).log_err() { + if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() + { workspace.update(cx, |workspace, cx| match mention { MentionUri::File { abs_path } => { let project = workspace.project(); @@ -4389,7 +4717,7 @@ impl AcpThreadView { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel - .open_saved_prompt_editor(path.as_path().into(), window, cx) + .open_saved_text_thread(path.as_path().into(), window, cx) .detach_and_log_err(cx); }); } @@ -4452,11 +4780,8 @@ impl AcpThreadView { let buffer = multibuffer.as_singleton(); if agent_location.buffer.upgrade() == buffer { let excerpt_id = multibuffer.excerpt_ids().first().cloned(); - let anchor = editor::Anchor::in_buffer( - excerpt_id.unwrap(), - buffer.unwrap().read(cx).remote_id(), - agent_location.position, - ); + let anchor = + editor::Anchor::in_buffer(excerpt_id.unwrap(), agent_location.position); editor.change_selections(Default::default(), window, cx, |selections| { selections.select_anchor_ranges([anchor..anchor]); }) @@ -4487,35 +4812,36 @@ impl AcpThreadView { .languages .language_for_name("Markdown"); - let (thread_summary, markdown) = if let Some(thread) = self.thread() { + let (thread_title, markdown) = if let Some(thread) = self.thread() { let thread = thread.read(cx); (thread.title().to_string(), thread.to_markdown(cx)) } else { return Task::ready(Ok(())); }; + let project = workspace.read(cx).project().clone(); window.spawn(cx, async move |cx| { let markdown_language = markdown_language_task.await?; + let buffer = project + .update(cx, |project, cx| project.create_buffer(false, cx))? + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(markdown, cx); + buffer.set_language(Some(markdown_language), cx); + buffer.set_capability(language::Capability::ReadWrite, cx); + })?; + workspace.update_in(cx, |workspace, window, cx| { - let project = workspace.project().clone(); - - if !project.read(cx).is_local() { - 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), true, cx) - }); - let buffer = cx.new(|cx| { - MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone()) - }); + let buffer = cx + .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.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.set_breadcrumb_header(thread_title); editor })), None, @@ -4523,9 +4849,7 @@ impl AcpThreadView { window, cx, ); - - anyhow::Ok(()) - })??; + })?; anyhow::Ok(()) }) } @@ -4568,14 +4892,29 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) { - if window.is_window_active() || !self.notifications.is_empty() { + if !self.notifications.is_empty() { + return; + } + + let settings = AgentSettings::get_global(cx); + + let window_is_inactive = !window.is_window_active(); + let panel_is_hidden = self + .workspace + .upgrade() + .map(|workspace| AgentPanel::is_hidden(&workspace, cx)) + .unwrap_or(true); + + let should_notify = window_is_inactive || panel_is_hidden; + + if !should_notify { return; } // TODO: Change this once we have title summarization for external agents. let title = self.agent.name(); - match AgentSettings::get_global(cx).notify_when_agent_waiting { + match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { if let Some(primary) = cx.primary_display() { self.pop_up(icon, caption.into(), title, window, primary, cx); @@ -4691,6 +5030,31 @@ impl AcpThreadView { } } + fn render_generating(&self, confirmation: bool) -> impl IntoElement { + h_flex() + .id("generating-spinner") + .py_2() + .px(rems_from_px(22.)) + .map(|this| { + if confirmation { + this.gap_2() + .child( + h_flex() + .w_2() + .child(SpinnerLabel::sand().size(LabelSize::Small)), + ) + .child( + LoadingLabel::new("Waiting Confirmation") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.child(SpinnerLabel::new().size(LabelSize::Small)) + } + }) + .into_any_element() + } + fn render_thread_controls( &self, thread: &Entity, @@ -4698,12 +5062,7 @@ impl AcpThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return h_flex().id("thread-controls-container").child( - div() - .py_2() - .px(rems_from_px(22.)) - .child(SpinnerLabel::new().size(LabelSize::Small)), - ); + return self.render_generating(false).into_any_element(); } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) @@ -4728,15 +5087,12 @@ impl AcpThreadView { })); let mut container = h_flex() - .id("thread-controls-container") - .group("thread-controls-container") .w_full() .py_2() .px_5() .gap_px() .opacity(0.6) - .hover(|style| style.opacity(1.)) - .flex_wrap() + .hover(|s| s.opacity(1.)) .justify_end(); if AgentSettings::get_global(cx).enable_feedback @@ -4746,23 +5102,13 @@ impl AcpThreadView { { let feedback = self.thread_feedback.feedback; - container = container - .child( - div().visible_on_hover("thread-controls-container").child( - Label::new(match feedback { - Some(ThreadFeedback::Positive) => "Thanks for your feedback!", - Some(ThreadFeedback::Negative) => { - "We appreciate your feedback and will use it to improve." - } - None => { - "Rating the thread sends all of your current conversation to the Zed team." - } - }) - .color(Color::Muted) - .size(LabelSize::XSmall) - .truncate(), - ), + let tooltip_meta = || { + SharedString::new( + "Rating the thread sends all of your current conversation to the Zed team.", ) + }; + + container = container .child( IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) .shape(ui::IconButtonShape::Square) @@ -4771,7 +5117,12 @@ impl AcpThreadView { Some(ThreadFeedback::Positive) => Color::Accent, _ => Color::Ignored, }) - .tooltip(Tooltip::text("Helpful Response")) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Positive) => { + Tooltip::text("Thanks for your feedback!")(window, cx) + } + _ => Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx), + }) .on_click(cx.listener(move |this, _, window, cx| { this.handle_feedback_click(ThreadFeedback::Positive, window, cx); })), @@ -4784,14 +5135,26 @@ impl AcpThreadView { Some(ThreadFeedback::Negative) => Color::Accent, _ => Color::Ignored, }) - .tooltip(Tooltip::text("Not Helpful")) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Negative) => { + Tooltip::text( + "We appreciate your feedback and will use it to improve in the future.", + )(window, cx) + } + _ => { + Tooltip::with_meta("Not Helpful Response", None, tooltip_meta(), cx) + } + }) .on_click(cx.listener(move |this, _, window, cx| { this.handle_feedback_click(ThreadFeedback::Negative, window, cx); })), ); } - container.child(open_as_markdown).child(scroll_to_top) + container + .child(open_as_markdown) + .child(scroll_to_top) + .into_any_element() } fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { @@ -4971,10 +5334,12 @@ impl AcpThreadView { }) } + /// Inserts the selected text into the message editor or the message being + /// edited, if any. pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context) { - self.message_editor.update(cx, |message_editor, cx| { - message_editor.insert_selections(window, cx); - }) + self.active_editor(cx).update(cx, |editor, cx| { + editor.insert_selections(window, cx); + }); } fn render_thread_retry_status_callout( @@ -5019,9 +5384,44 @@ impl AcpThreadView { ) } - fn render_thread_error(&self, window: &mut Window, cx: &mut Context) -> Option
{ + fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title("Codex on Windows") + .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") + .actions_slot( + Button::new("open-wsl-modal", "Open in WSL") + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |_, _, _window, cx| { + #[cfg(windows)] + _window.dispatch_action( + zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), + cx, + ); + cx.notify(); + } + })), + ) + .dismiss_action( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Warning")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.show_codex_windows_warning = false; + cx.notify(); + } + })), + ) + } + + fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context) -> Option
{ let content = match self.thread_error.as_ref()? { - ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx), + ThreadError::Other(error) => self.render_any_thread_error(error.clone(), window, cx), ThreadError::Refusal => self.render_refusal_error(cx), ThreadError::AuthenticationRequired(error) => { self.render_authentication_required_error(error.clone(), cx) @@ -5030,9 +5430,7 @@ impl AcpThreadView { ThreadError::ModelRequestLimitReached(plan) => { self.render_model_request_limit_reached_error(*plan, cx) } - ThreadError::ToolUseLimitReached => { - self.render_tool_use_limit_reached_error(window, cx)? - } + ThreadError::ToolUseLimitReached => self.render_tool_use_limit_reached_error(cx)?, }; Some(div().child(content)) @@ -5070,20 +5468,31 @@ impl AcpThreadView { ) } - fn get_current_model_name(&self, cx: &App) -> SharedString { + fn current_mode_id(&self, cx: &App) -> Option> { + if let Some(thread) = self.as_native_thread(cx) { + Some(thread.read(cx).profile().0.clone()) + } else if let Some(mode_selector) = self.mode_selector() { + Some(mode_selector.read(cx).mode().0) + } else { + None + } + } + + fn current_model_id(&self, cx: &App) -> Option { + self.model_selector + .as_ref() + .and_then(|selector| selector.read(cx).active_model(cx).map(|m| m.id.to_string())) + } + + fn current_model_name(&self, cx: &App) -> SharedString { // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet") // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI") // This provides better clarity about what refused the request - if self - .agent - .clone() - .downcast::() - .is_some() - { - // Native agent - use the model name + if self.as_native_connection(cx).is_some() { self.model_selector .as_ref() - .and_then(|selector| selector.read(cx).active_model_name(cx)) + .and_then(|selector| selector.read(cx).active_model(cx)) + .map(|model| model.name.clone()) .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI") @@ -5092,7 +5501,7 @@ impl AcpThreadView { } fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout { - let model_or_agent_name = self.get_current_model_name(cx); + let model_or_agent_name = self.current_model_name(cx); let refusal_message = format!( "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.", model_or_agent_name @@ -5107,7 +5516,12 @@ impl AcpThreadView { .dismiss_action(self.dismiss_error_button(cx)) } - fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout { + fn render_any_thread_error( + &mut self, + error: SharedString, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) -> Callout { let can_resume = self .thread() .map_or(false, |thread| thread.read(cx).can_resume(cx)); @@ -5120,11 +5534,24 @@ impl AcpThreadView { supports_burn_mode && thread.completion_mode() == CompletionMode::Normal }); + let markdown = if let Some(markdown) = &self.thread_error_markdown { + markdown.clone() + } else { + let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx)); + self.thread_error_markdown = Some(markdown.clone()); + markdown + }; + + let markdown_style = default_markdown_style(false, true, window, cx); + let description = self + .render_markdown(markdown, markdown_style) + .into_any_element(); + Callout::new() .severity(Severity::Error) - .title("Error") .icon(IconName::XCircle) - .description(error.clone()) + .title("An Error Happened") + .description_slot(description) .actions_slot( h_flex() .gap_0p5() @@ -5143,11 +5570,9 @@ impl AcpThreadView { }) .when(can_resume, |this| { this.child( - Button::new("retry", "Retry") - .icon(IconName::RotateCw) - .icon_position(IconPosition::Start) + IconButton::new("retry", IconName::RotateCw) .icon_size(IconSize::Small) - .label_size(LabelSize::Small) + .tooltip(Tooltip::text("Retry Generation")) .on_click(cx.listener(|this, _, _window, cx| { this.resume_chat(cx); })), @@ -5223,11 +5648,7 @@ impl AcpThreadView { .dismiss_action(self.dismiss_error_button(cx)) } - fn render_tool_use_limit_reached_error( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Option { + fn render_tool_use_limit_reached_error(&self, cx: &mut Context) -> Option { let thread = self.as_native_thread(cx)?; let supports_burn_mode = thread .read(cx) @@ -5254,7 +5675,6 @@ impl AcpThreadView { KeyBinding::for_action_in( &ContinueWithBurnMode, &focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(10.))), @@ -5278,13 +5698,8 @@ impl AcpThreadView { .layer(ElevationIndex::ModalSurface) .label_size(LabelSize::Small) .key_binding( - KeyBinding::for_action_in( - &ContinueThread, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), + KeyBinding::for_action_in(&ContinueThread, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(10.))), ) .on_click(cx.listener(|this, _, _window, cx| { this.resume_chat(cx); @@ -5299,7 +5714,6 @@ impl AcpThreadView { IconButton::new("copy", IconName::Copy) .icon_size(IconSize::Small) - .icon_color(Color::Muted) .tooltip(Tooltip::text("Copy Error Message")) .on_click(move |_, _, cx| { cx.write_to_clipboard(ClipboardItem::new_string(message.clone())) @@ -5309,7 +5723,6 @@ impl AcpThreadView { fn dismiss_error_button(&self, cx: &mut Context) -> impl IntoElement { IconButton::new("dismiss", IconName::Close) .icon_size(IconSize::Small) - .icon_color(Color::Muted) .tooltip(Tooltip::text("Dismiss Error")) .on_click(cx.listener({ move |this, _, _, cx| { @@ -5336,6 +5749,11 @@ impl AcpThreadView { provider_id: None, }; this.clear_thread_error(cx); + if let Some(message) = this.in_flight_prompt.take() { + this.message_editor.update(cx, |editor, cx| { + editor.set_message(message, window, cx); + }); + } let this = cx.weak_entity(); window.defer(cx, |window, cx| { Self::handle_auth_required(this, err, agent, connection, window, cx); @@ -5379,12 +5797,31 @@ impl AcpThreadView { HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| { history.delete_thread(thread.id.clone(), cx) }), - HistoryEntry::TextThread(context) => self.history_store.update(cx, |history, cx| { - history.delete_text_thread(context.path.clone(), cx) - }), + HistoryEntry::TextThread(text_thread) => { + self.history_store.update(cx, |history, cx| { + history.delete_text_thread(text_thread.path.clone(), cx) + }) + } }; task.detach_and_log_err(cx); } + + /// Returns the currently active editor, either for a message that is being + /// edited or the editor for a new message. + fn active_editor(&self, cx: &App) -> Entity { + if let Some(index) = self.editing_message + && let Some(editor) = self + .entry_view_state + .read(cx) + .entry(index) + .and_then(|e| e.message_editor()) + .cloned() + { + editor + } else { + self.message_editor.clone() + } + } } fn loading_contents_spinner(size: IconSize) -> AnyElement { @@ -5395,15 +5832,26 @@ fn loading_contents_spinner(size: IconSize) -> AnyElement { .into_any_element() } +fn placeholder_text(agent_name: &str, has_commands: bool) -> String { + if agent_name == "Zed Agent" { + format!("Message the {} — @ to include context", agent_name) + } else if has_commands { + format!( + "Message {} — @ to include context, / for commands", + agent_name + ) + } else { + format!("Message {} — @ to include context", agent_name) + } +} + impl Focusable for AcpThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { match self.thread_state { - ThreadState::Loading { .. } | ThreadState::Ready { .. } => { - self.message_editor.focus_handle(cx) - } - ThreadState::LoadError(_) | ThreadState::Unauthenticated { .. } => { - self.focus_handle.clone() - } + ThreadState::Ready { .. } => self.active_editor(cx).focus_handle(cx), + ThreadState::Loading { .. } + | ThreadState::LoadError(_) + | ThreadState::Unauthenticated { .. } => self.focus_handle.clone(), } } } @@ -5416,7 +5864,6 @@ impl Render for AcpThreadView { v_flex() .size_full() .key_context("AcpThread") - .on_action(cx.listener(Self::open_agent_diff)) .on_action(cx.listener(Self::toggle_burn_mode)) .on_action(cx.listener(Self::keep_all)) .on_action(cx.listener(Self::reject_all)) @@ -5444,7 +5891,7 @@ impl Render for AcpThreadView { .into_any(), ThreadState::Loading { .. } => v_flex() .flex_1() - .child(self.render_recent_history(window, cx)) + .child(self.render_recent_history(cx)) .into_any(), ThreadState::LoadError(e) => v_flex() .flex_1() @@ -5472,11 +5919,10 @@ impl Render for AcpThreadView { .flex_grow() .into_any(), ) - .vertical_scrollbar_for(self.list_state.clone(), window, cx) + .vertical_scrollbar_for(&self.list_state, window, cx) .into_any() } else { - this.child(self.render_recent_history(window, cx)) - .into_any() + this.child(self.render_recent_history(cx)).into_any() } }), }) @@ -5490,6 +5936,9 @@ impl Render for AcpThreadView { _ => this, }) .children(self.render_thread_retry_status_callout(window, cx)) + .when(self.show_codex_windows_warning, |this| { + this.child(self.render_codex_windows_warning(cx)) + }) .children(self.render_thread_error(window, cx)) .when_some( self.new_server_version_available.as_ref().filter(|_| { @@ -5518,7 +5967,7 @@ fn default_markdown_style( let theme_settings = ThemeSettings::get_global(cx); let colors = cx.theme().colors(); - let buffer_font_size = TextSize::Small.rems(cx); + let buffer_font_size = theme_settings.agent_buffer_font_size(cx); let mut text_style = window.text_style(); let line_height = buffer_font_size * 1.75; @@ -5530,9 +5979,9 @@ fn default_markdown_style( }; let font_size = if buffer_font { - TextSize::Small.rems(cx) + theme_settings.agent_buffer_font_size(cx) } else { - TextSize::Default.rems(cx) + theme_settings.agent_ui_font_size(cx) }; let text_color = if muted_text { @@ -5556,7 +6005,6 @@ fn default_markdown_style( syntax: cx.theme().syntax().clone(), selection_background_color: 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()), @@ -5605,13 +6053,13 @@ fn default_markdown_style( }, border_color: Some(colors.border_variant), background: Some(colors.editor_background.into()), - text: Some(TextStyleRefinement { + text: 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 { @@ -5624,6 +6072,7 @@ fn default_markdown_style( }, link: TextStyleRefinement { background_color: Some(colors.editor_foreground.opacity(0.025)), + color: Some(colors.text_accent), underline: Some(UnderlineStyle { color: Some(colors.text_accent.opacity(0.5)), thickness: px(1.), @@ -5675,10 +6124,10 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { pub(crate) mod tests { use acp_thread::StubAgentConnection; use agent_client_protocol::SessionId; - use assistant_context::ContextStore; - use editor::EditorSettings; + use assistant_text_thread::TextThreadStore; + use editor::MultiBufferOffset; use fs::FakeFs; - use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext}; + use gpui::{EventEmitter, TestAppContext, VisualTestContext}; use project::Project; use serde_json::json; use settings::SettingsStore; @@ -5782,27 +6231,18 @@ pub(crate) mod tests { async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) { init_test(cx); - let tool_call_id = acp::ToolCallId("1".into()); - let tool_call = acp::ToolCall { - id: tool_call_id.clone(), - title: "Label".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Pending, - content: vec!["hi".into()], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; + let tool_call_id = acp::ToolCallId::new("1"); + let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label") + .kind(acp::ToolKind::Edit) + .content(vec!["hi".into()]); let connection = StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( tool_call_id, - vec![acp::PermissionOption { - id: acp::PermissionOptionId("1".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }], + vec![acp::PermissionOption::new( + "1", + "Allow", + acp::PermissionOptionKind::AllowOnce, + )], )])); connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); @@ -5829,6 +6269,107 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + + // Window is active (don't deactivate), but panel will be hidden + // Note: In the test environment, the panel is not actually added to the dock, + // so is_agent_panel_hidden will return true + + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.run_until_parked(); + + // Should show notification because window is active but panel is hidden + assert!( + cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Expected notification when panel is hidden" + ); + } + + #[gpui::test] + async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + + // Deactivate window - should show notification regardless of setting + cx.deactivate_window(); + + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.run_until_parked(); + + // Should still show notification when window is inactive (existing behavior) + assert!( + cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Expected notification when window is inactive" + ); + } + + #[gpui::test] + async fn test_notification_respects_never_setting(cx: &mut TestAppContext) { + init_test(cx); + + // Set notify_when_agent_waiting to Never + cx.update(|cx| { + AgentSettings::override_global( + AgentSettings { + notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, + ..AgentSettings::get_global(cx).clone() + }, + cx, + ); + }); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + + // Window is active + + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.run_until_parked(); + + // Should NOT show notification because notify_when_agent_waiting is Never + assert!( + !cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Expected no notification when notify_when_agent_waiting is Never" + ); + } + async fn setup_thread_view( agent: impl AgentServer + 'static, cx: &mut TestAppContext, @@ -5838,10 +6379,10 @@ pub(crate) mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let context_store = - cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx))); + let text_thread_store = + cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx))); let history_store = - cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx))); + cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(text_thread_store, cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { @@ -5853,6 +6394,7 @@ pub(crate) mod tests { project, history_store, None, + false, window, cx, ) @@ -5919,9 +6461,9 @@ pub(crate) mod tests { impl StubAgentServer { fn default_response() -> Self { let conn = StubAgentConnection::new(); - conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk { - content: "Default response".into(), - }]); + conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Default response".into()), + )]); Self::new(conn) } } @@ -5930,10 +6472,6 @@ pub(crate) mod tests { where C: 'static + AgentConnection + Send + Clone, { - fn telemetry_id(&self) -> &'static str { - "test" - } - fn logo(&self) -> ui::IconName { ui::IconName::Ai } @@ -5960,6 +6498,10 @@ pub(crate) mod tests { struct SaboteurAgentConnection; impl AgentConnection for SaboteurAgentConnection { + fn telemetry_id(&self) -> SharedString { + "saboteur".into() + } + fn new_thread( self: Rc, project: Entity, @@ -5973,13 +6515,13 @@ pub(crate) mod tests { self, project, action_log, - SessionId("test".into()), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + SessionId::new("test"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }))) @@ -6020,6 +6562,10 @@ pub(crate) mod tests { struct RefusalAgentConnection; impl AgentConnection for RefusalAgentConnection { + fn telemetry_id(&self) -> SharedString { + "refusal".into() + } + fn new_thread( self: Rc, project: Entity, @@ -6033,13 +6579,13 @@ pub(crate) mod tests { self, project, action_log, - SessionId("test".into()), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + SessionId::new("test"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }))) @@ -6063,10 +6609,7 @@ pub(crate) mod tests { _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { - Task::ready(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - })) + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal))) } fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { @@ -6082,13 +6625,8 @@ pub(crate) mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - AgentSettings::register(cx); - workspace::init_settings(cx); - ThemeSettings::register(cx); - release_channel::init(SemanticVersion::default(), cx); - EditorSettings::register(cx); + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); prompt_store::init(cx) }); } @@ -6110,10 +6648,10 @@ pub(crate) mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let context_store = - cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx))); + let text_thread_store = + cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx))); let history_store = - cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx))); + cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(text_thread_store, cx))); let connection = Rc::new(StubAgentConnection::new()); let thread_view = cx.update(|window, cx| { @@ -6126,6 +6664,7 @@ pub(crate) mod tests { project.clone(), history_store.clone(), None, + false, window, cx, ) @@ -6139,24 +6678,14 @@ pub(crate) mod tests { .unwrap(); // First user message - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool1".into()), - title: "Edit file 1".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/test1.txt".into(), - old_text: Some("old content 1".into()), - new_text: "new content 1".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - })]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool1", "Edit file 1") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"), + )]), + )]); thread .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx)) @@ -6182,24 +6711,14 @@ pub(crate) mod tests { }); // Second user message - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool2".into()), - title: "Edit file 2".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/test2.txt".into(), - old_text: Some("old content 2".into()), - new_text: "new content 2".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - })]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool2", "Edit file 2") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"), + )]), + )]); thread .update(cx, |thread, cx| thread.send_raw("Another one", cx)) @@ -6272,13 +6791,9 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - }]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; add_to_workspace(thread_view.clone(), cx); @@ -6362,13 +6877,9 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - }]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; @@ -6406,13 +6917,9 @@ pub(crate) mod tests { }); // Send - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "New Response".into(), - annotations: None, - meta: None, - }), - }]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("New Response".into()), + )]); user_message_editor.update_in(cx, |_editor, window, cx| { window.dispatch_action(Box::new(Chat), cx); @@ -6499,13 +7006,7 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - }, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())), cx, ); connection.end_turn(session_id, acp::StopReason::EndTurn); @@ -6557,9 +7058,9 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk { - content: "Message 1 resp".into(), - }, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Message 1 resp".into(), + )), cx, ); }); @@ -6593,9 +7094,7 @@ pub(crate) mod tests { // Simulate a response sent after beginning to cancel connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk { - content: "onse".into(), - }, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())), cx, ); }); @@ -6626,9 +7125,9 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk { - content: "Message 2 response".into(), - }, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Message 2 response".into(), + )), cx, ); connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); @@ -6660,4 +7159,138 @@ pub(crate) mod tests { ) }); } + + #[gpui::test] + async fn test_message_editing_insert_selections(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Original message to edit", window, cx) + }); + thread_view.update_in(cx, |thread_view, window, cx| thread_view.send(window, cx)); + cx.run_until_parked(); + + let user_message_editor = thread_view.read_with(cx, |thread_view, cx| { + thread_view + .entry_view_state + .read(cx) + .entry(0) + .expect("Should have at least one entry") + .message_editor() + .expect("Should have message editor") + .clone() + }); + + cx.focus(&user_message_editor); + thread_view.read_with(cx, |thread_view, _cx| { + assert_eq!(thread_view.editing_message, Some(0)); + }); + + // Ensure to edit the focused message before proceeding otherwise, since + // its content is not different from what was sent, focus will be lost. + user_message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Original message to edit with ", window, cx) + }); + + // Create a simple buffer with some text so we can create a selection + // that will then be added to the message being edited. + let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| { + (thread_view.workspace.clone(), thread_view.project.clone()) + }); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("let a = 10 + 10;", None, false, cx) + }); + + workspace + .update_in(cx, |workspace, window, cx| { + let editor = cx.new(|cx| { + let mut editor = + Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); + + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]); + }); + + editor + }); + workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx); + }) + .unwrap(); + + thread_view.update_in(cx, |thread_view, window, cx| { + assert_eq!(thread_view.editing_message, Some(0)); + thread_view.insert_selections(window, cx); + }); + + user_message_editor.read_with(cx, |editor, cx| { + let text = editor.editor().read(cx).text(cx); + let expected_text = String::from("Original message to edit with selection "); + + assert_eq!(text, expected_text); + }); + } + + #[gpui::test] + async fn test_insert_selections(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Can you review this snippet ", window, cx) + }); + + // Create a simple buffer with some text so we can create a selection + // that will then be added to the message being edited. + let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| { + (thread_view.workspace.clone(), thread_view.project.clone()) + }); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("let a = 10 + 10;", None, false, cx) + }); + + workspace + .update_in(cx, |workspace, window, cx| { + let editor = cx.new(|cx| { + let mut editor = + Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); + + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]); + }); + + editor + }); + workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx); + }) + .unwrap(); + + thread_view.update_in(cx, |thread_view, window, cx| { + assert_eq!(thread_view.editing_message, None); + thread_view.insert_selections(window, cx); + }); + + thread_view.read_with(cx, |thread_view, cx| { + let text = thread_view.message_editor.read(cx).text(cx); + let expected_txt = String::from("Can you review this snippet selection "); + + assert_eq!(text, expected_txt); + }) + } } diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 3581baf4ec..24f019c605 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1,51 +1,53 @@ mod add_llm_provider_modal; -mod configure_context_server_modal; +pub mod configure_context_server_modal; mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; use std::{ops::Range, sync::Arc}; -use agent_settings::AgentSettings; +use agent::ContextServerRegistry; use anyhow::Result; -use assistant_tool::{ToolSource, ToolWorkingSet}; +use client::zed_urls; use cloud_llm_client::{Plan, PlanV1, PlanV2}; use collections::HashMap; use context_server::ContextServerId; -use editor::{Editor, SelectionEffects, scroll::Autoscroll}; +use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll}; use extension::ExtensionManifest; use extension_host::ExtensionStore; -use feature_flags::{CodexAcpFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{ Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable, - Hsla, ScrollHandle, Subscription, Task, WeakEntity, + ScrollHandle, Subscription, Task, WeakEntity, }; use language::LanguageRegistry; use language_model::{ LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID, }; +use language_models::AllLanguageModelSettings; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ - agent_server_store::{AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME}, + agent_server_store::{ + AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME, + }, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, }; use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ - Chip, CommonAnimationExt, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, - Indicator, PopoverMenu, Switch, SwitchColor, SwitchField, Tooltip, WithScrollbar, prelude::*, + ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, + DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use workspace::{Workspace, create_and_open_local_file}; -use zed_actions::ExtensionCategoryFilter; +use zed_actions::{ExtensionCategoryFilter, OpenBrowser}; pub(crate) use configure_context_server_modal::ConfigureContextServerModal; pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; -use crate::{ - AddContextServer, - agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, +use crate::agent_configuration::add_llm_provider_modal::{ + AddLlmProviderModal, LlmCompatibleProvider, }; pub struct AgentConfiguration { @@ -56,9 +58,8 @@ pub struct AgentConfiguration { focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, - expanded_context_server_tools: HashMap, expanded_provider_configurations: HashMap, - tools: Entity, + context_server_registry: Entity, _registry_subscription: Subscription, scroll_handle: ScrollHandle, _check_for_gemini: Task<()>, @@ -69,7 +70,7 @@ impl AgentConfiguration { fs: Arc, agent_server_store: Entity, context_server_store: Entity, - tools: Entity, + context_server_registry: Entity, language_registry: Arc, workspace: WeakEntity, window: &mut Window, @@ -105,9 +106,8 @@ impl AgentConfiguration { configuration_views_by_provider: HashMap::default(), agent_server_store, context_server_store, - expanded_context_server_tools: HashMap::default(), expanded_provider_configurations: HashMap::default(), - tools, + context_server_registry, _registry_subscription: registry_subscription, scroll_handle: ScrollHandle::new(), _check_for_gemini: Task::ready(()), @@ -156,7 +156,42 @@ pub enum AssistantConfigurationEvent { impl EventEmitter for AgentConfiguration {} +enum AgentIcon { + Name(IconName), + Path(SharedString), +} + impl AgentConfiguration { + fn render_section_title( + &mut self, + title: impl Into, + description: impl Into, + menu: AnyElement, + ) -> impl IntoElement { + h_flex() + .p_4() + .pb_0() + .mb_2p5() + .items_start() + .justify_between() + .child( + v_flex() + .w_full() + .gap_0p5() + .child( + h_flex() + .pr_1() + .w_full() + .gap_2() + .justify_between() + .flex_wrap() + .child(Headline::new(title.into())) + .child(menu), + ) + .child(Label::new(description.into()).color(Color::Muted)), + ) + } + fn render_provider_configuration_block( &mut self, provider: &Arc, @@ -291,7 +326,7 @@ impl AgentConfiguration { "Start New Thread", ) .full_width() - .style(ButtonStyle::Filled) + .style(ButtonStyle::Outlined) .layer(ElevationIndex::ModalSurface) .icon_position(IconPosition::Start) .icon(IconName::Thread) @@ -307,89 +342,127 @@ impl AgentConfiguration { } })), ) - }), + }) + .when( + is_expanded && is_removable_provider(&provider.id(), cx), + |this| { + this.child( + Button::new( + SharedString::from(format!("delete-provider-{provider_id}")), + "Remove Provider", + ) + .full_width() + .style(ButtonStyle::Outlined) + .icon_position(IconPosition::Start) + .icon(IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let provider = provider.clone(); + move |this, _event, window, cx| { + this.delete_provider(provider.clone(), window, cx); + } + })), + ) + }, + ), ) } + fn delete_provider( + &mut self, + provider: Arc, + window: &mut Window, + cx: &mut Context, + ) { + let fs = self.fs.clone(); + let provider_id = provider.id(); + + cx.spawn_in(window, async move |_, cx| { + cx.update(|_window, cx| { + update_settings_file(fs.clone(), cx, { + let provider_id = provider_id.clone(); + move |settings, _| { + if let Some(ref mut openai_compatible) = settings + .language_models + .as_mut() + .and_then(|lm| lm.openai_compatible.as_mut()) + { + let key_to_remove: Arc = Arc::from(provider_id.0.as_ref()); + openai_compatible.remove(&key_to_remove); + } + } + }); + }) + .log_err(); + + cx.update(|_window, cx| { + LanguageModelRegistry::global(cx).update(cx, { + let provider_id = provider_id.clone(); + move |registry, cx| { + registry.unregister_provider(provider_id, cx); + } + }) + }) + .log_err(); + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn render_provider_configuration_section( &mut self, cx: &mut Context, ) -> impl IntoElement { let providers = LanguageModelRegistry::read_global(cx).providers(); + let popover_menu = PopoverMenu::new("add-provider-popover") + .trigger( + Button::new("add-provider", "Add Provider") + .style(ButtonStyle::Outlined) + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small), + ) + .menu({ + let workspace = self.workspace.clone(); + move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.header("Compatible APIs").entry("OpenAI", None, { + let workspace = workspace.clone(); + move |window, cx| { + workspace + .update(cx, |workspace, cx| { + AddLlmProviderModal::toggle( + LlmCompatibleProvider::OpenAi, + workspace, + window, + cx, + ); + }) + .log_err(); + } + }) + })) + } + }) + .anchor(gpui::Corner::TopRight) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }); + v_flex() .w_full() - .child( - h_flex() - .p(DynamicSpacing::Base16.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .pb_0() - .mb_2p5() - .items_start() - .justify_between() - .child( - v_flex() - .w_full() - .gap_0p5() - .child( - h_flex() - .pr_1() - .w_full() - .gap_2() - .justify_between() - .child(Headline::new("LLM Providers")) - .child( - PopoverMenu::new("add-provider-popover") - .trigger( - Button::new("add-provider", "Add Provider") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) - .icon_position(IconPosition::Start) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .label_size(LabelSize::Small), - ) - .anchor(gpui::Corner::TopRight) - .menu({ - let workspace = self.workspace.clone(); - move |window, cx| { - Some(ContextMenu::build( - window, - cx, - |menu, _window, _cx| { - menu.header("Compatible APIs").entry( - "OpenAI", - None, - { - let workspace = - workspace.clone(); - move |window, cx| { - workspace - .update(cx, |workspace, cx| { - AddLlmProviderModal::toggle( - LlmCompatibleProvider::OpenAi, - workspace, - window, - cx, - ); - }) - .log_err(); - } - }, - ) - }, - )) - } - }), - ), - ) - .child( - Label::new("Add at least one provider to use AI-powered features with Zed's native agent.") - .color(Color::Muted), - ), - ), - ) + .child(self.render_section_title( + "LLM Providers", + "Add at least one provider to use AI-powered features with Zed's native agent.", + popover_menu.into_any_element(), + )) .child( div() .w_full() @@ -403,101 +476,6 @@ impl AgentConfiguration { ) } - fn render_command_permission(&mut self, cx: &mut Context) -> impl IntoElement { - let always_allow_tool_actions = AgentSettings::get_global(cx).always_allow_tool_actions; - let fs = self.fs.clone(); - - SwitchField::new( - "always-allow-tool-actions-switch", - "Allow running commands without asking for confirmation", - Some( - "The agent can perform potentially destructive actions without asking for your confirmation.".into(), - ), - always_allow_tool_actions, - move |state, _window, cx| { - let allow = state == &ToggleState::Selected; - update_settings_file(fs.clone(), cx, move |settings, _| { - settings.agent.get_or_insert_default().set_always_allow_tool_actions(allow); - }); - }, - ) - } - - fn render_single_file_review(&mut self, cx: &mut Context) -> impl IntoElement { - let single_file_review = AgentSettings::get_global(cx).single_file_review; - let fs = self.fs.clone(); - - SwitchField::new( - "single-file-review", - "Enable single-file agent reviews", - Some("Agent edits are also displayed in single-file editors for review.".into()), - single_file_review, - move |state, _window, cx| { - let allow = state == &ToggleState::Selected; - update_settings_file(fs.clone(), cx, move |settings, _| { - settings - .agent - .get_or_insert_default() - .set_single_file_review(allow); - }); - }, - ) - } - - fn render_sound_notification(&mut self, cx: &mut Context) -> impl IntoElement { - let play_sound_when_agent_done = AgentSettings::get_global(cx).play_sound_when_agent_done; - let fs = self.fs.clone(); - - SwitchField::new( - "sound-notification", - "Play sound when finished generating", - Some( - "Hear a notification sound when the agent is done generating changes or needs your input.".into(), - ), - play_sound_when_agent_done, - move |state, _window, cx| { - let allow = state == &ToggleState::Selected; - update_settings_file(fs.clone(), cx, move |settings, _| { - settings.agent.get_or_insert_default().set_play_sound_when_agent_done(allow); - }); - }, - ) - } - - fn render_modifier_to_send(&mut self, cx: &mut Context) -> impl IntoElement { - let use_modifier_to_send = AgentSettings::get_global(cx).use_modifier_to_send; - let fs = self.fs.clone(); - - SwitchField::new( - "modifier-send", - "Use modifier to submit a message", - Some( - "Make a modifier (cmd-enter on macOS, ctrl-enter on Linux or Windows) required to send messages.".into(), - ), - use_modifier_to_send, - move |state, _window, cx| { - let allow = state == &ToggleState::Selected; - update_settings_file(fs.clone(), cx, move |settings, _| { - settings.agent.get_or_insert_default().set_use_modifier_to_send(allow); - }); - }, - ) - } - - fn render_general_settings_section(&mut self, cx: &mut Context) -> impl IntoElement { - v_flex() - .p(DynamicSpacing::Base16.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .gap_2p5() - .border_b_1() - .border_color(cx.theme().colors().border) - .child(Headline::new("General Settings")) - .child(self.render_command_permission(cx)) - .child(self.render_single_file_review(cx)) - .child(self.render_sound_notification(cx)) - .child(self.render_modifier_to_send(cx)) - } - fn render_zed_plan_info(&self, plan: Option, cx: &mut Context) -> impl IntoElement { if let Some(plan) = plan { let free_chip_bg = cx @@ -535,10 +513,6 @@ impl AgentConfiguration { } } - fn card_item_border_color(&self, cx: &mut Context) -> Hsla { - cx.theme().colors().border.opacity(0.6) - } - fn render_context_servers_section( &mut self, window: &mut Window, @@ -567,20 +541,20 @@ impl AgentConfiguration { let add_server_popover = PopoverMenu::new("add-server-popover") .trigger( Button::new("add-server", "Add Server") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) + .style(ButtonStyle::Outlined) .icon_position(IconPosition::Start) .icon(IconName::Plus) .icon_size(IconSize::Small) .icon_color(Color::Muted) .label_size(LabelSize::Small), ) - .anchor(gpui::Corner::TopRight) .menu({ move |window, cx| { Some(ContextMenu::build(window, cx, |menu, _window, _cx| { menu.entry("Add Custom Server", None, { - |window, cx| window.dispatch_action(AddContextServer.boxed_clone(), cx) + |window, cx| { + window.dispatch_action(crate::AddContextServer.boxed_clone(), cx) + } }) .entry("Install from Extensions", None, { |window, cx| { @@ -598,64 +572,65 @@ impl AgentConfiguration { }) })) } + }) + .anchor(gpui::Corner::TopRight) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), }); v_flex() - .p(DynamicSpacing::Base16.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .gap_2() .border_b_1() .border_color(cx.theme().colors().border) + .child(self.render_section_title( + "Model Context Protocol (MCP) Servers", + "All MCP servers connected directly or via a Zed extension.", + add_server_popover.into_any_element(), + )) .child( - h_flex() + v_flex() + .pl_4() + .pb_4() + .pr_5() .w_full() - .items_start() - .justify_between() .gap_1() - .child( - v_flex() - .gap_0p5() - .child(Headline::new("Model Context Protocol (MCP) Servers")) - .child( - Label::new( - "All MCP servers connected directly or via a Zed extension.", - ) - .color(Color::Muted), - ), - ) - .child(add_server_popover), - ) - .child(v_flex().w_full().gap_1().map(|mut parent| { - if context_server_ids.is_empty() { - parent.child( - h_flex() - .p_4() - .justify_center() - .border_1() - .border_dashed() - .border_color(cx.theme().colors().border.opacity(0.6)) - .rounded_sm() - .child( - Label::new("No MCP servers added yet.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - } else { - for (index, context_server_id) in context_server_ids.into_iter().enumerate() { - if index > 0 { - parent = parent.child( - Divider::horizontal() - .color(DividerColor::BorderFaded) - .into_any_element(), - ); + .map(|mut parent| { + if context_server_ids.is_empty() { + parent.child( + h_flex() + .p_4() + .justify_center() + .border_1() + .border_dashed() + .border_color(cx.theme().colors().border.opacity(0.6)) + .rounded_sm() + .child( + Label::new("No MCP servers added yet.") + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + } else { + for (index, context_server_id) in + context_server_ids.into_iter().enumerate() + { + if index > 0 { + parent = parent.child( + Divider::horizontal() + .color(DividerColor::BorderFaded) + .into_any_element(), + ); + } + parent = parent.child(self.render_context_server( + context_server_id, + window, + cx, + )); + } + parent } - parent = - parent.child(self.render_context_server(context_server_id, window, cx)); - } - parent - } - })) + }), + ) } fn render_context_server( @@ -664,7 +639,6 @@ impl AgentConfiguration { window: &mut Window, cx: &mut Context, ) -> impl use<> + IntoElement { - let tools_by_source = self.tools.read(cx).tools_by_source(cx); let server_status = self .context_server_store .read(cx) @@ -677,15 +651,13 @@ impl AgentConfiguration { let is_running = matches!(server_status, ContextServerStatus::Running); let item_id = SharedString::from(context_server_id.0.clone()); - let is_from_extension = server_configuration - .as_ref() - .map(|config| { - matches!( - config.as_ref(), - ContextServerConfiguration::Extension { .. } - ) - }) - .unwrap_or(false); + // Servers without a configuration can only be provided by extensions. + let provided_by_extension = server_configuration.as_ref().is_none_or(|config| { + matches!( + config.as_ref(), + ContextServerConfiguration::Extension { .. } + ) + }); let error = if let ContextServerStatus::Error(error) = server_status.clone() { Some(error) @@ -693,26 +665,20 @@ impl AgentConfiguration { None }; - let are_tools_expanded = self - .expanded_context_server_tools - .get(&context_server_id) - .copied() - .unwrap_or_default(); - let tools = tools_by_source - .get(&ToolSource::ContextServer { - id: context_server_id.0.clone().into(), - }) - .map_or([].as_slice(), |tools| tools.as_slice()); - let tool_count = tools.len(); + let tool_count = self + .context_server_registry + .read(cx) + .tools_for_server(&context_server_id) + .count(); - let (source_icon, source_tooltip) = if is_from_extension { + let (source_icon, source_tooltip) = if provided_by_extension { ( - IconName::ZedMcpExtension, + IconName::ZedSrcExtension, "This MCP server was installed from an extension.", ) } else { ( - IconName::ZedMcpCustom, + IconName::ZedSrcCustom, "This custom MCP server was installed directly.", ) }; @@ -742,7 +708,10 @@ impl AgentConfiguration { "Server is stopped.", ), }; - + let is_remote = server_configuration + .as_ref() + .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. })) + .unwrap_or(false); let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") .trigger_with_tooltip( IconButton::new("context-server-config-menu", IconName::Settings) @@ -755,9 +724,8 @@ impl AgentConfiguration { let fs = self.fs.clone(); let context_server_id = context_server_id.clone(); let language_registry = self.language_registry.clone(); - let context_server_store = self.context_server_store.clone(); let workspace = self.workspace.clone(); - let tools = self.tools.clone(); + let context_server_registry = self.context_server_registry.clone(); move |window, cx| { Some(ContextMenu::build(window, cx, |menu, _window, _cx| { @@ -766,29 +734,36 @@ impl AgentConfiguration { let language_registry = language_registry.clone(); let workspace = workspace.clone(); move |window, cx| { - ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach_and_log_err(cx); + if is_remote { + crate::agent_configuration::configure_context_server_modal::ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach(); + } else { + ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach(); + } } - }).when(tool_count >= 1, |this| this.entry("View Tools", None, { + }).when(tool_count > 0, |this| this.entry("View Tools", None, { let context_server_id = context_server_id.clone(); - let tools = tools.clone(); + let context_server_registry = context_server_registry.clone(); let workspace = workspace.clone(); - move |window, cx| { let context_server_id = context_server_id.clone(); - let tools = tools.clone(); - let workspace = workspace.clone(); - workspace.update(cx, |workspace, cx| { ConfigureContextServerToolsModal::toggle( context_server_id, - tools, + context_server_registry.clone(), workspace, window, cx, @@ -801,23 +776,10 @@ impl AgentConfiguration { .entry("Uninstall", None, { let fs = fs.clone(); let context_server_id = context_server_id.clone(); - let context_server_store = context_server_store.clone(); let workspace = workspace.clone(); move |_, cx| { - let is_provided_by_extension = context_server_store - .read(cx) - .configuration_for_server(&context_server_id) - .as_ref() - .map(|config| { - matches!( - config.as_ref(), - ContextServerConfiguration::Extension { .. } - ) - }) - .unwrap_or(false); - let uninstall_extension_task = match ( - is_provided_by_extension, + provided_by_extension, resolve_extension_for_context_server(&context_server_id, cx), ) { (true, Some((id, manifest))) => { @@ -870,21 +832,13 @@ impl AgentConfiguration { .child( h_flex() .justify_between() - .when( - error.is_none() && are_tools_expanded && tool_count >= 1, - |element| { - element - .border_b_1() - .border_color(self.card_item_border_color(cx)) - }, - ) .child( h_flex() .flex_1() .min_w_0() .child( h_flex() - .id(SharedString::from(format!("tooltip-{}", item_id))) + .id(format!("tooltip-{}", item_id)) .h_full() .w_3() .mr_2() @@ -925,7 +879,6 @@ impl AgentConfiguration { .child(context_server_configuration_menu) .child( Switch::new("context-server-switch", is_running.into()) - .color(SwitchColor::Accent) .on_click({ let context_server_manager = self.context_server_store.clone(); let fs = self.fs.clone(); @@ -1001,19 +954,14 @@ impl AgentConfiguration { ), ); } - - if !are_tools_expanded || tools.is_empty() { - return parent; - } - parent }) } fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { - let user_defined_agents = self - .agent_server_store - .read(cx) + let agent_server_store = self.agent_server_store.read(cx); + + let user_defined_agents = agent_server_store .external_agents() .filter(|name| { name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME @@ -1021,108 +969,237 @@ impl AgentConfiguration { .cloned() .collect::>(); - let user_defined_agents = user_defined_agents + let user_defined_agents: Vec<_> = user_defined_agents .into_iter() .map(|name| { - self.render_agent_server(IconName::Ai, name) - .into_any_element() + let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { + AgentIcon::Path(icon_path) + } else { + AgentIcon::Name(IconName::Sparkle) + }; + let display_name = agent_server_store + .agent_display_name(&name) + .unwrap_or_else(|| name.0.clone()); + (name, icon, display_name) }) - .collect::>(); + .collect(); + + let add_agent_popover = PopoverMenu::new("add-agent-server-popover") + .trigger( + Button::new("add-agent", "Add Agent") + .style(ButtonStyle::Outlined) + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small), + ) + .menu({ + move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.entry("Install from Extensions", None, { + |window, cx| { + window.dispatch_action( + zed_actions::Extensions { + category_filter: Some( + ExtensionCategoryFilter::AgentServers, + ), + id: None, + } + .boxed_clone(), + cx, + ) + } + }) + .entry("Add Custom Agent", None, { + move |window, cx| { + if let Some(workspace) = window.root().flatten() { + let workspace = workspace.downgrade(); + window + .spawn(cx, async |cx| { + open_new_agent_servers_entry_in_settings_editor( + workspace, cx, + ) + .await + }) + .detach_and_log_err(cx); + } + } + }) + .separator() + .header("Learn More") + .item( + ContextMenuEntry::new("Agent Servers Docs") + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_position(IconPosition::End) + .handler({ + move |window, cx| { + window.dispatch_action( + Box::new(OpenBrowser { + url: zed_urls::agent_server_docs(cx), + }), + cx, + ); + } + }), + ) + .item( + ContextMenuEntry::new("ACP Docs") + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_position(IconPosition::End) + .handler({ + move |window, cx| { + window.dispatch_action( + Box::new(OpenBrowser { + url: "https://agentclientprotocol.com/".into(), + }), + cx, + ); + } + }), + ) + })) + } + }) + .anchor(gpui::Corner::TopRight) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }); v_flex() .border_b_1() .border_color(cx.theme().colors().border) .child( v_flex() - .p(DynamicSpacing::Base16.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .gap_2() + .child(self.render_section_title( + "External Agents", + "All agents connected through the Agent Client Protocol.", + add_agent_popover.into_any_element(), + )) .child( v_flex() - .gap_0p5() - .child( - h_flex() - .pr_1() - .w_full() - .gap_2() - .justify_between() - .child(Headline::new("External Agents")) - .child( - Button::new("add-agent", "Add Agent") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) - .icon_position(IconPosition::Start) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .label_size(LabelSize::Small) - .on_click( - move |_, window, cx| { - if let Some(workspace) = window.root().flatten() { - let workspace = workspace.downgrade(); - window - .spawn(cx, async |cx| { - open_new_agent_servers_entry_in_settings_editor( - workspace, - cx, - ).await - }) - .detach_and_log_err(cx); - } - } - ), - ) - ) - .child( - Label::new( - "All agents connected through the Agent Client Protocol.", - ) - .color(Color::Muted), - ), - ) - .child(self.render_agent_server( - IconName::AiClaude, - "Claude Code", - )) - .child(Divider::horizontal().color(DividerColor::BorderFaded)) - .when(cx.has_flag::(), |this| { - this + .p_4() + .pt_0() + .gap_2() .child(self.render_agent_server( - IconName::AiOpenAi, - "Codex", + AgentIcon::Name(IconName::AiClaude), + "Claude Code", + "Claude Code", + false, + cx, )) .child(Divider::horizontal().color(DividerColor::BorderFaded)) - }) - .child(self.render_agent_server( - IconName::AiGemini, - "Gemini CLI", - )) - .map(|mut parent| { - for agent in user_defined_agents { - parent = parent.child(Divider::horizontal().color(DividerColor::BorderFaded)) - .child(agent); - } - parent - }) + .child(self.render_agent_server( + AgentIcon::Name(IconName::AiOpenAi), + "Codex CLI", + "Codex CLI", + false, + cx, + )) + .child(Divider::horizontal().color(DividerColor::BorderFaded)) + .child(self.render_agent_server( + AgentIcon::Name(IconName::AiGemini), + "Gemini CLI", + "Gemini CLI", + false, + cx, + )) + .map(|mut parent| { + for (name, icon, display_name) in user_defined_agents { + parent = parent + .child( + Divider::horizontal().color(DividerColor::BorderFaded), + ) + .child(self.render_agent_server( + icon, + name, + display_name, + true, + cx, + )); + } + parent + }), + ), ) } fn render_agent_server( &self, - icon: IconName, - name: impl Into, + icon: AgentIcon, + id: impl Into, + display_name: impl Into, + external: bool, + cx: &mut Context, ) -> impl IntoElement { - h_flex().gap_1p5().justify_between().child( - h_flex() - .gap_1p5() - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) - .child(Label::new(name.into())) - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ), - ) + let id = id.into(); + let display_name = display_name.into(); + + let icon = match icon { + AgentIcon::Name(icon_name) => Icon::new(icon_name) + .size(IconSize::Small) + .color(Color::Muted), + AgentIcon::Path(icon_path) => Icon::from_external_svg(icon_path) + .size(IconSize::Small) + .color(Color::Muted), + }; + + let tooltip_id = SharedString::new(format!("agent-source-{}", id)); + let tooltip_message = format!( + "The {} agent was installed from an extension.", + display_name + ); + + let agent_server_name = ExternalAgentServerName(id.clone()); + + let uninstall_btn_id = SharedString::from(format!("uninstall-{}", id)); + let uninstall_button = IconButton::new(uninstall_btn_id, IconName::Trash) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Uninstall Agent Extension")) + .on_click(cx.listener(move |this, _, _window, cx| { + let agent_name = agent_server_name.clone(); + + if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| { + store.get_extension_id_for_agent(&agent_name) + }) { + ExtensionStore::global(cx) + .update(cx, |store, cx| store.uninstall_extension(ext_id, cx)) + .detach_and_log_err(cx); + } + })); + + h_flex() + .gap_1() + .justify_between() + .child( + h_flex() + .gap_1p5() + .child(icon) + .child(Label::new(display_name)) + .when(external, |this| { + this.child( + div() + .id(tooltip_id) + .flex_none() + .tooltip(Tooltip::text(tooltip_message)) + .child( + Icon::new(IconName::ZedSrcExtension) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + }) + .child( + Icon::new(IconName::Check) + .color(Color::Success) + .size(IconSize::Small), + ), + ) + .when(external, |this| this.child(uninstall_button)) } } @@ -1145,12 +1222,11 @@ impl Render for AgentConfiguration { .track_scroll(&self.scroll_handle) .size_full() .overflow_y_scroll() - .child(self.render_general_settings_section(cx)) .child(self.render_agent_servers_section(cx)) .child(self.render_context_servers_section(window, cx)) .child(self.render_provider_configuration_section(cx)), ) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx), + .vertical_scrollbar_for(&self.scroll_handle, window, cx), ) } } @@ -1284,11 +1360,12 @@ async fn open_new_agent_servers_entry_in_settings_editor( .custom .insert( server_name, - settings::CustomAgentServerSettings { + settings::CustomAgentServerSettings::Custom { path: "path_to_executable".into(), args: vec![], env: Some(HashMap::default()), default_mode: None, + default_model: None, }, ); } @@ -1303,7 +1380,15 @@ async fn open_new_agent_servers_entry_in_settings_editor( .map(|(range, _)| range.clone()) .collect::>(); - item.edit(edits, cx); + item.edit( + edits.into_iter().map(|(range, s)| { + ( + MultiBufferOffset(range.start)..MultiBufferOffset(range.end), + s, + ) + }), + cx, + ); if let Some((unique_server_name, buffer)) = unique_server_name.zip(item.buffer().read(cx).as_singleton()) { @@ -1316,7 +1401,9 @@ async fn open_new_agent_servers_entry_in_settings_editor( window, cx, |selections| { - selections.select_ranges(vec![range]); + selections.select_ranges(vec![ + MultiBufferOffset(range.start)..MultiBufferOffset(range.end), + ]); }, ); } @@ -1352,3 +1439,14 @@ fn find_text_in_buffer( None } } + +// OpenAI-compatible providers are user-configured and can be removed, +// whereas built-in providers (like Anthropic, OpenAI, Google, etc.) can't. +// +// If in the future we have more "API-compatible-type" of providers, +// they should be included here as removable providers. +fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> bool { + AllLanguageModelSettings::get_global(cx) + .openai_compatible + .contains_key(provider_id.0.as_ref()) +} diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 283eb9f288..e61250deb3 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -3,16 +3,42 @@ use std::sync::Arc; use anyhow::Result; use collections::HashSet; use fs::Fs; -use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, Task}; +use gpui::{ + DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, +}; use language_model::LanguageModelRegistry; use language_models::provider::open_ai_compatible::{AvailableModel, ModelCapabilities}; use settings::{OpenAiCompatibleSettingsContent, update_settings_file}; use ui::{ - Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, prelude::*, + Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, + WithScrollbar, prelude::*, }; -use ui_input::SingleLineInput; +use ui_input::InputField; use workspace::{ModalView, Workspace}; +fn single_line_input( + label: impl Into, + placeholder: impl Into, + text: Option<&str>, + tab_index: isize, + window: &mut Window, + cx: &mut App, +) -> Entity { + cx.new(|cx| { + let input = InputField::new(window, cx, placeholder) + .label(label) + .tab_index(tab_index) + .tab_stop(true); + + if let Some(text) = text { + input + .editor() + .update(cx, |editor, cx| editor.set_text(text, window, cx)); + } + input + }) +} + #[derive(Clone, Copy)] pub enum LlmCompatibleProvider { OpenAi, @@ -33,20 +59,22 @@ impl LlmCompatibleProvider { } struct AddLlmProviderInput { - provider_name: Entity, - api_url: Entity, - api_key: Entity, + provider_name: Entity, + api_url: Entity, + api_key: Entity, models: Vec, } impl AddLlmProviderInput { fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) -> Self { - let provider_name = single_line_input("Provider Name", provider.name(), None, window, cx); - let api_url = single_line_input("API URL", provider.api_url(), None, window, cx); + let provider_name = + single_line_input("Provider Name", provider.name(), None, 1, window, cx); + let api_url = single_line_input("API URL", provider.api_url(), None, 2, window, cx); let api_key = single_line_input( "API Key", "000000000000000000000000000000000000000000000000", None, + 3, window, cx, ); @@ -55,12 +83,13 @@ impl AddLlmProviderInput { provider_name, api_url, api_key, - models: vec![ModelInput::new(window, cx)], + models: vec![ModelInput::new(0, window, cx)], } } fn add_model(&mut self, window: &mut Window, cx: &mut App) { - self.models.push(ModelInput::new(window, cx)); + let model_index = self.models.len(); + self.models.push(ModelInput::new(model_index, window, cx)); } fn remove_model(&mut self, index: usize) { @@ -77,19 +106,22 @@ struct ModelCapabilityToggles { } struct ModelInput { - name: Entity, - max_completion_tokens: Entity, - max_output_tokens: Entity, - max_tokens: Entity, + name: Entity, + max_completion_tokens: Entity, + max_output_tokens: Entity, + max_tokens: Entity, capabilities: ModelCapabilityToggles, } impl ModelInput { - fn new(window: &mut Window, cx: &mut App) -> Self { + fn new(model_index: usize, window: &mut Window, cx: &mut App) -> Self { + let base_tab_index = (3 + (model_index * 4)) as isize; + let model_name = single_line_input( "Model Name", "e.g. gpt-4o, claude-opus-4, gemini-2.5-pro", None, + base_tab_index + 1, window, cx, ); @@ -97,6 +129,7 @@ impl ModelInput { "Max Completion Tokens", "200000", Some("200000"), + base_tab_index + 2, window, cx, ); @@ -104,10 +137,19 @@ impl ModelInput { "Max Output Tokens", "Max Output Tokens", Some("32000"), + base_tab_index + 3, window, cx, ); - let max_tokens = single_line_input("Max Tokens", "Max Tokens", Some("200000"), window, cx); + let max_tokens = single_line_input( + "Max Tokens", + "Max Tokens", + Some("200000"), + base_tab_index + 4, + window, + cx, + ); + let ModelCapabilities { tools, images, @@ -115,6 +157,7 @@ impl ModelInput { prompt_cache_key, chat_completions, } = ModelCapabilities::default(); + Self { name: model_name, max_completion_tokens, @@ -169,24 +212,6 @@ impl ModelInput { } } -fn single_line_input( - label: impl Into, - placeholder: impl Into, - text: Option<&str>, - window: &mut Window, - cx: &mut App, -) -> Entity { - cx.new(|cx| { - let input = SingleLineInput::new(window, cx, placeholder).label(label); - if let Some(text) = text { - input - .editor() - .update(cx, |editor, cx| editor.set_text(text, window, cx)); - } - input - }) -} - fn save_provider_to_settings( input: &AddLlmProviderInput, cx: &mut App, @@ -262,6 +287,7 @@ fn save_provider_to_settings( pub struct AddLlmProviderModal { provider: LlmCompatibleProvider, input: AddLlmProviderInput, + scroll_handle: ScrollHandle, focus_handle: FocusHandle, last_error: Option, } @@ -282,6 +308,7 @@ impl AddLlmProviderModal { provider, last_error: None, focus_handle: cx.focus_handle(), + scroll_handle: ScrollHandle::new(), } } @@ -436,6 +463,19 @@ impl AddLlmProviderModal { ) }) } + + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { + window.focus_next(); + } + + fn on_tab_prev( + &mut self, + _: &menu::SelectPrevious, + window: &mut Window, + _: &mut Context, + ) { + window.focus_prev(); + } } impl EventEmitter for AddLlmProviderModal {} @@ -452,12 +492,24 @@ impl Render for AddLlmProviderModal { fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); - div() + let window_size = window.viewport_size(); + let rem_size = window.rem_size(); + let is_large_window = window_size.height / rem_size > rems_from_px(600.).0; + + let modal_max_height = if is_large_window { + rems_from_px(450.) + } else { + rems_from_px(200.) + }; + + v_flex() .id("add-llm-provider-modal") .key_context("AddLlmProviderModal") .w(rems(34.)) .elevation_3(cx) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::on_tab)) + .on_action(cx.listener(Self::on_tab_prev)) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { this.focus_handle(cx).focus(window); })) @@ -480,17 +532,25 @@ impl Render for AddLlmProviderModal { ) }) .child( - v_flex() - .id("modal_content") + div() .size_full() - .max_h_128() - .overflow_y_scroll() - .px(DynamicSpacing::Base12.rems(cx)) - .gap(DynamicSpacing::Base04.rems(cx)) - .child(self.input.provider_name.clone()) - .child(self.input.api_url.clone()) - .child(self.input.api_key.clone()) - .child(self.render_model_section(cx)), + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + .child( + v_flex() + .id("modal_content") + .size_full() + .tab_group() + .max_h(modal_max_height) + .pl_3() + .pr_4() + .gap_2() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .child(self.input.provider_name.clone()) + .child(self.input.api_url.clone()) + .child(self.input.api_key.clone()) + .child(self.render_model_section(cx)), + ), ) .footer( ModalFooter::new().end_slot( @@ -502,7 +562,6 @@ impl Render for AddLlmProviderModal { KeyBinding::for_action_in( &menu::Cancel, &focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))), @@ -517,7 +576,6 @@ impl Render for AddLlmProviderModal { KeyBinding::for_action_in( &menu::Confirm, &focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))), @@ -535,16 +593,14 @@ impl Render for AddLlmProviderModal { #[cfg(test)] mod tests { use super::*; - use editor::EditorSettings; use fs::FakeFs; use gpui::{TestAppContext, VisualTestContext}; - use language::language_settings; use language_model::{ LanguageModelProviderId, LanguageModelProviderName, fake_provider::FakeLanguageModelProvider, }; use project::Project; - use settings::{Settings as _, SettingsStore}; + use settings::SettingsStore; use util::path; #[gpui::test] @@ -637,10 +693,10 @@ mod tests { cx.update(|_window, cx| { LanguageModelRegistry::global(cx).update(cx, |registry, cx| { registry.register_provider( - FakeLanguageModelProvider::new( + Arc::new(FakeLanguageModelProvider::new( LanguageModelProviderId::new("someprovider"), LanguageModelProviderName::new("Some Provider"), - ), + )), cx, ); }); @@ -664,7 +720,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let model_input = ModelInput::new(window, cx); + let model_input = ModelInput::new(0, window, cx); model_input.name.update(cx, |input, cx| { input.editor().update(cx, |editor, cx| { editor.set_text("somemodel", window, cx); @@ -705,7 +761,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let mut model_input = ModelInput::new(window, cx); + let mut model_input = ModelInput::new(0, window, cx); model_input.name.update(cx, |input, cx| { input.editor().update(cx, |editor, cx| { editor.set_text("somemodel", window, cx); @@ -732,7 +788,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let mut model_input = ModelInput::new(window, cx); + let mut model_input = ModelInput::new(0, window, cx); model_input.name.update(cx, |input, cx| { input.editor().update(cx, |editor, cx| { editor.set_text("somemodel", window, cx); @@ -759,13 +815,9 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - workspace::init_settings(cx); - Project::init_settings(cx); theme::init(theme::LoadThemes::JustBase, cx); - language_settings::init(cx); - EditorSettings::register(cx); + language_model::init_settings(cx); - language_models::init_settings(cx); }); let fs = FakeFs::new(cx.executor()); @@ -784,12 +836,7 @@ mod tests { models: Vec<(&str, &str, &str, &str)>, cx: &mut VisualTestContext, ) -> Option { - fn set_text( - input: &Entity, - text: &str, - window: &mut Window, - cx: &mut App, - ) { + fn set_text(input: &Entity, text: &str, window: &mut Window, cx: &mut App) { input.update(cx, |input, cx| { input.editor().update(cx, |editor, cx| { editor.set_text(text, window, cx); @@ -807,7 +854,7 @@ mod tests { models.iter().enumerate() { if i >= input.models.len() { - input.models.push(ModelInput::new(window, cx)); + input.models.push(ModelInput::new(i, window, cx)); } let model = &mut input.models[i]; set_text(&model.name, name, window, cx); diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index ce8e167dab..a0f0be886a 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -1,14 +1,12 @@ -use std::{ - path::PathBuf, - sync::{Arc, Mutex}, -}; +use std::sync::{Arc, Mutex}; use anyhow::{Context as _, Result}; +use collections::HashMap; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ - AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, - TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, + AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, + Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; @@ -20,10 +18,12 @@ use project::{ project_settings::{ContextServerSettings, ProjectSettings}, worktree_store::WorktreeStore, }; +use serde::Deserialize; use settings::{Settings as _, update_settings_file}; use theme::ThemeSettings; use ui::{ - CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*, + CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use workspace::{ModalView, Workspace}; @@ -36,6 +36,11 @@ enum ConfigurationTarget { id: ContextServerId, command: ContextServerCommand, }, + ExistingHttp { + id: ContextServerId, + url: String, + headers: HashMap, + }, Extension { id: ContextServerId, repository_url: Option, @@ -46,9 +51,11 @@ enum ConfigurationTarget { enum ConfigurationSource { New { editor: Entity, + is_http: bool, }, Existing { editor: Entity, + is_http: bool, }, Extension { id: ContextServerId, @@ -96,6 +103,7 @@ impl ConfigurationSource { match target { ConfigurationTarget::New => ConfigurationSource::New { editor: create_editor(context_server_input(None), jsonc_language, window, cx), + is_http: false, }, ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing { editor: create_editor( @@ -104,6 +112,20 @@ impl ConfigurationSource { window, cx, ), + is_http: false, + }, + ConfigurationTarget::ExistingHttp { + id, + url, + headers: auth, + } => ConfigurationSource::Existing { + editor: create_editor( + context_server_http_input(Some((id, url, auth))), + jsonc_language, + window, + cx, + ), + is_http: true, }, ConfigurationTarget::Extension { id, @@ -140,16 +162,30 @@ impl ConfigurationSource { fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> { match self { - ConfigurationSource::New { editor } | ConfigurationSource::Existing { editor } => { - parse_input(&editor.read(cx).text(cx)).map(|(id, command)| { - ( - id, - ContextServerSettings::Custom { - enabled: true, - command, - }, - ) - }) + ConfigurationSource::New { editor, is_http } + | ConfigurationSource::Existing { editor, is_http } => { + if *is_http { + parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth)| { + ( + id, + ContextServerSettings::Http { + enabled: true, + url, + headers: auth, + }, + ) + }) + } else { + parse_input(&editor.read(cx).text(cx)).map(|(id, command)| { + ( + id, + ContextServerSettings::Stdio { + enabled: true, + command, + }, + ) + }) + } } ConfigurationSource::Extension { id, @@ -185,11 +221,12 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) Some((id, cmd)) => { let args = serde_json::to_string(&cmd.args).unwrap(); let env = serde_json::to_string(&cmd.env.unwrap_or_default()).unwrap(); - (id.0.to_string(), cmd.path, args, env) + let cmd_path = serde_json::to_string(&cmd.path).unwrap(); + (id.0.to_string(), cmd_path, args, env) } None => ( "some-mcp-server".to_string(), - PathBuf::new(), + "".to_string(), "[]".to_string(), "{}".to_string(), ), @@ -200,17 +237,76 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) /// The name of your MCP server "{name}": {{ /// The command which runs the MCP server - "command": "{}", + "command": {command}, /// The arguments to pass to the MCP server "args": {args}, /// The environment variables to set "env": {env} }} -}}"#, - command.display() +}}"# ) } +fn context_server_http_input( + existing: Option<(ContextServerId, String, HashMap)>, +) -> String { + let (name, url, headers) = match existing { + Some((id, url, headers)) => { + let header = if headers.is_empty() { + r#"// "Authorization": "Bearer "#.to_string() + } else { + let json = serde_json::to_string_pretty(&headers).unwrap(); + let mut lines = json.split("\n").collect::>(); + if lines.len() > 1 { + lines.remove(0); + lines.pop(); + } + lines + .into_iter() + .map(|line| format!(" {}", line)) + .collect::() + }; + (id.0.to_string(), url, header) + } + None => ( + "some-remote-server".to_string(), + "https://example.com/mcp".to_string(), + r#"// "Authorization": "Bearer "#.to_string(), + ), + }; + + format!( + r#"{{ + /// The name of your remote MCP server + "{name}": {{ + /// The URL of the remote MCP server + "url": "{url}", + "headers": {{ + /// Any headers to send along + {headers} + }} + }} +}}"# + ) +} + +fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap)> { + #[derive(Deserialize)] + struct Temp { + url: String, + #[serde(default)] + headers: HashMap, + } + let value: HashMap = serde_json_lenient::from_str(text)?; + if value.len() != 1 { + anyhow::bail!("Expected exactly one context server configuration"); + } + + let (key, value) = value.into_iter().next().unwrap(); + + Ok((ContextServerId(key.into()), value.url, value.headers)) +} + fn resolve_context_server_extension( id: ContextServerId, worktree_store: Entity, @@ -252,6 +348,7 @@ pub struct ConfigureContextServerModal { source: ConfigurationSource, state: State, original_server_id: Option, + scroll_handle: ScrollHandle, } impl ConfigureContextServerModal { @@ -303,13 +400,22 @@ impl ConfigureContextServerModal { window.spawn(cx, async move |cx| { let target = match settings { - ContextServerSettings::Custom { + ContextServerSettings::Stdio { enabled: _, command, } => Some(ConfigurationTarget::Existing { id: server_id, command, }), + ContextServerSettings::Http { + enabled: _, + url, + headers, + } => Some(ConfigurationTarget::ExistingHttp { + id: server_id, + url, + headers, + }), ContextServerSettings::Extension { .. } => { match workspace .update(cx, |workspace, cx| { @@ -351,6 +457,7 @@ impl ConfigureContextServerModal { state: State::Idle, original_server_id: match &target { ConfigurationTarget::Existing { id, .. } => Some(id.clone()), + ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), ConfigurationTarget::Extension { id, .. } => Some(id.clone()), ConfigurationTarget::New => None, }, @@ -361,6 +468,7 @@ impl ConfigureContextServerModal { window, cx, ), + scroll_handle: ScrollHandle::new(), }) }) }) @@ -478,7 +586,7 @@ impl ModalView for ConfigureContextServerModal {} impl Focusable for ConfigureContextServerModal { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.source { - ConfigurationSource::New { editor } => editor.focus_handle(cx), + ConfigurationSource::New { editor, .. } => editor.focus_handle(cx), ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx), ConfigurationSource::Extension { editor, .. } => editor .as_ref() @@ -525,8 +633,8 @@ impl ConfigureContextServerModal { fn render_modal_content(&self, cx: &App) -> AnyElement { let editor = match &self.source { - ConfigurationSource::New { editor } => editor, - ConfigurationSource::Existing { editor } => editor, + ConfigurationSource::New { editor, .. } => editor, + ConfigurationSource::Existing { editor, .. } => editor, ConfigurationSource::Extension { editor, .. } => { let Some(editor) = editor else { return div().into_any_element(); @@ -566,7 +674,7 @@ impl ConfigureContextServerModal { .into_any_element() } - fn render_modal_footer(&self, window: &mut Window, cx: &mut Context) -> ModalFooter { + fn render_modal_footer(&self, cx: &mut Context) -> ModalFooter { let focus_handle = self.focus_handle(cx); let is_connecting = matches!(self.state, State::Waiting); @@ -584,12 +692,11 @@ impl ConfigureContextServerModal { .icon_size(IconSize::Small) .tooltip({ let repository_url = repository_url.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::with_meta( "Open Repository", None, repository_url.clone(), - window, cx, ) } @@ -599,6 +706,36 @@ impl ConfigureContextServerModal { move |_, _, cx| cx.open_url(&repository_url) }), ) + } else if let ConfigurationSource::New { is_http, .. } = &self.source { + let label = if *is_http { + "Configure Local" + } else { + "Configure Remote" + }; + let tooltip = if *is_http { + "Configure an MCP server that runs on stdin/stdout." + } else { + "Configure an MCP server that you connect to over HTTP" + }; + + Some( + Button::new("toggle-kind", label) + .tooltip(Tooltip::text(tooltip)) + .on_click(cx.listener(|this, _, window, cx| match &mut this.source { + ConfigurationSource::New { editor, is_http } => { + *is_http = !*is_http; + let new_text = if *is_http { + context_server_http_input(None) + } else { + context_server_input(None) + }; + editor.update(cx, |editor, cx| { + editor.set_text(new_text, window, cx); + }) + } + _ => {} + })), + ) } else { None }, @@ -616,7 +753,7 @@ impl ConfigureContextServerModal { }, ) .key_binding( - KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx) + KeyBinding::for_action_in(&menu::Cancel, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click( @@ -634,7 +771,7 @@ impl ConfigureContextServerModal { ) .disabled(is_connecting) .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, window, cx) + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click( @@ -700,16 +837,31 @@ impl Render for ConfigureContextServerModal { Modal::new("configure-context-server", None) .header(self.render_modal_header()) .section( - Section::new() - .child(self.render_modal_description(window, cx)) - .child(self.render_modal_content(cx)) - .child(match &self.state { - State::Idle => div(), - State::Waiting => Self::render_waiting_for_context_server(), - State::Error(error) => Self::render_modal_error(error.clone()), - }), + Section::new().child( + div() + .size_full() + .child( + div() + .id("modal-content") + .max_h(vh(0.7, window)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .child(self.render_modal_description(window, cx)) + .child(self.render_modal_content(cx)) + .child(match &self.state { + State::Idle => div(), + State::Waiting => { + Self::render_waiting_for_context_server() + } + State::Error(error) => { + Self::render_modal_error(error.clone()) + } + }), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx), + ), ) - .footer(self.render_modal_footer(window, cx)), + .footer(self.render_modal_footer(cx)), ) } } diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs index 5a59806972..5115e2f70c 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs @@ -1,4 +1,5 @@ -use assistant_tool::{ToolSource, ToolWorkingSet}; +use agent::ContextServerRegistry; +use collections::HashMap; use context_server::ContextServerId; use gpui::{ DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Window, prelude::*, @@ -8,37 +9,37 @@ use workspace::{ModalView, Workspace}; pub struct ConfigureContextServerToolsModal { context_server_id: ContextServerId, - tools: Entity, + context_server_registry: Entity, focus_handle: FocusHandle, - expanded_tools: std::collections::HashMap, + expanded_tools: HashMap, scroll_handle: ScrollHandle, } impl ConfigureContextServerToolsModal { fn new( context_server_id: ContextServerId, - tools: Entity, + context_server_registry: Entity, _window: &mut Window, cx: &mut Context, ) -> Self { Self { context_server_id, - tools, + context_server_registry, focus_handle: cx.focus_handle(), - expanded_tools: std::collections::HashMap::new(), + expanded_tools: HashMap::default(), scroll_handle: ScrollHandle::new(), } } pub fn toggle( context_server_id: ContextServerId, - tools: Entity, + context_server_registry: Entity, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) { workspace.toggle_modal(window, cx, |window, cx| { - Self::new(context_server_id, tools, window, cx) + Self::new(context_server_id, context_server_registry, window, cx) }); } @@ -51,13 +52,11 @@ impl ConfigureContextServerToolsModal { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let tools_by_source = self.tools.read(cx).tools_by_source(cx); - let server_tools = tools_by_source - .get(&ToolSource::ContextServer { - id: self.context_server_id.0.clone().into(), - }) - .map(|tools| tools.as_slice()) - .unwrap_or(&[]); + let tools = self + .context_server_registry + .read(cx) + .tools_for_server(&self.context_server_id) + .collect::>(); div() .size_full() @@ -70,11 +69,11 @@ impl ConfigureContextServerToolsModal { .max_h_128() .overflow_y_scroll() .track_scroll(&self.scroll_handle) - .children(server_tools.iter().enumerate().flat_map(|(index, tool)| { + .children(tools.iter().enumerate().flat_map(|(index, tool)| { let tool_name = tool.name(); let is_expanded = self .expanded_tools - .get(&tool_name) + .get(tool_name.as_ref()) .copied() .unwrap_or(false); @@ -88,7 +87,7 @@ impl ConfigureContextServerToolsModal { v_flex() .child( h_flex() - .id(SharedString::from(format!("tool-header-{}", index))) + .id(format!("tool-header-{}", index)) .py_1() .pl_1() .pr_2() @@ -110,7 +109,7 @@ impl ConfigureContextServerToolsModal { move |this, _event, _window, _cx| { let current = this .expanded_tools - .get(&tool_name) + .get(tool_name.as_ref()) .copied() .unwrap_or(false); this.expanded_tools @@ -127,7 +126,7 @@ impl ConfigureContextServerToolsModal { .into_any_element(), ]; - if index < server_tools.len() - 1 { + if index < tools.len() - 1 { items.push( h_flex() .w_full() @@ -139,7 +138,7 @@ impl ConfigureContextServerToolsModal { items })), ) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) .into_any_element() } } diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 9a7f0ed602..2f17349c3d 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -2,12 +2,15 @@ mod profile_modal_header; use std::sync::Arc; +use agent::ContextServerRegistry; use agent_settings::{AgentProfile, AgentProfileId, AgentSettings, builtin_profiles}; -use assistant_tool::ToolWorkingSet; use editor::Editor; use fs::Fs; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*}; -use settings::Settings as _; +use language_model::{LanguageModel, LanguageModelRegistry}; +use settings::{ + LanguageModelProviderSetting, LanguageModelSelection, Settings as _, update_settings_file, +}; use ui::{ KeyBinding, ListItem, ListItemSpacing, ListSeparator, Navigable, NavigableEntry, prelude::*, }; @@ -15,10 +18,9 @@ use workspace::{ModalView, Workspace}; use crate::agent_configuration::manage_profiles_modal::profile_modal_header::ProfileModalHeader; use crate::agent_configuration::tool_picker::{ToolPicker, ToolPickerDelegate}; +use crate::language_model_selector::{LanguageModelSelector, language_model_selector}; use crate::{AgentPanel, ManageProfiles}; -use super::tool_picker::ToolPickerMode; - enum Mode { ChooseProfile(ChooseProfileMode), NewProfile(NewProfileMode), @@ -33,6 +35,11 @@ enum Mode { tool_picker: Entity, _subscription: Subscription, }, + ConfigureDefaultModel { + profile_id: AgentProfileId, + model_picker: Entity, + _subscription: Subscription, + }, } impl Mode { @@ -84,6 +91,7 @@ pub struct ChooseProfileMode { pub struct ViewProfileMode { profile_id: AgentProfileId, fork_profile: NavigableEntry, + configure_default_model: NavigableEntry, configure_tools: NavigableEntry, configure_mcps: NavigableEntry, cancel_item: NavigableEntry, @@ -97,7 +105,8 @@ pub struct NewProfileMode { pub struct ManageProfilesModal { fs: Arc, - tools: Entity, + context_server_registry: Entity, + active_model: Option>, focus_handle: FocusHandle, mode: Mode, } @@ -111,10 +120,14 @@ impl ManageProfilesModal { workspace.register_action(|workspace, action: &ManageProfiles, window, cx| { if let Some(panel) = workspace.panel::(cx) { let fs = workspace.app_state().fs.clone(); - let thread_store = panel.read(cx).thread_store(); - let tools = thread_store.read(cx).tools(); + let active_model = panel + .read(cx) + .active_native_agent_thread(cx) + .and_then(|thread| thread.read(cx).model().cloned()); + + let context_server_registry = panel.read(cx).context_server_registry().clone(); workspace.toggle_modal(window, cx, |window, cx| { - let mut this = Self::new(fs, tools, window, cx); + let mut this = Self::new(fs, active_model, context_server_registry, window, cx); if let Some(profile_id) = action.customize_tools.clone() { this.configure_builtin_tools(profile_id, window, cx); @@ -128,7 +141,8 @@ impl ManageProfilesModal { pub fn new( fs: Arc, - tools: Entity, + active_model: Option>, + context_server_registry: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -136,7 +150,8 @@ impl ManageProfilesModal { Self { fs, - tools, + active_model, + context_server_registry, focus_handle, mode: Mode::choose_profile(window, cx), } @@ -174,6 +189,7 @@ impl ManageProfilesModal { self.mode = Mode::ViewProfile(ViewProfileMode { profile_id, fork_profile: NavigableEntry::focusable(cx), + configure_default_model: NavigableEntry::focusable(cx), configure_tools: NavigableEntry::focusable(cx), configure_mcps: NavigableEntry::focusable(cx), cancel_item: NavigableEntry::focusable(cx), @@ -181,6 +197,84 @@ impl ManageProfilesModal { self.focus_handle(cx).focus(window); } + fn configure_default_model( + &mut self, + profile_id: AgentProfileId, + window: &mut Window, + cx: &mut Context, + ) { + let fs = self.fs.clone(); + let profile_id_for_closure = profile_id.clone(); + + let model_picker = cx.new(|cx| { + let fs = fs.clone(); + let profile_id = profile_id_for_closure.clone(); + + language_model_selector( + { + let profile_id = profile_id.clone(); + move |cx| { + let settings = AgentSettings::get_global(cx); + + settings + .profiles + .get(&profile_id) + .and_then(|profile| profile.default_model.as_ref()) + .and_then(|selection| { + let registry = LanguageModelRegistry::read_global(cx); + let provider_id = language_model::LanguageModelProviderId( + gpui::SharedString::from(selection.provider.0.clone()), + ); + let provider = registry.provider(&provider_id)?; + let model = provider + .provided_models(cx) + .iter() + .find(|m| m.id().0 == selection.model.as_str())? + .clone(); + Some(language_model::ConfiguredModel { provider, model }) + }) + } + }, + move |model, cx| { + let provider = model.provider_id().0.to_string(); + let model_id = model.id().0.to_string(); + let profile_id = profile_id.clone(); + + update_settings_file(fs.clone(), cx, move |settings, _cx| { + let agent_settings = settings.agent.get_or_insert_default(); + if let Some(profiles) = agent_settings.profiles.as_mut() { + if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) { + profile.default_model = Some(LanguageModelSelection { + provider: LanguageModelProviderSetting(provider.clone()), + model: model_id.clone(), + }); + } + } + }); + }, + false, // Do not use popover styles for the model picker + self.focus_handle.clone(), + window, + cx, + ) + .modal(false) + }); + + let dismiss_subscription = cx.subscribe_in(&model_picker, window, { + let profile_id = profile_id.clone(); + move |this, _picker, _: &DismissEvent, window, cx| { + this.view_profile(profile_id.clone(), window, cx); + } + }); + + self.mode = Mode::ConfigureDefaultModel { + profile_id, + model_picker, + _subscription: dismiss_subscription, + }; + self.focus_handle(cx).focus(window); + } + fn configure_mcp_tools( &mut self, profile_id: AgentProfileId, @@ -193,10 +287,9 @@ impl ManageProfilesModal { }; let tool_picker = cx.new(|cx| { - let delegate = ToolPickerDelegate::new( - ToolPickerMode::McpTools, + let delegate = ToolPickerDelegate::mcp_tools( + &self.context_server_registry, self.fs.clone(), - self.tools.clone(), profile_id.clone(), profile, cx, @@ -230,10 +323,14 @@ impl ManageProfilesModal { }; let tool_picker = cx.new(|cx| { - let delegate = ToolPickerDelegate::new( - ToolPickerMode::BuiltinTools, + let delegate = ToolPickerDelegate::builtin_tools( + //todo: This causes the web search tool to show up even it only works when using zed hosted models + agent::supported_built_in_tool_names( + self.active_model.as_ref().map(|model| model.provider_id()), + ) + .map(|s| s.into()) + .collect::>(), self.fs.clone(), - self.tools.clone(), profile_id.clone(), profile, cx, @@ -268,6 +365,7 @@ impl ManageProfilesModal { Mode::ViewProfile(_) => {} Mode::ConfigureTools { .. } => {} Mode::ConfigureMcps { .. } => {} + Mode::ConfigureDefaultModel { .. } => {} } } @@ -290,6 +388,9 @@ impl ManageProfilesModal { Mode::ConfigureMcps { profile_id, .. } => { self.view_profile(profile_id.clone(), window, cx) } + Mode::ConfigureDefaultModel { profile_id, .. } => { + self.view_profile(profile_id.clone(), window, cx) + } } } } @@ -304,6 +405,7 @@ impl Focusable for ManageProfilesModal { Mode::ViewProfile(_) => self.focus_handle.clone(), Mode::ConfigureTools { tool_picker, .. } => tool_picker.focus_handle(cx), Mode::ConfigureMcps { tool_picker, .. } => tool_picker.focus_handle(cx), + Mode::ConfigureDefaultModel { model_picker, .. } => model_picker.focus_handle(cx), } } } @@ -320,7 +422,7 @@ impl ManageProfilesModal { let is_focused = profile.navigation.focus_handle.contains_focused(window, cx); div() - .id(SharedString::from(format!("profile-{}", profile.id))) + .id(format!("profile-{}", profile.id)) .track_focus(&profile.navigation.focus_handle) .on_action({ let profile_id = profile.id.clone(); @@ -329,7 +431,7 @@ impl ManageProfilesModal { }) }) .child( - ListItem::new(SharedString::from(format!("profile-{}", profile.id))) + ListItem::new(format!("profile-{}", profile.id)) .toggle_state(is_focused) .inset(true) .spacing(ListItemSpacing::Sparse) @@ -343,10 +445,9 @@ impl ManageProfilesModal { .size(LabelSize::Small) .color(Color::Muted), ) - .children(KeyBinding::for_action_in( + .child(KeyBinding::for_action_in( &menu::Confirm, &self.focus_handle, - window, cx, )), ) @@ -536,6 +637,47 @@ impl ManageProfilesModal { }), ), ) + .child( + div() + .id("configure-default-model") + .track_focus(&mode.configure_default_model.focus_handle) + .on_action({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _: &menu::Confirm, window, cx| { + this.configure_default_model( + profile_id.clone(), + window, + cx, + ); + }) + }) + .child( + ListItem::new("model-item") + .toggle_state( + mode.configure_default_model + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new("Configure Default Model")) + .on_click({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _, window, cx| { + this.configure_default_model( + profile_id.clone(), + window, + cx, + ); + }) + }), + ), + ) .child( div() .id("configure-builtin-tools") @@ -640,14 +782,13 @@ impl ManageProfilesModal { ) .child(Label::new("Go Back")) .end_slot( - div().children( + div().child( KeyBinding::for_action_in( &menu::Cancel, &self.focus_handle, - window, cx, ) - .map(|kb| kb.size(rems_from_px(12.))), + .size(rems_from_px(12.)), ), ) .on_click({ @@ -661,6 +802,7 @@ impl ManageProfilesModal { .into_any_element(), ) .entry(mode.fork_profile) + .entry(mode.configure_default_model) .entry(mode.configure_tools) .entry(mode.configure_mcps) .entry(mode.cancel_item) @@ -691,14 +833,9 @@ impl Render for ManageProfilesModal { ) .child(Label::new("Go Back")) .end_slot( - div().children( - KeyBinding::for_action_in( - &menu::Cancel, - &self.focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + div().child( + KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle, cx) + .size(rems_from_px(12.)), ), ) .on_click({ @@ -751,6 +888,29 @@ impl Render for ManageProfilesModal { .child(go_back_item) .into_any_element() } + Mode::ConfigureDefaultModel { + profile_id, + model_picker, + .. + } => { + let profile_name = settings + .profiles + .get(profile_id) + .map(|profile| profile.name.clone()) + .unwrap_or_else(|| "Unknown".into()); + + v_flex() + .pb_1() + .child(ProfileModalHeader::new( + format!("{profile_name} — Configure Default Model"), + Some(IconName::Ai), + )) + .child(ListSeparator) + .child(v_flex().w(rems(34.)).child(model_picker.clone())) + .child(ListSeparator) + .child(go_back_item) + .into_any_element() + } Mode::ConfigureMcps { profile_id, tool_picker, diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index c624948944..1c99f665ab 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -1,7 +1,7 @@ use std::{collections::BTreeMap, sync::Arc}; +use agent::ContextServerRegistry; use agent_settings::{AgentProfileId, AgentProfileSettings}; -use assistant_tool::{ToolSource, ToolWorkingSet}; use fs::Fs; use gpui::{App, Context, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, Window}; use picker::{Picker, PickerDelegate}; @@ -14,7 +14,7 @@ pub struct ToolPicker { } #[derive(Clone, Copy, Debug, PartialEq)] -pub enum ToolPickerMode { +enum ToolPickerMode { BuiltinTools, McpTools, } @@ -76,60 +76,80 @@ pub struct ToolPickerDelegate { } impl ToolPickerDelegate { - pub fn new( - mode: ToolPickerMode, + pub fn builtin_tools( + tool_names: Vec>, fs: Arc, - tool_set: Entity, profile_id: AgentProfileId, profile_settings: AgentProfileSettings, cx: &mut Context, ) -> Self { - let items = Arc::new(Self::resolve_items(mode, &tool_set, cx)); + Self::new( + Arc::new( + tool_names + .into_iter() + .map(|name| PickerItem::Tool { + name, + server_id: None, + }) + .collect(), + ), + ToolPickerMode::BuiltinTools, + fs, + profile_id, + profile_settings, + cx, + ) + } + pub fn mcp_tools( + registry: &Entity, + fs: Arc, + profile_id: AgentProfileId, + profile_settings: AgentProfileSettings, + cx: &mut Context, + ) -> Self { + let mut items = Vec::new(); + + for (id, tools) in registry.read(cx).servers() { + let server_id = id.clone().0; + items.push(PickerItem::ContextServer { + server_id: server_id.clone(), + }); + items.extend(tools.keys().map(|tool_name| PickerItem::Tool { + name: tool_name.clone().into(), + server_id: Some(server_id.clone()), + })); + } + + Self::new( + Arc::new(items), + ToolPickerMode::McpTools, + fs, + profile_id, + profile_settings, + cx, + ) + } + + fn new( + items: Arc>, + mode: ToolPickerMode, + fs: Arc, + profile_id: AgentProfileId, + profile_settings: AgentProfileSettings, + cx: &mut Context, + ) -> Self { Self { tool_picker: cx.entity().downgrade(), + mode, fs, items, profile_id, profile_settings, filtered_items: Vec::new(), selected_index: 0, - mode, } } - - fn resolve_items( - mode: ToolPickerMode, - tool_set: &Entity, - cx: &mut App, - ) -> Vec { - let mut items = Vec::new(); - for (source, tools) in tool_set.read(cx).tools_by_source(cx) { - match source { - ToolSource::Native => { - if mode == ToolPickerMode::BuiltinTools { - items.extend(tools.into_iter().map(|tool| PickerItem::Tool { - name: tool.name().into(), - server_id: None, - })); - } - } - ToolSource::ContextServer { id } => { - if mode == ToolPickerMode::McpTools && !tools.is_empty() { - let server_id: Arc = id.clone().into(); - items.push(PickerItem::ContextServer { - server_id: server_id.clone(), - }); - items.extend(tools.into_iter().map(|tool| PickerItem::Tool { - name: tool.name().into(), - server_id: Some(server_id.clone()), - })); - } - } - } - } - items - } } impl PickerDelegate for ToolPickerDelegate { @@ -294,6 +314,7 @@ impl PickerDelegate for ToolPickerDelegate { ) }) .collect(), + default_model: default_profile.default_model.clone(), }); if let Some(server_id) = server_id { diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 28d54c8fec..06fce64819 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1,6 +1,6 @@ use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll}; use acp_thread::{AcpThread, AcpThreadEvent}; -use action_log::ActionLog; +use action_log::ActionLogTelemetry; use agent_settings::AgentSettings; use anyhow::Result; use buffer_diff::DiffHunkStatus; @@ -13,8 +13,8 @@ use editor::{ scroll::Autoscroll, }; use gpui::{ - Action, AnyElement, AnyView, App, AppContext, Empty, Entity, EventEmitter, FocusHandle, - Focusable, Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*, + Action, AnyElement, App, AppContext, Empty, Entity, EventEmitter, FocusHandle, Focusable, + Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*, }; use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point}; @@ -40,87 +40,16 @@ use zed_actions::assistant::ToggleFocus; pub struct AgentDiffPane { multibuffer: Entity, editor: Entity, - thread: AgentDiffThread, + thread: Entity, focus_handle: FocusHandle, workspace: WeakEntity, title: SharedString, _subscriptions: Vec, } -#[derive(PartialEq, Eq, Clone)] -pub enum AgentDiffThread { - AcpThread(Entity), -} - -impl AgentDiffThread { - fn project(&self, cx: &App) -> Entity { - match self { - AgentDiffThread::AcpThread(thread) => thread.read(cx).project().clone(), - } - } - fn action_log(&self, cx: &App) -> Entity { - match self { - AgentDiffThread::AcpThread(thread) => thread.read(cx).action_log().clone(), - } - } - - fn title(&self, cx: &App) -> SharedString { - match self { - AgentDiffThread::AcpThread(thread) => thread.read(cx).title(), - } - } - - fn is_generating(&self, cx: &App) -> bool { - match self { - AgentDiffThread::AcpThread(thread) => { - thread.read(cx).status() == acp_thread::ThreadStatus::Generating - } - } - } - - fn has_pending_edit_tool_uses(&self, cx: &App) -> bool { - match self { - AgentDiffThread::AcpThread(thread) => thread.read(cx).has_pending_edit_tool_calls(), - } - } - - fn downgrade(&self) -> WeakAgentDiffThread { - match self { - AgentDiffThread::AcpThread(thread) => { - WeakAgentDiffThread::AcpThread(thread.downgrade()) - } - } - } -} - -impl From> for AgentDiffThread { - fn from(entity: Entity) -> Self { - AgentDiffThread::AcpThread(entity) - } -} - -#[derive(PartialEq, Eq, Clone)] -pub enum WeakAgentDiffThread { - AcpThread(WeakEntity), -} - -impl WeakAgentDiffThread { - pub fn upgrade(&self) -> Option { - match self { - WeakAgentDiffThread::AcpThread(weak) => weak.upgrade().map(AgentDiffThread::AcpThread), - } - } -} - -impl From> for WeakAgentDiffThread { - fn from(entity: WeakEntity) -> Self { - WeakAgentDiffThread::AcpThread(entity) - } -} - impl AgentDiffPane { pub fn deploy( - thread: impl Into, + thread: Entity, workspace: WeakEntity, window: &mut Window, cx: &mut App, @@ -131,12 +60,11 @@ impl AgentDiffPane { } pub fn deploy_in_workspace( - thread: impl Into, + thread: Entity, 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); @@ -153,7 +81,7 @@ impl AgentDiffPane { } pub fn new( - thread: AgentDiffThread, + thread: Entity, workspace: WeakEntity, window: &mut Window, cx: &mut Context, @@ -161,7 +89,7 @@ impl AgentDiffPane { let focus_handle = cx.focus_handle(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); - let project = thread.project(cx); + let project = thread.read(cx).project().clone(); let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); @@ -172,19 +100,16 @@ impl AgentDiffPane { editor }); - let action_log = thread.action_log(cx); + let action_log = thread.read(cx).action_log().clone(); let mut this = Self { _subscriptions: vec![ cx.observe_in(&action_log, window, |this, _action_log, window, cx| { this.update_excerpts(window, cx) }), - match &thread { - AgentDiffThread::AcpThread(thread) => cx - .subscribe(thread, |this, _thread, event, cx| { - this.handle_acp_thread_event(event, cx) - }), - }, + cx.subscribe(&thread, |this, _thread, event, cx| { + this.handle_acp_thread_event(event, cx) + }), ], title: SharedString::default(), multibuffer, @@ -199,8 +124,18 @@ impl AgentDiffPane { } fn update_excerpts(&mut self, window: &mut Window, cx: &mut Context) { - let changed_buffers = self.thread.action_log(cx).read(cx).changed_buffers(cx); - let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::>(); + let changed_buffers = self + .thread + .read(cx) + .action_log() + .read(cx) + .changed_buffers(cx); + let mut paths_to_delete = self + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); for (buffer, diff_handle) in changed_buffers { if buffer.read(cx).file().is_none() { @@ -215,7 +150,7 @@ impl AgentDiffPane { let diff_hunk_ranges = diff .hunks_intersecting_range( - language::Anchor::MIN..language::Anchor::MAX, + language::Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, cx, ) @@ -286,7 +221,7 @@ impl AgentDiffPane { } fn update_title(&mut self, cx: &mut Context) { - let new_title = self.thread.title(cx); + let new_title = self.thread.read(cx).title(); if new_title != self.title { self.title = new_title; cx.emit(EditorEvent::TitleChanged); @@ -348,16 +283,18 @@ impl AgentDiffPane { } fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context) { - self.thread - .action_log(cx) - .update(cx, |action_log, cx| action_log.keep_all_edits(cx)) + let telemetry = ActionLogTelemetry::from(self.thread.read(cx)); + let action_log = self.thread.read(cx).action_log().clone(); + action_log.update(cx, |action_log, cx| { + action_log.keep_all_edits(Some(telemetry), cx) + }); } } fn keep_edits_in_selection( editor: &mut Editor, buffer_snapshot: &MultiBufferSnapshot, - thread: &AgentDiffThread, + thread: &Entity, window: &mut Window, cx: &mut Context, ) { @@ -372,7 +309,7 @@ fn keep_edits_in_selection( fn reject_edits_in_selection( editor: &mut Editor, buffer_snapshot: &MultiBufferSnapshot, - thread: &AgentDiffThread, + thread: &Entity, window: &mut Window, cx: &mut Context, ) { @@ -386,7 +323,7 @@ fn reject_edits_in_selection( fn keep_edits_in_ranges( editor: &mut Editor, buffer_snapshot: &MultiBufferSnapshot, - thread: &AgentDiffThread, + thread: &Entity, ranges: Vec>, window: &mut Window, cx: &mut Context, @@ -401,8 +338,15 @@ 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.action_log(cx).update(cx, |action_log, cx| { - action_log.keep_edits_in_range(buffer, hunk.buffer_range.clone(), cx) + let action_log = thread.read(cx).action_log().clone(); + let telemetry = ActionLogTelemetry::from(thread.read(cx)); + action_log.update(cx, |action_log, cx| { + action_log.keep_edits_in_range( + buffer, + hunk.buffer_range.clone(), + Some(telemetry), + cx, + ) }); } } @@ -411,7 +355,7 @@ fn keep_edits_in_ranges( fn reject_edits_in_ranges( editor: &mut Editor, buffer_snapshot: &MultiBufferSnapshot, - thread: &AgentDiffThread, + thread: &Entity, ranges: Vec>, window: &mut Window, cx: &mut Context, @@ -435,11 +379,12 @@ fn reject_edits_in_ranges( } } + let action_log = thread.read(cx).action_log().clone(); + let telemetry = ActionLogTelemetry::from(thread.read(cx)); for (buffer, ranges) in ranges_by_buffer { - thread - .action_log(cx) + action_log .update(cx, |action_log, cx| { - action_log.reject_edits_in_ranges(buffer, ranges, cx) + action_log.reject_edits_in_ranges(buffer, ranges, Some(telemetry.clone()), cx) }) .detach_and_log_err(cx); } @@ -452,7 +397,10 @@ fn update_editor_selection( window: &mut Window, cx: &mut Context, ) { - let newest_cursor = editor.selections.newest::(cx).head(); + let newest_cursor = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); if !diff_hunks.iter().any(|hunk| { hunk.row_range @@ -536,7 +484,7 @@ impl Item for AgentDiffPane { } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { - let title = self.thread.title(cx); + let title = self.thread.read(cx).title(); Label::new(format!("Review: {}", title)) .color(if params.selected { Color::Default @@ -550,7 +498,7 @@ impl Item for AgentDiffPane { Some("Assistant Diff Opened") } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } @@ -573,16 +521,22 @@ impl Item for AgentDiffPane { }); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| Self::new(self.thread.clone(), self.workspace.clone(), window, cx))) + Task::ready(Some(cx.new(|cx| { + Self::new(self.thread.clone(), self.workspace.clone(), window, cx) + }))) } fn is_dirty(&self, cx: &App) -> bool { @@ -631,11 +585,11 @@ impl Item for AgentDiffPane { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.clone().into()) } else { None } @@ -666,7 +620,7 @@ impl Item for AgentDiffPane { } impl Render for AgentDiffPane { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.multibuffer.read(cx).is_empty(); let focus_handle = &self.focus_handle; @@ -699,7 +653,6 @@ impl Render for AgentDiffPane { .key_binding(KeyBinding::for_action_in( &ToggleFocus, &focus_handle.clone(), - window, cx, )) .on_click(|_event, window, cx| { @@ -712,18 +665,11 @@ impl Render for AgentDiffPane { } } -fn diff_hunk_controls(thread: &AgentDiffThread) -> editor::RenderDiffHunkControlsFn { +fn diff_hunk_controls(thread: &Entity) -> editor::RenderDiffHunkControlsFn { let thread = thread.clone(); Arc::new( - move |row, - status: &DiffHunkStatus, - hunk_range, - is_created_file, - line_height, - editor: &Entity, - window: &mut Window, - cx: &mut App| { + move |row, status, hunk_range, is_created_file, line_height, editor, _, cx| { { render_diff_hunk_controls( row, @@ -733,7 +679,6 @@ fn diff_hunk_controls(thread: &AgentDiffThread) -> editor::RenderDiffHunkControl line_height, &thread, editor, - window, cx, ) } @@ -747,9 +692,8 @@ fn render_diff_hunk_controls( hunk_range: Range, is_created_file: bool, line_height: Pixels, - thread: &AgentDiffThread, + thread: &Entity, editor: &Entity, - window: &mut Window, cx: &mut App, ) -> AnyElement { let editor = editor.clone(); @@ -772,13 +716,8 @@ fn render_diff_hunk_controls( Button::new(("reject", row as u64), "Reject") .disabled(is_created_file) .key_binding( - KeyBinding::for_action_in( - &Reject, - &editor.read(cx).focus_handle(cx), - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + KeyBinding::for_action_in(&Reject, &editor.read(cx).focus_handle(cx), cx) + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click({ let editor = editor.clone(); @@ -799,7 +738,7 @@ fn render_diff_hunk_controls( }), Button::new(("keep", row as u64), "Keep") .key_binding( - KeyBinding::for_action_in(&Keep, &editor.read(cx).focus_handle(cx), window, cx) + KeyBinding::for_action_in(&Keep, &editor.read(cx).focus_handle(cx), cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click({ @@ -830,14 +769,8 @@ fn render_diff_hunk_controls( // .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Next Hunk", - &GoToHunk, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, cx) } }) .on_click({ @@ -866,12 +799,11 @@ fn render_diff_hunk_controls( // .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Previous Hunk", &GoToPreviousHunk, &focus_handle, - window, cx, ) } @@ -983,9 +915,7 @@ impl AgentDiffToolbar { None => ToolbarItemLocation::Hidden, Some(AgentDiffToolbarItem::Pane(_)) => ToolbarItemLocation::PrimaryRight, Some(AgentDiffToolbarItem::Editor { state, .. }) => match state { - EditorState::Generating | EditorState::Reviewing => { - ToolbarItemLocation::PrimaryRight - } + EditorState::Reviewing => ToolbarItemLocation::PrimaryRight, EditorState::Idle => ToolbarItemLocation::Hidden, }, } @@ -1036,7 +966,7 @@ impl ToolbarItemView for AgentDiffToolbar { } impl Render for AgentDiffToolbar { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let spinner_icon = div() .px_0p5() .id("generating") @@ -1063,7 +993,6 @@ impl Render for AgentDiffToolbar { let content = match state { EditorState::Idle => return Empty.into_any(), - EditorState::Generating => vec![spinner_icon], EditorState::Reviewing => vec![ h_flex() .child( @@ -1111,7 +1040,6 @@ impl Render for AgentDiffToolbar { KeyBinding::for_action_in( &RejectAll, &editor_focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))) @@ -1126,7 +1054,6 @@ impl Render for AgentDiffToolbar { KeyBinding::for_action_in( &KeepAll, &editor_focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))) @@ -1179,8 +1106,11 @@ impl Render for AgentDiffToolbar { return Empty.into_any(); }; - let has_pending_edit_tool_use = - agent_diff.read(cx).thread.has_pending_edit_tool_uses(cx); + let has_pending_edit_tool_use = agent_diff + .read(cx) + .thread + .read(cx) + .has_pending_edit_tool_calls(); if has_pending_edit_tool_use { return div().px_2().child(spinner_icon).into_any(); @@ -1203,13 +1133,8 @@ impl Render for AgentDiffToolbar { .child( Button::new("reject-all", "Reject All") .key_binding({ - KeyBinding::for_action_in( - &RejectAll, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))) + KeyBinding::for_action_in(&RejectAll, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))) }) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&RejectAll, window, cx) @@ -1218,13 +1143,8 @@ impl Render for AgentDiffToolbar { .child( Button::new("keep-all", "Keep All") .key_binding({ - KeyBinding::for_action_in( - &KeepAll, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))) + KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))) }) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&KeepAll, window, cx) @@ -1247,11 +1167,10 @@ pub struct AgentDiff { pub enum EditorState { Idle, Reviewing, - Generating, } struct WorkspaceThread { - thread: WeakAgentDiffThread, + thread: WeakEntity, _thread_subscriptions: (Subscription, Subscription), singleton_editors: HashMap, HashMap, Subscription>>, _settings_subscription: Subscription, @@ -1276,23 +1195,23 @@ impl AgentDiff { pub fn set_active_thread( workspace: &WeakEntity, - thread: impl Into, + thread: Entity, window: &mut Window, cx: &mut App, ) { Self::global(cx).update(cx, |this, cx| { - this.register_active_thread_impl(workspace, thread.into(), window, cx); + this.register_active_thread_impl(workspace, thread, window, cx); }); } fn register_active_thread_impl( &mut self, workspace: &WeakEntity, - thread: AgentDiffThread, + thread: Entity, window: &mut Window, cx: &mut Context, ) { - let action_log = thread.action_log(cx); + let action_log = thread.read(cx).action_log().clone(); let action_log_subscription = cx.observe_in(&action_log, window, { let workspace = workspace.clone(); @@ -1301,14 +1220,12 @@ impl AgentDiff { } }); - let thread_subscription = match &thread { - 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) - } - }), - }; + let thread_subscription = 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 @@ -1385,7 +1302,7 @@ impl AgentDiff { fn register_review_action( workspace: &mut Workspace, - review: impl Fn(&Entity, &AgentDiffThread, &mut Window, &mut App) -> PostReviewState + review: impl Fn(&Entity, &Entity, &mut Window, &mut App) -> PostReviewState + 'static, this: &Entity, ) { @@ -1545,7 +1462,7 @@ impl AgentDiff { return; }; - let action_log = thread.action_log(cx); + let action_log = thread.read(cx).action_log(); let changed_buffers = action_log.read(cx).changed_buffers(cx); let mut unaffected = self.reviewing_editors.clone(); @@ -1570,15 +1487,11 @@ impl AgentDiff { multibuffer.add_diff(diff_handle.clone(), cx); }); - let new_state = if thread.is_generating(cx) { - EditorState::Generating - } else { - EditorState::Reviewing - }; + let reviewing_state = EditorState::Reviewing; let previous_state = self .reviewing_editors - .insert(weak_editor.clone(), new_state.clone()); + .insert(weak_editor.clone(), reviewing_state.clone()); if previous_state.is_none() { editor.update(cx, |editor, cx| { @@ -1591,7 +1504,9 @@ impl AgentDiff { unaffected.remove(weak_editor); } - if new_state == EditorState::Reviewing && previous_state != Some(new_state) { + if reviewing_state == EditorState::Reviewing + && previous_state != Some(reviewing_state) + { // Jump to first hunk when we enter review mode editor.update(cx, |editor, cx| { let snapshot = multibuffer.read(cx).snapshot(cx); @@ -1666,7 +1581,7 @@ impl AgentDiff { fn keep_all( editor: &Entity, - thread: &AgentDiffThread, + thread: &Entity, window: &mut Window, cx: &mut App, ) -> PostReviewState { @@ -1686,7 +1601,7 @@ impl AgentDiff { fn reject_all( editor: &Entity, - thread: &AgentDiffThread, + thread: &Entity, window: &mut Window, cx: &mut App, ) -> PostReviewState { @@ -1706,7 +1621,7 @@ impl AgentDiff { fn keep( editor: &Entity, - thread: &AgentDiffThread, + thread: &Entity, window: &mut Window, cx: &mut App, ) -> PostReviewState { @@ -1719,7 +1634,7 @@ impl AgentDiff { fn reject( editor: &Entity, - thread: &AgentDiffThread, + thread: &Entity, window: &mut Window, cx: &mut App, ) -> PostReviewState { @@ -1742,7 +1657,7 @@ impl AgentDiff { fn review_in_active_editor( &mut self, workspace: &mut Workspace, - review: impl Fn(&Entity, &AgentDiffThread, &mut Window, &mut App) -> PostReviewState, + review: impl Fn(&Entity, &Entity, &mut Window, &mut App) -> PostReviewState, window: &mut Window, cx: &mut Context, ) -> Option>> { @@ -1764,7 +1679,7 @@ impl AgentDiff { if let PostReviewState::AllReviewed = review(&editor, &thread, window, cx) && let Some(curr_buffer) = editor.read(cx).buffer().read(cx).as_singleton() { - let changed_buffers = thread.action_log(cx).read(cx).changed_buffers(cx); + let changed_buffers = thread.read(cx).action_log().read(cx).changed_buffers(cx); let mut keys = changed_buffers.keys().cycle(); keys.find(|k| *k == &curr_buffer); @@ -1807,14 +1722,12 @@ mod tests { use super::*; use crate::Keep; use acp_thread::AgentConnection as _; - use agent_settings::AgentSettings; use editor::EditorSettings; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use project::{FakeFs, Project}; use serde_json::json; - use settings::{Settings, SettingsStore}; + use settings::SettingsStore; use std::{path::Path, rc::Rc}; - use theme::ThemeSettings; use util::path; #[gpui::test] @@ -1822,13 +1735,8 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - AgentSettings::register(cx); prompt_store::init(cx); - workspace::init_settings(cx); - ThemeSettings::register(cx); - EditorSettings::register(cx); + theme::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); }); @@ -1855,8 +1763,7 @@ mod tests { .await .unwrap(); - let thread = AgentDiffThread::AcpThread(thread); - let action_log = cx.read(|cx| thread.action_log(cx)); + let action_log = cx.read(|cx| thread.read(cx).action_log().clone()); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); @@ -1896,7 +1803,9 @@ mod tests { ); assert_eq!( editor - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(1, 0)..Point::new(1, 0) ); @@ -1910,7 +1819,9 @@ mod tests { ); assert_eq!( editor - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -1931,7 +1842,9 @@ mod tests { ); assert_eq!( editor - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -1963,7 +1876,9 @@ mod tests { ); assert_eq!( editor - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -1974,13 +1889,8 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - AgentSettings::register(cx); prompt_store::init(cx); - workspace::init_settings(cx); - ThemeSettings::register(cx); - EditorSettings::register(cx); + theme::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); workspace::register_project_item::(cx); }); @@ -2036,7 +1946,6 @@ mod tests { let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); // Set the active thread - let thread = AgentDiffThread::AcpThread(thread); cx.update(|window, cx| { AgentDiff::set_active_thread(&workspace.downgrade(), thread.clone(), window, cx) }); @@ -2120,7 +2029,9 @@ mod tests { ); assert_eq!( editor1 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(1, 0)..Point::new(1, 0) ); @@ -2161,7 +2072,9 @@ mod tests { ); assert_eq!( editor1 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2182,7 +2095,9 @@ mod tests { ); assert_eq!( editor1 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2208,7 +2123,9 @@ mod tests { ); assert_eq!( editor1 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2241,7 +2158,9 @@ mod tests { ); assert_eq!( editor2 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(0, 0)..Point::new(0, 0) ); diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index fe25cadc3c..9c26341430 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -7,7 +7,7 @@ use gpui::{Entity, FocusHandle, SharedString}; use picker::popover_menu::PickerPopoverMenu; use settings::update_settings_file; use std::sync::Arc; -use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*}; +use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; use zed_actions::agent::ToggleModelSelector; pub struct AgentModelSelector { @@ -25,6 +25,8 @@ impl AgentModelSelector { window: &mut Window, cx: &mut Context, ) -> Self { + let focus_handle_clone = focus_handle.clone(); + Self { selector: cx.new(move |cx| { let fs = fs.clone(); @@ -47,6 +49,8 @@ impl AgentModelSelector { } } }, + true, // Use popover styles for picker + focus_handle_clone, window, cx, ) @@ -59,6 +63,10 @@ impl AgentModelSelector { pub fn toggle(&self, window: &mut Window, cx: &mut Context) { self.menu_handle.toggle(window, cx); } + + pub fn active_model(&self, cx: &App) -> Option { + self.selector.read(cx).delegate.active_model(cx) + } } impl Render for AgentModelSelector { @@ -70,6 +78,11 @@ impl Render for AgentModelSelector { .unwrap_or_else(|| SharedString::from("Select a Model")); let provider_icon = model.as_ref().map(|model| model.provider.icon()); + let color = if self.menu_handle.is_deployed() { + Color::Accent + } else { + Color::Muted + }; let focus_handle = self.focus_handle.clone(); @@ -77,32 +90,31 @@ impl Render for AgentModelSelector { self.selector.clone(), ButtonLike::new("active-model") .when_some(provider_icon, |this, icon| { - this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)) + this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) }) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child( Label::new(model_name) - .color(Color::Muted) + .color(color) .size(LabelSize::Small) .ml_0p5(), ) .child( Icon::new(IconName::ChevronDown) - .color(Color::Muted) - .size(IconSize::XSmall), + .color(color) + .size(IconSize::Small), ), - move |window, cx| { - Tooltip::for_action_in( - "Change Model", - &ToggleModelSelector, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) }, - gpui::Corner::BottomRight, + gpui::Corner::TopRight, cx, ) .with_handle(self.menu_handle.clone()) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }) .render(window, cx) } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 2bd0fe4c80..97c7aecb8e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,26 +1,24 @@ -use std::ops::Range; -use std::path::Path; -use std::rc::Rc; -use std::sync::Arc; +use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; use acp_thread::AcpThread; -use agent2::{DbThreadMetadata, HistoryEntry}; +use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; -use project::agent_server_store::{ - AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME, +use project::{ + ExternalAgentServerName, + agent_server_store::{CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME}, }; use serde::{Deserialize, Serialize}; use settings::{ DefaultAgentView as DefaultView, LanguageModelProviderSetting, LanguageModelSelection, }; -use zed_actions::OpenBrowser; + use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent}; -use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; +use crate::ManageProfiles; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ - AddContextServer, DeleteRecentlyOpenThread, Follow, InlineAssistant, NewTextThread, NewThread, - OpenActiveThreadAsMarkdown, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, + AddContextServer, AgentDiffPane, Follow, InlineAssistant, NewTextThread, NewThread, + OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, acp::AcpThreadView, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, @@ -29,27 +27,25 @@ use crate::{ ui::{AgentOnboardingModal, EndTrialUpsell}, }; use crate::{ - ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary, placeholder_command, -}; -use agent::{ - context_store::ContextStore, - history_store::{HistoryEntryId, HistoryStore}, - thread_store::{TextThreadStore, ThreadStore}, + ExpandMessageEditor, + acp::{AcpThreadHistory, ThreadHistoryEvent}, }; +use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; use anyhow::{Result, anyhow}; -use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_slash_command::SlashCommandWorkingSet; -use assistant_tool::ToolWorkingSet; +use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary}; use client::{UserStore, zed_urls}; use cloud_llm_client::{Plan, PlanV1, PlanV2, UsageLimit}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; +use extension::ExtensionEvents; +use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, Entity, EventEmitter, - ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, Task, UpdateGlobal, - WeakEntity, prelude::*, + Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, + Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, + Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::{ConfigurationError, LanguageModelRegistry}; @@ -57,12 +53,11 @@ use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; -use settings::{Settings, SettingsStore, update_settings_file}; +use settings::{Settings, update_settings_file}; use theme::ThemeSettings; -use ui::utils::WithRemSize; use ui::{ Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, - ProgressBar, Tab, Tooltip, prelude::*, + ProgressBar, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ @@ -71,11 +66,12 @@ use workspace::{ }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, - agent::{OpenAcpOnboardingModal, OpenOnboardingModal, OpenSettings, ResetOnboarding}, + agent::{ + OpenAcpOnboardingModal, OpenOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding, + }, assistant::{OpenRulesLibrary, ToggleFocus}, }; -use feature_flags::{CodexAcpFeatureFlag, FeatureFlagAppExt as _}; const AGENT_PANEL_KEY: &str = "agent_panel"; #[derive(Serialize, Deserialize, Debug)] @@ -104,6 +100,12 @@ pub fn init(cx: &mut App) { } }, ) + .register_action(|workspace, _: &ExpandMessageEditor, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx)); + } + }) .register_action(|workspace, _: &OpenHistory, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -119,7 +121,7 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &NewTextThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx)); + panel.update(cx, |panel, cx| panel.new_text_thread(window, cx)); } }) .register_action(|workspace, action: &NewExternalAgentThread, window, cx| { @@ -141,6 +143,16 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &Follow, window, cx| { workspace.follow(CollaboratorId::Agent, window, cx); }) + .register_action(|workspace, _: &OpenAgentDiff, window, cx| { + let thread = workspace + .panel::(cx) + .and_then(|panel| panel.read(cx).active_thread_view().cloned()) + .and_then(|thread_view| thread_view.read(cx).thread().cloned()); + + if let Some(thread) = thread { + AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); + } + }) .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -183,6 +195,13 @@ pub fn init(cx: &mut App) { }) .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| { TrialEndUpsell::set_dismissed(false, cx); + }) + .register_action(|workspace, _: &ResetAgentZoom, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.reset_agent_zoom(window, cx); + }); + } }); }, ) @@ -194,7 +213,7 @@ enum ActiveView { thread_view: Entity, }, TextThread { - context_editor: Entity, + text_thread_editor: Entity, title_editor: Entity, buffer_search_bar: Entity, _subscriptions: Vec, @@ -213,23 +232,20 @@ enum WhichFontSize { #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub enum AgentType { #[default] - Zed, + NativeAgent, TextThread, Gemini, ClaudeCode, Codex, - NativeAgent, Custom { name: SharedString, - command: AgentServerCommand, }, } impl AgentType { fn label(&self) -> SharedString { match self { - Self::Zed | Self::TextThread => "Zed Agent".into(), - Self::NativeAgent => "Agent 2".into(), + Self::NativeAgent | Self::TextThread => "Zed Agent".into(), Self::Gemini => "Gemini CLI".into(), Self::ClaudeCode => "Claude Code".into(), Self::Codex => "Codex".into(), @@ -239,11 +255,11 @@ impl AgentType { fn icon(&self) -> Option { match self { - Self::Zed | Self::NativeAgent | Self::TextThread => None, + Self::NativeAgent | Self::TextThread => None, Self::Gemini => Some(IconName::AiGemini), Self::ClaudeCode => Some(IconName::AiClaude), Self::Codex => Some(IconName::AiOpenAi), - Self::Custom { .. } => Some(IconName::Terminal), + Self::Custom { .. } => Some(IconName::Sparkle), } } } @@ -254,7 +270,7 @@ impl From for AgentType { ExternalAgent::Gemini => Self::Gemini, ExternalAgent::ClaudeCode => Self::ClaudeCode, ExternalAgent::Codex => Self::Codex, - ExternalAgent::Custom { name, command } => Self::Custom { name, command }, + ExternalAgent::Custom { name } => Self::Custom { name }, ExternalAgent::NativeAgent => Self::NativeAgent, } } @@ -274,7 +290,7 @@ impl ActiveView { pub fn native_agent( fs: Arc, prompt_store: Option>, - acp_history_store: Entity, + history_store: Entity, project: Entity, workspace: WeakEntity, window: &mut Window, @@ -282,13 +298,14 @@ impl ActiveView { ) -> Self { let thread_view = cx.new(|cx| { crate::acp::AcpThreadView::new( - ExternalAgent::NativeAgent.server(fs, acp_history_store.clone()), + ExternalAgent::NativeAgent.server(fs, history_store.clone()), None, None, workspace, project, - acp_history_store, + history_store, prompt_store, + false, window, cx, ) @@ -297,15 +314,14 @@ impl ActiveView { Self::ExternalAgentThread { thread_view } } - pub fn prompt_editor( - context_editor: Entity, - history_store: Entity, - acp_history_store: Entity, + pub fn text_thread( + text_thread_editor: Entity, + acp_history_store: Entity, language_registry: Arc, window: &mut Window, cx: &mut App, ) -> Self { - let title = context_editor.read(cx).title(cx).to_string(); + let title = text_thread_editor.read(cx).title(cx).to_string(); let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -321,7 +337,7 @@ impl ActiveView { let subscriptions = vec![ window.subscribe(&editor, cx, { { - let context_editor = context_editor.clone(); + let text_thread_editor = text_thread_editor.clone(); move |editor, event, window, cx| match event { EditorEvent::BufferEdited => { if suppress_first_edit { @@ -330,19 +346,19 @@ impl ActiveView { } let new_summary = editor.read(cx).text(cx); - context_editor.update(cx, |context_editor, cx| { - context_editor - .context() - .update(cx, |assistant_context, cx| { - assistant_context.set_custom_summary(new_summary, cx); + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor + .text_thread() + .update(cx, |text_thread, cx| { + text_thread.set_custom_summary(new_summary, cx); }) }) } EditorEvent::Blurred => { if editor.read(cx).text(cx).is_empty() { - let summary = context_editor + let summary = text_thread_editor .read(cx) - .context() + .text_thread() .read(cx) .summary() .or_default(); @@ -356,36 +372,24 @@ impl ActiveView { } } }), - window.subscribe(&context_editor.read(cx).context().clone(), cx, { + window.subscribe(&text_thread_editor.read(cx).text_thread().clone(), cx, { let editor = editor.clone(); - move |assistant_context, event, window, cx| match event { - ContextEvent::SummaryGenerated => { - let summary = assistant_context.read(cx).summary().or_default(); + move |text_thread, event, window, cx| match event { + TextThreadEvent::SummaryGenerated => { + let summary = text_thread.read(cx).summary().or_default(); editor.update(cx, |editor, cx| { editor.set_text(summary, window, cx); }) } - ContextEvent::PathChanged { old_path, new_path } => { - history_store.update(cx, |history_store, cx| { - if let Some(old_path) = old_path { - history_store - .replace_recently_opened_text_thread(old_path, new_path, cx); - } else { - history_store.push_recently_opened_entry( - HistoryEntryId::Context(new_path.clone()), - cx, - ); - } - }); - + TextThreadEvent::PathChanged { old_path, new_path } => { acp_history_store.update(cx, |history_store, cx| { if let Some(old_path) = old_path { history_store .replace_recently_opened_text_thread(old_path, new_path, cx); } else { history_store.push_recently_opened_entry( - agent2::HistoryEntryId::TextThread(new_path.clone()), + agent::HistoryEntryId::TextThread(new_path.clone()), cx, ); } @@ -399,11 +403,11 @@ impl ActiveView { let buffer_search_bar = cx.new(|cx| BufferSearchBar::new(Some(language_registry), window, cx)); buffer_search_bar.update(cx, |buffer_search_bar, cx| { - buffer_search_bar.set_active_pane_item(Some(&context_editor), window, cx) + buffer_search_bar.set_active_pane_item(Some(&text_thread_editor), window, cx) }); Self::TextThread { - context_editor, + text_thread_editor, title_editor: editor, buffer_search_bar, _subscriptions: subscriptions, @@ -418,21 +422,20 @@ pub struct AgentPanel { project: Entity, fs: Arc, language_registry: Arc, - thread_store: Entity, acp_history: Entity, - acp_history_store: Entity, - context_store: Entity, + history_store: Entity, + text_thread_store: Entity, prompt_store: Option>, - inline_assist_context_store: Entity, + context_server_registry: Entity, configuration: Option>, configuration_subscription: Option, active_view: ActiveView, previous_view: Option, - history_store: Entity, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, - assistant_navigation_menu_handle: PopoverMenuHandle, - assistant_navigation_menu: Option>, + agent_navigation_menu_handle: PopoverMenuHandle, + agent_navigation_menu: Option>, + _extension_subscription: Option, width: Option, height: Option, zoomed: bool, @@ -470,33 +473,6 @@ impl AgentPanel { Ok(prompt_store) => prompt_store.await.ok(), Err(_) => None, }; - let tools = cx.new(|_| ToolWorkingSet::default())?; - let thread_store = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().clone(); - ThreadStore::load( - project, - tools.clone(), - prompt_store.clone(), - prompt_builder.clone(), - cx, - ) - })? - .await?; - - let slash_commands = Arc::new(SlashCommandWorkingSet::default()); - let context_store = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().clone(); - assistant_context::ContextStore::new( - project, - prompt_builder.clone(), - slash_commands, - cx, - ) - })? - .await?; - let serialized_panel = if let Some(panel) = cx .background_spawn(async move { KEY_VALUE_STORE.read_kvp(AGENT_PANEL_KEY) }) .await @@ -508,17 +484,22 @@ impl AgentPanel { None }; - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| { - Self::new( - workspace, - thread_store, - context_store, - prompt_store, - window, + let slash_commands = Arc::new(SlashCommandWorkingSet::default()); + let text_thread_store = workspace + .update(cx, |workspace, cx| { + let project = workspace.project().clone(); + assistant_text_thread::TextThreadStore::new( + project, + prompt_builder, + slash_commands, cx, ) - }); + })? + .await?; + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = + cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx)); panel.as_mut(cx).loading = true; if let Some(serialized_panel) = serialized_panel { @@ -545,8 +526,7 @@ impl AgentPanel { fn new( workspace: &Workspace, - thread_store: Entity, - context_store: Entity, + text_thread_store: Entity, prompt_store: Option>, window: &mut Window, cx: &mut Context, @@ -558,13 +538,11 @@ impl AgentPanel { let client = workspace.client().clone(); let workspace = workspace.weak_handle(); - let inline_assist_context_store = - cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade()))); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(context_store.clone(), [], cx)); - - let acp_history_store = cx.new(|cx| agent2::HistoryStore::new(context_store.clone(), cx)); - let acp_history = cx.new(|cx| AcpThreadHistory::new(acp_history_store.clone(), window, cx)); + let history_store = cx.new(|cx| agent::HistoryStore::new(text_thread_store.clone(), cx)); + let acp_history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); cx.subscribe_in( &acp_history, window, @@ -579,32 +557,29 @@ impl AgentPanel { ); } ThreadHistoryEvent::Open(HistoryEntry::TextThread(thread)) => { - this.open_saved_prompt_editor(thread.path.clone(), window, cx) + this.open_saved_text_thread(thread.path.clone(), window, cx) .detach_and_log_err(cx); } }, ) .detach(); - cx.observe(&history_store, |_, _, cx| cx.notify()).detach(); - let panel_type = AgentSettings::get_global(cx).default_view; let active_view = match panel_type { DefaultView::Thread => ActiveView::native_agent( fs.clone(), prompt_store.clone(), - acp_history_store.clone(), + history_store.clone(), project.clone(), workspace.clone(), window, cx, ), DefaultView::TextThread => { - let context = - context_store.update(cx, |context_store, cx| context_store.create(cx)); + let context = text_thread_store.update(cx, |store, cx| store.create(cx)); let lsp_adapter_delegate = make_lsp_adapter_delegate(&project.clone(), cx).unwrap(); - let context_editor = cx.new(|cx| { - let mut editor = TextThreadEditor::for_context( + let text_thread_editor = cx.new(|cx| { + let mut editor = TextThreadEditor::for_text_thread( context, fs.clone(), workspace.clone(), @@ -616,10 +591,9 @@ impl AgentPanel { editor.insert_default_prompt(window, cx); editor }); - ActiveView::prompt_editor( - context_editor, + ActiveView::text_thread( + text_thread_editor, history_store.clone(), - acp_history_store.clone(), language_registry.clone(), window, cx, @@ -631,21 +605,24 @@ impl AgentPanel { window.defer(cx, move |window, cx| { let panel = weak_panel.clone(); - let assistant_navigation_menu = + let agent_navigation_menu = ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| { if let Some(panel) = panel.upgrade() { menu = Self::populate_recently_opened_menu_section(menu, panel, cx); } - menu.action("View All", Box::new(OpenHistory)) - .end_slot_action(DeleteRecentlyOpenThread.boxed_clone()) + + menu = menu + .action("View All", Box::new(OpenHistory)) .fixed_width(px(320.).into()) .keep_open_on_confirm(false) - .key_context("NavigationMenu") + .key_context("NavigationMenu"); + + menu }); weak_panel .update(cx, |panel, cx| { cx.subscribe_in( - &assistant_navigation_menu, + &agent_navigation_menu, window, |_, menu, _: &DismissEvent, window, cx| { menu.update(cx, |menu, _| { @@ -655,7 +632,7 @@ impl AgentPanel { }, ) .detach(); - panel.assistant_navigation_menu = Some(assistant_navigation_menu); + panel.agent_navigation_menu = Some(agent_navigation_menu); }) .ok(); }); @@ -671,35 +648,55 @@ impl AgentPanel { ) }); - Self { + // Subscribe to extension events to sync agent servers when extensions change + let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx) + { + Some( + cx.subscribe(&extension_events, |this, _source, event, cx| match event { + extension::Event::ExtensionInstalled(_) + | extension::Event::ExtensionUninstalled(_) + | extension::Event::ExtensionsInstalledChanged => { + this.sync_agent_servers_from_extensions(cx); + } + _ => {} + }), + ) + } else { + None + }; + + let mut panel = Self { active_view, workspace, user_store, project: project.clone(), fs: fs.clone(), language_registry, - thread_store: thread_store.clone(), - context_store, + text_thread_store, prompt_store, configuration: None, configuration_subscription: None, - inline_assist_context_store, + context_server_registry, previous_view: None, - history_store: history_store.clone(), new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), - assistant_navigation_menu_handle: PopoverMenuHandle::default(), - assistant_navigation_menu: None, + agent_navigation_menu_handle: PopoverMenuHandle::default(), + agent_navigation_menu: None, + _extension_subscription: extension_subscription, width: None, height: None, zoomed: false, pending_serialization: None, onboarding, acp_history, - acp_history_store, + history_store, selected_agent: AgentType::default(), loading: false, - } + }; + + // Initial sync of agent servers from extensions + panel.sync_agent_servers_from_extensions(cx); + panel } pub fn toggle_focus( @@ -720,16 +717,31 @@ impl AgentPanel { &self.prompt_store } - pub(crate) fn inline_assist_context_store(&self) -> &Entity { - &self.inline_assist_context_store + pub(crate) fn thread_store(&self) -> &Entity { + &self.history_store } - pub(crate) fn thread_store(&self) -> &Entity { - &self.thread_store + pub(crate) fn context_server_registry(&self) -> &Entity { + &self.context_server_registry } - pub(crate) fn text_thread_store(&self) -> &Entity { - &self.context_store + pub fn is_hidden(workspace: &Entity, cx: &App) -> bool { + let workspace_read = workspace.read(cx); + + workspace_read + .panel::(cx) + .map(|panel| { + let panel_id = Entity::entity_id(&panel); + + let is_visible = workspace_read.all_docks().iter().any(|dock| { + dock.read(cx) + .visible_panel() + .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id) + }); + + !is_visible + }) + .unwrap_or(true) } fn active_thread_view(&self) -> Option<&Entity> { @@ -750,7 +762,7 @@ impl AgentPanel { cx: &mut Context, ) { let Some(thread) = self - .acp_history_store + .history_store .read(cx) .thread_from_session_id(&action.from_session_id) else { @@ -766,18 +778,18 @@ impl AgentPanel { ); } - fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context) { + fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context) { telemetry::event!("Agent Thread Started", agent = "zed-text"); let context = self - .context_store + .text_thread_store .update(cx, |context_store, cx| context_store.create(cx)); let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project, cx) .log_err() .flatten(); - let context_editor = cx.new(|cx| { - let mut editor = TextThreadEditor::for_context( + let text_thread_editor = cx.new(|cx| { + let mut editor = TextThreadEditor::for_text_thread( context, self.fs.clone(), self.workspace.clone(), @@ -796,18 +808,18 @@ impl AgentPanel { } self.set_active_view( - ActiveView::prompt_editor( - context_editor.clone(), + ActiveView::text_thread( + text_thread_editor.clone(), self.history_store.clone(), - self.acp_history_store.clone(), self.language_registry.clone(), window, cx, ), + true, window, cx, ); - context_editor.focus_handle(cx).focus(window); + text_thread_editor.focus_handle(cx).focus(window); } fn external_thread( @@ -825,13 +837,13 @@ impl AgentPanel { const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; - #[derive(Default, Serialize, Deserialize)] + #[derive(Serialize, Deserialize)] struct LastUsedExternalAgent { agent: crate::ExternalAgent, } let loading = self.loading; - let history = self.acp_history_store.clone(); + let history = self.history_store.clone(); cx.spawn_in(window, async move |this, cx| { let ext_agent = match agent_choice { @@ -866,16 +878,12 @@ impl AgentPanel { .and_then(|value| { serde_json::from_str::(&value).log_err() }) - .unwrap_or_default() - .agent + .map(|agent| agent.agent) + .unwrap_or(ExternalAgent::NativeAgent) } } }; - if !loading { - telemetry::event!("Agent Thread Started", agent = ext_agent.name()); - } - let server = ext_agent.server(fs, history); this.update_in(cx, |this, window, cx| { @@ -892,14 +900,20 @@ impl AgentPanel { summarize_thread, workspace.clone(), project, - this.acp_history_store.clone(), + this.history_store.clone(), this.prompt_store.clone(), + !loading, window, cx, ) }); - this.set_active_view(ActiveView::ExternalAgentThread { thread_view }, window, cx); + this.set_active_view( + ActiveView::ExternalAgentThread { thread_view }, + !loading, + window, + cx, + ); }) }) .detach_and_log_err(cx); @@ -929,40 +943,46 @@ impl AgentPanel { .detach_and_log_err(cx); } + fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(thread_view) = self.active_thread_view() { + thread_view.update(cx, |view, cx| { + view.expand_message_editor(&ExpandMessageEditor, window, cx); + view.focus_handle(cx).focus(window); + }); + } + } + fn open_history(&mut self, window: &mut Window, cx: &mut Context) { if matches!(self.active_view, ActiveView::History) { if let Some(previous_view) = self.previous_view.take() { - self.set_active_view(previous_view, window, cx); + self.set_active_view(previous_view, true, window, cx); } } else { - self.thread_store - .update(cx, |thread_store, cx| thread_store.reload(cx)) - .detach_and_log_err(cx); - self.set_active_view(ActiveView::History, window, cx); + self.set_active_view(ActiveView::History, true, window, cx); } cx.notify(); } - pub(crate) fn open_saved_prompt_editor( + pub(crate) fn open_saved_text_thread( &mut self, path: Arc, window: &mut Window, cx: &mut Context, ) -> Task> { - let context = self - .context_store - .update(cx, |store, cx| store.open_local_context(path, cx)); + let text_thread_task = self + .history_store + .update(cx, |store, cx| store.load_text_thread(path, cx)); cx.spawn_in(window, async move |this, cx| { - let context = context.await?; + let text_thread = text_thread_task.await?; this.update_in(cx, |this, window, cx| { - this.open_prompt_editor(context, window, cx); + this.open_text_thread(text_thread, window, cx); }) }) } - pub(crate) fn open_prompt_editor( + pub(crate) fn open_text_thread( &mut self, - context: Entity, + text_thread: Entity, window: &mut Window, cx: &mut Context, ) { @@ -970,8 +990,8 @@ impl AgentPanel { .log_err() .flatten(); let editor = cx.new(|cx| { - TextThreadEditor::for_context( - context, + TextThreadEditor::for_text_thread( + text_thread, self.fs.clone(), self.workspace.clone(), self.project.clone(), @@ -987,14 +1007,14 @@ impl AgentPanel { } self.set_active_view( - ActiveView::prompt_editor( + ActiveView::text_thread( editor, self.history_store.clone(), - self.acp_history_store.clone(), self.language_registry.clone(), window, cx, ), + true, window, cx, ); @@ -1010,8 +1030,10 @@ impl AgentPanel { ActiveView::ExternalAgentThread { thread_view } => { thread_view.focus_handle(cx).focus(window); } - ActiveView::TextThread { context_editor, .. } => { - context_editor.focus_handle(cx).focus(window); + ActiveView::TextThread { + text_thread_editor, .. + } => { + text_thread_editor.focus_handle(cx).focus(window); } ActiveView::History | ActiveView::Configuration => {} } @@ -1028,7 +1050,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - self.assistant_navigation_menu_handle.toggle(window, cx); + self.agent_navigation_menu_handle.toggle(window, cx); } pub fn toggle_options_menu( @@ -1074,13 +1096,21 @@ impl AgentPanel { update_settings_file(self.fs.clone(), cx, move |settings, cx| { let agent_ui_font_size = ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta; + let agent_buffer_font_size = + ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta; + let _ = settings .theme .agent_ui_font_size .insert(theme::clamp_font_size(agent_ui_font_size).into()); + let _ = settings + .theme + .agent_buffer_font_size + .insert(theme::clamp_font_size(agent_buffer_font_size).into()); }); } else { theme::adjust_agent_ui_font_size(cx, |size| size + delta); + theme::adjust_agent_buffer_font_size(cx, |size| size + delta); } } WhichFontSize::BufferFont => { @@ -1101,12 +1131,19 @@ impl AgentPanel { if action.persist { update_settings_file(self.fs.clone(), cx, move |settings, _| { settings.theme.agent_ui_font_size = None; + settings.theme.agent_buffer_font_size = None; }); } else { theme::reset_agent_ui_font_size(cx); + theme::reset_agent_buffer_font_size(cx); } } + pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context) { + theme::reset_agent_ui_font_size(cx); + theme::reset_agent_buffer_font_size(cx); + } + pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context) { if self.zoomed { cx.emit(PanelEvent::ZoomOut); @@ -1121,16 +1158,15 @@ impl AgentPanel { pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let context_server_store = self.project.read(cx).context_server_store(); - let tools = self.thread_store.read(cx).tools(); let fs = self.fs.clone(); - self.set_active_view(ActiveView::Configuration, window, cx); + self.set_active_view(ActiveView::Configuration, true, window, cx); self.configuration = Some(cx.new(|cx| { AgentConfiguration::new( fs, agent_server_store, context_server_store, - tools, + self.context_server_registry.clone(), self.language_registry.clone(), self.workspace.clone(), window, @@ -1198,7 +1234,7 @@ impl AgentPanel { }); } - self.new_thread(&NewThread::default(), window, cx); + self.new_thread(&NewThread, window, cx); if let Some((thread, model)) = self .active_native_agent_thread(cx) .zip(provider.default_model(cx)) @@ -1220,7 +1256,7 @@ impl AgentPanel { } } - pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option> { + pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { ActiveView::ExternalAgentThread { thread_view, .. } => { thread_view.read(cx).as_native_thread(cx) @@ -1229,9 +1265,11 @@ impl AgentPanel { } } - pub(crate) fn active_context_editor(&self) -> Option> { + pub(crate) fn active_text_thread_editor(&self) -> Option> { match &self.active_view { - ActiveView::TextThread { context_editor, .. } => Some(context_editor.clone()), + ActiveView::TextThread { + text_thread_editor, .. + } => Some(text_thread_editor.clone()), _ => None, } } @@ -1239,6 +1277,7 @@ impl AgentPanel { fn set_active_view( &mut self, new_view: ActiveView, + focus: bool, window: &mut Window, cx: &mut Context, ) { @@ -1252,21 +1291,16 @@ impl AgentPanel { let new_is_special = new_is_history || new_is_config; match &new_view { - ActiveView::TextThread { context_editor, .. } => { - self.history_store.update(cx, |store, cx| { - if let Some(path) = context_editor.read(cx).context().read(cx).path() { - store.push_recently_opened_entry(HistoryEntryId::Context(path.clone()), cx) - } - }); - self.acp_history_store.update(cx, |store, cx| { - if let Some(path) = context_editor.read(cx).context().read(cx).path() { - store.push_recently_opened_entry( - agent2::HistoryEntryId::TextThread(path.clone()), - cx, - ) - } - }) - } + ActiveView::TextThread { + text_thread_editor, .. + } => self.history_store.update(cx, |store, cx| { + if let Some(path) = text_thread_editor.read(cx).text_thread().read(cx).path() { + store.push_recently_opened_entry( + agent::HistoryEntryId::TextThread(path.clone()), + cx, + ) + } + }), ActiveView::ExternalAgentThread { .. } => {} ActiveView::History | ActiveView::Configuration => {} } @@ -1282,7 +1316,9 @@ impl AgentPanel { self.active_view = new_view; } - self.focus_handle(cx).focus(window); + if focus { + self.focus_handle(cx).focus(window); + } } fn populate_recently_opened_menu_section( @@ -1292,7 +1328,7 @@ impl AgentPanel { ) -> ContextMenu { let entries = panel .read(cx) - .acp_history_store + .history_store .read(cx) .recently_opened_entries(cx); @@ -1315,15 +1351,15 @@ impl AgentPanel { let entry = entry.clone(); panel .update(cx, move |this, cx| match &entry { - agent2::HistoryEntry::AcpThread(entry) => this.external_thread( + agent::HistoryEntry::AcpThread(entry) => this.external_thread( Some(ExternalAgent::NativeAgent), Some(entry.clone()), None, window, cx, ), - agent2::HistoryEntry::TextThread(entry) => this - .open_saved_prompt_editor(entry.path.clone(), window, cx) + agent::HistoryEntry::TextThread(entry) => this + .open_saved_text_thread(entry.path.clone(), window, cx) .detach_and_log_err(cx), }) .ok(); @@ -1337,7 +1373,7 @@ impl AgentPanel { move |_window, cx| { panel .update(cx, |this, cx| { - this.acp_history_store.update(cx, |history_store, cx| { + this.history_store.update(cx, |history_store, cx| { history_store.remove_recently_opened_entry(&id, cx); }); }) @@ -1356,6 +1392,31 @@ impl AgentPanel { self.selected_agent.clone() } + fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context) { + if let Some(extension_store) = ExtensionStore::try_global(cx) { + let (manifests, extensions_dir) = { + let store = extension_store.read(cx); + let installed = store.installed_extensions(); + let manifests: Vec<_> = installed + .iter() + .map(|(id, entry)| (id.clone(), entry.manifest.clone())) + .collect(); + let extensions_dir = paths::extensions_dir().join("installed"); + (manifests, extensions_dir) + }; + + self.project.update(cx, |project, cx| { + project.agent_server_store().update(cx, |store, cx| { + let manifest_refs: Vec<_> = manifests + .iter() + .map(|(id, manifest)| (id.as_ref(), manifest.as_ref())) + .collect(); + store.sync_extension_agents(manifest_refs, extensions_dir, cx); + }); + }); + } + } + pub fn new_agent_thread( &mut self, agent: AgentType, @@ -1363,15 +1424,6 @@ impl AgentPanel { cx: &mut Context, ) { match agent { - AgentType::Zed => { - window.dispatch_action( - NewThread { - from_thread_id: None, - } - .boxed_clone(), - cx, - ); - } AgentType::TextThread => { window.dispatch_action(NewTextThread.boxed_clone(), cx); } @@ -1401,8 +1453,8 @@ impl AgentPanel { self.serialize(cx); self.external_thread(Some(crate::ExternalAgent::Codex), None, None, window, cx) } - AgentType::Custom { name, command } => self.external_thread( - Some(crate::ExternalAgent::Custom { name, command }), + AgentType::Custom { name } => self.external_thread( + Some(crate::ExternalAgent::Custom { name }), None, None, window, @@ -1432,7 +1484,9 @@ impl Focusable for AgentPanel { match &self.active_view { ActiveView::ExternalAgentThread { thread_view, .. } => thread_view.focus_handle(cx), ActiveView::History => self.acp_history.focus_handle(cx), - ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), + ActiveView::TextThread { + text_thread_editor, .. + } => text_thread_editor.focus_handle(cx), ActiveView::Configuration => { if let Some(configuration) = self.configuration.as_ref() { configuration.focus_handle(cx) @@ -1455,6 +1509,10 @@ impl Panel for AgentPanel { "AgentPanel" } + fn panel_key() -> &'static str { + AGENT_PANEL_KEY + } + fn position(&self, _window: &Window, cx: &App) -> DockPosition { agent_panel_dock_position(cx) } @@ -1563,17 +1621,17 @@ impl AgentPanel { } ActiveView::TextThread { title_editor, - context_editor, + text_thread_editor, .. } => { - let summary = context_editor.read(cx).context().read(cx).summary(); + let summary = text_thread_editor.read(cx).text_thread().read(cx).summary(); match summary { - ContextSummary::Pending => Label::new(ContextSummary::DEFAULT) + TextThreadSummary::Pending => Label::new(TextThreadSummary::DEFAULT) .color(Color::Muted) .truncate() .into_any_element(), - ContextSummary::Content(summary) => { + TextThreadSummary::Content(summary) => { if summary.done { div() .w_full() @@ -1586,17 +1644,17 @@ impl AgentPanel { .into_any_element() } } - ContextSummary::Error => h_flex() + TextThreadSummary::Error => h_flex() .w_full() .child(title_editor.clone()) .child( IconButton::new("retry-summary-generation", IconName::RotateCcw) .icon_size(IconSize::Small) .on_click({ - let context_editor = context_editor.clone(); + let text_thread_editor = text_thread_editor.clone(); move |_, _window, cx| { - context_editor.update(cx, |context_editor, cx| { - context_editor.regenerate_summary(cx); + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor.regenerate_summary(cx); }); } }) @@ -1651,12 +1709,11 @@ impl AgentPanel { .icon_size(IconSize::Small), { let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Toggle Agent Menu", &ToggleOptionsMenu, &focus_handle, - window, cx, ) } @@ -1717,10 +1774,9 @@ impl AgentPanel { }), ) .action("Add Custom Server…", Box::new(AddContextServer)) - .separator(); - - menu = menu - .action("Rules…", Box::new(OpenRulesLibrary::default())) + .separator() + .action("Rules", Box::new(OpenRulesLibrary::default())) + .action("Profiles", Box::new(ManageProfiles::default())) .action("Settings", Box::new(OpenSettings)) .separator() .action(full_screen_label, Box::new(ToggleZoom)); @@ -1747,21 +1803,20 @@ impl AgentPanel { .trigger_with_tooltip( IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small), { - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Toggle Recent Threads", &ToggleNavigationMenu, &focus_handle, - window, cx, ) } }, ) .anchor(corner) - .with_handle(self.assistant_navigation_menu_handle.clone()) + .with_handle(self.agent_navigation_menu_handle.clone()) .menu({ - let menu = self.assistant_navigation_menu.clone(); + let menu = self.agent_navigation_menu.clone(); move |window, cx| { telemetry::event!("View Thread History Clicked"); @@ -1786,8 +1841,8 @@ impl AgentPanel { this.go_back(&workspace::GoBack, window, cx); })) .tooltip({ - move |window, cx| { - Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, window, cx) + move |_window, cx| { + Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx) } }) } @@ -1796,6 +1851,19 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); + let (selected_agent_custom_icon, selected_agent_label) = + if let AgentType::Custom { name, .. } = &self.selected_agent { + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + + let label = store + .agent_display_name(&ExternalAgentServerName(name.clone())) + .unwrap_or_else(|| self.selected_agent.label()); + (icon, label) + } else { + (None, self.selected_agent.label()) + }; + let active_thread = match &self.active_view { ActiveView::ExternalAgentThread { thread_view } => { thread_view.read(cx).as_native_thread(cx) @@ -1808,12 +1876,11 @@ impl AgentPanel { IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small), { let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( - "New…", + "New Thread…", &ToggleNewThreadMenu, &focus_handle, - window, cx, ) } @@ -1822,6 +1889,9 @@ impl AgentPanel { .anchor(Corner::TopRight) .with_handle(self.new_thread_menu_handle.clone()) .menu({ + let selected_agent = self.selected_agent.clone(); + let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type; + let workspace = self.workspace.clone(); let is_via_collab = workspace .update(cx, |workspace, cx| { @@ -1834,9 +1904,7 @@ impl AgentPanel { let active_thread = active_thread.clone(); Some(ContextMenu::build(window, cx, |menu, _window, cx| { - menu - .context(focus_handle.clone()) - .header("Zed Agent") + menu.context(focus_handle.clone()) .when_some(active_thread, |this, active_thread| { let thread = active_thread.read(cx); @@ -1860,9 +1928,11 @@ impl AgentPanel { } }) .item( - ContextMenuEntry::new("New Thread") - .action(NewThread::default().boxed_clone()) - .icon(IconName::Thread) + ContextMenuEntry::new("Zed Agent") + .when(is_agent_selected(AgentType::NativeAgent) | is_agent_selected(AgentType::TextThread) , |this| { + this.action(Box::new(NewExternalAgentThread { agent: None })) + }) + .icon(IconName::ZedAgent) .icon_color(Color::Muted) .handler({ let workspace = workspace.clone(); @@ -1886,10 +1956,10 @@ impl AgentPanel { }), ) .item( - ContextMenuEntry::new("New Text Thread") + ContextMenuEntry::new("Text Thread") + .action(NewTextThread.boxed_clone()) .icon(IconName::TextThread) .icon_color(Color::Muted) - .action(NewTextThread.boxed_clone()) .handler({ let workspace = workspace.clone(); move |window, cx| { @@ -1914,7 +1984,10 @@ impl AgentPanel { .separator() .header("External Agents") .item( - ContextMenuEntry::new("New Claude Code Thread") + ContextMenuEntry::new("Claude Code") + .when(is_agent_selected(AgentType::ClaudeCode), |this| { + this.action(Box::new(NewExternalAgentThread { agent: None })) + }) .icon(IconName::AiClaude) .disabled(is_via_collab) .icon_color(Color::Muted) @@ -1939,36 +2012,40 @@ impl AgentPanel { } }), ) - .when(cx.has_flag::(), |this| { - this.item( - ContextMenuEntry::new("New Codex Thread") - .icon(IconName::AiOpenAi) - .disabled(is_via_collab) - .icon_color(Color::Muted) - .handler({ - let workspace = workspace.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = - workspace.panel::(cx) - { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - AgentType::Codex, - window, - cx, - ); - }); - } - }); - } - } - }), - ) - }) .item( - ContextMenuEntry::new("New Gemini CLI Thread") + ContextMenuEntry::new("Codex CLI") + .when(is_agent_selected(AgentType::Codex), |this| { + this.action(Box::new(NewExternalAgentThread { agent: None })) + }) + .icon(IconName::AiOpenAi) + .disabled(is_via_collab) + .icon_color(Color::Muted) + .handler({ + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_agent_thread( + AgentType::Codex, + window, + cx, + ); + }); + } + }); + } + } + }), + ) + .item( + ContextMenuEntry::new("Gemini CLI") + .when(is_agent_selected(AgentType::Gemini), |this| { + this.action(Box::new(NewExternalAgentThread { agent: None })) + }) .icon(IconName::AiGemini) .icon_color(Color::Muted) .disabled(is_via_collab) @@ -1994,84 +2071,127 @@ impl AgentPanel { }), ) .map(|mut menu| { + let agent_server_store = agent_server_store.read(cx); let agent_names = agent_server_store - .read(cx) .external_agents() .filter(|name| { - name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME + name.0 != GEMINI_NAME + && name.0 != CLAUDE_CODE_NAME + && name.0 != CODEX_NAME }) .cloned() .collect::>(); - let custom_settings = cx.global::().get::(None).custom.clone(); + for agent_name in agent_names { - menu = menu.item( - ContextMenuEntry::new(format!("New {} Thread", agent_name)) - .icon(IconName::Terminal) - .icon_color(Color::Muted) - .disabled(is_via_collab) - .handler({ - let workspace = workspace.clone(); - let agent_name = agent_name.clone(); - let custom_settings = custom_settings.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = - workspace.panel::(cx) - { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - AgentType::Custom { - name: agent_name.clone().into(), - command: custom_settings - .get(&agent_name.0) - .map(|settings| { - settings.command.clone() - }) - .unwrap_or(placeholder_command()), - }, - window, - cx, - ); - }); - } - }); - } - } + let icon_path = agent_server_store.agent_icon(&agent_name); + let display_name = agent_server_store + .agent_display_name(&agent_name) + .unwrap_or_else(|| agent_name.0.clone()); + + let mut entry = ContextMenuEntry::new(display_name); + + if let Some(icon_path) = icon_path { + entry = entry.custom_icon_svg(icon_path); + } else { + entry = entry.icon(IconName::Sparkle); + } + entry = entry + .when( + is_agent_selected(AgentType::Custom { + name: agent_name.0.clone(), }), - ); + |this| { + this.action(Box::new(NewExternalAgentThread { agent: None })) + }, + ) + .icon_color(Color::Muted) + .disabled(is_via_collab) + .handler({ + let workspace = workspace.clone(); + let agent_name = agent_name.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_agent_thread( + AgentType::Custom { + name: agent_name + .clone() + .into(), + }, + window, + cx, + ); + }); + } + }); + } + } + }); + + menu = menu.item(entry); } menu }) - .separator().link( - "Add Other Agents", - OpenBrowser { - url: zed_urls::external_agents_docs(cx), - } - .boxed_clone(), - ) + .separator() + .item( + ContextMenuEntry::new("Add More Agents") + .icon(IconName::Plus) + .icon_color(Color::Muted) + .handler({ + move |window, cx| { + window.dispatch_action(Box::new(zed_actions::Extensions { + category_filter: Some( + zed_actions::ExtensionCategoryFilter::AgentServers, + ), + id: None, + }), cx) + } + }), + ) })) } }); - let selected_agent_label = self.selected_agent.label(); + let is_thread_loading = self + .active_thread_view() + .map(|thread| thread.read(cx).is_loading()) + .unwrap_or(false); + + let has_custom_icon = selected_agent_custom_icon.is_some(); + let selected_agent = div() .id("selected_agent_icon") - .when_some(self.selected_agent.icon(), |this, icon| { - this.px(DynamicSpacing::Base02.rems(cx)) - .child(Icon::new(icon).color(Color::Muted)) - .tooltip(move |window, cx| { - Tooltip::with_meta( - selected_agent_label.clone(), - None, - "Selected Agent", - window, - cx, - ) - }) + .when_some(selected_agent_custom_icon, |this, icon_path| { + this.px_1() + .child(Icon::from_external_svg(icon_path).color(Color::Muted)) }) - .into_any_element(); + .when(!has_custom_icon, |this| { + this.when_some(self.selected_agent.icon(), |this, icon| { + this.px_1().child(Icon::new(icon).color(Color::Muted)) + }) + }) + .tooltip(move |_, cx| { + Tooltip::with_meta(selected_agent_label.clone(), None, "Selected Agent", cx) + }); + + let selected_agent = if is_thread_loading { + selected_agent + .with_animation( + "pulsating-icon", + Animation::new(Duration::from_secs(1)) + .repeat() + .with_easing(pulsating_between(0.2, 0.6)), + |icon, delta| icon.opacity(delta), + ) + .into_any_element() + } else { + selected_agent.into_any_element() + }; h_flex() .id("agent-panel-toolbar") @@ -2170,10 +2290,7 @@ impl AgentPanel { false } _ => { - let history_is_empty = self.acp_history_store.read(cx).is_empty(cx) - && self - .history_store - .update(cx, |store, cx| store.recent_entries(1, cx).is_empty()); + let history_is_empty = self.history_store.read(cx).is_empty(cx); let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) .providers() @@ -2247,7 +2364,6 @@ impl AgentPanel { border_bottom: bool, configuration_error: &ConfigurationError, focus_handle: &FocusHandle, - window: &mut Window, cx: &mut App, ) -> impl IntoElement { let zed_provider_configured = AgentSettings::get_global(cx) @@ -2296,7 +2412,7 @@ impl AgentPanel { .style(ButtonStyle::Tinted(ui::TintColor::Warning)) .label_size(LabelSize::Small) .key_binding( - KeyBinding::for_action_in(&OpenSettings, focus_handle, window, cx) + KeyBinding::for_action_in(&OpenSettings, focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_event, window, cx| { @@ -2312,9 +2428,9 @@ impl AgentPanel { } } - fn render_prompt_editor( + fn render_text_thread( &self, - context_editor: &Entity, + text_thread_editor: &Entity, buffer_search_bar: &Entity, window: &mut Window, cx: &mut Context, @@ -2348,7 +2464,7 @@ impl AgentPanel { ) }) }) - .child(context_editor.clone()) + .child(text_thread_editor.clone()) .child(self.render_drag_target(cx)) } @@ -2424,10 +2540,12 @@ impl AgentPanel { thread_view.insert_dragged_files(paths, added_worktrees, window, cx); }); } - ActiveView::TextThread { context_editor, .. } => { - context_editor.update(cx, |context_editor, cx| { + ActiveView::TextThread { + text_thread_editor, .. + } => { + text_thread_editor.update(cx, |text_thread_editor, cx| { TextThreadEditor::insert_dragged_files( - context_editor, + text_thread_editor, paths, added_worktrees, window, @@ -2443,8 +2561,8 @@ impl AgentPanel { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); match &self.active_view { - ActiveView::ExternalAgentThread { .. } => key_context.add("external_agent_thread"), - ActiveView::TextThread { .. } => key_context.add("prompt_editor"), + ActiveView::ExternalAgentThread { .. } => key_context.add("acp_thread"), + ActiveView::TextThread { .. } => key_context.add("text_thread"), ActiveView::History | ActiveView::Configuration => {} } key_context @@ -2498,7 +2616,7 @@ impl Render for AgentPanel { .child(self.render_drag_target(cx)), ActiveView::History => parent.child(self.acp_history.clone()), ActiveView::TextThread { - context_editor, + text_thread_editor, buffer_search_bar, .. } => { @@ -2514,15 +2632,14 @@ impl Render for AgentPanel { true, err, &self.focus_handle(cx), - window, cx, )) } else { this } }) - .child(self.render_prompt_editor( - context_editor, + .child(self.render_text_thread( + text_thread_editor, buffer_search_bar, window, cx, @@ -2563,29 +2680,24 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { cx: &mut Context, ) { InlineAssistant::update_global(cx, |assistant, cx| { - let Some(project) = self - .workspace - .upgrade() - .map(|workspace| workspace.read(cx).project().downgrade()) - else { + let Some(workspace) = self.workspace.upgrade() else { return; }; - let prompt_store = None; - let thread_store = None; - let text_thread_store = None; - let context_store = cx.new(|_| ContextStore::new(project.clone(), None)); + let Some(panel) = workspace.read(cx).panel::(cx) else { + return; + }; + let project = workspace.read(cx).project().downgrade(); + let thread_store = panel.read(cx).thread_store().clone(); assistant.assist( prompt_editor, self.workspace.clone(), - context_store, project, - prompt_store, thread_store, - text_thread_store, + None, initial_prompt, window, cx, - ) + ); }) } @@ -2602,17 +2714,17 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { pub struct ConcreteAssistantPanelDelegate; impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { - fn active_context_editor( + fn active_text_thread_editor( &self, workspace: &mut Workspace, _window: &mut Window, cx: &mut Context, ) -> Option> { let panel = workspace.panel::(cx)?; - panel.read(cx).active_context_editor() + panel.read(cx).active_text_thread_editor() } - fn open_saved_context( + fn open_local_text_thread( &self, workspace: &mut Workspace, path: Arc, @@ -2624,14 +2736,14 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { }; panel.update(cx, |panel, cx| { - panel.open_saved_prompt_editor(path, window, cx) + panel.open_saved_text_thread(path, window, cx) }) } - fn open_remote_context( + fn open_remote_text_thread( &self, _workspace: &mut Workspace, - _context_id: assistant_context::ContextId, + _text_thread_id: assistant_text_thread::TextThreadId, _window: &mut Window, _cx: &mut Context, ) -> Task>> { @@ -2662,15 +2774,15 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { thread_view.update(cx, |thread_view, cx| { thread_view.insert_selections(window, cx); }); - } else if let Some(context_editor) = panel.active_context_editor() { + } else if let Some(text_thread_editor) = panel.active_text_thread_editor() { let snapshot = buffer.read(cx).snapshot(cx); let selection_ranges = selection_ranges .into_iter() .map(|range| range.to_point(&snapshot)) .collect::>(); - context_editor.update(cx, |context_editor, cx| { - context_editor.quote_ranges(selection_ranges, snapshot, window, cx) + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor.quote_ranges(selection_ranges, snapshot, window, cx) }); } }); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 2c439a7254..3a0cc74bef 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,16 +1,16 @@ -mod acp; +pub mod acp; mod agent_configuration; mod agent_diff; mod agent_model_selector; mod agent_panel; mod buffer_codegen; -mod context_picker; +mod completion_provider; +mod context; mod context_server_configuration; -mod context_strip; mod inline_assistant; mod inline_prompt_editor; mod language_model_selector; -mod message_editor; +mod mention_set; mod profile_selector; mod slash_command; mod slash_command_picker; @@ -22,20 +22,21 @@ mod ui; use std::rc::Rc; use std::sync::Arc; -use agent::ThreadId; use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; use command_palette_hooks::CommandPaletteFilter; -use feature_flags::FeatureFlagAppExt as _; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{Action, App, Entity, SharedString, actions}; -use language::LanguageRegistry; +use language::{ + LanguageRegistry, + language_settings::{AllLanguageSettings, EditPredictionProvider}, +}; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, + ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; use project::DisableAiSettings; -use project::agent_server_store::AgentServerCommand; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -54,8 +55,6 @@ actions!( [ /// Creates a new text-based conversation thread. NewTextThread, - /// Toggles the context picker interface for adding files, symbols, or other context. - ToggleContextPicker, /// Toggles the menu to create new agent threads. ToggleNewThreadMenu, /// Toggles the navigation menu for switching between threads and views. @@ -68,10 +67,10 @@ actions!( ToggleProfileSelector, /// Cycles through available session modes. CycleModeSelector, - /// Removes all added context from the current conversation. - RemoveAllContext, /// Expands the message editor to full size. ExpandMessageEditor, + /// Removes all thread history. + RemoveHistory, /// Opens the conversation history view. OpenHistory, /// Adds a context server to the configuration. @@ -92,10 +91,6 @@ actions!( 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. @@ -129,20 +124,11 @@ actions!( ] ); -#[derive(Clone, Copy, Debug, PartialEq, Eq, Action)] -#[action(namespace = agent)] -#[action(deprecated_aliases = ["assistant::QuoteSelection"])] -/// Quotes the current selection in the agent panel's message editor. -pub struct QuoteSelection; - /// 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)] -pub struct NewThread { - #[serde(default)] - from_thread_id: Option, -} +pub struct NewThread; /// Creates a new external agent conversation thread. #[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)] @@ -161,52 +147,28 @@ pub struct NewNativeAgentThreadFromSummary { } // TODO unify this with AgentType -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] -enum ExternalAgent { - #[default] +pub enum ExternalAgent { Gemini, ClaudeCode, Codex, NativeAgent, - Custom { - name: SharedString, - command: AgentServerCommand, - }, -} - -fn placeholder_command() -> AgentServerCommand { - AgentServerCommand { - path: "/placeholder".into(), - args: vec![], - env: None, - } + Custom { name: SharedString }, } impl ExternalAgent { - fn name(&self) -> &'static str { - match self { - Self::NativeAgent => "zed", - Self::Gemini => "gemini-cli", - Self::ClaudeCode => "claude-code", - Self::Codex => "codex", - Self::Custom { .. } => "custom", - } - } - pub fn server( &self, fs: Arc, - history: Entity, + history: Entity, ) -> Rc { match self { Self::Gemini => Rc::new(agent_servers::Gemini), Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode), Self::Codex => Rc::new(agent_servers::Codex), - Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)), - Self::Custom { name, command: _ } => { - Rc::new(agent_servers::CustomAgentServer::new(name.clone())) - } + Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, history)), + Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())), } } } @@ -241,11 +203,6 @@ impl ModelUsageContext { } } } - - pub fn language_model(&self, cx: &App) -> Option> { - self.configured_model(cx) - .map(|configured_model| configured_model.model) - } } /// Initializes the `agent` crate. @@ -257,9 +214,7 @@ pub fn init( is_eval: bool, cx: &mut App, ) { - AgentSettings::register(cx); - - assistant_context::init(client.clone(), cx); + assistant_text_thread::init(client, cx); rules_library::init(cx); if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when @@ -267,19 +222,13 @@ pub fn init( init_language_model_settings(cx); } assistant_slash_command::init(cx); - agent::init(fs.clone(), cx); agent_panel::init(cx); context_server_configuration::init(language_registry.clone(), fs.clone(), cx); TextThreadEditor::init(cx); register_slash_commands(cx); - inline_assistant::init( - fs.clone(), - prompt_builder.clone(), - client.telemetry().clone(), - cx, - ); - terminal_inline_assistant::init(fs.clone(), prompt_builder, client.telemetry().clone(), cx); + inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); + terminal_inline_assistant::init(fs.clone(), prompt_builder, cx); cx.observe_new(move |workspace, window, cx| { ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) }) @@ -295,56 +244,93 @@ pub fn init( update_command_palette_filter(app_cx); }) .detach(); + + cx.on_flags_ready(|_, cx| { + update_command_palette_filter(cx); + }) + .detach(); } fn update_command_palette_filter(cx: &mut App) { let disable_ai = DisableAiSettings::get_global(cx).disable_ai; + let agent_enabled = AgentSettings::get_global(cx).enabled; + let agent_v2_enabled = cx.has_flag::(); + let edit_prediction_provider = AllLanguageSettings::get_global(cx) + .edit_predictions + .provider; + CommandPaletteFilter::update_global(cx, |filter, _| { + use editor::actions::{ + AcceptEditPrediction, AcceptNextLineEditPrediction, AcceptNextWordEditPrediction, + NextEditPrediction, PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, + }; + let edit_prediction_actions = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; + if disable_ai { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); filter.hide_namespace("assistant"); filter.hide_namespace("copilot"); filter.hide_namespace("supermaven"); filter.hide_namespace("zed_predict_onboarding"); filter.hide_namespace("edit_prediction"); - use editor::actions::{ - AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction, - PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, - }; - let edit_prediction_actions = [ - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - ]; filter.hide_action_types(&edit_prediction_actions); filter.hide_action_types(&[TypeId::of::()]); } else { - filter.show_namespace("agent"); + if agent_enabled { + filter.show_namespace("agent"); + filter.show_namespace("agents"); + } else { + filter.hide_namespace("agent"); + filter.hide_namespace("agents"); + } + filter.show_namespace("assistant"); - filter.show_namespace("copilot"); + + match edit_prediction_provider { + EditPredictionProvider::None => { + filter.hide_namespace("edit_prediction"); + filter.hide_namespace("copilot"); + filter.hide_namespace("supermaven"); + filter.hide_action_types(&edit_prediction_actions); + } + EditPredictionProvider::Copilot => { + filter.show_namespace("edit_prediction"); + filter.show_namespace("copilot"); + filter.hide_namespace("supermaven"); + filter.show_action_types(edit_prediction_actions.iter()); + } + EditPredictionProvider::Supermaven => { + filter.show_namespace("edit_prediction"); + filter.hide_namespace("copilot"); + filter.show_namespace("supermaven"); + filter.show_action_types(edit_prediction_actions.iter()); + } + EditPredictionProvider::Zed + | EditPredictionProvider::Codestral + | EditPredictionProvider::Experimental(_) => { + filter.show_namespace("edit_prediction"); + filter.hide_namespace("copilot"); + filter.hide_namespace("supermaven"); + filter.show_action_types(edit_prediction_actions.iter()); + } + } + filter.show_namespace("zed_predict_onboarding"); - - filter.show_namespace("edit_prediction"); - - use editor::actions::{ - AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction, - PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, - }; - let edit_prediction_actions = [ - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - ]; - filter.show_action_types(edit_prediction_actions.iter()); - filter.show_action_types(&[TypeId::of::()]); + if !agent_v2_enabled { + filter.hide_action_types(&[TypeId::of::()]); + } } }); } @@ -433,3 +419,139 @@ fn register_slash_commands(cx: &mut App) { }) .detach(); } + +#[cfg(test)] +mod tests { + use super::*; + use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; + use command_palette_hooks::CommandPaletteFilter; + use editor::actions::AcceptEditPrediction; + use gpui::{BorrowAppContext, TestAppContext, px}; + use project::DisableAiSettings; + use settings::{ + DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting, Settings, SettingsStore, + }; + + #[gpui::test] + fn test_agent_command_palette_visibility(cx: &mut TestAppContext) { + // Init settings + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + command_palette_hooks::init(cx); + AgentSettings::register(cx); + DisableAiSettings::register(cx); + AllLanguageSettings::register(cx); + }); + + let agent_settings = AgentSettings { + enabled: true, + button: true, + dock: DockPosition::Right, + agents_panel_dock: DockSide::Left, + default_width: px(300.), + default_height: px(600.), + default_model: None, + inline_assistant_model: None, + inline_assistant_use_streaming_tools: false, + commit_message_model: None, + thread_summary_model: None, + inline_alternatives: vec![], + default_profile: AgentProfileId::default(), + default_view: DefaultAgentView::Thread, + profiles: Default::default(), + always_allow_tool_actions: false, + notify_when_agent_waiting: NotifyWhenAgentWaiting::default(), + play_sound_when_agent_done: false, + single_file_review: false, + model_parameters: vec![], + preferred_completion_mode: CompletionMode::Normal, + enable_feedback: false, + expand_edit_card: true, + expand_terminal_card: true, + use_modifier_to_send: true, + message_editor_min_lines: 1, + }; + + cx.update(|cx| { + AgentSettings::override_global(agent_settings.clone(), cx); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); + + // Initial update + update_command_palette_filter(cx); + }); + + // Assert visible + cx.update(|cx| { + let filter = CommandPaletteFilter::try_global(cx).unwrap(); + assert!( + !filter.is_hidden(&NewThread), + "NewThread should be visible by default" + ); + }); + + // Disable agent + cx.update(|cx| { + let mut new_settings = agent_settings.clone(); + new_settings.enabled = false; + AgentSettings::override_global(new_settings, cx); + + // Trigger update + update_command_palette_filter(cx); + }); + + // Assert hidden + cx.update(|cx| { + let filter = CommandPaletteFilter::try_global(cx).unwrap(); + assert!( + filter.is_hidden(&NewThread), + "NewThread should be hidden when agent is disabled" + ); + }); + + // Test EditPredictionProvider + // Enable EditPredictionProvider::Copilot + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |s| { + s.project + .all_languages + .features + .get_or_insert(Default::default()) + .edit_prediction_provider = Some(EditPredictionProvider::Copilot); + }); + }); + update_command_palette_filter(cx); + }); + + cx.update(|cx| { + let filter = CommandPaletteFilter::try_global(cx).unwrap(); + assert!( + !filter.is_hidden(&AcceptEditPrediction), + "EditPrediction should be visible when provider is Copilot" + ); + }); + + // Disable EditPredictionProvider (None) + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |s| { + s.project + .all_languages + .features + .get_or_insert(Default::default()) + .edit_prediction_provider = Some(EditPredictionProvider::None); + }); + }); + update_command_palette_filter(cx); + }); + + cx.update(|cx| { + let filter = CommandPaletteFilter::try_global(cx).unwrap(); + assert!( + filter.is_hidden(&AcceptEditPrediction), + "EditPrediction should be hidden when provider is None" + ); + }); + } +} diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 2309aad754..2539527874 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,28 +1,34 @@ -use crate::inline_prompt_editor::CodegenStatus; -use agent::{ - ContextStore, - context::{ContextLoadResult, load_context}, -}; +use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus}; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; +use uuid::Uuid; + use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; +use feature_flags::{FeatureFlagAppExt as _, InlineAssistantUseToolFeatureFlag}; use futures::{ - SinkExt, Stream, StreamExt, TryStreamExt as _, channel::mpsc, future::LocalBoxFuture, join, + SinkExt, Stream, StreamExt, TryStreamExt as _, + channel::mpsc, + future::{LocalBoxFuture, Shared}, + join, + stream::BoxStream, }; -use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Subscription, Task, WeakEntity}; -use language::{Buffer, IndentKind, Point, TransactionId, line_diff}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; +use language::{Buffer, IndentKind, LanguageName, Point, TransactionId, line_diff}; use language_model::{ - LanguageModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelTextStream, Role, report_assistant_event, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelRequestTool, LanguageModelTextStream, LanguageModelToolChoice, + LanguageModelToolUse, Role, TokenUsage, }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; -use project::Project; -use prompt_store::{PromptBuilder, PromptStore}; +use prompt_store::PromptBuilder; use rope::Rope; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings as _; use smol::future::FutureExt; use std::{ cmp, @@ -35,7 +41,26 @@ use std::{ time::Instant, }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + +/// Use this tool when you cannot or should not make a rewrite. This includes: +/// - The user's request is unclear, ambiguous, or nonsensical +/// - The requested change cannot be made by only editing the section +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct FailureMessageInput { + /// A brief message to the user explaining why you're unable to fulfill the request or to ask a question about the request. + #[serde(default)] + pub message: String, +} + +/// Replaces text in tags with your replacement_text. +/// Only use this tool when you are confident you understand the user's request and can fulfill it +/// by editing the marked section. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct RewriteSectionInput { + /// The text to replace the section with. + #[serde(default)] + pub replacement_text: String, +} pub struct BufferCodegen { alternatives: Vec>, @@ -45,12 +70,9 @@ pub struct BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - context_store: Entity, - project: WeakEntity, - prompt_store: Option>, - telemetry: Arc, builder: Arc, pub is_insertion: bool, + session_id: Uuid, } impl BufferCodegen { @@ -58,10 +80,7 @@ impl BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - context_store: Entity, - project: WeakEntity, - prompt_store: Option>, - telemetry: Arc, + session_id: Uuid, builder: Arc, cx: &mut Context, ) -> Self { @@ -70,11 +89,8 @@ impl BufferCodegen { buffer.clone(), range.clone(), false, - Some(context_store.clone()), - project.clone(), - prompt_store.clone(), - Some(telemetry.clone()), builder.clone(), + session_id, cx, ) }); @@ -87,11 +103,8 @@ impl BufferCodegen { buffer, range, initial_transaction_id, - context_store, - project, - prompt_store, - telemetry, builder, + session_id, }; this.activate(0, cx); this @@ -106,10 +119,18 @@ impl BufferCodegen { .push(cx.subscribe(&codegen, |_, _, event, cx| cx.emit(*event))); } + pub fn active_completion(&self, cx: &App) -> Option { + self.active_alternative().read(cx).current_completion() + } + pub fn active_alternative(&self) -> &Entity { &self.alternatives[self.active_alternative] } + pub fn language_name(&self, cx: &App) -> Option { + self.active_alternative().read(cx).language_name(cx) + } + pub fn status<'a>(&self, cx: &'a App) -> &'a CodegenStatus { &self.active_alternative().read(cx).status } @@ -150,6 +171,7 @@ impl BufferCodegen { &mut self, primary_model: Arc, user_prompt: String, + context_task: Shared>>, cx: &mut Context, ) -> Result<()> { let alternative_models = LanguageModelRegistry::read_global(cx) @@ -167,11 +189,8 @@ impl BufferCodegen { self.buffer.clone(), self.range.clone(), false, - Some(self.context_store.clone()), - self.project.clone(), - self.prompt_store.clone(), - Some(self.telemetry.clone()), self.builder.clone(), + self.session_id, cx, ) })); @@ -182,7 +201,7 @@ impl BufferCodegen { .zip(&self.alternatives) { alternative.update(cx, |alternative, cx| { - alternative.start(user_prompt.clone(), model.clone(), cx) + alternative.start(user_prompt.clone(), context_task.clone(), model.clone(), cx) })?; } @@ -230,6 +249,14 @@ impl BufferCodegen { pub fn last_equal_ranges<'a>(&self, cx: &'a App) -> &'a [Range] { self.active_alternative().read(cx).last_equal_ranges() } + + pub fn selected_text<'a>(&self, cx: &'a App) -> Option<&'a str> { + self.active_alternative().read(cx).selected_text() + } + + pub fn session_id(&self) -> Uuid { + self.session_id + } } impl EventEmitter for BufferCodegen {} @@ -245,10 +272,6 @@ pub struct CodegenAlternative { status: CodegenStatus, generation: Task<()>, diff: Diff, - context_store: Option>, - project: WeakEntity, - prompt_store: Option>, - telemetry: Option>, _subscription: gpui::Subscription, builder: Arc, active: bool, @@ -256,7 +279,11 @@ pub struct CodegenAlternative { line_operations: Vec, elapsed_time: Option, completion: Option, + selected_text: Option, pub message_id: Option, + session_id: Uuid, + pub description: Option, + pub failure: Option, } impl EventEmitter for CodegenAlternative {} @@ -266,11 +293,8 @@ impl CodegenAlternative { buffer: Entity, range: Range, active: bool, - context_store: Option>, - project: WeakEntity, - prompt_store: Option>, - telemetry: Option>, builder: Arc, + session_id: Uuid, cx: &mut Context, ) -> Self { let snapshot = buffer.read(cx).snapshot(cx); @@ -293,7 +317,7 @@ impl CodegenAlternative { let mut buffer = Buffer::local_normalized(text, line_ending, cx); buffer.set_language(language, cx); if let Some(language_registry) = language_registry { - buffer.set_language_registry(language_registry) + buffer.set_language_registry(language_registry); } buffer }); @@ -309,21 +333,28 @@ impl CodegenAlternative { status: CodegenStatus::Idle, generation: Task::ready(()), diff: Diff::default(), - context_store, - project, - prompt_store, - telemetry, - _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), builder, - active, + active: active, edits: Vec::new(), line_operations: Vec::new(), range, elapsed_time: None, completion: None, + selected_text: None, + session_id, + description: None, + failure: None, + _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), } } + pub fn language_name(&self, cx: &App) -> Option { + self.old_buffer + .read(cx) + .language() + .map(|language| language.name()) + } + pub fn set_active(&mut self, active: bool, cx: &mut Context) { if active != self.active { self.active = active; @@ -365,12 +396,22 @@ impl CodegenAlternative { &self.last_equal_ranges } + pub fn use_streaming_tools(model: &dyn LanguageModel, cx: &App) -> bool { + model.supports_streaming_tools() + && cx.has_flag::() + && AgentSettings::get_global(cx).inline_assistant_use_streaming_tools + } + pub fn start( &mut self, user_prompt: String, + context_task: Shared>>, model: Arc, cx: &mut Context, ) -> Result<()> { + // Clear the model explanation since the user has started a new generation. + self.description = None; + if let Some(transformation_transaction_id) = self.transformation_transaction_id.take() { self.buffer.update(cx, |buffer, cx| { buffer.undo_transaction(transformation_transaction_id, cx); @@ -379,27 +420,38 @@ impl CodegenAlternative { self.edit_position = Some(self.range.start.bias_right(&self.snapshot)); - let api_key = model.api_key(cx); - let telemetry_id = model.telemetry_id(); - let provider_id = model.provider_id(); - let stream: LocalBoxFuture> = - if user_prompt.trim().to_lowercase() == "delete" { - async { Ok(LanguageModelTextStream::default()) }.boxed_local() - } else { - let request = self.build_request(&model, user_prompt, cx)?; - cx.spawn(async move |_, cx| { - Ok(model.stream_completion_text(request.await, cx).await?) - }) - .boxed_local() - }; - self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx); + if Self::use_streaming_tools(model.as_ref(), cx) { + let request = self.build_request(&model, user_prompt, context_task, cx)?; + let completion_events = cx.spawn({ + let model = model.clone(); + async move |_, cx| model.stream_completion(request.await, cx).await + }); + self.generation = self.handle_completion(model, completion_events, cx); + } else { + let stream: LocalBoxFuture> = + if user_prompt.trim().to_lowercase() == "delete" { + async { Ok(LanguageModelTextStream::default()) }.boxed_local() + } else { + let request = self.build_request(&model, user_prompt, context_task, cx)?; + cx.spawn({ + let model = model.clone(); + async move |_, cx| { + Ok(model.stream_completion_text(request.await, cx).await?) + } + }) + .boxed_local() + }; + self.generation = self.handle_stream(model, stream, cx); + } + Ok(()) } - fn build_request( + fn build_request_tools( &self, model: &Arc, user_prompt: String, + context_task: Shared>>, cx: &mut App, ) -> Result> { let buffer = self.buffer.read(cx).snapshot(cx); @@ -429,23 +481,119 @@ impl CodegenAlternative { anyhow::bail!("invalid transformation range"); }; - let prompt = self + let system_prompt = self .builder - .generate_inline_transformation_prompt(user_prompt, language_name, buffer, range) + .generate_inline_transformation_prompt_tools( + language_name, + buffer, + range.start.0..range.end.0, + ) .context("generating content prompt")?; - let context_task = self.context_store.as_ref().map(|context_store| { - if let Some(project) = self.project.upgrade() { - let context = context_store - .read(cx) - .context() - .cloned() - .collect::>(); - load_context(context, &project, &self.prompt_store, cx) - } else { - Task::ready(ContextLoadResult::default()) + let temperature = AgentSettings::temperature_for_model(model, cx); + + let tool_input_format = model.tool_input_format(); + let tool_choice = model + .supports_tool_choice(LanguageModelToolChoice::Any) + .then_some(LanguageModelToolChoice::Any); + + Ok(cx.spawn(async move |_cx| { + let mut messages = vec![LanguageModelRequestMessage { + role: Role::System, + content: vec![system_prompt.into()], + cache: false, + reasoning_details: None, + }]; + + let mut user_message = LanguageModelRequestMessage { + role: Role::User, + content: Vec::new(), + cache: false, + reasoning_details: None, + }; + + if let Some(context) = context_task.await { + context.add_to_request_message(&mut user_message); } - }); + + user_message.content.push(user_prompt.into()); + messages.push(user_message); + + let tools = vec![ + LanguageModelRequestTool { + name: "rewrite_section".to_string(), + description: "Replaces text in tags with your replacement_text.".to_string(), + input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + }, + LanguageModelRequestTool { + name: "failure_message".to_string(), + description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), + input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + }, + ]; + + LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: Some(CompletionIntent::InlineAssist), + mode: None, + tools, + tool_choice, + stop: Vec::new(), + temperature, + messages, + thinking_allowed: false, + } + })) + } + + fn build_request( + &self, + model: &Arc, + user_prompt: String, + context_task: Shared>>, + cx: &mut App, + ) -> Result> { + if Self::use_streaming_tools(model.as_ref(), cx) { + return self.build_request_tools(model, user_prompt, context_task, cx); + } + + let buffer = self.buffer.read(cx).snapshot(cx); + let language = buffer.language_at(self.range.start); + let language_name = if let Some(language) = language.as_ref() { + if Arc::ptr_eq(language, &language::PLAIN_TEXT) { + None + } else { + Some(language.name()) + } + } else { + None + }; + + let language_name = language_name.as_ref(); + let start = buffer.point_to_buffer_offset(self.range.start); + let end = buffer.point_to_buffer_offset(self.range.end); + let (buffer, range) = if let Some((start, end)) = start.zip(end) { + let (start_buffer, start_buffer_offset) = start; + let (end_buffer, end_buffer_offset) = end; + if start_buffer.remote_id() == end_buffer.remote_id() { + (start_buffer.clone(), start_buffer_offset..end_buffer_offset) + } else { + anyhow::bail!("invalid transformation range"); + } + } else { + anyhow::bail!("invalid transformation range"); + }; + + let prompt = self + .builder + .generate_inline_transformation_prompt( + user_prompt, + language_name, + buffer, + range.start.0..range.end.0, + ) + .context("generating content prompt")?; let temperature = AgentSettings::temperature_for_model(model, cx); @@ -454,13 +602,11 @@ impl CodegenAlternative { role: Role::User, content: Vec::new(), cache: false, + reasoning_details: None, }; - if let Some(context_task) = context_task { - context_task - .await - .loaded_context - .add_to_request_message(&mut request_message); + if let Some(context) = context_task.await { + context.add_to_request_message(&mut request_message); } request_message.content.push(prompt.into()); @@ -482,18 +628,30 @@ impl CodegenAlternative { pub fn handle_stream( &mut self, - model_telemetry_id: String, - model_provider_id: String, - model_api_key: Option, + model: Arc, stream: impl 'static + Future>, cx: &mut Context, - ) { + ) -> Task<()> { + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); let start_time = Instant::now(); + + // Make a new snapshot and re-resolve anchor in case the document was modified. + // This can happen often if the editor loses focus and is saved + reformatted, + // as in https://github.com/zed-industries/zed/issues/39088 + self.snapshot = self.buffer.read(cx).snapshot(cx); + self.range = self.snapshot.anchor_after(self.range.start) + ..self.snapshot.anchor_after(self.range.end); + let snapshot = self.snapshot.clone(); let selected_text = snapshot .text_for_range(self.range.start..self.range.end) .collect::(); + self.selected_text = Some(selected_text.to_string()); + let selection_start = self.range.start.to_point(&snapshot); // Start with the indentation of the first line in the selection @@ -515,8 +673,6 @@ impl CodegenAlternative { } } - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); let language_name = { let multibuffer = self.buffer.read(cx); let snapshot = multibuffer.snapshot(cx); @@ -533,8 +689,10 @@ impl CodegenAlternative { let completion = Arc::new(Mutex::new(String::new())); let completion_clone = completion.clone(); - self.generation = cx.spawn(async move |codegen, cx| { + cx.notify(); + cx.spawn(async move |codegen, cx| { let stream = stream.await; + let token_usage = stream .as_ref() .ok() @@ -547,10 +705,11 @@ impl CodegenAlternative { let model_telemetry_id = model_telemetry_id.clone(); let model_provider_id = model_provider_id.clone(); let (mut diff_tx, mut diff_rx) = mpsc::channel(1); - let executor = cx.background_executor().clone(); let message_id = message_id.clone(); - let line_based_stream_diff: Task> = - cx.background_spawn(async move { + let line_based_stream_diff: Task> = cx.background_spawn({ + let anthropic_reporter = anthropic_reporter.clone(); + let language_name = language_name.clone(); + async move { let mut response_latency = None; let request_start = Instant::now(); let diff = async { @@ -558,6 +717,7 @@ impl CodegenAlternative { stream?.stream.map_err(|error| error.into()), ); futures::pin_mut!(chunks); + let mut diff = StreamingDiff::new(selected_text.to_string()); let mut line_diff = LineDiff::default(); @@ -646,27 +806,30 @@ impl CodegenAlternative { let result = diff.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - message_id, - kind: AssistantKind::Inline, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id, - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - telemetry, - http_client, - model_api_key, - &executor, + telemetry::event!( + "Assistant Responded", + kind = "inline", + phase = "response", + session_id = session_id.to_string(), + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name.as_ref().map(|n| n.to_string()), + message_id = message_id.as_deref(), + response_latency = response_latency, + error_message = error_message.as_deref(), ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Response, + language_name: language_name.map(|n| n.to_string()), + message_id, + }); + result?; Ok(()) - }); + } + }); while let Some((char_ops, line_ops)) = diff_rx.next().await { codegen.update(cx, |codegen, cx| { @@ -744,12 +907,30 @@ impl CodegenAlternative { output_tokens = usage.output_tokens, ) } + cx.emit(CodegenEvent::Finished); cx.notify(); }) .ok(); - }); - cx.notify(); + }) + } + + pub fn current_completion(&self) -> Option { + self.completion.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_description(&self) -> Option { + self.description.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_failure(&self) -> Option { + self.failure.clone() + } + + pub fn selected_text(&self) -> Option<&str> { + self.selected_text.as_deref() } pub fn stop(&mut self, cx: &mut Context) { @@ -923,6 +1104,219 @@ impl CodegenAlternative { .ok(); }) } + + fn handle_completion( + &mut self, + model: Arc, + completion_stream: Task< + Result< + BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, + >, + cx: &mut Context, + ) -> Task<()> { + self.diff = Diff::default(); + self.status = CodegenStatus::Pending; + + cx.notify(); + // Leaving this in generation so that STOP equivalent events are respected even + // while we're still pre-processing the completion event + cx.spawn(async move |codegen, cx| { + let finish_with_status = |status: CodegenStatus, cx: &mut AsyncApp| { + let _ = codegen.update(cx, |this, cx| { + this.status = status; + cx.emit(CodegenEvent::Finished); + cx.notify(); + }); + }; + + let mut completion_events = match completion_stream.await { + Ok(events) => events, + Err(err) => { + finish_with_status(CodegenStatus::Error(err.into()), cx); + return; + } + }; + + enum ToolUseOutput { + Rewrite { + text: String, + description: Option, + }, + Failure(String), + } + + enum ModelUpdate { + Description(String), + Failure(String), + } + + let chars_read_so_far = Arc::new(Mutex::new(0usize)); + let process_tool_use = move |tool_use: LanguageModelToolUse| -> Option { + let mut chars_read_so_far = chars_read_so_far.lock(); + match tool_use.name.as_ref() { + "rewrite_section" => { + let Ok(input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + let text = input.replacement_text[*chars_read_so_far..].to_string(); + *chars_read_so_far = input.replacement_text.len(); + Some(ToolUseOutput::Rewrite { + text, + description: None, + }) + } + "failure_message" => { + let Ok(mut input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + Some(ToolUseOutput::Failure(std::mem::take(&mut input.message))) + } + _ => None, + } + }; + + let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded::(); + + cx.spawn({ + let codegen = codegen.clone(); + async move |cx| { + while let Some(update) = message_rx.next().await { + let _ = codegen.update(cx, |this, _cx| match update { + ModelUpdate::Description(d) => this.description = Some(d), + ModelUpdate::Failure(f) => this.failure = Some(f), + }); + } + } + }) + .detach(); + + let mut message_id = None; + let mut first_text = None; + let last_token_usage = Arc::new(Mutex::new(TokenUsage::default())); + let total_text = Arc::new(Mutex::new(String::new())); + + loop { + if let Some(first_event) = completion_events.next().await { + match first_event { + Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => { + message_id = Some(id); + } + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + if let Some(output) = process_tool_use(tool_use) { + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.unbounded_send(update); + } + first_text = text; + if first_text.is_some() { + break; + } + } + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + } + Ok(e) => { + log::warn!("Unexpected event: {:?}", e); + break; + } + Err(e) => { + finish_with_status(CodegenStatus::Error(e.into()), cx); + break; + } + } + } + } + + let Some(first_text) = first_text else { + finish_with_status(CodegenStatus::Done, cx); + return; + }; + + let move_last_token_usage = last_token_usage.clone(); + + let text_stream = Box::pin(futures::stream::once(async { Ok(first_text) }).chain( + completion_events.filter_map(move |e| { + let process_tool_use = process_tool_use.clone(); + let last_token_usage = move_last_token_usage.clone(); + let total_text = total_text.clone(); + let mut message_tx = message_tx.clone(); + async move { + match e { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + let Some(output) = process_tool_use(tool_use) else { + return None; + }; + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.send(update).await; + } + text.map(Ok) + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + None + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + None + } + Ok(LanguageModelCompletionEvent::Stop(_reason)) => None, + e => { + log::error!("UNEXPECTED EVENT {:?}", e); + None + } + } + } + }), + )); + + let language_model_text_stream = LanguageModelTextStream { + message_id: message_id, + stream: text_stream, + last_token_usage, + }; + + let Some(task) = codegen + .update(cx, move |codegen, cx| { + codegen.handle_stream(model, async { Ok(language_model_text_stream) }, cx) + }) + .ok() + else { + return; + }; + + task.await; + }) + } } #[derive(Copy, Clone, Debug)] @@ -1078,18 +1472,16 @@ impl Diff { #[cfg(test)] mod tests { use super::*; - use fs::FakeFs; use futures::{ Stream, stream::{self}, }; use gpui::TestAppContext; use indoc::indoc; - use language::{ - Buffer, Language, LanguageConfig, LanguageMatcher, Point, language_settings, - tree_sitter_rust, - }; + use language::{Buffer, Point}; + use language_model::fake_provider::FakeLanguageModel; use language_model::{LanguageModelRegistry, TokenUsage}; + use languages::rust_lang; use rand::prelude::*; use settings::SettingsStore; use std::{future, sync::Arc}; @@ -1106,25 +1498,20 @@ mod tests { } } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(4, 5)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), true, - None, - project.downgrade(), - None, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1173,25 +1560,20 @@ mod tests { le } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); snapshot.anchor_before(Point::new(1, 6))..snapshot.anchor_after(Point::new(1, 6)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), true, - None, - project.downgrade(), - None, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1242,25 +1624,20 @@ mod tests { " \n", "}\n" // ); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); snapshot.anchor_before(Point::new(1, 2))..snapshot.anchor_after(Point::new(1, 2)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), true, - None, - project.downgrade(), - None, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1318,18 +1695,13 @@ mod tests { snapshot.anchor_before(Point::new(0, 0))..snapshot.anchor_after(Point::new(4, 2)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), true, - None, - project.downgrade(), - None, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1368,25 +1740,20 @@ mod tests { let x = 0; } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(1, 14)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), false, - None, - project.downgrade(), - None, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1468,8 +1835,6 @@ mod tests { fn init_test(cx: &mut TestAppContext) { cx.update(LanguageModelRegistry::test); cx.set_global(cx.update(SettingsStore::test)); - cx.update(Project::init_settings); - cx.update(language_settings::init); } fn simulate_response_stream( @@ -1477,11 +1842,10 @@ mod tests { cx: &mut TestAppContext, ) -> mpsc::UnboundedSender { let (chunks_tx, chunks_rx) = mpsc::unbounded(); + let model = Arc::new(FakeLanguageModel::default()); codegen.update(cx, |codegen, cx| { - codegen.handle_stream( - String::new(), - String::new(), - None, + codegen.generation = codegen.handle_stream( + model, future::ready(Ok(LanguageModelTextStream { message_id: None, stream: chunks_rx.map(Ok).boxed(), @@ -1492,27 +1856,4 @@ mod tests { }); chunks_tx } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_indents_query( - r#" - (call_expression) @indent - (field_expression) @indent - (_ "(" ")" @end) @indent - (_ "{" "}" @end) @indent - "#, - ) - .unwrap() - } } diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs similarity index 54% rename from crates/agent_ui/src/acp/completion_provider.rs rename to crates/agent_ui/src/completion_provider.rs index 8cbae5a542..a2b6e0510e 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -1,40 +1,133 @@ -use std::cell::RefCell; +use std::cmp::Reverse; use std::ops::Range; use std::path::PathBuf; -use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; use acp_thread::MentionUri; -use agent_client_protocol as acp; -use agent2::{HistoryEntry, HistoryStore}; +use agent::{HistoryEntry, HistoryStore}; use anyhow::Result; -use editor::{CompletionProvider, Editor, ExcerptId}; -use fuzzy::{StringMatch, StringMatchCandidate}; +use editor::{ + CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH, +}; +use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; -use language::{Buffer, CodeLabel, HighlightId}; +use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; +use ordered_float::OrderedFloat; use project::lsp_store::{CompletionDocumentation, SymbolLocation}; use project::{ - Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, Project, - ProjectPath, Symbol, WorktreeId, + Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, + PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId, }; -use prompt_store::PromptStore; +use prompt_store::{PromptId, PromptStore, UserPromptId}; use rope::Point; use text::{Anchor, ToPoint as _}; use ui::prelude::*; +use util::ResultExt as _; +use util::paths::PathStyle; use util::rel_path::RelPath; +use util::truncate_and_remove_front; use workspace::Workspace; use crate::AgentPanel; -use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; -use crate::context_picker::file_context_picker::{FileMatch, search_files}; -use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules}; -use crate::context_picker::symbol_context_picker::SymbolMatch; -use crate::context_picker::symbol_context_picker::search_symbols; -use crate::context_picker::{ - ContextPickerAction, ContextPickerEntry, ContextPickerMode, selection_ranges, -}; +use crate::mention_set::MentionSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptContextEntry { + Mode(PromptContextType), + Action(PromptContextAction), +} + +impl PromptContextEntry { + pub fn keyword(&self) -> &'static str { + match self { + Self::Mode(mode) => mode.keyword(), + Self::Action(action) => action.keyword(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptContextType { + File, + Symbol, + Fetch, + Thread, + Rules, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptContextAction { + AddSelections, +} + +impl PromptContextAction { + pub fn keyword(&self) -> &'static str { + match self { + Self::AddSelections => "selection", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::AddSelections => "Selection", + } + } + + pub fn icon(&self) -> IconName { + match self { + Self::AddSelections => IconName::Reader, + } + } +} + +impl TryFrom<&str> for PromptContextType { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "file" => Ok(Self::File), + "symbol" => Ok(Self::Symbol), + "fetch" => Ok(Self::Fetch), + "thread" => Ok(Self::Thread), + "rule" => Ok(Self::Rules), + _ => Err(format!("Invalid context picker mode: {}", value)), + } + } +} + +impl PromptContextType { + pub fn keyword(&self) -> &'static str { + match self { + Self::File => "file", + Self::Symbol => "symbol", + Self::Fetch => "fetch", + Self::Thread => "thread", + Self::Rules => "rule", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::File => "Files & Directories", + Self::Symbol => "Symbols", + Self::Fetch => "Fetch", + Self::Thread => "Threads", + Self::Rules => "Rules", + } + } + + pub fn icon(&self) -> IconName { + match self { + Self::File => IconName::File, + Self::Symbol => IconName::Code, + Self::Fetch => IconName::ToolWeb, + Self::Thread => IconName::Thread, + Self::Rules => IconName::Reader, + } + } +} pub(crate) enum Match { File(FileMatch), @@ -46,11 +139,6 @@ pub(crate) enum Match { Entry(EntryMatch), } -pub struct EntryMatch { - mat: Option, - entry: ContextPickerEntry, -} - impl Match { pub fn score(&self) -> f64 { match self { @@ -65,58 +153,95 @@ impl Match { } } -pub struct ContextPickerCompletionProvider { - message_editor: WeakEntity, - workspace: WeakEntity, - history_store: Entity, - prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, +pub struct EntryMatch { + mat: Option, + entry: PromptContextEntry, } -impl ContextPickerCompletionProvider { +#[derive(Debug, Clone)] +pub struct RulesContextEntry { + pub prompt_id: UserPromptId, + pub title: SharedString, +} + +#[derive(Debug, Clone)] +pub struct AvailableCommand { + pub name: Arc, + pub description: Arc, + pub requires_argument: bool, +} + +pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { + fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool { + self.supported_modes(cx).contains(&mode) + } + fn supported_modes(&self, cx: &App) -> Vec; + fn supports_images(&self, cx: &App) -> bool; + + fn available_commands(&self, cx: &App) -> Vec; + fn confirm_command(&self, cx: &mut App); +} + +pub struct PromptCompletionProvider { + source: Arc, + editor: WeakEntity, + mention_set: Entity, + history_store: Entity, + prompt_store: Option>, + workspace: WeakEntity, +} + +impl PromptCompletionProvider { pub fn new( - message_editor: WeakEntity, - workspace: WeakEntity, + source: T, + editor: WeakEntity, + mention_set: Entity, history_store: Entity, prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, + workspace: WeakEntity, ) -> Self { Self { - message_editor, + source: Arc::new(source), + editor, + mention_set, workspace, history_store, prompt_store, - prompt_capabilities, - available_commands, } } fn completion_for_entry( - entry: ContextPickerEntry, + entry: PromptContextEntry, source_range: Range, - message_editor: WeakEntity, + editor: WeakEntity, + mention_set: WeakEntity, workspace: &Entity, cx: &mut App, ) -> Option { match entry { - ContextPickerEntry::Mode(mode) => Some(Completion { + PromptContextEntry::Mode(mode) => Some(Completion { replace_range: source_range, new_text: format!("@{} ", mode.keyword()), label: CodeLabel::plain(mode.label().to_string(), None), icon_path: Some(mode.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is // inserted confirm: Some(Arc::new(|_, _, _| true)), }), - ContextPickerEntry::Action(action) => { - Self::completion_for_action(action, source_range, message_editor, workspace, cx) - } + PromptContextEntry::Action(action) => Self::completion_for_action( + action, + source_range, + editor, + mention_set, + workspace, + cx, + ), } } @@ -124,7 +249,10 @@ impl ContextPickerCompletionProvider { thread_entry: HistoryEntry, source_range: Range, recent: bool, - editor: WeakEntity, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, + workspace: Entity, cx: &mut App, ) -> Completion { let uri = thread_entry.mention_uri(); @@ -145,13 +273,18 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, icon_path: Some(icon_for_completion), confirm: Some(confirm_completion_callback( thread_entry.title().clone(), source_range.start, new_text_len - 1, - editor, uri, + source, + editor, + mention_set, + workspace, )), } } @@ -159,7 +292,10 @@ impl ContextPickerCompletionProvider { fn completion_for_rules( rule: RulesContextEntry, source_range: Range, - editor: WeakEntity, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, + workspace: Entity, cx: &mut App, ) -> Completion { let uri = MentionUri::Rule { @@ -176,13 +312,18 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, icon_path: Some(icon_path), confirm: Some(confirm_completion_callback( rule.title, source_range.start, new_text_len - 1, - editor, uri, + source, + editor, + mention_set, + workspace, )), } } @@ -193,20 +334,25 @@ impl ContextPickerCompletionProvider { is_recent: bool, is_directory: bool, source_range: Range, - message_editor: WeakEntity, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, + workspace: Entity, project: Entity, + label_max_chars: usize, cx: &mut App, ) -> Option { let path_style = project.read(cx).path_style(cx); let (file_name, directory) = - crate::context_picker::file_context_picker::extract_file_name_and_directory( - &project_path.path, - path_prefix, - path_style, - ); + extract_file_name_and_directory(&project_path.path, path_prefix, path_style); - let label = - build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx); + let label = build_code_label_for_path( + &file_name, + directory.as_ref().map(|s| s.as_ref()), + None, + label_max_chars, + cx, + ); let abs_path = project.read(cx).absolute_path(&project_path, cx)?; @@ -232,13 +378,18 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( file_name, source_range.start, new_text_len - 1, - message_editor, uri, + source, + editor, + mention_set, + workspace, )), }) } @@ -246,23 +397,37 @@ impl ContextPickerCompletionProvider { fn completion_for_symbol( symbol: Symbol, source_range: Range, - message_editor: WeakEntity, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, workspace: Entity, + label_max_chars: usize, cx: &mut App, ) -> Option { let project = workspace.read(cx).project().clone(); - let label = CodeLabel::plain(symbol.name.clone(), None); - - let abs_path = match &symbol.path { - SymbolLocation::InProject(project_path) => { - project.read(cx).absolute_path(&project_path, cx)? - } + let (abs_path, file_name) = match &symbol.path { + SymbolLocation::InProject(project_path) => ( + project.read(cx).absolute_path(&project_path, cx)?, + project_path.path.file_name()?.to_string().into(), + ), SymbolLocation::OutsideProject { abs_path, signature: _, - } => PathBuf::from(abs_path.as_ref()), + } => ( + PathBuf::from(abs_path.as_ref()), + abs_path.file_name().map(|f| f.to_string_lossy())?, + ), }; + + let label = build_code_label_for_path( + &symbol.name, + Some(&file_name), + Some(symbol.range.start.0.row + 1), + label_max_chars, + cx, + ); + let uri = MentionUri::Symbol { abs_path, name: symbol.name.clone(), @@ -278,13 +443,18 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( symbol.name.into(), source_range.start, new_text_len - 1, - message_editor, uri, + source, + editor, + mention_set, + workspace, )), }) } @@ -292,7 +462,10 @@ impl ContextPickerCompletionProvider { fn completion_for_fetch( source_range: Range, url_to_fetch: SharedString, - message_editor: WeakEntity, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, + workspace: Entity, cx: &mut App, ) -> Option { let new_text = format!("@fetch {} ", url_to_fetch); @@ -310,26 +483,32 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( url_to_fetch.to_string().into(), source_range.start, new_text.len() - 1, - message_editor, mention_uri, + source, + editor, + mention_set, + workspace, )), }) } pub(crate) fn completion_for_action( - action: ContextPickerAction, + action: PromptContextAction, source_range: Range, - message_editor: WeakEntity, + editor: WeakEntity, + mention_set: WeakEntity, workspace: &Entity, cx: &mut App, ) -> Option { let (new_text, on_action) = match action { - ContextPickerAction::AddSelections => { + PromptContextAction::AddSelections => { const PLACEHOLDER: &str = "selection "; let selections = selection_ranges(workspace, cx) .into_iter() @@ -348,20 +527,24 @@ impl ContextPickerCompletionProvider { let callback = Arc::new({ let source_range = source_range.clone(); move |_, window: &mut Window, cx: &mut App| { + let editor = editor.clone(); let selections = selections.clone(); - let message_editor = message_editor.clone(); + let mention_set = mention_set.clone(); let source_range = source_range.clone(); window.defer(cx, move |window, cx| { - message_editor - .update(cx, |message_editor, cx| { - message_editor.confirm_mention_for_selection( - source_range, - selections, - window, - cx, - ) - }) - .ok(); + if let Some(editor) = editor.upgrade() { + mention_set + .update(cx, |store, cx| { + store.confirm_mention_for_selection( + source_range, + selections, + editor, + window, + cx, + ) + }) + .ok(); + } }); false } @@ -378,6 +561,8 @@ impl ContextPickerCompletionProvider { icon_path: Some(action.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -386,12 +571,8 @@ impl ContextPickerCompletionProvider { }) } - fn search_slash_commands( - &self, - query: String, - cx: &mut App, - ) -> Task> { - let commands = self.available_commands.borrow().clone(); + fn search_slash_commands(&self, query: String, cx: &mut App) -> Task> { + let commands = self.source.available_commands(cx); if commands.is_empty() { return Task::ready(Vec::new()); } @@ -423,7 +604,7 @@ impl ContextPickerCompletionProvider { fn search_mentions( &self, - mode: Option, + mode: Option, query: String, cancellation_flag: Arc, cx: &mut App, @@ -432,7 +613,7 @@ impl ContextPickerCompletionProvider { return Task::ready(Vec::default()); }; match mode { - Some(ContextPickerMode::File) => { + Some(PromptContextType::File) => { let search_files_task = search_files(query, cancellation_flag, &workspace, cx); cx.background_spawn(async move { search_files_task @@ -443,7 +624,7 @@ impl ContextPickerCompletionProvider { }) } - Some(ContextPickerMode::Symbol) => { + Some(PromptContextType::Symbol) => { let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx); cx.background_spawn(async move { search_symbols_task @@ -454,7 +635,7 @@ impl ContextPickerCompletionProvider { }) } - Some(ContextPickerMode::Thread) => { + Some(PromptContextType::Thread) => { let search_threads_task = search_threads(query, cancellation_flag, &self.history_store, cx); cx.background_spawn(async move { @@ -466,7 +647,7 @@ impl ContextPickerCompletionProvider { }) } - Some(ContextPickerMode::Fetch) => { + Some(PromptContextType::Fetch) => { if !query.is_empty() { Task::ready(vec![Match::Fetch(query.into())]) } else { @@ -474,7 +655,7 @@ impl ContextPickerCompletionProvider { } } - Some(ContextPickerMode::Rules) => { + Some(PromptContextType::Rules) => { if let Some(prompt_store) = self.prompt_store.as_ref() { let search_rules_task = search_rules(query, cancellation_flag, prompt_store, cx); @@ -564,11 +745,11 @@ impl ContextPickerCompletionProvider { let mut recent = Vec::with_capacity(6); let mut mentions = self - .message_editor - .read_with(cx, |message_editor, _cx| message_editor.mentions()) - .unwrap_or_default(); + .mention_set + .read_with(cx, |store, _cx| store.mentions()); let workspace = workspace.read(cx); let project = workspace.project().read(cx); + let include_root_name = workspace.visible_worktrees(cx).count() > 1; if let Some(agent_panel) = workspace.panel::(cx) && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx) @@ -595,7 +776,11 @@ impl ContextPickerCompletionProvider { project .worktree_for_id(project_path.worktree_id, cx) .map(|worktree| { - let path_prefix = worktree.read(cx).root_name().into(); + let path_prefix = if include_root_name { + worktree.read(cx).root_name().into() + } else { + RelPath::empty().into() + }; Match::File(FileMatch { mat: fuzzy::PathMatch { score: 1., @@ -612,7 +797,7 @@ impl ContextPickerCompletionProvider { }), ); - if self.prompt_capabilities.borrow().embedded_context { + if self.source.supports_context(PromptContextType::Thread, cx) { const RECENT_COUNT: usize = 2; let threads = self .history_store @@ -633,81 +818,61 @@ impl ContextPickerCompletionProvider { &self, workspace: &Entity, cx: &mut App, - ) -> Vec { - let embedded_context = self.prompt_capabilities.borrow().embedded_context; - let mut entries = if embedded_context { - vec![ - ContextPickerEntry::Mode(ContextPickerMode::File), - ContextPickerEntry::Mode(ContextPickerMode::Symbol), - ContextPickerEntry::Mode(ContextPickerMode::Thread), - ] - } else { - // File is always available, but we don't need a mode entry - vec![] - }; + ) -> Vec { + let mut entries = vec![ + PromptContextEntry::Mode(PromptContextType::File), + PromptContextEntry::Mode(PromptContextType::Symbol), + ]; + + if self.source.supports_context(PromptContextType::Thread, cx) { + entries.push(PromptContextEntry::Mode(PromptContextType::Thread)); + } let has_selection = workspace .read(cx) .active_item(cx) .and_then(|item| item.downcast::()) .is_some_and(|editor| { - editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx)) + editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }) }); if has_selection { - entries.push(ContextPickerEntry::Action( - ContextPickerAction::AddSelections, + entries.push(PromptContextEntry::Action( + PromptContextAction::AddSelections, )); } - if embedded_context { - if self.prompt_store.is_some() { - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules)); - } + if self.prompt_store.is_some() && self.source.supports_context(PromptContextType::Rules, cx) + { + entries.push(PromptContextEntry::Mode(PromptContextType::Rules)); + } - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch)); + if self.source.supports_context(PromptContextType::Fetch, cx) { + entries.push(PromptContextEntry::Mode(PromptContextType::Fetch)); } entries } } -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 { +impl CompletionProvider for PromptCompletionProvider { fn completions( &self, _excerpt_id: ExcerptId, buffer: &Entity, buffer_position: Anchor, _trigger: CompletionContext, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Task>> { - let state = buffer.update(cx, |buffer, _cx| { + 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()?; - ContextCompletion::try_parse( - line, - offset_to_line, - self.prompt_capabilities.borrow().embedded_context, - ) + PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx)) }); let Some(state) = state else { return Task::ready(Ok(Vec::new())); @@ -722,10 +887,11 @@ impl CompletionProvider for ContextPickerCompletionProvider { let source_range = snapshot.anchor_before(state.source_range().start) ..snapshot.anchor_after(state.source_range().end); - let editor = self.message_editor.clone(); - + let source = self.source.clone(); + let editor = self.editor.clone(); + let mention_set = self.mention_set.downgrade(); match state { - ContextCompletion::SlashCommand(SlashCommandCompletion { + PromptCompletion::SlashCommand(SlashCommandCompletion { command, argument, .. }) => { let search_task = self.search_slash_commands(command.unwrap_or_default(), cx); @@ -740,7 +906,8 @@ impl CompletionProvider for ContextPickerCompletionProvider { format!("/{} ", command.name) }; - let is_missing_argument = argument.is_none() && command.input.is_some(); + let is_missing_argument = + command.requires_argument && argument.is_none(); Completion { replace_range: source_range.clone(), new_text, @@ -750,32 +917,26 @@ impl CompletionProvider for ContextPickerCompletionProvider { )), source: project::CompletionSource::Custom, icon_path: None, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(Arc::new({ - let editor = editor.clone(); + let source = source.clone(); move |intent, _window, cx| { if !is_missing_argument { cx.defer({ - let editor = editor.clone(); - move |cx| { - editor - .update(cx, |_editor, cx| { - match intent { - CompletionIntent::Complete - | CompletionIntent::CompleteWithInsert - | CompletionIntent::CompleteWithReplace => { - if !is_missing_argument { - cx.emit(MessageEditorEvent::Send); - } - } - CompletionIntent::Compose => {} - } - }) - .ok(); + let source = source.clone(); + move |cx| match intent { + CompletionIntent::Complete + | CompletionIntent::CompleteWithInsert + | CompletionIntent::CompleteWithReplace => { + source.confirm_command(cx); + } + CompletionIntent::Compose => {} } }); } - is_missing_argument + false } })), } @@ -793,11 +954,36 @@ impl CompletionProvider for ContextPickerCompletionProvider { }]) }) } - ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => { + PromptCompletion::Mention(MentionCompletion { mode, argument, .. }) => { let query = argument.unwrap_or_default(); let search_task = self.search_mentions(mode, query, Arc::::default(), cx); + // Calculate maximum characters available for the full label (file_name + space + directory) + // based on maximum menu width after accounting for padding, spacing, and icon width + let label_max_chars = { + // Base06 left padding + Base06 gap + Base06 right padding + icon width + let used_pixels = DynamicSpacing::Base06.px(cx) * 3.0 + + IconSize::XSmall.rems() * window.rem_size(); + + let style = window.text_style(); + let font_id = window.text_system().resolve_font(&style.font()); + let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size()); + + // Fallback em_width of 10px matches file_finder.rs fallback for TextSize::Small + let em_width = cx + .text_system() + .em_width(font_id, font_size) + .unwrap_or(px(10.0)); + + // Calculate available pixels for text (file_name + directory) + // Using max width since dynamic_width allows the menu to expand up to this + let available_pixels = COMPLETION_MENU_MAX_WIDTH - used_pixels; + + // Convert to character count (total available for file_name + directory) + (f32::from(available_pixels) / f32::from(em_width)) as usize + }; + cx.spawn(async move |_, cx| { let matches = search_task.await; @@ -811,14 +997,30 @@ impl CompletionProvider for ContextPickerCompletionProvider { path: mat.path.clone(), }; + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + let path_prefix = if mat.path.is_empty() { + project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| mat.path_prefix.clone()) + } else { + mat.path_prefix.clone() + }; + Self::completion_for_path( project_path, - &mat.path_prefix, + &path_prefix, is_recent, mat.is_dir, source_range.clone(), + source.clone(), editor.clone(), + mention_set.clone(), + workspace.clone(), project.clone(), + label_max_chars, cx, ) } @@ -827,8 +1029,11 @@ impl CompletionProvider for ContextPickerCompletionProvider { Self::completion_for_symbol( symbol, source_range.clone(), + source.clone(), editor.clone(), + mention_set.clone(), workspace.clone(), + label_max_chars, cx, ) } @@ -837,7 +1042,10 @@ impl CompletionProvider for ContextPickerCompletionProvider { thread, source_range.clone(), false, + source.clone(), editor.clone(), + mention_set.clone(), + workspace.clone(), cx, )), @@ -845,21 +1053,30 @@ impl CompletionProvider for ContextPickerCompletionProvider { thread, source_range.clone(), true, + source.clone(), editor.clone(), + mention_set.clone(), + workspace.clone(), cx, )), Match::Rules(user_rules) => Some(Self::completion_for_rules( user_rules, source_range.clone(), + source.clone(), editor.clone(), + mention_set.clone(), + workspace.clone(), cx, )), Match::Fetch(url) => Self::completion_for_fetch( source_range.clone(), url, + source.clone(), editor.clone(), + mention_set.clone(), + workspace.clone(), cx, ), @@ -868,6 +1085,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { entry, source_range.clone(), editor.clone(), + mention_set.clone(), &workspace, cx, ) @@ -896,7 +1114,6 @@ impl CompletionProvider for ContextPickerCompletionProvider { position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); @@ -905,16 +1122,24 @@ impl CompletionProvider for ContextPickerCompletionProvider { let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); if let Some(line) = lines.next() { - ContextCompletion::try_parse( - line, - offset_to_line, - self.prompt_capabilities.borrow().embedded_context, - ) - .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) + PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx)) + .filter(|completion| { + // Right now we don't support completing arguments of slash commands + let is_slash_command_with_argument = matches!( + completion, + PromptCompletion::SlashCommand(SlashCommandCompletion { + argument: Some(_), + .. + }) + ); + !is_slash_command_with_argument + }) + .map(|completion| { + completion.source_range().start <= offset_to_line + position.column as usize + && completion.source_range().end + >= offset_to_line + position.column as usize + }) + .unwrap_or(false) } else { false } @@ -929,80 +1154,56 @@ impl CompletionProvider for ContextPickerCompletionProvider { } } -pub(crate) fn search_threads( - query: String, - cancellation_flag: Arc, - history_store: &Entity, - cx: &mut App, -) -> Task> { - let threads = history_store.read(cx).entries().collect(); - if query.is_empty() { - return Task::ready(threads); - } - - let executor = cx.background_executor().clone(); - cx.background_spawn(async move { - let candidates = threads - .iter() - .enumerate() - .map(|(id, thread)| StringMatchCandidate::new(id, thread.title())) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - 100, - &cancellation_flag, - executor, - ) - .await; - - matches - .into_iter() - .map(|mat| threads[mat.candidate_id].clone()) - .collect() - }) -} - -fn confirm_completion_callback( +fn confirm_completion_callback( crease_text: SharedString, start: Anchor, content_len: usize, - message_editor: WeakEntity, mention_uri: MentionUri, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, + workspace: Entity, ) -> Arc bool + Send + Sync> { Arc::new(move |_, window, cx| { - let message_editor = message_editor.clone(); + let source = source.clone(); + let editor = editor.clone(); + let mention_set = mention_set.clone(); let crease_text = crease_text.clone(); let mention_uri = mention_uri.clone(); + let workspace = workspace.clone(); window.defer(cx, move |window, cx| { - message_editor - .clone() - .update(cx, |message_editor, cx| { - message_editor - .confirm_mention_completion( - crease_text, - start, - content_len, - mention_uri, - window, - cx, - ) - .detach(); - }) - .ok(); + if let Some(editor) = editor.upgrade() { + mention_set + .clone() + .update(cx, |mention_set, cx| { + mention_set + .confirm_mention_completion( + crease_text, + start, + content_len, + mention_uri, + source.supports_images(cx), + editor, + &workspace, + window, + cx, + ) + .detach(); + }) + .ok(); + } }); false }) } -enum ContextCompletion { +#[derive(Debug, PartialEq)] +enum PromptCompletion { SlashCommand(SlashCommandCompletion), Mention(MentionCompletion), } -impl ContextCompletion { +impl PromptCompletion { fn source_range(&self) -> Range { match self { Self::SlashCommand(completion) => completion.source_range.clone(), @@ -1010,16 +1211,19 @@ impl ContextCompletion { } } - fn try_parse(line: &str, offset_to_line: usize, allow_non_file_mentions: bool) -> Option { - if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) { - Some(Self::SlashCommand(command)) - } else if let Some(mention) = - MentionCompletion::try_parse(allow_non_file_mentions, line, offset_to_line) - { - Some(Self::Mention(mention)) - } else { - None + fn try_parse( + line: &str, + offset_to_line: usize, + supported_modes: &[PromptContextType], + ) -> Option { + if line.contains('@') { + if let Some(mention) = + MentionCompletion::try_parse(line, offset_to_line, supported_modes) + { + return Some(Self::Mention(mention)); + } } + SlashCommandCompletion::try_parse(line, offset_to_line).map(Self::SlashCommand) } } @@ -1071,12 +1275,16 @@ impl SlashCommandCompletion { #[derive(Debug, Default, PartialEq)] struct MentionCompletion { source_range: Range, - mode: Option, + mode: Option, argument: Option, } impl MentionCompletion { - fn try_parse(allow_non_file_mentions: bool, line: &str, offset_to_line: usize) -> Option { + fn try_parse( + line: &str, + offset_to_line: usize, + supported_modes: &[PromptContextType], + ) -> Option { let last_mention_start = line.rfind('@')?; // No whitespace immediately after '@' @@ -1110,8 +1318,8 @@ impl MentionCompletion { // Safe since we check no leading whitespace above end += mode_text.len(); - if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() - && (allow_non_file_mentions || matches!(parsed_mode, ContextPickerMode::File)) + if let Some(parsed_mode) = PromptContextType::try_from(mode_text).ok() + && supported_modes.contains(&parsed_mode) { mode = Some(parsed_mode); } else { @@ -1145,10 +1353,382 @@ impl MentionCompletion { } } +pub(crate) fn search_files( + query: String, + cancellation_flag: Arc, + workspace: &Entity, + cx: &App, +) -> Task> { + if query.is_empty() { + let workspace = workspace.read(cx); + let project = workspace.project().read(cx); + let visible_worktrees = workspace.visible_worktrees(cx).collect::>(); + let include_root_name = visible_worktrees.len() > 1; + + let recent_matches = workspace + .recent_navigation_history(Some(10), cx) + .into_iter() + .map(|(project_path, _)| { + let path_prefix = if include_root_name { + project + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| RelPath::empty().into()) + } else { + RelPath::empty().into() + }; + + FileMatch { + mat: PathMatch { + score: 0., + positions: Vec::new(), + worktree_id: project_path.worktree_id.to_usize(), + path: project_path.path, + path_prefix, + distance_to_relative_ancestor: 0, + is_dir: false, + }, + is_recent: true, + } + }); + + let file_matches = visible_worktrees.into_iter().flat_map(|worktree| { + let worktree = worktree.read(cx); + let path_prefix: Arc = if include_root_name { + worktree.root_name().into() + } else { + RelPath::empty().into() + }; + worktree.entries(false, 0).map(move |entry| FileMatch { + mat: PathMatch { + score: 0., + positions: Vec::new(), + worktree_id: worktree.id().to_usize(), + path: entry.path.clone(), + path_prefix: path_prefix.clone(), + distance_to_relative_ancestor: 0, + is_dir: entry.is_dir(), + }, + is_recent: false, + }) + }); + + Task::ready(recent_matches.chain(file_matches).collect()) + } else { + let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); + let include_root_name = worktrees.len() > 1; + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + + PathMatchCandidateSet { + snapshot: worktree.snapshot(), + include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored), + include_root_name, + candidates: project::Candidates::Entries, + } + }) + .collect::>(); + + let executor = cx.background_executor().clone(); + cx.foreground_executor().spawn(async move { + fuzzy::match_path_sets( + candidate_sets.as_slice(), + query.as_str(), + &None, + false, + 100, + &cancellation_flag, + executor, + ) + .await + .into_iter() + .map(|mat| FileMatch { + mat, + is_recent: false, + }) + .collect::>() + }) + } +} + +pub(crate) fn search_symbols( + query: String, + cancellation_flag: Arc, + workspace: &Entity, + cx: &mut App, +) -> Task> { + let symbols_task = workspace.update(cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, cx| project.symbols(&query, cx)) + }); + let project = workspace.read(cx).project().clone(); + cx.spawn(async move |cx| { + let Some(symbols) = symbols_task.await.log_err() else { + return Vec::new(); + }; + let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> = + project + .update(cx, |project, cx| { + symbols + .iter() + .enumerate() + .map(|(id, symbol)| { + StringMatchCandidate::new(id, symbol.label.filter_text()) + }) + .partition(|candidate| match &symbols[candidate.id].path { + SymbolLocation::InProject(project_path) => project + .entry_for_path(project_path, cx) + .is_some_and(|e| !e.is_ignored), + SymbolLocation::OutsideProject { .. } => false, + }) + }) + .log_err() + else { + return Vec::new(); + }; + + const MAX_MATCHES: usize = 100; + let mut visible_matches = cx.background_executor().block(fuzzy::match_strings( + &visible_match_candidates, + &query, + false, + true, + MAX_MATCHES, + &cancellation_flag, + cx.background_executor().clone(), + )); + let mut external_matches = cx.background_executor().block(fuzzy::match_strings( + &external_match_candidates, + &query, + false, + true, + MAX_MATCHES - visible_matches.len().min(MAX_MATCHES), + &cancellation_flag, + cx.background_executor().clone(), + )); + let sort_key_for_match = |mat: &StringMatch| { + let symbol = &symbols[mat.candidate_id]; + (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text()) + }; + + visible_matches.sort_unstable_by_key(sort_key_for_match); + external_matches.sort_unstable_by_key(sort_key_for_match); + let mut matches = visible_matches; + matches.append(&mut external_matches); + + matches + .into_iter() + .map(|mut mat| { + let symbol = symbols[mat.candidate_id].clone(); + let filter_start = symbol.label.filter_range.start; + for position in &mut mat.positions { + *position += filter_start; + } + SymbolMatch { symbol } + }) + .collect() + }) +} + +pub(crate) fn search_threads( + query: String, + cancellation_flag: Arc, + thread_store: &Entity, + cx: &mut App, +) -> Task> { + let threads = thread_store.read(cx).entries().collect(); + if query.is_empty() { + return Task::ready(threads); + } + + let executor = cx.background_executor().clone(); + cx.background_spawn(async move { + let candidates = threads + .iter() + .enumerate() + .map(|(id, thread)| StringMatchCandidate::new(id, thread.title())) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + 100, + &cancellation_flag, + executor, + ) + .await; + + matches + .into_iter() + .map(|mat| threads[mat.candidate_id].clone()) + .collect() + }) +} + +pub(crate) fn search_rules( + query: String, + cancellation_flag: Arc, + prompt_store: &Entity, + cx: &mut App, +) -> Task> { + let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx); + cx.background_spawn(async move { + search_task + .await + .into_iter() + .flat_map(|metadata| { + // Default prompts are filtered out as they are automatically included. + if metadata.default { + None + } else { + match metadata.id { + PromptId::EditWorkflow => None, + PromptId::User { uuid } => Some(RulesContextEntry { + prompt_id: uuid, + title: metadata.title?, + }), + } + } + }) + .collect::>() + }) +} + +pub struct SymbolMatch { + pub symbol: Symbol, +} + +pub struct FileMatch { + pub mat: PathMatch, + pub is_recent: bool, +} + +pub fn extract_file_name_and_directory( + path: &RelPath, + path_prefix: &RelPath, + path_style: PathStyle, +) -> (SharedString, Option) { + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + if path.is_empty() && !path_prefix.is_empty() { + return (path_prefix.display(path_style).to_string().into(), None); + } + + let full_path = path_prefix.join(path); + let file_name = full_path.file_name().unwrap_or_default(); + let display_path = full_path.display(path_style); + let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len()); + ( + file_name.to_string().into(), + Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()), + ) +} + +fn build_code_label_for_path( + file: &str, + directory: Option<&str>, + line_number: Option, + label_max_chars: usize, + cx: &App, +) -> CodeLabel { + let variable_highlight_id = cx + .theme() + .syntax() + .highlight_id("variable") + .map(HighlightId); + let mut label = CodeLabelBuilder::default(); + + label.push_str(file, None); + label.push_str(" ", None); + + if let Some(directory) = directory { + let file_name_chars = file.chars().count(); + // Account for: file_name + space (ellipsis is handled by truncate_and_remove_front) + let directory_max_chars = label_max_chars + .saturating_sub(file_name_chars) + .saturating_sub(1); + let truncated_directory = truncate_and_remove_front(directory, directory_max_chars.max(5)); + label.push_str(&truncated_directory, variable_highlight_id); + } + if let Some(line_number) = line_number { + label.push_str(&format!(" L{}", line_number), variable_highlight_id); + } + label.build() +} + +fn selection_ranges( + workspace: &Entity, + cx: &mut App, +) -> Vec<(Entity, Range)> { + let Some(editor) = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + else { + return Vec::new(); + }; + + editor.update(cx, |editor, cx| { + let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); + + let buffer = editor.buffer().clone().read(cx); + let snapshot = buffer.snapshot(cx); + + selections + .into_iter() + .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end)) + .flat_map(|range| { + let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?; + let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?; + if start_buffer != end_buffer { + return None; + } + Some((start_buffer, start..end)) + }) + .collect::>() + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_prompt_completion_parse() { + let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol]; + + assert_eq!( + PromptCompletion::try_parse("/", 0, &supported_modes), + Some(PromptCompletion::SlashCommand(SlashCommandCompletion { + source_range: 0..1, + command: None, + argument: None, + })) + ); + + assert_eq!( + PromptCompletion::try_parse("@", 0, &supported_modes), + Some(PromptCompletion::Mention(MentionCompletion { + source_range: 0..1, + mode: None, + argument: None, + })) + ); + + assert_eq!( + PromptCompletion::try_parse("/test @file", 0, &supported_modes), + Some(PromptCompletion::Mention(MentionCompletion { + source_range: 6..11, + mode: Some(PromptContextType::File), + argument: None, + })) + ); + } + #[test] fn test_slash_command_completion_parse() { assert_eq!( @@ -1218,10 +1798,15 @@ mod tests { #[test] fn test_mention_completion_parse() { - assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None); + let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol]; assert_eq!( - MentionCompletion::try_parse(true, "Lorem @", 0), + MentionCompletion::try_parse("Lorem Ipsum", 0, &supported_modes), + None + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @", 0, &supported_modes), Some(MentionCompletion { source_range: 6..7, mode: None, @@ -1230,52 +1815,52 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file", 0), + MentionCompletion::try_parse("Lorem @file", 0, &supported_modes), Some(MentionCompletion { source_range: 6..11, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: None, }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file ", 0), + MentionCompletion::try_parse("Lorem @file ", 0, &supported_modes), Some(MentionCompletion { source_range: 6..12, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: None, }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file main.rs", 0), + MentionCompletion::try_parse("Lorem @file main.rs", 0, &supported_modes), Some(MentionCompletion { source_range: 6..19, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file main.rs ", 0), + MentionCompletion::try_parse("Lorem @file main.rs ", 0, &supported_modes), Some(MentionCompletion { source_range: 6..19, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file main.rs Ipsum", 0), + MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0, &supported_modes), Some(MentionCompletion { source_range: 6..19, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @main", 0), + MentionCompletion::try_parse("Lorem @main", 0, &supported_modes), Some(MentionCompletion { source_range: 6..11, mode: None, @@ -1284,7 +1869,7 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @main ", 0), + MentionCompletion::try_parse("Lorem @main ", 0, &supported_modes), Some(MentionCompletion { source_range: 6..12, mode: None, @@ -1292,41 +1877,47 @@ mod tests { }) ); - assert_eq!(MentionCompletion::try_parse(true, "Lorem @main m", 0), None); + assert_eq!( + MentionCompletion::try_parse("Lorem @main m", 0, &supported_modes), + None + ); - assert_eq!(MentionCompletion::try_parse(true, "test@", 0), None); + assert_eq!( + MentionCompletion::try_parse("test@", 0, &supported_modes), + None + ); // Allowed non-file mentions assert_eq!( - MentionCompletion::try_parse(true, "Lorem @symbol main", 0), + MentionCompletion::try_parse("Lorem @symbol main", 0, &supported_modes), Some(MentionCompletion { source_range: 6..18, - mode: Some(ContextPickerMode::Symbol), + mode: Some(PromptContextType::Symbol), argument: Some("main".to_string()), }) ); // Disallowed non-file mentions assert_eq!( - MentionCompletion::try_parse(false, "Lorem @symbol main", 0), + MentionCompletion::try_parse("Lorem @symbol main", 0, &[PromptContextType::File]), None ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem@symbol", 0), + MentionCompletion::try_parse("Lorem@symbol", 0, &supported_modes), None, "Should not parse mention inside word" ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @ file", 0), + MentionCompletion::try_parse("Lorem @ file", 0, &supported_modes), None, "Should not parse with a space after @" ); assert_eq!( - MentionCompletion::try_parse(true, "@ file", 0), + MentionCompletion::try_parse("@ file", 0, &supported_modes), None, "Should not parse with a space after @ at the start of the line" ); diff --git a/crates/agent_ui/src/context.rs b/crates/agent_ui/src/context.rs new file mode 100644 index 0000000000..ad8c95ba3e --- /dev/null +++ b/crates/agent_ui/src/context.rs @@ -0,0 +1,63 @@ +use crate::mention_set::Mention; +use gpui::{AppContext as _, Entity, Task}; +use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent}; +use ui::App; +use util::ResultExt as _; + +use crate::mention_set::MentionSet; + +#[derive(Debug, Clone, Default)] +pub struct LoadedContext { + pub text: String, + pub images: Vec, +} + +impl LoadedContext { + pub fn add_to_request_message(&self, request_message: &mut LanguageModelRequestMessage) { + if !self.text.is_empty() { + request_message + .content + .push(MessageContent::Text(self.text.to_string())); + } + + if !self.images.is_empty() { + // Some providers only support image parts after an initial text part + if request_message.content.is_empty() { + request_message + .content + .push(MessageContent::Text("Images attached by user:".to_string())); + } + + for image in &self.images { + request_message + .content + .push(MessageContent::Image(image.clone())) + } + } + } +} + +/// Loads and formats a collection of contexts. +pub fn load_context(mention_set: &Entity, cx: &mut App) -> Task> { + let task = mention_set.update(cx, |mention_set, cx| mention_set.contents(true, cx)); + cx.background_spawn(async move { + let mentions = task.await.log_err()?; + let mut loaded_context = LoadedContext::default(); + loaded_context + .text + .push_str("The following items were attached by the user.\n"); + for (_, (_, mention)) in mentions { + match mention { + Mention::Text { content, .. } => { + loaded_context.text.push_str(&content); + } + Mention::Image(mention_image) => loaded_context.images.push(LanguageModelImage { + source: mention_image.data, + ..LanguageModelImage::empty() + }), + Mention::Link => {} + } + } + Some(loaded_context) + }) +} diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs deleted file mode 100644 index 58edecdf3d..0000000000 --- a/crates/agent_ui/src/context_picker.rs +++ /dev/null @@ -1,944 +0,0 @@ -mod completion_provider; -pub(crate) mod fetch_context_picker; -pub(crate) mod file_context_picker; -pub(crate) mod rules_context_picker; -pub(crate) mod symbol_context_picker; -pub(crate) mod thread_context_picker; - -use std::ops::Range; -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use collections::HashSet; -pub use completion_provider::ContextPickerCompletionProvider; -use editor::display_map::{Crease, CreaseId, CreaseMetadata, FoldId}; -use editor::{Anchor, Editor, ExcerptId, FoldPlaceholder, ToOffset}; -use fetch_context_picker::FetchContextPicker; -use file_context_picker::FileContextPicker; -use file_context_picker::render_file_context_entry; -use gpui::{ - App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, - WeakEntity, -}; -use language::Buffer; -use multi_buffer::MultiBufferRow; -use project::ProjectPath; -use prompt_store::PromptStore; -use rules_context_picker::{RulesContextEntry, RulesContextPicker}; -use symbol_context_picker::SymbolContextPicker; -use thread_context_picker::{ - ThreadContextEntry, ThreadContextPicker, render_thread_context_entry, unordered_thread_entries, -}; -use ui::{ - ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, prelude::*, -}; -use util::paths::PathStyle; -use util::rel_path::RelPath; -use workspace::{Workspace, notifications::NotifyResultExt}; - -use agent::{ - ThreadId, - context::RULES_ICON, - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerEntry { - Mode(ContextPickerMode), - Action(ContextPickerAction), -} - -impl ContextPickerEntry { - pub fn keyword(&self) -> &'static str { - match self { - Self::Mode(mode) => mode.keyword(), - Self::Action(action) => action.keyword(), - } - } - - pub fn label(&self) -> &'static str { - match self { - Self::Mode(mode) => mode.label(), - Self::Action(action) => action.label(), - } - } - - pub fn icon(&self) -> IconName { - match self { - Self::Mode(mode) => mode.icon(), - Self::Action(action) => action.icon(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerMode { - File, - Symbol, - Fetch, - Thread, - Rules, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerAction { - AddSelections, -} - -impl ContextPickerAction { - pub fn keyword(&self) -> &'static str { - match self { - Self::AddSelections => "selection", - } - } - - pub fn label(&self) -> &'static str { - match self { - Self::AddSelections => "Selection", - } - } - - pub fn icon(&self) -> IconName { - match self { - Self::AddSelections => IconName::Reader, - } - } -} - -impl TryFrom<&str> for ContextPickerMode { - type Error = String; - - fn try_from(value: &str) -> Result { - match value { - "file" => Ok(Self::File), - "symbol" => Ok(Self::Symbol), - "fetch" => Ok(Self::Fetch), - "thread" => Ok(Self::Thread), - "rule" => Ok(Self::Rules), - _ => Err(format!("Invalid context picker mode: {}", value)), - } - } -} - -impl ContextPickerMode { - pub fn keyword(&self) -> &'static str { - match self { - Self::File => "file", - Self::Symbol => "symbol", - Self::Fetch => "fetch", - Self::Thread => "thread", - Self::Rules => "rule", - } - } - - pub fn label(&self) -> &'static str { - match self { - Self::File => "Files & Directories", - Self::Symbol => "Symbols", - Self::Fetch => "Fetch", - Self::Thread => "Threads", - Self::Rules => "Rules", - } - } - - pub fn icon(&self) -> IconName { - match self { - Self::File => IconName::File, - Self::Symbol => IconName::Code, - Self::Fetch => IconName::ToolWeb, - Self::Thread => IconName::Thread, - Self::Rules => RULES_ICON, - } - } -} - -#[derive(Debug, Clone)] -enum ContextPickerState { - Default(Entity), - File(Entity), - Symbol(Entity), - Fetch(Entity), - Thread(Entity), - Rules(Entity), -} - -pub(super) struct ContextPicker { - mode: ContextPickerState, - workspace: WeakEntity, - context_store: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, - prompt_store: Option>, - _subscriptions: Vec, -} - -impl ContextPicker { - pub fn new( - workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let subscriptions = context_store - .upgrade() - .map(|context_store| { - cx.observe(&context_store, |this, _, cx| this.notify_current_picker(cx)) - }) - .into_iter() - .chain( - thread_store - .as_ref() - .and_then(|thread_store| thread_store.upgrade()) - .map(|thread_store| { - cx.observe(&thread_store, |this, _, cx| this.notify_current_picker(cx)) - }), - ) - .collect::>(); - - let prompt_store = thread_store.as_ref().and_then(|thread_store| { - thread_store - .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone()) - .ok() - .flatten() - }); - - ContextPicker { - mode: ContextPickerState::Default(ContextMenu::build( - window, - cx, - |menu, _window, _cx| menu, - )), - workspace, - context_store, - thread_store, - text_thread_store, - prompt_store, - _subscriptions: subscriptions, - } - } - - pub fn init(&mut self, window: &mut Window, cx: &mut Context) { - self.mode = ContextPickerState::Default(self.build_menu(window, cx)); - cx.notify(); - } - - fn build_menu(&mut self, window: &mut Window, cx: &mut Context) -> Entity { - let context_picker = cx.entity(); - - let menu = ContextMenu::build(window, cx, move |menu, _window, cx| { - let Some(workspace) = self.workspace.upgrade() else { - return menu; - }; - let path_style = workspace.read(cx).path_style(cx); - let recent = self.recent_entries(cx); - let has_recent = !recent.is_empty(); - let recent_entries = recent - .into_iter() - .enumerate() - .map(|(ix, entry)| { - self.recent_menu_item(context_picker.clone(), ix, entry, path_style) - }) - .collect::>(); - - let entries = self - .workspace - .upgrade() - .map(|workspace| { - available_context_picker_entries( - &self.prompt_store, - &self.thread_store, - &workspace, - cx, - ) - }) - .unwrap_or_default(); - - menu.when(has_recent, |menu| { - menu.custom_row(|_, _| { - div() - .mb_1() - .child( - Label::new("Recent") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .into_any_element() - }) - }) - .extend(recent_entries) - .when(has_recent, |menu| menu.separator()) - .extend(entries.into_iter().map(|entry| { - let context_picker = context_picker.clone(); - - ContextMenuEntry::new(entry.label()) - .icon(entry.icon()) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .handler(move |window, cx| { - context_picker.update(cx, |this, cx| this.select_entry(entry, window, cx)) - }) - })) - .keep_open_on_confirm(true) - }); - - cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| { - cx.emit(DismissEvent); - }) - .detach(); - - menu - } - - /// Whether threads are allowed as context. - pub fn allow_threads(&self) -> bool { - self.thread_store.is_some() - } - - fn select_entry( - &mut self, - entry: ContextPickerEntry, - window: &mut Window, - cx: &mut Context, - ) { - let context_picker = cx.entity().downgrade(); - - match entry { - ContextPickerEntry::Mode(mode) => match mode { - ContextPickerMode::File => { - self.mode = ContextPickerState::File(cx.new(|cx| { - FileContextPicker::new( - context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - ContextPickerMode::Symbol => { - self.mode = ContextPickerState::Symbol(cx.new(|cx| { - SymbolContextPicker::new( - context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - ContextPickerMode::Rules => { - if let Some(prompt_store) = self.prompt_store.as_ref() { - self.mode = ContextPickerState::Rules(cx.new(|cx| { - RulesContextPicker::new( - prompt_store.clone(), - context_picker.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - } - ContextPickerMode::Fetch => { - self.mode = ContextPickerState::Fetch(cx.new(|cx| { - FetchContextPicker::new( - context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - ContextPickerMode::Thread => { - if let Some((thread_store, text_thread_store)) = self - .thread_store - .as_ref() - .zip(self.text_thread_store.as_ref()) - { - self.mode = ContextPickerState::Thread(cx.new(|cx| { - ThreadContextPicker::new( - thread_store.clone(), - text_thread_store.clone(), - context_picker.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - } - }, - ContextPickerEntry::Action(action) => match action { - ContextPickerAction::AddSelections => { - if let Some((context_store, workspace)) = - self.context_store.upgrade().zip(self.workspace.upgrade()) - { - add_selections_as_context(&context_store, &workspace, cx); - } - - cx.emit(DismissEvent); - } - }, - } - - cx.notify(); - cx.focus_self(window); - } - - pub fn select_first(&mut self, window: &mut Window, cx: &mut Context) { - // Other variants already select their first entry on open automatically - if let ContextPickerState::Default(entity) = &self.mode { - entity.update(cx, |entity, cx| { - entity.select_first(&Default::default(), window, cx) - }) - } - } - - fn recent_menu_item( - &self, - context_picker: Entity, - ix: usize, - entry: RecentEntry, - path_style: PathStyle, - ) -> ContextMenuItem { - match entry { - RecentEntry::File { - project_path, - path_prefix, - } => { - let context_store = self.context_store.clone(); - let worktree_id = project_path.worktree_id; - let path = project_path.path.clone(); - - ContextMenuItem::custom_entry( - move |_window, cx| { - render_file_context_entry( - ElementId::named_usize("ctx-recent", ix), - worktree_id, - &path, - &path_prefix, - false, - path_style, - context_store.clone(), - cx, - ) - .into_any() - }, - move |window, cx| { - context_picker.update(cx, |this, cx| { - this.add_recent_file(project_path.clone(), window, cx); - }) - }, - None, - ) - } - RecentEntry::Thread(thread) => { - let context_store = self.context_store.clone(); - let view_thread = thread.clone(); - - ContextMenuItem::custom_entry( - move |_window, cx| { - render_thread_context_entry(&view_thread, context_store.clone(), cx) - .into_any() - }, - move |window, cx| { - context_picker.update(cx, |this, cx| { - this.add_recent_thread(thread.clone(), window, cx) - .detach_and_log_err(cx); - }) - }, - None, - ) - } - } - } - - fn add_recent_file( - &self, - project_path: ProjectPath, - window: &mut Window, - cx: &mut Context, - ) { - let Some(context_store) = self.context_store.upgrade() else { - return; - }; - - let task = context_store.update(cx, |context_store, cx| { - context_store.add_file_from_path(project_path.clone(), true, cx) - }); - - cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx)) - .detach(); - - cx.notify(); - } - - fn add_recent_thread( - &self, - entry: ThreadContextEntry, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let Some(context_store) = self.context_store.upgrade() else { - return Task::ready(Err(anyhow!("context store not available"))); - }; - - match entry { - ThreadContextEntry::Thread { id, .. } => { - let Some(thread_store) = self - .thread_store - .as_ref() - .and_then(|thread_store| thread_store.upgrade()) - else { - return Task::ready(Err(anyhow!("thread store not available"))); - }; - - let open_thread_task = - thread_store.update(cx, |this, cx| this.open_thread(&id, window, cx)); - cx.spawn(async move |this, cx| { - let thread = open_thread_task.await?; - context_store.update(cx, |context_store, cx| { - context_store.add_thread(thread, true, cx); - })?; - this.update(cx, |_this, cx| cx.notify()) - }) - } - ThreadContextEntry::Context { path, .. } => { - let Some(text_thread_store) = self - .text_thread_store - .as_ref() - .and_then(|thread_store| thread_store.upgrade()) - else { - return Task::ready(Err(anyhow!("text thread store not available"))); - }; - - let task = text_thread_store - .update(cx, |this, cx| this.open_local_context(path.clone(), cx)); - cx.spawn(async move |this, cx| { - let thread = task.await?; - context_store.update(cx, |context_store, cx| { - context_store.add_text_thread(thread, true, cx); - })?; - this.update(cx, |_this, cx| cx.notify()) - }) - } - } - } - - fn recent_entries(&self, cx: &mut App) -> Vec { - let Some(workspace) = self.workspace.upgrade() else { - return vec![]; - }; - - let Some(context_store) = self.context_store.upgrade() else { - return vec![]; - }; - - recent_context_picker_entries_with_store( - context_store, - self.thread_store.clone(), - self.text_thread_store.clone(), - workspace, - None, - cx, - ) - } - - fn notify_current_picker(&mut self, cx: &mut Context) { - match &self.mode { - ContextPickerState::Default(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::File(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::Rules(entity) => entity.update(cx, |_, cx| cx.notify()), - } - } -} - -impl EventEmitter for ContextPicker {} - -impl Focusable for ContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - match &self.mode { - ContextPickerState::Default(menu) => menu.focus_handle(cx), - ContextPickerState::File(file_picker) => file_picker.focus_handle(cx), - ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx), - ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx), - ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx), - ContextPickerState::Rules(user_rules_picker) => user_rules_picker.focus_handle(cx), - } - } -} - -impl Render for ContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex() - .w(px(400.)) - .min_w(px(400.)) - .map(|parent| match &self.mode { - ContextPickerState::Default(menu) => parent.child(menu.clone()), - ContextPickerState::File(file_picker) => parent.child(file_picker.clone()), - ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()), - ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()), - ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()), - ContextPickerState::Rules(user_rules_picker) => { - parent.child(user_rules_picker.clone()) - } - }) - } -} - -pub(crate) enum RecentEntry { - File { - project_path: ProjectPath, - path_prefix: Arc, - }, - Thread(ThreadContextEntry), -} - -pub(crate) fn available_context_picker_entries( - prompt_store: &Option>, - thread_store: &Option>, - workspace: &Entity, - cx: &mut App, -) -> Vec { - let mut entries = vec![ - ContextPickerEntry::Mode(ContextPickerMode::File), - ContextPickerEntry::Mode(ContextPickerMode::Symbol), - ]; - - let has_selection = workspace - .read(cx) - .active_item(cx) - .and_then(|item| item.downcast::()) - .is_some_and(|editor| editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))); - if has_selection { - entries.push(ContextPickerEntry::Action( - ContextPickerAction::AddSelections, - )); - } - - if thread_store.is_some() { - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Thread)); - } - - if prompt_store.is_some() { - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules)); - } - - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch)); - - entries -} - -fn recent_context_picker_entries_with_store( - context_store: Entity, - thread_store: Option>, - text_thread_store: Option>, - workspace: Entity, - exclude_path: Option, - cx: &App, -) -> Vec { - let project = workspace.read(cx).project(); - - let mut exclude_paths = context_store.read(cx).file_paths(cx); - exclude_paths.extend(exclude_path); - - let exclude_paths = exclude_paths - .into_iter() - .filter_map(|project_path| project.read(cx).absolute_path(&project_path, cx)) - .collect(); - - let exclude_threads = context_store.read(cx).thread_ids(); - - recent_context_picker_entries( - thread_store, - text_thread_store, - workspace, - &exclude_paths, - exclude_threads, - cx, - ) -} - -pub(crate) fn recent_context_picker_entries( - thread_store: Option>, - text_thread_store: Option>, - workspace: Entity, - exclude_paths: &HashSet, - _exclude_threads: &HashSet, - cx: &App, -) -> Vec { - let mut recent = Vec::with_capacity(6); - let workspace = workspace.read(cx); - let project = workspace.project().read(cx); - - recent.extend( - workspace - .recent_navigation_history_iter(cx) - .filter(|(_, abs_path)| { - abs_path - .as_ref() - .is_none_or(|path| !exclude_paths.contains(path.as_path())) - }) - .take(4) - .filter_map(|(project_path, _)| { - project - .worktree_for_id(project_path.worktree_id, cx) - .map(|worktree| RecentEntry::File { - project_path, - path_prefix: worktree.read(cx).root_name().into(), - }) - }), - ); - - if let Some((thread_store, text_thread_store)) = thread_store - .and_then(|store| store.upgrade()) - .zip(text_thread_store.and_then(|store| store.upgrade())) - { - let mut threads = unordered_thread_entries(thread_store, text_thread_store, cx) - .filter(|(_, thread)| match thread { - ThreadContextEntry::Thread { .. } => false, - ThreadContextEntry::Context { .. } => true, - }) - .collect::>(); - - const RECENT_COUNT: usize = 2; - if threads.len() > RECENT_COUNT { - threads.select_nth_unstable_by_key(RECENT_COUNT - 1, |(updated_at, _)| { - std::cmp::Reverse(*updated_at) - }); - threads.truncate(RECENT_COUNT); - } - threads.sort_unstable_by_key(|(updated_at, _)| std::cmp::Reverse(*updated_at)); - - recent.extend( - threads - .into_iter() - .map(|(_, thread)| RecentEntry::Thread(thread)), - ); - } - - recent -} - -fn add_selections_as_context( - context_store: &Entity, - workspace: &Entity, - cx: &mut App, -) { - let selection_ranges = selection_ranges(workspace, cx); - context_store.update(cx, |context_store, cx| { - for (buffer, range) in selection_ranges { - context_store.add_selection(buffer, range, cx); - } - }) -} - -pub(crate) fn selection_ranges( - workspace: &Entity, - cx: &mut App, -) -> Vec<(Entity, Range)> { - let Some(editor) = workspace - .read(cx) - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - else { - return Vec::new(); - }; - - editor.update(cx, |editor, cx| { - let selections = editor.selections.all_adjusted(cx); - - let buffer = editor.buffer().clone().read(cx); - let snapshot = buffer.snapshot(cx); - - selections - .into_iter() - .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end)) - .flat_map(|range| { - let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?; - let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?; - if start_buffer != end_buffer { - return None; - } - Some((start_buffer, start..end)) - }) - .collect::>() - }) -} - -pub(crate) fn insert_crease_for_mention( - excerpt_id: ExcerptId, - crease_start: text::Anchor, - content_len: usize, - crease_label: SharedString, - crease_icon_path: SharedString, - editor_entity: Entity, - window: &mut Window, - cx: &mut App, -) -> Option { - editor_entity.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - - let start = snapshot.anchor_in_excerpt(excerpt_id, crease_start)?; - - let start = start.bias_right(&snapshot); - let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); - - let crease = crease_for_mention( - crease_label, - crease_icon_path, - start..end, - editor_entity.downgrade(), - ); - - let ids = editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases(vec![crease], false, window, cx); - - Some(ids[0]) - }) -} - -pub fn crease_for_mention( - label: SharedString, - icon_path: SharedString, - range: Range, - editor_entity: WeakEntity, -) -> Crease { - let placeholder = FoldPlaceholder { - render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity), - merge_adjacent: false, - ..Default::default() - }; - - let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any(); - - Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer) - .with_metadata(CreaseMetadata { icon_path, label }) -} - -fn render_fold_icon_button( - icon_path: SharedString, - label: SharedString, - editor: WeakEntity, -) -> Arc, &mut App) -> AnyElement> { - Arc::new({ - move |fold_id, fold_range, cx| { - let is_in_text_selection = editor - .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx)) - .unwrap_or_default(); - - ButtonLike::new(fold_id) - .style(ButtonStyle::Filled) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_in_text_selection) - .child( - h_flex() - .gap_1() - .child( - Icon::from_path(icon_path.clone()) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(label.clone()) - .size(LabelSize::Small) - .buffer_font(cx) - .single_line(), - ), - ) - .into_any_element() - } - }) -} - -fn fold_toggle( - name: &'static str, -) -> impl Fn( - MultiBufferRow, - bool, - Arc, - &mut Window, - &mut App, -) -> AnyElement { - move |row, is_folded, fold, _window, _cx| { - Disclosure::new((name, row.0 as u64), !is_folded) - .toggle_state(is_folded) - .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) - .into_any_element() - } -} - -pub struct MentionLink; - -impl MentionLink { - const FILE: &str = "@file"; - const SYMBOL: &str = "@symbol"; - const SELECTION: &str = "@selection"; - const THREAD: &str = "@thread"; - const FETCH: &str = "@fetch"; - const RULE: &str = "@rule"; - - const TEXT_THREAD_URL_PREFIX: &str = "text-thread://"; - - pub fn for_file(file_name: &str, full_path: &str) -> String { - format!("[@{}]({}:{})", file_name, Self::FILE, full_path) - } - - pub fn for_symbol(symbol_name: &str, full_path: &str) -> String { - format!( - "[@{}]({}:{}:{})", - symbol_name, - Self::SYMBOL, - full_path, - symbol_name - ) - } - - pub fn for_selection(file_name: &str, full_path: &str, line_range: Range) -> String { - format!( - "[@{} ({}-{})]({}:{}:{}-{})", - file_name, - line_range.start + 1, - line_range.end + 1, - Self::SELECTION, - full_path, - line_range.start, - line_range.end - ) - } - - pub fn for_thread(thread: &ThreadContextEntry) -> String { - match thread { - ThreadContextEntry::Thread { id, title } => { - format!("[@{}]({}:{})", title, Self::THREAD, id) - } - ThreadContextEntry::Context { path, title } => { - let filename = path.file_name().unwrap_or_default().to_string_lossy(); - let escaped_filename = urlencoding::encode(&filename); - format!( - "[@{}]({}:{}{})", - title, - Self::THREAD, - Self::TEXT_THREAD_URL_PREFIX, - escaped_filename - ) - } - } - } - - pub fn for_fetch(url: &str) -> String { - format!("[@{}]({}:{})", url, Self::FETCH, url) - } - - pub fn for_rule(rule: &RulesContextEntry) -> String { - format!("[@{}]({}:{})", rule.title, Self::RULE, rule.prompt_id.0) - } -} diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs deleted file mode 100644 index 33a5a621a1..0000000000 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ /dev/null @@ -1,1515 +0,0 @@ -use std::ops::Range; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use agent::context_store::ContextStore; -use anyhow::Result; -use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _}; -use file_icons::FileIcons; -use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{App, Entity, Task, WeakEntity}; -use http_client::HttpClientWithUrl; -use itertools::Itertools; -use language::{Buffer, CodeLabel, HighlightId}; -use lsp::CompletionContext; -use project::lsp_store::SymbolLocation; -use project::{ - Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, ProjectPath, - Symbol, WorktreeId, -}; -use prompt_store::PromptStore; -use rope::Point; -use text::{Anchor, OffsetRangeExt, ToPoint}; -use ui::prelude::*; -use util::ResultExt as _; -use util::paths::PathStyle; -use util::rel_path::RelPath; -use workspace::Workspace; - -use agent::{ - Thread, - context::{AgentContextHandle, AgentContextKey, RULES_ICON}, - thread_store::{TextThreadStore, ThreadStore}, -}; - -use super::fetch_context_picker::fetch_url_content; -use super::file_context_picker::{FileMatch, search_files}; -use super::rules_context_picker::{RulesContextEntry, search_rules}; -use super::symbol_context_picker::SymbolMatch; -use super::symbol_context_picker::search_symbols; -use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads}; -use super::{ - ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry, - available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges, -}; -use crate::message_editor::ContextCreasesAddon; - -pub(crate) enum Match { - File(FileMatch), - Symbol(SymbolMatch), - Thread(ThreadMatch), - Fetch(SharedString), - Rules(RulesContextEntry), - Entry(EntryMatch), -} - -pub struct EntryMatch { - mat: Option, - entry: ContextPickerEntry, -} - -impl Match { - pub fn score(&self) -> f64 { - match self { - Match::File(file) => file.mat.score, - Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.), - Match::Thread(_) => 1., - Match::Symbol(_) => 1., - Match::Fetch(_) => 1., - Match::Rules(_) => 1., - } - } -} - -fn search( - mode: Option, - query: String, - cancellation_flag: Arc, - recent_entries: Vec, - prompt_store: Option>, - thread_store: Option>, - text_thread_context_store: Option>, - workspace: Entity, - cx: &mut App, -) -> Task> { - match mode { - Some(ContextPickerMode::File) => { - let search_files_task = search_files(query, cancellation_flag, &workspace, cx); - cx.background_spawn(async move { - search_files_task - .await - .into_iter() - .map(Match::File) - .collect() - }) - } - - Some(ContextPickerMode::Symbol) => { - let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx); - cx.background_spawn(async move { - search_symbols_task - .await - .into_iter() - .map(Match::Symbol) - .collect() - }) - } - - Some(ContextPickerMode::Thread) => { - if let Some((thread_store, context_store)) = thread_store - .as_ref() - .and_then(|t| t.upgrade()) - .zip(text_thread_context_store.as_ref().and_then(|t| t.upgrade())) - { - let search_threads_task = - search_threads(query, cancellation_flag, thread_store, context_store, cx); - cx.background_spawn(async move { - search_threads_task - .await - .into_iter() - .map(Match::Thread) - .collect() - }) - } else { - Task::ready(Vec::new()) - } - } - - Some(ContextPickerMode::Fetch) => { - if !query.is_empty() { - Task::ready(vec![Match::Fetch(query.into())]) - } else { - Task::ready(Vec::new()) - } - } - - Some(ContextPickerMode::Rules) => { - if let Some(prompt_store) = prompt_store.as_ref() { - let search_rules_task = search_rules(query, cancellation_flag, prompt_store, cx); - cx.background_spawn(async move { - search_rules_task - .await - .into_iter() - .map(Match::Rules) - .collect::>() - }) - } else { - Task::ready(Vec::new()) - } - } - - None => { - if query.is_empty() { - let mut matches = recent_entries - .into_iter() - .map(|entry| match entry { - super::RecentEntry::File { - project_path, - path_prefix, - } => Match::File(FileMatch { - mat: fuzzy::PathMatch { - score: 1., - positions: Vec::new(), - worktree_id: project_path.worktree_id.to_usize(), - path: project_path.path, - path_prefix, - is_dir: false, - distance_to_relative_ancestor: 0, - }, - is_recent: true, - }), - super::RecentEntry::Thread(thread_context_entry) => { - Match::Thread(ThreadMatch { - thread: thread_context_entry, - is_recent: true, - }) - } - }) - .collect::>(); - - matches.extend( - available_context_picker_entries(&prompt_store, &thread_store, &workspace, cx) - .into_iter() - .map(|mode| { - Match::Entry(EntryMatch { - entry: mode, - mat: None, - }) - }), - ); - - Task::ready(matches) - } else { - let executor = cx.background_executor().clone(); - - let search_files_task = - search_files(query.clone(), cancellation_flag, &workspace, cx); - - let entries = - available_context_picker_entries(&prompt_store, &thread_store, &workspace, cx); - let entry_candidates = entries - .iter() - .enumerate() - .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword())) - .collect::>(); - - cx.background_spawn(async move { - let mut matches = search_files_task - .await - .into_iter() - .map(Match::File) - .collect::>(); - - let entry_matches = fuzzy::match_strings( - &entry_candidates, - &query, - false, - true, - 100, - &Arc::new(AtomicBool::default()), - executor, - ) - .await; - - matches.extend(entry_matches.into_iter().map(|mat| { - Match::Entry(EntryMatch { - entry: entries[mat.candidate_id], - mat: Some(mat), - }) - })); - - matches.sort_by(|a, b| { - b.score() - .partial_cmp(&a.score()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - matches - }) - } - } - } -} - -pub struct ContextPickerCompletionProvider { - workspace: WeakEntity, - context_store: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, - editor: WeakEntity, - excluded_buffer: Option>, -} - -impl ContextPickerCompletionProvider { - pub fn new( - workspace: WeakEntity, - context_store: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, - editor: WeakEntity, - exclude_buffer: Option>, - ) -> Self { - Self { - workspace, - context_store, - thread_store, - text_thread_store, - editor, - excluded_buffer: exclude_buffer, - } - } - - fn completion_for_entry( - entry: ContextPickerEntry, - excerpt_id: ExcerptId, - source_range: Range, - editor: Entity, - context_store: Entity, - workspace: &Entity, - cx: &mut App, - ) -> Option { - match entry { - ContextPickerEntry::Mode(mode) => Some(Completion { - replace_range: source_range, - new_text: format!("@{} ", mode.keyword()), - label: CodeLabel::plain(mode.label().to_string(), None), - icon_path: Some(mode.icon().path().into()), - documentation: None, - source: project::CompletionSource::Custom, - insert_text_mode: None, - // This ensures that when a user accepts this completion, the - // completion menu will still be shown after "@category " is - // inserted - confirm: Some(Arc::new(|_, _, _| true)), - }), - ContextPickerEntry::Action(action) => { - let (new_text, on_action) = match action { - ContextPickerAction::AddSelections => { - let selections = selection_ranges(workspace, cx); - - let selection_infos = selections - .iter() - .map(|(buffer, range)| { - let full_path = buffer - .read(cx) - .file() - .map(|file| file.full_path(cx)) - .unwrap_or_else(|| PathBuf::from("untitled")); - let file_name = full_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - let line_range = range.to_point(&buffer.read(cx).snapshot()); - - let link = MentionLink::for_selection( - &file_name, - &full_path.to_string_lossy(), - line_range.start.row as usize..line_range.end.row as usize, - ); - (file_name, link, line_range) - }) - .collect::>(); - - let new_text = format!( - "{} ", - selection_infos.iter().map(|(_, link, _)| link).join(" ") - ); - - let callback = Arc::new({ - move |_, window: &mut Window, cx: &mut App| { - context_store.update(cx, |context_store, cx| { - for (buffer, range) in &selections { - context_store.add_selection( - buffer.clone(), - range.clone(), - cx, - ); - } - }); - - let editor = editor.clone(); - let selection_infos = selection_infos.clone(); - window.defer(cx, move |window, cx| { - let mut current_offset = 0; - for (file_name, link, line_range) in selection_infos.iter() { - let snapshot = - editor.read(cx).buffer().read(cx).snapshot(cx); - let Some(start) = snapshot - .anchor_in_excerpt(excerpt_id, source_range.start) - else { - return; - }; - - let offset = start.to_offset(&snapshot) + current_offset; - let text_len = link.len(); - - let range = snapshot.anchor_after(offset) - ..snapshot.anchor_after(offset + text_len); - - let crease = super::crease_for_mention( - format!( - "{} ({}-{})", - file_name, - line_range.start.row + 1, - line_range.end.row + 1 - ) - .into(), - IconName::Reader.path().into(), - range, - editor.downgrade(), - ); - - editor.update(cx, |editor, cx| { - editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases(vec![crease], false, window, cx); - }); - - current_offset += text_len + 1; - } - }); - - false - } - }); - - (new_text, callback) - } - }; - - Some(Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(action.label().to_string(), None), - icon_path: Some(action.icon().path().into()), - documentation: None, - source: project::CompletionSource::Custom, - insert_text_mode: None, - // This ensures that when a user accepts this completion, the - // completion menu will still be shown after "@category " is - // inserted - confirm: Some(on_action), - }) - } - } - } - - fn completion_for_thread( - thread_entry: ThreadContextEntry, - excerpt_id: ExcerptId, - source_range: Range, - recent: bool, - editor: Entity, - context_store: Entity, - thread_store: Entity, - text_thread_store: Entity, - ) -> Completion { - let icon_for_completion = if recent { - IconName::HistoryRerun - } else { - IconName::Thread - }; - let new_text = format!("{} ", MentionLink::for_thread(&thread_entry)); - let new_text_len = new_text.len(); - Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(thread_entry.title().to_string(), None), - documentation: None, - insert_text_mode: None, - source: project::CompletionSource::Custom, - icon_path: Some(icon_for_completion.path().into()), - confirm: Some(confirm_completion_callback( - IconName::Thread.path().into(), - thread_entry.title().clone(), - excerpt_id, - source_range.start, - new_text_len - 1, - editor, - context_store.clone(), - move |window, cx| match &thread_entry { - ThreadContextEntry::Thread { id, .. } => { - let thread_id = id.clone(); - let context_store = context_store.clone(); - let thread_store = thread_store.clone(); - window.spawn::<_, Option<_>>(cx, async move |cx| { - let thread: Entity = thread_store - .update_in(cx, |thread_store, window, cx| { - thread_store.open_thread(&thread_id, window, cx) - }) - .ok()? - .await - .log_err()?; - let context = context_store - .update(cx, |context_store, cx| { - context_store.add_thread(thread, false, cx) - }) - .ok()??; - Some(context) - }) - } - ThreadContextEntry::Context { path, .. } => { - let path = path.clone(); - let context_store = context_store.clone(); - let text_thread_store = text_thread_store.clone(); - cx.spawn::<_, Option<_>>(async move |cx| { - let thread = text_thread_store - .update(cx, |store, cx| store.open_local_context(path, cx)) - .ok()? - .await - .log_err()?; - let context = context_store - .update(cx, |context_store, cx| { - context_store.add_text_thread(thread, false, cx) - }) - .ok()??; - Some(context) - }) - } - }, - )), - } - } - - fn completion_for_rules( - rules: RulesContextEntry, - excerpt_id: ExcerptId, - source_range: Range, - editor: Entity, - context_store: Entity, - ) -> Completion { - let new_text = format!("{} ", MentionLink::for_rule(&rules)); - let new_text_len = new_text.len(); - Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(rules.title.to_string(), None), - documentation: None, - insert_text_mode: None, - source: project::CompletionSource::Custom, - icon_path: Some(RULES_ICON.path().into()), - confirm: Some(confirm_completion_callback( - RULES_ICON.path().into(), - rules.title.clone(), - excerpt_id, - source_range.start, - new_text_len - 1, - editor, - context_store.clone(), - move |_, cx| { - let user_prompt_id = rules.prompt_id; - let context = context_store.update(cx, |context_store, cx| { - context_store.add_rules(user_prompt_id, false, cx) - }); - Task::ready(context) - }, - )), - } - } - - fn completion_for_fetch( - source_range: Range, - url_to_fetch: SharedString, - excerpt_id: ExcerptId, - editor: Entity, - context_store: Entity, - http_client: Arc, - ) -> Completion { - let new_text = format!("{} ", MentionLink::for_fetch(&url_to_fetch)); - let new_text_len = new_text.len(); - Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(url_to_fetch.to_string(), None), - documentation: None, - source: project::CompletionSource::Custom, - icon_path: Some(IconName::ToolWeb.path().into()), - insert_text_mode: None, - confirm: Some(confirm_completion_callback( - IconName::ToolWeb.path().into(), - url_to_fetch.clone(), - excerpt_id, - source_range.start, - new_text_len - 1, - editor, - context_store.clone(), - move |_, cx| { - let context_store = context_store.clone(); - let http_client = http_client.clone(); - let url_to_fetch = url_to_fetch.clone(); - cx.spawn(async move |cx| { - if let Some(context) = context_store - .read_with(cx, |context_store, _| { - context_store.get_url_context(url_to_fetch.clone()) - }) - .ok()? - { - return Some(context); - } - let content = cx - .background_spawn(fetch_url_content( - http_client, - url_to_fetch.to_string(), - )) - .await - .log_err()?; - context_store - .update(cx, |context_store, cx| { - context_store.add_fetched_url(url_to_fetch.to_string(), content, cx) - }) - .ok() - }) - }, - )), - } - } - - fn completion_for_path( - project_path: ProjectPath, - path_prefix: &RelPath, - is_recent: bool, - is_directory: bool, - excerpt_id: ExcerptId, - source_range: Range, - path_style: PathStyle, - editor: Entity, - context_store: Entity, - cx: &App, - ) -> Completion { - let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory( - &project_path.path, - path_prefix, - path_style, - ); - - 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 path = Path::new(&full_path); - let crease_icon_path = if is_directory { - FileIcons::get_folder_icon(false, path, cx) - .unwrap_or_else(|| IconName::Folder.path().into()) - } else { - FileIcons::get_icon(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, - excerpt_id, - source_range.start, - new_text_len - 1, - editor, - context_store.clone(), - move |_, cx| { - if is_directory { - Task::ready( - context_store - .update(cx, |context_store, cx| { - context_store.add_directory(&project_path, false, cx) - }) - .log_err() - .flatten(), - ) - } else { - let result = context_store.update(cx, |context_store, cx| { - context_store.add_file_from_path(project_path.clone(), false, cx) - }); - cx.spawn(async move |_| result.await.log_err().flatten()) - } - }, - )), - } - } - - fn completion_for_symbol( - symbol: Symbol, - excerpt_id: ExcerptId, - source_range: Range, - editor: Entity, - context_store: Entity, - workspace: Entity, - cx: &mut App, - ) -> Option { - let path_style = workspace.read(cx).path_style(cx); - let SymbolLocation::InProject(symbol_path) = &symbol.path else { - return None; - }; - let path_prefix = workspace - .read(cx) - .project() - .read(cx) - .worktree_for_id(symbol_path.worktree_id, cx)? - .read(cx) - .root_name(); - - let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory( - &symbol_path.path, - path_prefix, - path_style, - ); - let full_path = if let Some(directory) = directory { - format!("{}{}", directory, file_name) - } else { - file_name.to_string() - }; - - let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); - 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(); - Some(Completion { - replace_range: source_range.clone(), - new_text, - label, - documentation: None, - source: project::CompletionSource::Custom, - icon_path: Some(IconName::Code.path().into()), - insert_text_mode: None, - confirm: Some(confirm_completion_callback( - IconName::Code.path().into(), - symbol.name.clone().into(), - excerpt_id, - source_range.start, - new_text_len - 1, - editor, - context_store.clone(), - move |_, cx| { - let symbol = symbol.clone(); - let context_store = context_store.clone(); - let workspace = workspace.clone(); - let result = super::symbol_context_picker::add_symbol( - symbol, - false, - workspace, - context_store.downgrade(), - cx, - ); - cx.spawn(async move |_| result.await.log_err()?.0) - }, - )), - }) - } -} - -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 snapshot = buffer.read(cx).snapshot(); - let position = buffer_position.to_point(&snapshot); - let line_start = Point::new(position.row, 0); - let offset_to_line = snapshot.point_to_offset(line_start); - let mut lines = snapshot.text_for_range(line_start..position).lines(); - let Some(line) = lines.next() else { - return Task::ready(Ok(Vec::new())); - }; - let Some(state) = MentionCompletion::try_parse(line, offset_to_line) else { - return Task::ready(Ok(Vec::new())); - }; - - let Some((workspace, context_store)) = - self.workspace.upgrade().zip(self.context_store.upgrade()) - else { - return Task::ready(Ok(Vec::new())); - }; - - let source_range = snapshot.anchor_before(state.source_range.start) - ..snapshot.anchor_after(state.source_range.end); - - let thread_store = self.thread_store.clone(); - let text_thread_store = self.text_thread_store.clone(); - let editor = self.editor.clone(); - let http_client = workspace.read(cx).client().http_client(); - let path_style = workspace.read(cx).path_style(cx); - - let MentionCompletion { mode, argument, .. } = state; - let query = argument.unwrap_or_else(|| "".to_string()); - - let excluded_path = self - .excluded_buffer - .as_ref() - .and_then(WeakEntity::upgrade) - .and_then(|b| b.read(cx).file()) - .map(|file| ProjectPath::from_file(file.as_ref(), cx)); - - let recent_entries = recent_context_picker_entries_with_store( - context_store.clone(), - thread_store.clone(), - text_thread_store.clone(), - workspace.clone(), - excluded_path.clone(), - cx, - ); - - let prompt_store = thread_store.as_ref().and_then(|thread_store| { - thread_store - .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone()) - .ok() - .flatten() - }); - - let search_task = search( - mode, - query, - Arc::::default(), - recent_entries, - prompt_store, - thread_store.clone(), - text_thread_store.clone(), - workspace.clone(), - cx, - ); - - cx.spawn(async move |_, cx| { - let matches = search_task.await; - let Some(editor) = editor.upgrade() else { - return Ok(Vec::new()); - }; - - let completions = cx.update(|cx| { - matches - .into_iter() - .filter_map(|mat| match mat { - Match::File(FileMatch { mat, is_recent }) => { - let project_path = ProjectPath { - worktree_id: WorktreeId::from_usize(mat.worktree_id), - path: mat.path.clone(), - }; - - if excluded_path.as_ref() == Some(&project_path) { - return None; - } - - Some(Self::completion_for_path( - project_path, - &mat.path_prefix, - is_recent, - mat.is_dir, - excerpt_id, - source_range.clone(), - path_style, - editor.clone(), - context_store.clone(), - cx, - )) - } - - Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol( - symbol, - excerpt_id, - source_range.clone(), - editor.clone(), - context_store.clone(), - workspace.clone(), - cx, - ), - - Match::Thread(ThreadMatch { - thread, is_recent, .. - }) => { - let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?; - let text_thread_store = - text_thread_store.as_ref().and_then(|t| t.upgrade())?; - Some(Self::completion_for_thread( - thread, - excerpt_id, - source_range.clone(), - is_recent, - editor.clone(), - context_store.clone(), - thread_store, - text_thread_store, - )) - } - - Match::Rules(user_rules) => Some(Self::completion_for_rules( - user_rules, - excerpt_id, - source_range.clone(), - editor.clone(), - context_store.clone(), - )), - - Match::Fetch(url) => Some(Self::completion_for_fetch( - source_range.clone(), - url, - excerpt_id, - editor.clone(), - context_store.clone(), - http_client.clone(), - )), - - Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry( - entry, - excerpt_id, - source_range.clone(), - editor.clone(), - context_store.clone(), - &workspace, - cx, - ), - }) - .collect() - })?; - - Ok(vec![CompletionResponse { - completions, - display_options: CompletionDisplayOptions::default(), - // Since this does its own filtering (see `filter_completions()` returns false), - // there is no benefit to computing whether this set of completions is incomplete. - is_incomplete: true, - }]) - }) - } - - 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, - excerpt_id: ExcerptId, - start: Anchor, - content_len: usize, - editor: Entity, - context_store: Entity, - add_context_fn: impl Fn(&mut Window, &mut App) -> Task> - + Send - + Sync - + 'static, -) -> Arc bool + Send + Sync> { - Arc::new(move |_, window, cx| { - let context = add_context_fn(window, cx); - - let crease_text = crease_text.clone(); - let crease_icon_path = crease_icon_path.clone(); - let editor = editor.clone(); - let context_store = context_store.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, - ); - cx.spawn(async move |cx| { - let crease_id = crease_id?; - let context = context.await?; - editor - .update(cx, |editor, cx| { - if let Some(addon) = editor.addon_mut::() { - addon.add_creases( - &context_store, - AgentContextKey(context), - [(crease_id, crease_text)], - cx, - ); - } - }) - .ok() - }) - .detach(); - }); - false - }) -} - -#[derive(Debug, Default, PartialEq)] -struct MentionCompletion { - source_range: Range, - mode: Option, - 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) - .is_some_and(|c| !c.is_whitespace()) - { - return None; - } - - let rest_of_line = &line[last_mention_start + 1..]; - - let mut mode = None; - let mut argument = None; - - let mut parts = rest_of_line.split_whitespace(); - let mut end = last_mention_start + 1; - if let Some(mode_text) = parts.next() { - end += mode_text.len(); - - if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() { - mode = Some(parsed_mode); - } else { - argument = Some(mode_text.to_string()); - } - match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) { - Some(whitespace_count) => { - if let Some(argument_text) = parts.next() { - argument = Some(argument_text.to_string()); - end += whitespace_count + argument_text.len(); - } - } - None => { - // Rest of line is entirely whitespace - end += rest_of_line.len() - mode_text.len(); - } - } - } - - Some(Self { - source_range: last_mention_start + offset_to_line..end + offset_to_line, - mode, - argument, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use editor::AnchorRangeExt; - 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, rel_path::rel_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, - mode: None, - argument: None, - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file", 0), - Some(MentionCompletion { - source_range: 6..11, - mode: Some(ContextPickerMode::File), - argument: None, - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file ", 0), - Some(MentionCompletion { - source_range: 6..12, - mode: Some(ContextPickerMode::File), - argument: None, - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs", 0), - Some(MentionCompletion { - source_range: 6..19, - mode: Some(ContextPickerMode::File), - argument: Some("main.rs".to_string()), - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs ", 0), - Some(MentionCompletion { - source_range: 6..19, - mode: Some(ContextPickerMode::File), - argument: Some("main.rs".to_string()), - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0), - Some(MentionCompletion { - source_range: 6..19, - mode: Some(ContextPickerMode::File), - argument: Some("main.rs".to_string()), - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @main", 0), - Some(MentionCompletion { - source_range: 6..11, - mode: None, - 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) - } - } - - 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![ - rel_path("a/one.txt"), - rel_path("a/two.txt"), - rel_path("a/three.txt"), - rel_path("a/four.txt"), - rel_path("b/five.txt"), - rel_path("b/six.txt"), - rel_path("b/seven.txt"), - rel_path("b/eight.txt"), - ]; - - let slash = PathStyle::local().separator(); - - let mut opened_editors = Vec::new(); - for path in paths { - let buffer = workspace - .update_in(&mut cx, |workspace, window, cx| { - workspace.open_path( - ProjectPath { - worktree_id, - path: 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 context_store = cx.new(|_| ContextStore::new(project.downgrade(), None)); - - let editor_entity = editor.downgrade(); - editor.update_in(&mut cx, |editor, window, cx| { - let last_opened_buffer = opened_editors.last().and_then(|editor| { - editor - .downcast::()? - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .as_ref() - .map(Entity::downgrade) - }); - window.focus(&editor.focus_handle(cx)); - editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( - workspace.downgrade(), - context_store.downgrade(), - None, - None, - editor_entity, - last_opened_buffer, - )))); - }); - - 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), - &[ - format!("seven.txt dir{slash}b{slash}"), - format!("six.txt dir{slash}b{slash}"), - format!("five.txt dir{slash}b{slash}"), - format!("four.txt dir{slash}a{slash}"), - "Files & Directories".into(), - "Symbols".into(), - "Fetch".into() - ] - ); - }); - - // 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 @file "); - assert!(editor.has_visible_completions_menu()); - }); - - cx.simulate_input("one"); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file one"); - assert!(editor.has_visible_completions_menu()); - assert_eq!( - current_completion_labels(editor), - vec![format!("one.txt dir{slash}a{slash}")] - ); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - assert!(editor.has_visible_completions_menu()); - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) ") - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] - ); - }); - - cx.simulate_input(" "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) ") - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] - ); - }); - - cx.simulate_input("Ipsum "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum "), - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] - ); - }); - - cx.simulate_input("@file "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum @file "), - ); - assert!(editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] - ); - }); - - editor.update_in(&mut cx, |editor, 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), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum [@seven.txt](@file:dir{slash}b{slash}seven.txt) ") - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80) - ] - ); - }); - - cx.simulate_input("\n@"); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum [@seven.txt](@file:dir{slash}b{slash}seven.txt) \n@") - ); - assert!(editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80) - ] - ); - }); - - editor.update_in(&mut cx, |editor, 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), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum [@seven.txt](@file:dir{slash}b{slash}seven.txt) \n[@six.txt](@file:dir{slash}b{slash}six.txt) ") - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80), - Point::new(1, 0)..Point::new(1, 31) - ] - ); - }); - } - - fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec> { - let snapshot = editor.buffer().read(cx).snapshot(cx); - editor.display_map.update(cx, |display_map, cx| { - display_map - .snapshot(cx) - .folds_in_range(0..snapshot.len()) - .map(|fold| fold.range.to_point(&snapshot)) - .collect() - }) - } - - fn current_completion_labels(editor: &Editor) -> Vec { - let completions = editor.current_completions().expect("Missing completions"); - completions - .into_iter() - .map(|completion| completion.label.text) - .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/context_picker/fetch_context_picker.rs b/crates/agent_ui/src/context_picker/fetch_context_picker.rs deleted file mode 100644 index dd558b2a1c..0000000000 --- a/crates/agent_ui/src/context_picker/fetch_context_picker.rs +++ /dev/null @@ -1,253 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::Arc; - -use agent::context_store::ContextStore; -use anyhow::{Context as _, Result, bail}; -use futures::AsyncReadExt as _; -use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; -use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown}; -use http_client::{AsyncBody, HttpClientWithUrl}; -use picker::{Picker, PickerDelegate}; -use ui::{Context, ListItem, Window, prelude::*}; -use workspace::Workspace; - -use crate::context_picker::ContextPicker; - -pub struct FetchContextPicker { - picker: Entity>, -} - -impl FetchContextPicker { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = FetchContextPickerDelegate::new(context_picker, workspace, context_store); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - Self { picker } - } -} - -impl Focusable for FetchContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for FetchContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -enum ContentType { - Html, - Plaintext, - Json, -} - -pub struct FetchContextPickerDelegate { - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - url: String, -} - -impl FetchContextPickerDelegate { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - ) -> Self { - FetchContextPickerDelegate { - context_picker, - workspace, - context_store, - url: String::new(), - } - } -} - -pub(crate) async fn fetch_url_content( - http_client: Arc, - url: String, -) -> Result { - let url = if !url.starts_with("https://") && !url.starts_with("http://") { - format!("https://{url}") - } else { - url - }; - - let mut response = http_client.get(&url, AsyncBody::default(), true).await?; - - let mut body = Vec::new(); - response - .body_mut() - .read_to_end(&mut body) - .await - .context("error reading response body")?; - - if response.status().is_client_error() { - let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); - } - - let Some(content_type) = response.headers().get("content-type") else { - bail!("missing Content-Type header"); - }; - let content_type = content_type - .to_str() - .context("invalid Content-Type header")?; - let content_type = match content_type { - "text/html" => ContentType::Html, - "text/plain" => ContentType::Plaintext, - "application/json" => ContentType::Json, - _ => ContentType::Html, - }; - - match content_type { - ContentType::Html => { - let mut handlers: Vec = vec![ - Rc::new(RefCell::new(markdown::WebpageChromeRemover)), - Rc::new(RefCell::new(markdown::ParagraphHandler)), - Rc::new(RefCell::new(markdown::HeadingHandler)), - Rc::new(RefCell::new(markdown::ListHandler)), - Rc::new(RefCell::new(markdown::TableHandler::new())), - Rc::new(RefCell::new(markdown::StyledTextHandler)), - ]; - if url.contains("wikipedia.org") { - use html_to_markdown::structure::wikipedia; - - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); - handlers.push(Rc::new( - RefCell::new(wikipedia::WikipediaCodeHandler::new()), - )); - } else { - handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); - } - - convert_html_to_markdown(&body[..], &mut handlers) - } - ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), - ContentType::Json => { - let json: serde_json::Value = serde_json::from_slice(&body)?; - - Ok(format!( - "```json\n{}\n```", - serde_json::to_string_pretty(&json)? - )) - } - } -} - -impl PickerDelegate for FetchContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - if self.url.is_empty() { 0 } else { 1 } - } - - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - Some("Enter the URL that you would like to fetch".into()) - } - - fn selected_index(&self) -> usize { - 0 - } - - fn set_selected_index( - &mut self, - _ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Enter a URL…".into() - } - - fn update_matches( - &mut self, - query: String, - _window: &mut Window, - _cx: &mut Context>, - ) -> Task<()> { - self.url = query; - - Task::ready(()) - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - let http_client = workspace.read(cx).client().http_client(); - let url = self.url.clone(); - cx.spawn_in(window, async move |this, cx| { - let text = cx - .background_spawn(fetch_url_content(http_client, url.clone())) - .await?; - - this.update(cx, |this, cx| { - this.delegate.context_store.update(cx, |context_store, cx| { - context_store.add_fetched_url(url, text, cx) - }) - })??; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let added = self - .context_store - .upgrade() - .is_some_and(|context_store| context_store.read(cx).includes_url(&self.url)); - - Some( - ListItem::new(ix) - .inset(true) - .toggle_state(selected) - .child(Label::new(self.url.clone())) - .when(added, |child| { - child.disabled(true).end_slot( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }), - ) - } -} diff --git a/crates/agent_ui/src/context_picker/file_context_picker.rs b/crates/agent_ui/src/context_picker/file_context_picker.rs deleted file mode 100644 index 4f7a430840..0000000000 --- a/crates/agent_ui/src/context_picker/file_context_picker.rs +++ /dev/null @@ -1,367 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use file_icons::FileIcons; -use fuzzy::PathMatch; -use gpui::{ - App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity, -}; -use picker::{Picker, PickerDelegate}; -use project::{PathMatchCandidateSet, ProjectPath, WorktreeId}; -use ui::{ListItem, Tooltip, prelude::*}; -use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; -use workspace::Workspace; - -use crate::context_picker::ContextPicker; -use agent::context_store::{ContextStore, FileInclusion}; - -pub struct FileContextPicker { - picker: Entity>, -} - -impl FileContextPicker { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = FileContextPickerDelegate::new(context_picker, workspace, context_store); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - Self { picker } - } -} - -impl Focusable for FileContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for FileContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -pub struct FileContextPickerDelegate { - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - matches: Vec, - selected_index: usize, -} - -impl FileContextPickerDelegate { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - ) -> Self { - Self { - context_picker, - workspace, - context_store, - matches: Vec::new(), - selected_index: 0, - } - } -} - -impl PickerDelegate for FileContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search files & directories…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let Some(workspace) = self.workspace.upgrade() else { - return Task::ready(()); - }; - - let search_task = search_files(query, Arc::::default(), &workspace, cx); - - cx.spawn_in(window, async move |this, cx| { - // TODO: This should be probably be run in the background. - let paths = search_task.await; - - this.update(cx, |this, _cx| { - this.delegate.matches = paths; - }) - .log_err(); - }) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { - let Some(FileMatch { mat, .. }) = self.matches.get(self.selected_index) else { - return; - }; - - let project_path = ProjectPath { - worktree_id: WorktreeId::from_usize(mat.worktree_id), - path: mat.path.clone(), - }; - - let is_directory = mat.is_dir; - - self.context_store - .update(cx, |context_store, cx| { - if is_directory { - context_store - .add_directory(&project_path, true, cx) - .log_err(); - } else { - context_store - .add_file_from_path(project_path.clone(), true, cx) - .detach_and_log_err(cx); - } - }) - .ok(); - } - - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let FileMatch { mat, .. } = &self.matches.get(ix)?; - let workspace = self.workspace.upgrade()?; - let path_style = workspace.read(cx).path_style(cx); - - Some( - ListItem::new(ix) - .inset(true) - .toggle_state(selected) - .child(render_file_context_entry( - ElementId::named_usize("file-ctx-picker", ix), - WorktreeId::from_usize(mat.worktree_id), - &mat.path, - &mat.path_prefix, - mat.is_dir, - path_style, - self.context_store.clone(), - cx, - )), - ) - } -} - -pub struct FileMatch { - pub mat: PathMatch, - pub is_recent: bool, -} - -pub(crate) fn search_files( - query: String, - cancellation_flag: Arc, - workspace: &Entity, - cx: &App, -) -> Task> { - if query.is_empty() { - let workspace = workspace.read(cx); - let project = workspace.project().read(cx); - let recent_matches = workspace - .recent_navigation_history(Some(10), cx) - .into_iter() - .filter_map(|(project_path, _)| { - let worktree = project.worktree_for_id(project_path.worktree_id, cx)?; - Some(FileMatch { - mat: PathMatch { - score: 0., - positions: Vec::new(), - worktree_id: project_path.worktree_id.to_usize(), - path: project_path.path, - path_prefix: worktree.read(cx).root_name().into(), - distance_to_relative_ancestor: 0, - is_dir: false, - }, - is_recent: true, - }) - }); - - let file_matches = project.worktrees(cx).flat_map(|worktree| { - let worktree = worktree.read(cx); - worktree.entries(false, 0).map(move |entry| FileMatch { - mat: PathMatch { - score: 0., - positions: Vec::new(), - worktree_id: worktree.id().to_usize(), - path: entry.path.clone(), - path_prefix: worktree.root_name().into(), - distance_to_relative_ancestor: 0, - is_dir: entry.is_dir(), - }, - is_recent: false, - }) - }); - - Task::ready(recent_matches.chain(file_matches).collect()) - } else { - let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); - let candidate_sets = worktrees - .into_iter() - .map(|worktree| { - let worktree = worktree.read(cx); - - PathMatchCandidateSet { - snapshot: worktree.snapshot(), - include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored), - include_root_name: true, - candidates: project::Candidates::Entries, - } - }) - .collect::>(); - - let executor = cx.background_executor().clone(); - cx.foreground_executor().spawn(async move { - fuzzy::match_path_sets( - candidate_sets.as_slice(), - query.as_str(), - &None, - false, - 100, - &cancellation_flag, - executor, - ) - .await - .into_iter() - .map(|mat| FileMatch { - mat, - is_recent: false, - }) - .collect::>() - }) - } -} - -pub fn extract_file_name_and_directory( - path: &RelPath, - path_prefix: &RelPath, - path_style: PathStyle, -) -> (SharedString, Option) { - let full_path = path_prefix.join(path); - let file_name = full_path.file_name().unwrap_or_default(); - let display_path = full_path.display(path_style); - let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len()); - ( - file_name.to_string().into(), - Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()), - ) -} - -pub fn render_file_context_entry( - id: ElementId, - worktree_id: WorktreeId, - path: &Arc, - path_prefix: &Arc, - is_directory: bool, - path_style: PathStyle, - context_store: WeakEntity, - cx: &App, -) -> Stateful
{ - let (file_name, directory) = extract_file_name_and_directory(path, path_prefix, path_style); - - let added = context_store.upgrade().and_then(|context_store| { - let project_path = ProjectPath { - worktree_id, - path: path.clone(), - }; - if is_directory { - context_store - .read(cx) - .path_included_in_directory(&project_path, cx) - } else { - context_store.read(cx).file_path_included(&project_path, cx) - } - }); - - let file_icon = if is_directory { - FileIcons::get_folder_icon(false, path.as_std_path(), cx) - } else { - FileIcons::get_icon(path.as_std_path(), cx) - } - .map(Icon::from_path) - .unwrap_or_else(|| Icon::new(IconName::File)); - - h_flex() - .id(id) - .gap_1p5() - .w_full() - .child(file_icon.size(IconSize::Small).color(Color::Muted)) - .child( - h_flex() - .gap_1() - .child(Label::new(file_name)) - .children(directory.map(|directory| { - Label::new(directory) - .size(LabelSize::Small) - .color(Color::Muted) - })), - ) - .when_some(added, |el, added| match added { - FileInclusion::Direct => el.child( - h_flex() - .w_full() - .justify_end() - .gap_0p5() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ), - FileInclusion::InDirectory { full_path } => { - let directory_full_path = full_path.to_string_lossy().into_owned(); - - el.child( - h_flex() - .w_full() - .justify_end() - .gap_0p5() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Included").size(LabelSize::Small)), - ) - .tooltip(Tooltip::text(format!("in {directory_full_path}"))) - } - }) -} diff --git a/crates/agent_ui/src/context_picker/rules_context_picker.rs b/crates/agent_ui/src/context_picker/rules_context_picker.rs deleted file mode 100644 index 677011577a..0000000000 --- a/crates/agent_ui/src/context_picker/rules_context_picker.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; -use picker::{Picker, PickerDelegate}; -use prompt_store::{PromptId, PromptStore, UserPromptId}; -use ui::{ListItem, prelude::*}; -use util::ResultExt as _; - -use crate::context_picker::ContextPicker; -use agent::context::RULES_ICON; -use agent::context_store::{self, ContextStore}; - -pub struct RulesContextPicker { - picker: Entity>, -} - -impl RulesContextPicker { - pub fn new( - prompt_store: Entity, - context_picker: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = RulesContextPickerDelegate::new(prompt_store, context_picker, context_store); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - RulesContextPicker { picker } - } -} - -impl Focusable for RulesContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for RulesContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -#[derive(Debug, Clone)] -pub struct RulesContextEntry { - pub prompt_id: UserPromptId, - pub title: SharedString, -} - -pub struct RulesContextPickerDelegate { - prompt_store: Entity, - context_picker: WeakEntity, - context_store: WeakEntity, - matches: Vec, - selected_index: usize, -} - -impl RulesContextPickerDelegate { - pub fn new( - prompt_store: Entity, - context_picker: WeakEntity, - context_store: WeakEntity, - ) -> Self { - RulesContextPickerDelegate { - prompt_store, - context_picker, - context_store, - matches: Vec::new(), - selected_index: 0, - } - } -} - -impl PickerDelegate for RulesContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search available rules…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let search_task = search_rules( - query, - Arc::new(AtomicBool::default()), - &self.prompt_store, - cx, - ); - cx.spawn_in(window, async move |this, cx| { - let matches = search_task.await; - this.update(cx, |this, cx| { - this.delegate.matches = matches; - this.delegate.selected_index = 0; - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index) else { - return; - }; - - self.context_store - .update(cx, |context_store, cx| { - context_store.add_rules(entry.prompt_id, true, cx) - }) - .log_err(); - } - - fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let thread = &self.matches.get(ix)?; - - Some(ListItem::new(ix).inset(true).toggle_state(selected).child( - render_thread_context_entry(thread, self.context_store.clone(), cx), - )) - } -} - -pub fn render_thread_context_entry( - user_rules: &RulesContextEntry, - context_store: WeakEntity, - cx: &mut App, -) -> Div { - let added = context_store.upgrade().is_some_and(|context_store| { - context_store - .read(cx) - .includes_user_rules(user_rules.prompt_id) - }); - - h_flex() - .gap_1p5() - .w_full() - .justify_between() - .child( - h_flex() - .gap_1p5() - .max_w_72() - .child( - Icon::new(RULES_ICON) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new(user_rules.title.clone()).truncate()), - ) - .when(added, |el| { - el.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }) -} - -pub(crate) fn search_rules( - query: String, - cancellation_flag: Arc, - prompt_store: &Entity, - cx: &mut App, -) -> Task> { - let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx); - cx.background_spawn(async move { - search_task - .await - .into_iter() - .flat_map(|metadata| { - // Default prompts are filtered out as they are automatically included. - if metadata.default { - None - } else { - match metadata.id { - PromptId::EditWorkflow => None, - PromptId::User { uuid } => Some(RulesContextEntry { - prompt_id: uuid, - title: metadata.title?, - }), - } - } - }) - .collect::>() - }) -} diff --git a/crates/agent_ui/src/context_picker/symbol_context_picker.rs b/crates/agent_ui/src/context_picker/symbol_context_picker.rs deleted file mode 100644 index 5b89f09de8..0000000000 --- a/crates/agent_ui/src/context_picker/symbol_context_picker.rs +++ /dev/null @@ -1,415 +0,0 @@ -use std::cmp::Reverse; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use anyhow::{Result, anyhow}; -use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{ - App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity, -}; -use ordered_float::OrderedFloat; -use picker::{Picker, PickerDelegate}; -use project::lsp_store::SymbolLocation; -use project::{DocumentSymbol, Symbol}; -use ui::{ListItem, prelude::*}; -use util::ResultExt as _; -use workspace::Workspace; - -use crate::context_picker::ContextPicker; -use agent::context::AgentContextHandle; -use agent::context_store::ContextStore; - -pub struct SymbolContextPicker { - picker: Entity>, -} - -impl SymbolContextPicker { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = SymbolContextPickerDelegate::new(context_picker, workspace, context_store); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - Self { picker } - } -} - -impl Focusable for SymbolContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for SymbolContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -pub struct SymbolContextPickerDelegate { - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - matches: Vec, - selected_index: usize, -} - -impl SymbolContextPickerDelegate { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - ) -> Self { - Self { - context_picker, - workspace, - context_store, - matches: Vec::new(), - selected_index: 0, - } - } -} - -impl PickerDelegate for SymbolContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search symbols…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let Some(workspace) = self.workspace.upgrade() else { - return Task::ready(()); - }; - - let search_task = search_symbols(query, Arc::::default(), &workspace, cx); - let context_store = self.context_store.clone(); - cx.spawn_in(window, async move |this, cx| { - let symbols = search_task.await; - - let symbol_entries = context_store - .read_with(cx, |context_store, cx| { - compute_symbol_entries(symbols, context_store, cx) - }) - .log_err() - .unwrap_or_default(); - - this.update(cx, |this, _cx| { - this.delegate.matches = symbol_entries; - }) - .log_err(); - }) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { - let Some(mat) = self.matches.get(self.selected_index) else { - return; - }; - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - let add_symbol_task = add_symbol( - mat.symbol.clone(), - true, - workspace, - self.context_store.clone(), - cx, - ); - - let selected_index = self.selected_index; - cx.spawn(async move |this, cx| { - let (_, included) = add_symbol_task.await?; - this.update(cx, |this, _| { - if let Some(mat) = this.delegate.matches.get_mut(selected_index) { - mat.is_included = included; - } - }) - }) - .detach_and_log_err(cx); - } - - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - _: &mut Context>, - ) -> Option { - let mat = &self.matches.get(ix)?; - - Some(ListItem::new(ix).inset(true).toggle_state(selected).child( - render_symbol_context_entry(ElementId::named_usize("symbol-ctx-picker", ix), mat), - )) - } -} - -pub(crate) struct SymbolEntry { - pub symbol: Symbol, - pub is_included: bool, -} - -pub(crate) fn add_symbol( - symbol: Symbol, - remove_if_exists: bool, - workspace: Entity, - context_store: WeakEntity, - cx: &mut App, -) -> Task, bool)>> { - let project = workspace.read(cx).project().clone(); - let open_buffer_task = project.update(cx, |project, cx| { - let SymbolLocation::InProject(symbol_path) = &symbol.path else { - return Task::ready(Err(anyhow!("can't add symbol from outside of project"))); - }; - project.open_buffer(symbol_path.clone(), cx) - }); - cx.spawn(async move |cx| { - let buffer = open_buffer_task.await?; - let document_symbols = project - .update(cx, |project, cx| project.document_symbols(&buffer, cx))? - .await?; - - // Try to find a matching document symbol. Document symbols include - // not only the symbol itself (e.g. function name), but they also - // include the context that they contain (e.g. function body). - let (name, range, enclosing_range) = if let Some(DocumentSymbol { - name, - range, - selection_range, - .. - }) = - find_matching_symbol(&symbol, document_symbols.as_slice()) - { - (name, selection_range, range) - } else { - // If we do not find a matching document symbol, fall back to - // just the symbol itself - (symbol.name, symbol.range.clone(), symbol.range) - }; - - let (range, enclosing_range) = buffer.read_with(cx, |buffer, _| { - ( - buffer.anchor_after(range.start)..buffer.anchor_before(range.end), - buffer.anchor_after(enclosing_range.start) - ..buffer.anchor_before(enclosing_range.end), - ) - })?; - - context_store.update(cx, move |context_store, cx| { - context_store.add_symbol( - buffer, - name.into(), - range, - enclosing_range, - remove_if_exists, - cx, - ) - }) - }) -} - -fn find_matching_symbol(symbol: &Symbol, candidates: &[DocumentSymbol]) -> Option { - let mut candidates = candidates.iter(); - let mut candidate = candidates.next()?; - - loop { - if candidate.range.start > symbol.range.end { - return None; - } - if candidate.range.end < symbol.range.start { - candidate = candidates.next()?; - continue; - } - if candidate.selection_range == symbol.range { - return Some(candidate.clone()); - } - if candidate.range.start <= symbol.range.start && symbol.range.end <= candidate.range.end { - candidates = candidate.children.iter(); - candidate = candidates.next()?; - continue; - } - return None; - } -} - -pub struct SymbolMatch { - pub symbol: Symbol, -} - -pub(crate) fn search_symbols( - query: String, - cancellation_flag: Arc, - workspace: &Entity, - cx: &mut App, -) -> Task> { - let symbols_task = workspace.update(cx, |workspace, cx| { - workspace - .project() - .update(cx, |project, cx| project.symbols(&query, cx)) - }); - let project = workspace.read(cx).project().clone(); - cx.spawn(async move |cx| { - let Some(symbols) = symbols_task.await.log_err() else { - return Vec::new(); - }; - let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> = - project - .update(cx, |project, cx| { - symbols - .iter() - .enumerate() - .map(|(id, symbol)| { - StringMatchCandidate::new(id, symbol.label.filter_text()) - }) - .partition(|candidate| match &symbols[candidate.id].path { - SymbolLocation::InProject(project_path) => project - .entry_for_path(project_path, cx) - .is_some_and(|e| !e.is_ignored), - SymbolLocation::OutsideProject { .. } => false, - }) - }) - .log_err() - else { - return Vec::new(); - }; - - const MAX_MATCHES: usize = 100; - let mut visible_matches = cx.background_executor().block(fuzzy::match_strings( - &visible_match_candidates, - &query, - false, - true, - MAX_MATCHES, - &cancellation_flag, - cx.background_executor().clone(), - )); - let mut external_matches = cx.background_executor().block(fuzzy::match_strings( - &external_match_candidates, - &query, - false, - true, - MAX_MATCHES - visible_matches.len().min(MAX_MATCHES), - &cancellation_flag, - cx.background_executor().clone(), - )); - let sort_key_for_match = |mat: &StringMatch| { - let symbol = &symbols[mat.candidate_id]; - (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text()) - }; - - visible_matches.sort_unstable_by_key(sort_key_for_match); - external_matches.sort_unstable_by_key(sort_key_for_match); - let mut matches = visible_matches; - matches.append(&mut external_matches); - - matches - .into_iter() - .map(|mut mat| { - let symbol = symbols[mat.candidate_id].clone(); - let filter_start = symbol.label.filter_range.start; - for position in &mut mat.positions { - *position += filter_start; - } - SymbolMatch { symbol } - }) - .collect() - }) -} - -fn compute_symbol_entries( - symbols: Vec, - context_store: &ContextStore, - cx: &App, -) -> Vec { - symbols - .into_iter() - .map(|SymbolMatch { symbol, .. }| SymbolEntry { - is_included: context_store.includes_symbol(&symbol, cx), - symbol, - }) - .collect::>() -} - -pub fn render_symbol_context_entry(id: ElementId, entry: &SymbolEntry) -> Stateful
{ - let path = match &entry.symbol.path { - SymbolLocation::InProject(project_path) => { - project_path.path.file_name().unwrap_or_default().into() - } - SymbolLocation::OutsideProject { - abs_path, - signature: _, - } => abs_path - .file_name() - .map(|f| f.to_string_lossy()) - .unwrap_or_default(), - }; - let symbol_location = format!("{} L{}", path, entry.symbol.range.start.0.row + 1); - - h_flex() - .id(id) - .gap_1p5() - .w_full() - .child( - Icon::new(IconName::Code) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - h_flex() - .gap_1() - .child(Label::new(&entry.symbol.name)) - .child( - Label::new(symbol_location) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .when(entry.is_included, |el| { - el.child( - h_flex() - .w_full() - .justify_end() - .gap_0p5() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }) -} diff --git a/crates/agent_ui/src/context_picker/thread_context_picker.rs b/crates/agent_ui/src/context_picker/thread_context_picker.rs deleted file mode 100644 index 9e843779c2..0000000000 --- a/crates/agent_ui/src/context_picker/thread_context_picker.rs +++ /dev/null @@ -1,361 +0,0 @@ -use std::path::Path; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use chrono::{DateTime, Utc}; -use fuzzy::StringMatchCandidate; -use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; -use picker::{Picker, PickerDelegate}; -use ui::{ListItem, prelude::*}; - -use crate::context_picker::ContextPicker; -use agent::{ - ThreadId, - context_store::{self, ContextStore}, - thread_store::{TextThreadStore, ThreadStore}, -}; - -pub struct ThreadContextPicker { - picker: Entity>, -} - -impl ThreadContextPicker { - pub fn new( - thread_store: WeakEntity, - text_thread_context_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = ThreadContextPickerDelegate::new( - thread_store, - text_thread_context_store, - context_picker, - context_store, - ); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - ThreadContextPicker { picker } - } -} - -impl Focusable for ThreadContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for ThreadContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -#[derive(Debug, Clone)] -pub enum ThreadContextEntry { - Thread { - id: ThreadId, - title: SharedString, - }, - Context { - path: Arc, - title: SharedString, - }, -} - -impl ThreadContextEntry { - pub fn title(&self) -> &SharedString { - match self { - Self::Thread { title, .. } => title, - Self::Context { title, .. } => title, - } - } -} - -pub struct ThreadContextPickerDelegate { - thread_store: WeakEntity, - text_thread_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - matches: Vec, - selected_index: usize, -} - -impl ThreadContextPickerDelegate { - pub fn new( - thread_store: WeakEntity, - text_thread_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - ) -> Self { - ThreadContextPickerDelegate { - thread_store, - context_picker, - context_store, - text_thread_store, - matches: Vec::new(), - selected_index: 0, - } - } -} - -impl PickerDelegate for ThreadContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search threads…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let Some((thread_store, text_thread_context_store)) = self - .thread_store - .upgrade() - .zip(self.text_thread_store.upgrade()) - else { - return Task::ready(()); - }; - - let search_task = search_threads( - query, - Arc::new(AtomicBool::default()), - thread_store, - text_thread_context_store, - cx, - ); - cx.spawn_in(window, async move |this, cx| { - let matches = search_task.await; - this.update(cx, |this, cx| { - this.delegate.matches = matches.into_iter().map(|mat| mat.thread).collect(); - this.delegate.selected_index = 0; - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index) else { - return; - }; - - match entry { - ThreadContextEntry::Thread { id, .. } => { - let Some(thread_store) = self.thread_store.upgrade() else { - return; - }; - let open_thread_task = - thread_store.update(cx, |this, cx| this.open_thread(id, window, cx)); - - cx.spawn(async move |this, cx| { - let thread = open_thread_task.await?; - this.update(cx, |this, cx| { - this.delegate - .context_store - .update(cx, |context_store, cx| { - context_store.add_thread(thread, true, cx) - }) - .ok(); - }) - }) - .detach_and_log_err(cx); - } - ThreadContextEntry::Context { path, .. } => { - let Some(text_thread_store) = self.text_thread_store.upgrade() else { - return; - }; - let task = text_thread_store - .update(cx, |this, cx| this.open_local_context(path.clone(), cx)); - - cx.spawn(async move |this, cx| { - let thread = task.await?; - this.update(cx, |this, cx| { - this.delegate - .context_store - .update(cx, |context_store, cx| { - context_store.add_text_thread(thread, true, cx) - }) - .ok(); - }) - }) - .detach_and_log_err(cx); - } - } - } - - fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let thread = &self.matches.get(ix)?; - - Some(ListItem::new(ix).inset(true).toggle_state(selected).child( - render_thread_context_entry(thread, self.context_store.clone(), cx), - )) - } -} - -pub fn render_thread_context_entry( - entry: &ThreadContextEntry, - context_store: WeakEntity, - cx: &mut App, -) -> Div { - let is_added = match entry { - ThreadContextEntry::Thread { id, .. } => context_store - .upgrade() - .is_some_and(|ctx_store| ctx_store.read(cx).includes_thread(id)), - ThreadContextEntry::Context { path, .. } => context_store - .upgrade() - .is_some_and(|ctx_store| ctx_store.read(cx).includes_text_thread(path)), - }; - - h_flex() - .gap_1p5() - .w_full() - .justify_between() - .child( - h_flex() - .gap_1p5() - .max_w_72() - .child( - Icon::new(IconName::Thread) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new(entry.title().clone()).truncate()), - ) - .when(is_added, |el| { - el.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }) -} - -#[derive(Clone)] -pub struct ThreadMatch { - pub thread: ThreadContextEntry, - pub is_recent: bool, -} - -pub fn unordered_thread_entries( - thread_store: Entity, - text_thread_store: Entity, - cx: &App, -) -> impl Iterator, ThreadContextEntry)> { - let threads = thread_store - .read(cx) - .reverse_chronological_threads() - .map(|thread| { - ( - thread.updated_at, - ThreadContextEntry::Thread { - id: thread.id.clone(), - title: thread.summary.clone(), - }, - ) - }); - - let text_threads = text_thread_store - .read(cx) - .unordered_contexts() - .map(|context| { - ( - context.mtime.to_utc(), - ThreadContextEntry::Context { - path: context.path.clone(), - title: context.title.clone(), - }, - ) - }); - - threads.chain(text_threads) -} - -pub(crate) fn search_threads( - query: String, - cancellation_flag: Arc, - thread_store: Entity, - text_thread_store: Entity, - cx: &mut App, -) -> Task> { - let mut threads = - unordered_thread_entries(thread_store, text_thread_store, cx).collect::>(); - threads.sort_unstable_by_key(|(updated_at, _)| std::cmp::Reverse(*updated_at)); - - let executor = cx.background_executor().clone(); - cx.background_spawn(async move { - if query.is_empty() { - threads - .into_iter() - .map(|(_, thread)| ThreadMatch { - thread, - is_recent: false, - }) - .collect() - } else { - let candidates = threads - .iter() - .enumerate() - .map(|(id, (_, thread))| StringMatchCandidate::new(id, thread.title())) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - 100, - &cancellation_flag, - executor, - ) - .await; - - matches - .into_iter() - .map(|mat| ThreadMatch { - thread: threads[mat.candidate_id].1.clone(), - is_recent: false, - }) - .collect() - } - }) -} diff --git a/crates/agent_ui/src/context_strip.rs b/crates/agent_ui/src/context_strip.rs deleted file mode 100644 index b75b933de4..0000000000 --- a/crates/agent_ui/src/context_strip.rs +++ /dev/null @@ -1,625 +0,0 @@ -use crate::{ - AcceptSuggestedContext, AgentPanel, FocusDown, FocusLeft, FocusRight, FocusUp, - ModelUsageContext, RemoveAllContext, RemoveFocusedContext, ToggleContextPicker, - context_picker::ContextPicker, - ui::{AddedContext, ContextPill}, -}; -use agent::context_store::SuggestedContext; -use agent::{ - context::AgentContextHandle, - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, -}; -use collections::HashSet; -use editor::Editor; -use gpui::{ - App, Bounds, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, Task, WeakEntity, -}; -use itertools::Itertools; -use project::ProjectItem; -use rope::Point; -use std::rc::Rc; -use text::ToPoint as _; -use ui::{PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; -use util::ResultExt as _; -use workspace::Workspace; -use zed_actions::assistant::OpenRulesLibrary; - -pub struct ContextStrip { - context_store: Entity, - context_picker: Entity, - context_picker_menu_handle: PopoverMenuHandle, - focus_handle: FocusHandle, - suggest_context_kind: SuggestContextKind, - workspace: WeakEntity, - thread_store: Option>, - _subscriptions: Vec, - focused_index: Option, - children_bounds: Option>>, - model_usage_context: ModelUsageContext, -} - -impl ContextStrip { - pub fn new( - context_store: Entity, - workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, - context_picker_menu_handle: PopoverMenuHandle, - suggest_context_kind: SuggestContextKind, - model_usage_context: ModelUsageContext, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let context_picker = cx.new(|cx| { - ContextPicker::new( - workspace.clone(), - thread_store.clone(), - text_thread_store, - context_store.downgrade(), - window, - cx, - ) - }); - - let focus_handle = cx.focus_handle(); - - let subscriptions = vec![ - cx.observe(&context_store, |_, _, cx| cx.notify()), - cx.subscribe_in(&context_picker, window, Self::handle_context_picker_event), - cx.on_focus(&focus_handle, window, Self::handle_focus), - cx.on_blur(&focus_handle, window, Self::handle_blur), - ]; - - Self { - context_store: context_store.clone(), - context_picker, - context_picker_menu_handle, - focus_handle, - suggest_context_kind, - workspace, - thread_store, - _subscriptions: subscriptions, - focused_index: None, - children_bounds: None, - model_usage_context, - } - } - - /// Whether or not the context strip has items to display - pub fn has_context_items(&self, cx: &App) -> bool { - self.context_store.read(cx).context().next().is_some() - || self.suggested_context(cx).is_some() - } - - fn added_contexts(&self, cx: &App) -> Vec { - if let Some(workspace) = self.workspace.upgrade() { - let project = workspace.read(cx).project().read(cx); - let prompt_store = self - .thread_store - .as_ref() - .and_then(|thread_store| thread_store.upgrade()) - .and_then(|thread_store| thread_store.read(cx).prompt_store().as_ref()); - - let current_model = self.model_usage_context.language_model(cx); - - self.context_store - .read(cx) - .context() - .flat_map(|context| { - AddedContext::new_pending( - context.clone(), - prompt_store, - project, - current_model.as_ref(), - cx, - ) - }) - .collect::>() - } else { - Vec::new() - } - } - - fn suggested_context(&self, cx: &App) -> Option { - match self.suggest_context_kind { - SuggestContextKind::Thread => self.suggested_thread(cx), - } - } - - fn suggested_thread(&self, cx: &App) -> Option { - if !self.context_picker.read(cx).allow_threads() { - return None; - } - - let workspace = self.workspace.upgrade()?; - let panel = workspace.read(cx).panel::(cx)?.read(cx); - - if let Some(active_context_editor) = panel.active_context_editor() { - let context = active_context_editor.read(cx).context(); - let weak_context = context.downgrade(); - let context = context.read(cx); - let path = context.path()?; - - if self.context_store.read(cx).includes_text_thread(path) { - return None; - } - - Some(SuggestedContext::TextThread { - name: context.summary().or_default(), - context: weak_context, - }) - } else { - None - } - } - - fn handle_context_picker_event( - &mut self, - _picker: &Entity, - _event: &DismissEvent, - _window: &mut Window, - cx: &mut Context, - ) { - cx.emit(ContextStripEvent::PickerDismissed); - } - - fn handle_focus(&mut self, _window: &mut Window, cx: &mut Context) { - self.focused_index = self.last_pill_index(); - cx.notify(); - } - - fn handle_blur(&mut self, _window: &mut Window, cx: &mut Context) { - self.focused_index = None; - cx.notify(); - } - - fn focus_left(&mut self, _: &FocusLeft, _window: &mut Window, cx: &mut Context) { - self.focused_index = match self.focused_index { - Some(index) if index > 0 => Some(index - 1), - _ => self.last_pill_index(), - }; - - cx.notify(); - } - - fn focus_right(&mut self, _: &FocusRight, _window: &mut Window, cx: &mut Context) { - let Some(last_index) = self.last_pill_index() else { - return; - }; - - self.focused_index = match self.focused_index { - Some(index) if index < last_index => Some(index + 1), - _ => Some(0), - }; - - cx.notify(); - } - - fn focus_up(&mut self, _: &FocusUp, _window: &mut Window, cx: &mut Context) { - let Some(focused_index) = self.focused_index else { - return; - }; - - if focused_index == 0 { - return cx.emit(ContextStripEvent::BlurredUp); - } - - let Some((focused, pills)) = self.focused_bounds(focused_index) else { - return; - }; - - let iter = pills[..focused_index].iter().enumerate().rev(); - self.focused_index = Self::find_best_horizontal_match(focused, iter).or(Some(0)); - cx.notify(); - } - - fn focus_down(&mut self, _: &FocusDown, _window: &mut Window, cx: &mut Context) { - let Some(focused_index) = self.focused_index else { - return; - }; - - let last_index = self.last_pill_index(); - - if self.focused_index == last_index { - return cx.emit(ContextStripEvent::BlurredDown); - } - - let Some((focused, pills)) = self.focused_bounds(focused_index) else { - return; - }; - - let iter = pills.iter().enumerate().skip(focused_index + 1); - self.focused_index = Self::find_best_horizontal_match(focused, iter).or(last_index); - cx.notify(); - } - - fn focused_bounds(&self, focused: usize) -> Option<(&Bounds, &[Bounds])> { - let pill_bounds = self.pill_bounds()?; - let focused = pill_bounds.get(focused)?; - - Some((focused, pill_bounds)) - } - - fn pill_bounds(&self) -> Option<&[Bounds]> { - let bounds = self.children_bounds.as_ref()?; - let eraser = if bounds.len() < 3 { 0 } else { 1 }; - let pills = &bounds[1..bounds.len() - eraser]; - - if pills.is_empty() { None } else { Some(pills) } - } - - fn last_pill_index(&self) -> Option { - Some(self.pill_bounds()?.len() - 1) - } - - fn find_best_horizontal_match<'a>( - focused: &'a Bounds, - iter: impl Iterator)>, - ) -> Option { - let mut best = None; - - let focused_left = focused.left(); - let focused_right = focused.right(); - - for (index, probe) in iter { - if probe.origin.y == focused.origin.y { - continue; - } - - let overlap = probe.right().min(focused_right) - probe.left().max(focused_left); - - best = match best { - Some((_, prev_overlap, y)) if probe.origin.y != y || prev_overlap > overlap => { - break; - } - Some(_) | None => Some((index, overlap, probe.origin.y)), - }; - } - - best.map(|(index, _, _)| index) - } - - fn open_context(&mut self, context: &AgentContextHandle, window: &mut Window, cx: &mut App) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - match context { - AgentContextHandle::File(file_context) => { - if let Some(project_path) = file_context.project_path(cx) { - workspace.update(cx, |workspace, cx| { - workspace - .open_path(project_path, None, true, window, cx) - .detach_and_log_err(cx); - }); - } - } - - AgentContextHandle::Directory(directory_context) => { - let entry_id = directory_context.entry_id; - workspace.update(cx, |workspace, cx| { - workspace.project().update(cx, |_project, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry_id)); - }) - }) - } - - AgentContextHandle::Symbol(symbol_context) => { - let buffer = symbol_context.buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let snapshot = buffer.snapshot(); - let target_position = symbol_context.range.start.to_point(&snapshot); - open_editor_at_position(project_path, target_position, &workspace, window, cx) - .detach(); - } - } - - AgentContextHandle::Selection(selection_context) => { - let buffer = selection_context.buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let snapshot = buffer.snapshot(); - let target_position = selection_context.range.start.to_point(&snapshot); - - open_editor_at_position(project_path, target_position, &workspace, window, cx) - .detach(); - } - } - - AgentContextHandle::FetchedUrl(fetched_url_context) => { - cx.open_url(&fetched_url_context.url); - } - - AgentContextHandle::Thread(_thread_context) => {} - - AgentContextHandle::TextThread(text_thread_context) => { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - let context = text_thread_context.context.clone(); - window.defer(cx, move |window, cx| { - panel.update(cx, |panel, cx| { - panel.open_prompt_editor(context, window, cx) - }); - }); - } - }) - } - - AgentContextHandle::Rules(rules_context) => window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(rules_context.prompt_id.0), - }), - cx, - ), - - AgentContextHandle::Image(_) => {} - } - } - - fn remove_focused_context( - &mut self, - _: &RemoveFocusedContext, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(index) = self.focused_index { - let added_contexts = self.added_contexts(cx); - let Some(context) = added_contexts.get(index) else { - return; - }; - - self.context_store.update(cx, |this, cx| { - this.remove_context(&context.handle, cx); - }); - - let is_now_empty = added_contexts.len() == 1; - if is_now_empty { - cx.emit(ContextStripEvent::BlurredEmpty); - } else { - self.focused_index = Some(index.saturating_sub(1)); - cx.notify(); - } - } - } - - fn is_suggested_focused(&self, added_contexts: &Vec) -> bool { - // We only suggest one item after the actual context - self.focused_index == Some(added_contexts.len()) - } - - fn accept_suggested_context( - &mut self, - _: &AcceptSuggestedContext, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(suggested) = self.suggested_context(cx) - && self.is_suggested_focused(&self.added_contexts(cx)) - { - self.add_suggested_context(&suggested, cx); - } - } - - fn add_suggested_context(&mut self, suggested: &SuggestedContext, cx: &mut Context) { - self.context_store.update(cx, |context_store, cx| { - context_store.add_suggested_context(suggested, cx) - }); - cx.notify(); - } -} - -impl Focusable for ContextStrip { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for ContextStrip { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let context_picker = self.context_picker.clone(); - let focus_handle = self.focus_handle.clone(); - - let added_contexts = self.added_contexts(cx); - let dupe_names = added_contexts - .iter() - .map(|c| c.name.clone()) - .sorted() - .tuple_windows() - .filter(|(a, b)| a == b) - .map(|(a, _)| a) - .collect::>(); - let no_added_context = added_contexts.is_empty(); - - let suggested_context = self.suggested_context(cx).map(|suggested_context| { - ( - suggested_context, - self.is_suggested_focused(&added_contexts), - ) - }); - - h_flex() - .flex_wrap() - .gap_1() - .track_focus(&focus_handle) - .key_context("ContextStrip") - .on_action(cx.listener(Self::focus_up)) - .on_action(cx.listener(Self::focus_right)) - .on_action(cx.listener(Self::focus_down)) - .on_action(cx.listener(Self::focus_left)) - .on_action(cx.listener(Self::remove_focused_context)) - .on_action(cx.listener(Self::accept_suggested_context)) - .on_children_prepainted({ - let entity = cx.entity().downgrade(); - move |children_bounds, _window, cx| { - entity - .update(cx, |this, _| { - this.children_bounds = Some(children_bounds); - }) - .ok(); - } - }) - .child( - PopoverMenu::new("context-picker") - .menu({ - let context_picker = context_picker.clone(); - move |window, cx| { - context_picker.update(cx, |this, cx| { - this.init(window, cx); - }); - - Some(context_picker.clone()) - } - }) - .on_open({ - let context_picker = context_picker.downgrade(); - Rc::new(move |window, cx| { - context_picker - .update(cx, |context_picker, cx| { - context_picker.select_first(window, cx); - }) - .ok(); - }) - }) - .trigger_with_tooltip( - IconButton::new("add-context", IconName::Plus) - .icon_size(IconSize::Small) - .style(ui::ButtonStyle::Filled), - { - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Add Context", - &ToggleContextPicker, - &focus_handle, - window, - cx, - ) - } - }, - ) - .attach(gpui::Corner::TopLeft) - .anchor(gpui::Corner::BottomLeft) - .offset(gpui::Point { - x: px(0.0), - y: px(-2.0), - }) - .with_handle(self.context_picker_menu_handle.clone()), - ) - .children( - added_contexts - .into_iter() - .enumerate() - .map(|(i, added_context)| { - let name = added_context.name.clone(); - let context = added_context.handle.clone(); - ContextPill::added( - added_context, - dupe_names.contains(&name), - self.focused_index == Some(i), - Some({ - let context = context.clone(); - let context_store = self.context_store.clone(); - Rc::new(cx.listener(move |_this, _event, _window, cx| { - context_store.update(cx, |this, cx| { - this.remove_context(&context, cx); - }); - cx.notify(); - })) - }), - ) - .on_click({ - Rc::new(cx.listener(move |this, event: &ClickEvent, window, cx| { - if event.click_count() > 1 { - this.open_context(&context, window, cx); - } else { - this.focused_index = Some(i); - } - cx.notify(); - })) - }) - }), - ) - .when_some(suggested_context, |el, (suggested, focused)| { - el.child( - ContextPill::suggested( - suggested.name().clone(), - suggested.icon_path(), - suggested.kind(), - focused, - ) - .on_click(Rc::new(cx.listener( - move |this, _event, _window, cx| { - this.add_suggested_context(&suggested, cx); - }, - ))), - ) - }) - .when(!no_added_context, { - move |parent| { - parent.child( - IconButton::new("remove-all-context", IconName::Eraser) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Remove All Context", - &RemoveAllContext, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener({ - let focus_handle = focus_handle.clone(); - move |_this, _event, window, cx| { - focus_handle.dispatch_action(&RemoveAllContext, window, cx); - } - })), - ) - } - }) - .into_any() - } -} - -pub enum ContextStripEvent { - PickerDismissed, - BlurredEmpty, - BlurredDown, - BlurredUp, -} - -impl EventEmitter for ContextStrip {} - -pub enum SuggestContextKind { - Thread, -} - -fn open_editor_at_position( - project_path: project::ProjectPath, - target_position: Point, - workspace: &Entity, - window: &mut Window, - cx: &mut App, -) -> Task<()> { - let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_path(project_path, None, true, window, cx) - }); - window.spawn(cx, async move |cx| { - if let Some(active_editor) = open_task - .await - .log_err() - .and_then(|item| item.downcast::()) - { - active_editor - .downgrade() - .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(target_position, window, cx); - }) - .log_err(); - } - }) -} diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index d24dc4ab78..6e3ab7a162 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -1,23 +1,26 @@ +use language_model::AnthropicEventData; +use language_model::report_anthropic_event; use std::cmp; use std::mem; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use uuid::Uuid; +use crate::context::load_context; +use crate::mention_set::MentionSet; use crate::{ AgentPanel, buffer_codegen::{BufferCodegen, CodegenAlternative, CodegenEvent}, inline_prompt_editor::{CodegenStatus, InlineAssistId, PromptEditor, PromptEditorEvent}, terminal_inline_assistant::TerminalInlineAssistant, }; -use agent::{ - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, -}; +use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; use collections::{HashMap, HashSet, VecDeque, hash_map}; +use editor::EditorSnapshot; +use editor::MultiBufferOffset; use editor::RowExt; use editor::SelectionEffects; use editor::scroll::ScrollOffset; @@ -31,20 +34,19 @@ use editor::{ }, }; use fs::Fs; +use futures::{FutureExt, channel::mpsc}; use gpui::{ App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal, WeakEntity, Window, point, }; use language::{Buffer, Point, Selection, TransactionId}; -use language_model::{ - ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event, -}; +use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use project::{CodeAction, DisableAiSettings, LspAction, Project, ProjectTransaction}; use prompt_store::{PromptBuilder, PromptStore}; use settings::{Settings, SettingsStore}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::{OffsetRangeExt, ToPoint as _}; use ui::prelude::*; @@ -52,13 +54,8 @@ use util::{RangeExt, ResultExt, maybe}; use workspace::{ItemHandle, Toast, Workspace, dock::Panel, notifications::NotificationId}; use zed_actions::agent::OpenSettings; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(InlineAssistant::new(fs, prompt_builder)); cx.observe_global::(|cx| { if DisableAiSettings::get_global(cx).disable_ai { @@ -98,18 +95,14 @@ pub struct InlineAssistant { confirmed_assists: HashMap>, prompt_history: VecDeque, prompt_builder: Arc, - telemetry: Arc, fs: Arc, + _inline_assistant_completions: Option>>, } impl Global for InlineAssistant {} impl InlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: InlineAssistId::default(), next_assist_group_id: InlineAssistGroupId::default(), @@ -119,8 +112,8 @@ impl InlineAssistant { confirmed_assists: HashMap::default(), prompt_history: VecDeque::default(), prompt_builder, - telemetry, fs, + _inline_assistant_completions: None, } } @@ -209,24 +202,15 @@ impl InlineAssistant { window: &mut Window, cx: &mut App, ) { - let is_assistant2_enabled = !DisableAiSettings::get_global(cx).disable_ai; + let is_ai_enabled = !DisableAiSettings::get_global(cx).disable_ai; if let Some(editor) = item.act_as::(cx) { editor.update(cx, |editor, cx| { - if is_assistant2_enabled { - let panel = workspace.read(cx).panel::(cx); - let thread_store = panel - .as_ref() - .map(|agent_panel| agent_panel.read(cx).thread_store().downgrade()); - let text_thread_store = panel - .map(|agent_panel| agent_panel.read(cx).text_thread_store().downgrade()); - + if is_ai_enabled { editor.add_code_action_provider( Rc::new(AssistantCodeActionProvider { editor: cx.entity().downgrade(), workspace: workspace.downgrade(), - thread_store, - text_thread_store, }), window, cx, @@ -238,9 +222,6 @@ impl InlineAssistant { editor.cancel(&Default::default(), window, cx); } } - - // Remove the Assistant1 code action provider, as it still might be registered. - editor.remove_code_action_provider("assistant".into(), window, cx); } else { editor.remove_code_action_provider( ASSISTANT_CODE_ACTION_PROVIDER_ID.into(), @@ -282,9 +263,7 @@ impl InlineAssistant { let agent_panel = agent_panel.read(cx); let prompt_store = agent_panel.prompt_store().as_ref().cloned(); - let thread_store = Some(agent_panel.thread_store().downgrade()); - let text_thread_store = Some(agent_panel.text_thread_store().downgrade()); - let context_store = agent_panel.inline_assist_context_store().clone(); + let thread_store = agent_panel.thread_store().clone(); let handle_assist = |window: &mut Window, cx: &mut Context| match inline_assist_target { @@ -293,15 +272,13 @@ impl InlineAssistant { assistant.assist( &active_editor, cx.entity().downgrade(), - context_store, workspace.project().downgrade(), - prompt_store, thread_store, - text_thread_store, + prompt_store, action.prompt.clone(), window, cx, - ) + ); }) } InlineAssistTarget::Terminal(active_terminal) => { @@ -310,14 +287,13 @@ impl InlineAssistant { &active_terminal, cx.entity().downgrade(), workspace.project().downgrade(), - prompt_store, thread_store, - text_thread_store, + prompt_store, action.prompt.clone(), window, cx, - ) - }) + ); + }); } }; @@ -358,23 +334,20 @@ impl InlineAssistant { } } - pub fn assist( + fn codegen_ranges( &mut self, editor: &Entity, - workspace: WeakEntity, - context_store: Entity, - project: WeakEntity, - prompt_store: Option>, - thread_store: Option>, - text_thread_store: Option>, - initial_prompt: Option, + snapshot: &EditorSnapshot, window: &mut Window, cx: &mut App, - ) { - let (snapshot, initial_selections, newest_selection) = editor.update(cx, |editor, cx| { - let selections = editor.selections.all::(cx); - let newest_selection = editor.selections.newest::(cx); - (editor.snapshot(window, cx), selections, newest_selection) + ) -> Option<(Vec>, Selection)> { + let (initial_selections, newest_selection) = editor.update(cx, |editor, _| { + ( + editor.selections.all::(&snapshot.display_snapshot), + editor + .selections + .newest::(&snapshot.display_snapshot), + ) }); // Check if there is already an inline assistant that contains the @@ -387,7 +360,7 @@ impl InlineAssistant { && newest_selection.end.row <= range.end.row { self.focus_assist(*assist_id, window, cx); - return; + return None; } } } @@ -395,17 +368,9 @@ impl InlineAssistant { let mut selections = Vec::>::new(); let mut newest_selection = None; for mut selection in initial_selections { - if selection.end > selection.start { - selection.start.column = 0; - // If the selection ends at the start of the line, we don't want to include it. - if selection.end.column == 0 { - selection.end.row -= 1; - } - selection.end.column = snapshot - .buffer_snapshot() - .line_len(MultiBufferRow(selection.end.row)); - } else if let Some(fold) = - snapshot.crease_for_buffer_row(MultiBufferRow(selection.end.row)) + if selection.end == selection.start + && let Some(fold) = + snapshot.crease_for_buffer_row(MultiBufferRow(selection.end.row)) { selection.start = fold.range().start; selection.end = fold.range().end; @@ -432,6 +397,15 @@ impl InlineAssistant { } } } + } else { + selection.start.column = 0; + // If the selection ends at the start of the line, we don't want to include it. + if selection.end.column == 0 && selection.start.row != selection.end.row { + selection.end.row -= 1; + } + selection.end.column = snapshot + .buffer_snapshot() + .line_len(MultiBufferRow(selection.end.row)); } if let Some(prev_selection) = selections.last_mut() @@ -458,28 +432,55 @@ impl InlineAssistant { { let anchor_range = Anchor::range_in_buffer( excerpt_id, - buffer.remote_id(), buffer.anchor_before(buffer_range.start)..buffer.anchor_after(buffer_range.end), ); codegen_ranges.push(anchor_range); if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { - self.telemetry.report_assistant_event(AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, - phase: AssistantPhase::Invoked, - message_id: None, - model: model.model.telemetry_id(), - model_provider: model.provider.id().to_string(), - response_latency: None, - error_message: None, - language_name: buffer.language().map(|language| language.name().to_proto()), - }); + telemetry::event!( + "Assistant Invoked", + kind = "inline", + phase = "invoked", + model = model.model.telemetry_id(), + model_provider = model.provider.id().to_string(), + language_name = buffer.language().map(|language| language.name().to_proto()) + ); + + report_anthropic_event( + &model.model, + AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Invoked, + language_name: buffer.language().map(|language| language.name().to_proto()), + message_id: None, + }, + cx, + ); } } + Some((codegen_ranges, newest_selection)) + } + + fn batch_assist( + &mut self, + editor: &Entity, + workspace: WeakEntity, + project: WeakEntity, + thread_store: Entity, + prompt_store: Option>, + initial_prompt: Option, + window: &mut Window, + codegen_ranges: &[Range], + newest_selection: Option>, + initial_transaction_id: Option, + cx: &mut App, + ) -> Option { + let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); + let assist_group_id = self.next_assist_group_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), @@ -489,17 +490,15 @@ impl InlineAssistant { let mut assists = Vec::new(); let mut assist_to_focus = None; + for range in codegen_ranges { let assist_id = self.next_assist_id.post_inc(); let codegen = cx.new(|cx| { BufferCodegen::new( editor.read(cx).buffer().clone(), range.clone(), - None, - context_store.clone(), - project.clone(), - prompt_store.clone(), - self.telemetry.clone(), + initial_transaction_id, + session_id, self.prompt_builder.clone(), cx, ) @@ -513,35 +512,39 @@ impl InlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen.clone(), + session_id, self.fs.clone(), - context_store.clone(), - workspace.clone(), thread_store.clone(), - text_thread_store.clone(), + prompt_store.clone(), + project.clone(), + workspace.clone(), window, cx, ) }); - if assist_to_focus.is_none() { + if let Some(newest_selection) = newest_selection.as_ref() + && assist_to_focus.is_none() + { let focus_assist = if newest_selection.reversed { - range.start.to_point(snapshot) == newest_selection.start + range.start.to_point(&snapshot) == newest_selection.start } else { - range.end.to_point(snapshot) == newest_selection.end + range.end.to_point(&snapshot) == newest_selection.end }; if focus_assist { assist_to_focus = Some(assist_id); } } - let [prompt_block_id, end_block_id] = - self.insert_assist_blocks(editor, &range, &prompt_editor, cx); + let [prompt_block_id, tool_description_block_id, end_block_id] = + self.insert_assist_blocks(&editor, &range, &prompt_editor, cx); assists.push(( assist_id, - range, + range.clone(), prompt_editor, prompt_block_id, + tool_description_block_id, end_block_id, )); } @@ -550,8 +553,25 @@ impl InlineAssistant { .assists_by_editor .entry(editor.downgrade()) .or_insert_with(|| EditorInlineAssists::new(editor, window, cx)); + + let assist_to_focus = if let Some(focus_id) = assist_to_focus { + Some(focus_id) + } else if assists.len() >= 1 { + Some(assists[0].0) + } else { + None + }; + let mut assist_group = InlineAssistGroup::new(); - for (assist_id, range, prompt_editor, prompt_block_id, end_block_id) in assists { + for ( + assist_id, + range, + prompt_editor, + prompt_block_id, + tool_description_block_id, + end_block_id, + ) in assists + { let codegen = prompt_editor.read(cx).codegen().clone(); self.assists.insert( @@ -562,6 +582,7 @@ impl InlineAssistant { editor, &prompt_editor, prompt_block_id, + tool_description_block_id, end_block_id, range, codegen, @@ -573,11 +594,50 @@ impl InlineAssistant { assist_group.assist_ids.push(assist_id); editor_assists.assist_ids.push(assist_id); } + self.assist_groups.insert(assist_group_id, assist_group); + assist_to_focus + } + + pub fn assist( + &mut self, + editor: &Entity, + workspace: WeakEntity, + project: WeakEntity, + thread_store: Entity, + prompt_store: Option>, + initial_prompt: Option, + window: &mut Window, + cx: &mut App, + ) -> Option { + let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); + + let Some((codegen_ranges, newest_selection)) = + self.codegen_ranges(editor, &snapshot, window, cx) + else { + return None; + }; + + let assist_to_focus = self.batch_assist( + editor, + workspace, + project, + thread_store, + prompt_store, + initial_prompt, + window, + &codegen_ranges, + Some(newest_selection), + None, + cx, + ); + if let Some(assist_id) = assist_to_focus { self.focus_assist(assist_id, window, cx); } + + assist_to_focus } pub fn suggest_assist( @@ -588,18 +648,11 @@ impl InlineAssistant { initial_transaction_id: Option, focus: bool, workspace: Entity, + thread_store: Entity, prompt_store: Option>, - thread_store: Option>, - text_thread_store: Option>, window: &mut Window, cx: &mut App, ) -> InlineAssistId { - let assist_group_id = self.next_assist_group_id.post_inc(); - let prompt_buffer = cx.new(|cx| Buffer::local(&initial_prompt, cx)); - let prompt_buffer = cx.new(|cx| MultiBuffer::singleton(prompt_buffer, cx)); - - let assist_id = self.next_assist_id.post_inc(); - let buffer = editor.read(cx).buffer().clone(); { let snapshot = buffer.read(cx).read(cx); @@ -608,68 +661,22 @@ impl InlineAssistant { } let project = workspace.read(cx).project().downgrade(); - let context_store = cx.new(|_cx| ContextStore::new(project.clone(), thread_store.clone())); - let codegen = cx.new(|cx| { - BufferCodegen::new( - editor.read(cx).buffer().clone(), - range.clone(), - initial_transaction_id, - context_store.clone(), - project, - prompt_store, - self.telemetry.clone(), - self.prompt_builder.clone(), - cx, - ) - }); - - let editor_margins = Arc::new(Mutex::new(EditorMargins::default())); - let prompt_editor = cx.new(|cx| { - PromptEditor::new_buffer( - assist_id, - editor_margins, - self.prompt_history.clone(), - prompt_buffer.clone(), - codegen.clone(), - self.fs.clone(), - context_store, - workspace.downgrade(), - thread_store, - text_thread_store, - window, - cx, - ) - }); - - let [prompt_block_id, end_block_id] = - self.insert_assist_blocks(editor, &range, &prompt_editor, cx); - - let editor_assists = self - .assists_by_editor - .entry(editor.downgrade()) - .or_insert_with(|| EditorInlineAssists::new(editor, window, cx)); - - let mut assist_group = InlineAssistGroup::new(); - self.assists.insert( - assist_id, - InlineAssist::new( - assist_id, - assist_group_id, + let assist_id = self + .batch_assist( editor, - &prompt_editor, - prompt_block_id, - end_block_id, - range, - codegen.clone(), workspace.downgrade(), + project, + thread_store, + prompt_store, + Some(initial_prompt), window, + &[range], + None, + initial_transaction_id, cx, - ), - ); - assist_group.assist_ids.push(assist_id); - editor_assists.assist_ids.push(assist_id); - self.assist_groups.insert(assist_group_id, assist_group); + ) + .expect("batch_assist returns an id if there's only one range"); if focus { self.focus_assist(assist_id, window, cx); @@ -684,7 +691,7 @@ impl InlineAssistant { range: &Range, prompt_editor: &Entity>, cx: &mut App, - ) -> [CustomBlockId; 2] { + ) -> [CustomBlockId; 3] { let prompt_editor_height = prompt_editor.update(cx, |prompt_editor, cx| { prompt_editor .editor @@ -698,6 +705,14 @@ impl InlineAssistant { render: build_assist_editor_renderer(prompt_editor), priority: 0, }, + // Placeholder for tool description - will be updated dynamically + BlockProperties { + style: BlockStyle::Flex, + placement: BlockPlacement::Below(range.end), + height: Some(0), + render: Arc::new(|_cx| div().into_any_element()), + priority: 0, + }, BlockProperties { style: BlockStyle::Sticky, placement: BlockPlacement::Below(range.end), @@ -716,7 +731,7 @@ impl InlineAssistant { editor.update(cx, |editor, cx| { let block_ids = editor.insert_blocks(assist_blocks, None, cx); - [block_ids[0], block_ids[1]] + [block_ids[0], block_ids[1], block_ids[2]] }) } @@ -808,7 +823,9 @@ impl InlineAssistant { if editor.read(cx).selections.count() == 1 { let (selection, buffer) = editor.update(cx, |editor, cx| { ( - editor.selections.newest::(cx), + editor + .selections + .newest::(&editor.display_snapshot(cx)), editor.buffer().read(cx).snapshot(cx), ) }); @@ -839,7 +856,9 @@ impl InlineAssistant { if editor.read(cx).selections.count() == 1 { let (selection, buffer) = editor.update(cx, |editor, cx| { ( - editor.selections.newest::(cx), + editor + .selections + .newest::(&editor.display_snapshot(cx)), editor.buffer().read(cx).snapshot(cx), ) }); @@ -856,12 +875,14 @@ impl InlineAssistant { } else { let distance_from_selection = assist_range .start - .abs_diff(selection.start) - .min(assist_range.start.abs_diff(selection.end)) + .0 + .abs_diff(selection.start.0) + .min(assist_range.start.0.abs_diff(selection.end.0)) + assist_range .end - .abs_diff(selection.start) - .min(assist_range.end.abs_diff(selection.end)); + .0 + .abs_diff(selection.start.0) + .min(assist_range.end.0.abs_diff(selection.end.0)); match closest_assist_fallback { Some((_, old_distance)) => { if distance_from_selection < old_distance { @@ -938,7 +959,7 @@ impl InlineAssistant { EditorEvent::Edited { transaction_id } => { let buffer = editor.read(cx).buffer().read(cx); let edited_ranges = - buffer.edited_ranges_for_transaction::(*transaction_id, cx); + buffer.edited_ranges_for_transaction::(*transaction_id, cx); let snapshot = buffer.snapshot(cx); for assist_id in editor_assists.assist_ids.clone() { @@ -1039,8 +1060,6 @@ impl InlineAssistant { } let active_alternative = assist.codegen.read(cx).active_alternative().clone(); - let message_id = active_alternative.read(cx).message_id.clone(); - if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { let language_name = assist.editor.upgrade().and_then(|editor| { let multibuffer = editor.read(cx).buffer().read(cx); @@ -1049,28 +1068,49 @@ impl InlineAssistant { ranges .first() .and_then(|(buffer, _, _)| buffer.language()) - .map(|language| language.name()) + .map(|language| language.name().0.to_string()) }); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, + + let codegen = assist.codegen.read(cx); + let session_id = codegen.session_id(); + let message_id = active_alternative.read(cx).message_id.clone(); + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + telemetry::event!( + event_type, + phase, + session_id = session_id.to_string(), + kind = "inline", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + message_id = message_id.as_deref(), + ); + + report_anthropic_event( + &model.model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: anthropic_event_type, + language_name, message_id, - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.model.telemetry_id(), - model_provider: model.model.provider_id().to_string(), - response_latency: None, - error_message: None, - language_name: language_name.map(|name| name.to_proto()), }, - Some(self.telemetry.clone()), - cx.http_client(), - model.model.api_key(cx), - cx.background_executor(), + cx, ); } @@ -1102,6 +1142,9 @@ impl InlineAssistant { let mut to_remove = decorations.removed_line_block_ids; to_remove.insert(decorations.prompt_block_id); to_remove.insert(decorations.end_block_id); + if let Some(tool_description_block_id) = decorations.model_explanation { + to_remove.insert(tool_description_block_id); + } editor.remove_blocks(to_remove, None, cx); }); @@ -1277,7 +1320,8 @@ impl InlineAssistant { return; } - let Some(user_prompt) = assist.user_prompt(cx) else { + let Some((user_prompt, mention_set)) = assist.user_prompt(cx).zip(assist.mention_set(cx)) + else { return; }; @@ -1293,9 +1337,12 @@ impl InlineAssistant { return; }; + let context_task = load_context(&mention_set, cx).shared(); assist .codegen - .update(cx, |codegen, cx| codegen.start(model, user_prompt, cx)) + .update(cx, |codegen, cx| { + codegen.start(model, user_prompt, context_task, cx) + }) .log_err(); } @@ -1441,6 +1488,7 @@ impl InlineAssistant { multi_buffer.update(cx, |multi_buffer, cx| { multi_buffer.push_excerpts( old_buffer.clone(), + // todo(lw): buffer_start and buffer_end might come from different snapshots! Some(ExcerptRange::new(buffer_start..buffer_end)), cx, ); @@ -1452,6 +1500,7 @@ impl InlineAssistant { editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); editor.set_show_wrap_guides(false, cx); editor.set_show_gutter(false, cx); + editor.set_offset_content(false, cx); editor.scroll_manager.set_forbid_vertical_scroll(true); editor.set_read_only(true); editor.set_show_edit_predictions(Some(false), window, cx); @@ -1511,8 +1560,8 @@ impl InlineAssistant { return Some(InlineAssistTarget::Terminal(terminal_view)); } - let context_editor = agent_panel - .and_then(|panel| panel.read(cx).active_context_editor()) + let text_thread_editor = agent_panel + .and_then(|panel| panel.read(cx).active_text_thread_editor()) .and_then(|editor| { let editor = &editor.read(cx).editor().clone(); if editor.read(cx).is_focused(window) { @@ -1522,8 +1571,8 @@ impl InlineAssistant { } }); - if let Some(context_editor) = context_editor { - Some(InlineAssistTarget::Editor(context_editor)) + if let Some(text_thread_editor) = text_thread_editor { + Some(InlineAssistTarget::Editor(text_thread_editor)) } else if let Some(workspace_editor) = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) @@ -1536,6 +1585,27 @@ impl InlineAssistant { .map(InlineAssistTarget::Terminal) } } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_completion_receiver( + &mut self, + sender: mpsc::UnboundedSender>, + ) { + self._inline_assistant_completions = Some(sender); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_codegen( + &mut self, + assist_id: InlineAssistId, + cx: &mut App, + ) -> Option> { + self.assists.get(&assist_id).map(|inline_assist| { + inline_assist + .codegen + .update(cx, |codegen, _cx| codegen.active_alternative().clone()) + }) + } } struct EditorInlineAssists { @@ -1669,6 +1739,7 @@ impl InlineAssist { editor: &Entity, prompt_editor: &Entity>, prompt_block_id: CustomBlockId, + tool_description_block_id: CustomBlockId, end_block_id: CustomBlockId, range: Range, codegen: Entity, @@ -1683,7 +1754,8 @@ impl InlineAssist { decorations: Some(InlineAssistDecorations { prompt_block_id, prompt_editor: prompt_editor.clone(), - removed_line_block_ids: HashSet::default(), + removed_line_block_ids: Default::default(), + model_explanation: Some(tool_description_block_id), end_block_id, }), range, @@ -1735,6 +1807,16 @@ impl InlineAssist { && assist.decorations.is_none() && let Some(workspace) = assist.workspace.upgrade() { + #[cfg(any(test, feature = "test-support"))] + if let Some(sender) = &mut this._inline_assistant_completions { + sender + .unbounded_send(Err(anyhow::anyhow!( + "Inline assistant error: {}", + error + ))) + .ok(); + } + let error = format!("Inline assistant error: {}", error); workspace.update(cx, |workspace, cx| { struct InlineAssistantError; @@ -1745,6 +1827,11 @@ impl InlineAssist { workspace.show_toast(Toast::new(id, error), cx); }) + } else { + #[cfg(any(test, feature = "test-support"))] + if let Some(sender) = &mut this._inline_assistant_completions { + sender.unbounded_send(Ok(assist_id)).ok(); + } } if assist.decorations.is_none() { @@ -1761,23 +1848,27 @@ impl InlineAssist { let decorations = self.decorations.as_ref()?; Some(decorations.prompt_editor.read(cx).prompt(cx)) } + + fn mention_set(&self, cx: &App) -> Option> { + let decorations = self.decorations.as_ref()?; + Some(decorations.prompt_editor.read(cx).mention_set().clone()) + } } struct InlineAssistDecorations { prompt_block_id: CustomBlockId, prompt_editor: Entity>, removed_line_block_ids: HashSet, + model_explanation: Option, end_block_id: CustomBlockId, } struct AssistantCodeActionProvider { editor: WeakEntity, workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, } -const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant2"; +const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant"; impl CodeActionProvider for AssistantCodeActionProvider { fn id(&self) -> Arc { @@ -1845,11 +1936,20 @@ impl CodeActionProvider for AssistantCodeActionProvider { ) -> Task> { let editor = self.editor.clone(); let workspace = self.workspace.clone(); - let thread_store = self.thread_store.clone(); - let text_thread_store = self.text_thread_store.clone(); let prompt_store = PromptStore::global(cx); window.spawn(cx, async move |cx| { let workspace = workspace.upgrade().context("workspace was released")?; + let thread_store = cx.update(|_window, cx| { + anyhow::Ok( + workspace + .read(cx) + .panel::(cx) + .context("missing agent panel")? + .read(cx) + .thread_store() + .clone(), + ) + })??; let editor = editor.upgrade().context("editor was released")?; let range = editor .update(cx, |editor, cx| { @@ -1878,12 +1978,7 @@ impl CodeActionProvider for AssistantCodeActionProvider { } let multibuffer_snapshot = multibuffer.read(cx); - Some( - multibuffer_snapshot - .anchor_in_excerpt(excerpt_id, action.range.start)? - ..multibuffer_snapshot - .anchor_in_excerpt(excerpt_id, action.range.end)?, - ) + multibuffer_snapshot.anchor_range_in_excerpt(excerpt_id, action.range) }) })? .context("invalid range")?; @@ -1897,9 +1992,8 @@ impl CodeActionProvider for AssistantCodeActionProvider { None, true, workspace, - prompt_store, thread_store, - text_thread_store, + prompt_store, window, cx, ); @@ -1932,3 +2026,357 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } } + +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] +pub mod test { + + use std::sync::Arc; + + use agent::HistoryStore; + use assistant_text_thread::TextThreadStore; + use client::{Client, UserStore}; + use editor::{Editor, MultiBuffer, MultiBufferOffset}; + use fs::FakeFs; + use futures::channel::mpsc; + use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; + use language::Buffer; + use project::Project; + use prompt_store::PromptBuilder; + use smol::stream::StreamExt as _; + use util::test::marked_text_ranges; + use workspace::Workspace; + + use crate::InlineAssistant; + + #[derive(Debug)] + pub enum InlineAssistantOutput { + Success { + completion: Option, + description: Option, + full_buffer_text: String, + }, + Failure { + failure: String, + }, + // These fields are used for logging + #[allow(unused)] + Malformed { + completion: Option, + description: Option, + failure: Option, + }, + } + + pub fn run_inline_assistant_test( + base_buffer: String, + prompt: String, + setup: SetupF, + test: TestF, + cx: &mut TestAppContext, + ) -> InlineAssistantOutput + where + SetupF: FnOnce(&mut gpui::VisualTestContext), + TestF: FnOnce(&mut gpui::VisualTestContext), + { + let fs = FakeFs::new(cx.executor()); + let app_state = cx.update(|cx| workspace::AppState::test(cx)); + let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); + let http = Arc::new(reqwest_client::ReqwestClient::user_agent("agent tests").unwrap()); + let client = cx.update(|cx| { + cx.set_http_client(http); + Client::production(cx) + }); + let mut inline_assistant = InlineAssistant::new(fs.clone(), prompt_builder); + + let (tx, mut completion_rx) = mpsc::unbounded(); + inline_assistant.set_completion_receiver(tx); + + // Initialize settings and client + cx.update(|cx| { + gpui_tokio::init(cx); + settings::init(cx); + client::init(&client, cx); + workspace::init(app_state.clone(), cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store, client.clone(), cx); + + cx.set_global(inline_assistant); + }); + + let project = cx + .executor() + .block_test(async { Project::test(fs.clone(), [], cx).await }); + + // Create workspace with window + let (workspace, cx) = cx.add_window_view(|window, cx| { + window.activate_window(); + Workspace::new(None, project.clone(), app_state.clone(), window, cx) + }); + + setup(cx); + + let (_editor, buffer) = cx.update(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.new(|cx| Editor::for_multibuffer(multibuffer, None, window, cx)); + editor.update(cx, |editor, cx| { + let (unmarked_text, selection_ranges) = marked_text_ranges(&base_buffer, true); + editor.set_text(unmarked_text, window, cx); + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges( + selection_ranges.into_iter().map(|range| { + MultiBufferOffset(range.start)..MultiBufferOffset(range.end) + }), + ) + }) + }); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Add editor to workspace + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + + // Call assist method + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let assist_id = inline_assistant + .assist( + &editor, + workspace.downgrade(), + project.downgrade(), + history_store, // thread_store + None, // prompt_store + Some(prompt), + window, + cx, + ) + .unwrap(); + + inline_assistant.start_assist(assist_id, window, cx); + }); + + (editor, buffer) + }); + + cx.run_until_parked(); + + test(cx); + + let assist_id = cx + .executor() + .block_test(async { completion_rx.next().await }) + .unwrap() + .unwrap(); + + let (completion, description, failure) = cx.update(|_, cx| { + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let codegen = inline_assistant.get_codegen(assist_id, cx).unwrap(); + + let completion = codegen.read(cx).current_completion(); + let description = codegen.read(cx).current_description(); + let failure = codegen.read(cx).current_failure(); + + (completion, description, failure) + }) + }); + + if failure.is_some() && (completion.is_some() || description.is_some()) { + InlineAssistantOutput::Malformed { + completion, + description, + failure, + } + } else if let Some(failure) = failure { + InlineAssistantOutput::Failure { failure } + } else { + InlineAssistantOutput::Success { + completion, + description, + full_buffer_text: buffer.read_with(cx, |buffer, _| buffer.text()), + } + } + } +} + +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] +pub mod evals { + use std::str::FromStr; + + use eval_utils::{EvalOutput, NoProcessor}; + use gpui::TestAppContext; + use language_model::{LanguageModelRegistry, SelectedModel}; + use rand::{SeedableRng as _, rngs::StdRng}; + + use crate::inline_assistant::test::{InlineAssistantOutput, run_inline_assistant_test}; + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_single_cursor_edit() { + run_eval( + 20, + 1.0, + "Rename this variable to buffer_text".to_string(), + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "} + .to_string(), + exact_buffer_match(indoc::indoc! {" + struct EvalExampleStruct { + buffer_text: String, + prompt: String, + } + "}), + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_cant_do() { + run_eval( + 20, + 0.95, + "Rename the struct to EvalExampleStructNope", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_unclear() { + run_eval( + 20, + 0.95, + "Make exactly the change I want you to make", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } + + fn run_eval( + iterations: usize, + expected_pass_ratio: f32, + prompt: impl Into, + buffer: impl Into, + judge: impl Fn(InlineAssistantOutput) -> eval_utils::EvalOutput<()> + Send + Sync + 'static, + ) { + let buffer = buffer.into(); + let prompt = prompt.into(); + + eval_utils::eval(iterations, expected_pass_ratio, NoProcessor, move || { + let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); + let mut cx = TestAppContext::build(dispatcher, None); + cx.skip_drawing(); + + let output = run_inline_assistant_test( + buffer.clone(), + prompt.clone(), + |cx| { + // Reconfigure to use a real model instead of the fake one + let model_name = std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-latest".into()); + + let selected_model = SelectedModel::from_str(&model_name) + .expect("Invalid model format. Use 'provider/model-id'"); + + log::info!("Selected model: {selected_model:?}"); + + cx.update(|_, cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.select_inline_assistant_model(Some(&selected_model), cx); + }); + }); + }, + |_cx| { + log::info!("Waiting for actual response from the LLM..."); + }, + &mut cx, + ); + + cx.quit(); + + judge(output) + }); + } + + fn uncertain_output(output: InlineAssistantOutput) -> EvalOutput<()> { + match &output { + o @ InlineAssistantOutput::Success { + completion, + description, + .. + } => { + if description.is_some() && completion.is_none() { + EvalOutput::passed(format!( + "Assistant produced no completion, but a description:\n{}", + description.as_ref().unwrap() + )) + } else { + EvalOutput::failed(format!("Assistant produced a completion:\n{:?}", o)) + } + } + InlineAssistantOutput::Failure { + failure: error_message, + } => EvalOutput::passed(format!( + "Assistant produced a failure message: {}", + error_message + )), + o @ InlineAssistantOutput::Malformed { .. } => { + EvalOutput::failed(format!("Assistant produced a malformed response:\n{:?}", o)) + } + } + } + + fn exact_buffer_match( + correct_output: impl Into, + ) -> impl Fn(InlineAssistantOutput) -> EvalOutput<()> { + let correct_output = correct_output.into(); + move |output| match output { + InlineAssistantOutput::Success { + description, + full_buffer_text, + .. + } => { + if full_buffer_text == correct_output && description.is_none() { + EvalOutput::passed("Assistant output matches") + } else if full_buffer_text == correct_output { + EvalOutput::failed(format!( + "Assistant output produced an unescessary description description:\n{:?}", + description + )) + } else { + EvalOutput::failed(format!( + "Assistant output does not match expected output:\n{:?}\ndescription:\n{:?}", + full_buffer_text, description + )) + } + } + o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + } + } +} diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index f6347dcb6b..51e65447b2 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -1,46 +1,67 @@ -use agent::{ - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, -}; -use collections::VecDeque; +use agent::HistoryStore; +use collections::{HashMap, VecDeque}; use editor::actions::Paste; -use editor::display_map::EditorMargins; +use editor::code_context_menus::CodeContextMenu; +use editor::display_map::{CreaseId, EditorMargins}; +use editor::{AnchorRangeExt as _, MultiBufferOffset, ToOffset as _}; use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, actions::{MoveDown, MoveUp}, }; +use feature_flags::{FeatureFlagAppExt, InlineAssistantUseToolFeatureFlag}; use fs::Fs; use gpui::{ - AnyElement, App, ClipboardEntry, Context, CursorStyle, Entity, EventEmitter, FocusHandle, - Focusable, Subscription, TextStyle, WeakEntity, Window, + AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, + Subscription, TextStyle, TextStyleRefinement, WeakEntity, Window, actions, }; use language_model::{LanguageModel, LanguageModelRegistry}; +use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; +use project::Project; +use prompt_store::PromptStore; use settings::Settings; use std::cmp; +use std::ops::Range; use std::rc::Rc; use std::sync::Arc; use theme::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; -use workspace::Workspace; +use uuid::Uuid; +use workspace::notifications::NotificationId; +use workspace::{Toast, Workspace}; use zed_actions::agent::ToggleModelSelector; use crate::agent_model_selector::AgentModelSelector; -use crate::buffer_codegen::BufferCodegen; -use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider}; -use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; -use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases}; +use crate::buffer_codegen::{BufferCodegen, CodegenAlternative}; +use crate::completion_provider::{ + PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextType, +}; +use crate::mention_set::paste_images_as_context; +use crate::mention_set::{MentionSet, crease_for_mention}; use crate::terminal_codegen::TerminalCodegen; use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; -use crate::{RemoveAllContext, ToggleContextPicker}; + +actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]); + +enum CompletionState { + Pending, + Generated { completion_text: Option }, + Rated, +} + +struct SessionState { + session_id: Uuid, + completion: CompletionState, +} pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, - context_store: Entity, - context_strip: Entity, - context_picker_menu_handle: PopoverMenuHandle, + mention_set: Entity, + history_store: Entity, + prompt_store: Option>, + workspace: WeakEntity, model_selector: Entity, edited_since_done: bool, prompt_history: VecDeque, @@ -48,8 +69,8 @@ pub struct PromptEditor { pending_prompt: String, _codegen_subscription: Subscription, editor_subscriptions: Vec, - _context_strip_subscription: Subscription, show_rate_limit_notice: bool, + session_state: SessionState, _phantom: std::marker::PhantomData, } @@ -62,7 +83,7 @@ impl Render for PromptEditor { const RIGHT_PADDING: Pixels = px(9.); - let (left_gutter_width, right_padding) = match &self.mode { + let (left_gutter_width, right_padding, explanation) = match &self.mode { PromptEditorMode::Buffer { id: _, codegen, @@ -80,38 +101,67 @@ impl Render for PromptEditor { let left_gutter_width = gutter.full_width() + (gutter.margin / 2.0); let right_padding = editor_margins.right + RIGHT_PADDING; - (left_gutter_width, right_padding) + let active_alternative = codegen.active_alternative().read(cx); + let explanation = active_alternative + .description + .clone() + .or_else(|| active_alternative.failure.clone()); + + (left_gutter_width, right_padding, explanation) } PromptEditorMode::Terminal { .. } => { // Give the equivalent of the same left-padding that we're using on the right - (Pixels::from(40.0), Pixels::from(24.)) + (Pixels::from(40.0), Pixels::from(24.), None) } }; let bottom_padding = match &self.mode { PromptEditorMode::Buffer { .. } => rems_from_px(2.0), - PromptEditorMode::Terminal { .. } => rems_from_px(8.0), + PromptEditorMode::Terminal { .. } => rems_from_px(4.0), }; buttons.extend(self.render_buttons(window, cx)); + let menu_visible = self.is_completions_menu_visible(cx); + let add_context_button = IconButton::new("add-context", IconName::AtSign) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .when(!menu_visible, |this| { + this.tooltip(move |_window, cx| { + Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx) + }) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.trigger_completion_menu(window, cx); + })); + + let markdown = window.use_state(cx, |_, cx| Markdown::new("".into(), None, None, cx)); + + if let Some(explanation) = &explanation { + markdown.update(cx, |markdown, cx| { + markdown.reset(SharedString::from(explanation), cx); + }); + } + + let explanation_label = self + .render_markdown(markdown, markdown_style(window, cx)) + .into_any_element(); + v_flex() .key_context("PromptEditor") .capture_action(cx.listener(Self::paste)) - .bg(cx.theme().colors().editor_background) .block_mouse_except_scroll() - .gap_0p5() - .border_y_1() - .border_color(cx.theme().status().info_border) .size_full() .pt_0p5() .pb(bottom_padding) .pr(right_padding) + .gap_0p5() + .justify_center() + .border_y_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) .child( h_flex() - .items_start() - .cursor(CursorStyle::Arrow) - .on_action(cx.listener(Self::toggle_context_picker)) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { this.model_selector .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); @@ -120,19 +170,20 @@ impl Render for PromptEditor { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_down)) - .on_action(cx.listener(Self::remove_all_context)) + .on_action(cx.listener(Self::thumbs_up)) + .on_action(cx.listener(Self::thumbs_down)) .capture_action(cx.listener(Self::cycle_prev)) .capture_action(cx.listener(Self::cycle_next)) .child( WithRemSize::new(ui_font_size) + .h_full() + .w(left_gutter_width) .flex() .flex_row() .flex_shrink_0() .items_center() - .h_full() - .w(left_gutter_width) .justify_center() - .gap_2() + .gap_1() .child(self.render_close_button(cx)) .map(|el| { let CodegenStatus::Error(error) = self.codegen_status(cx) else { @@ -163,26 +214,83 @@ impl Render for PromptEditor { .flex_row() .items_center() .gap_1() + .child(add_context_button) + .child(self.model_selector.clone()) .children(buttons), ), ), ) - .child( - WithRemSize::new(ui_font_size) - .flex() - .flex_row() - .items_center() - .child(h_flex().flex_shrink_0().w(left_gutter_width)) - .child( - h_flex() - .w_full() - .pl_1() - .items_start() - .justify_between() - .child(self.context_strip.clone()) - .child(self.model_selector.clone()), - ), - ) + .when_some(explanation, |this, _| { + this.child( + h_flex() + .size_full() + .justify_center() + .child(div().w(left_gutter_width + px(6.))) + .child( + div() + .size_full() + .min_w_0() + .pt(rems_from_px(3.)) + .pl_0p5() + .flex_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(explanation_label), + ), + ) + }) + } +} + +fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let theme_settings = ThemeSettings::get_global(cx); + let colors = cx.theme().colors(); + let mut text_style = window.text_style(); + + text_style.refine(&TextStyleRefinement { + font_family: Some(theme_settings.ui_font.family.clone()), + color: Some(colors.text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style: text_style.clone(), + syntax: cx.theme().syntax().clone(), + selection_background_color: colors.element_selection_background, + 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() + }), + }), + 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()), + background_color: Some(colors.editor_foreground.opacity(0.08)), + ..Default::default() + }, + ..Default::default() } } @@ -211,6 +319,19 @@ impl PromptEditor { )); } + fn assign_completion_provider(&mut self, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.set_completion_provider(Some(Rc::new(PromptCompletionProvider::new( + PromptEditorCompletionProviderDelegate, + cx.weak_entity(), + self.mention_set.clone(), + self.history_store.clone(), + self.prompt_store.clone(), + self.workspace.clone(), + )))); + }); + } + pub fn set_show_cursor_when_unfocused( &mut self, show_cursor_when_unfocused: bool, @@ -223,27 +344,40 @@ impl PromptEditor { pub fn unlink(&mut self, window: &mut Window, cx: &mut Context) { let prompt = self.prompt(cx); - let existing_creases = self.editor.update(cx, extract_message_creases); - + let existing_creases = self.editor.update(cx, |editor, cx| { + extract_message_creases(editor, &self.mention_set, window, cx) + }); let focus = self.editor.focus_handle(cx).contains_focused(window, cx); + let mut creases = vec![]; self.editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, Self::MAX_LINES as usize, window, cx); editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); editor.set_placeholder_text("Add a prompt…", window, cx); editor.set_text(prompt, window, cx); - insert_message_creases( - &mut editor, - &existing_creases, - &self.context_store, - window, - cx, - ); + creases = insert_message_creases(&mut editor, &existing_creases, window, cx); if focus { window.focus(&editor.focus_handle(cx)); } editor }); + + self.mention_set.update(cx, |mention_set, _cx| { + debug_assert_eq!( + creases.len(), + mention_set.creases().len(), + "Missing creases" + ); + + let mentions = mention_set + .clear() + .zip(creases) + .map(|((_, value), id)| (id, value)) + .collect::>(); + mention_set.set_mentions(mentions); + }); + + self.assign_completion_provider(cx); self.subscribe_to_editor(window, cx); } @@ -261,53 +395,39 @@ impl PromptEditor { let agent_panel_keybinding = ui::text_for_action(&zed_actions::assistant::ToggleFocus, window, cx) - .map(|keybinding| format!("{keybinding} to chat ― ")) + .map(|keybinding| format!("{keybinding} to chat")) .unwrap_or_default(); - format!("{action}… ({agent_panel_keybinding}↓↑ for history)") + format!("{action}… ({agent_panel_keybinding} ― ↓↑ for history — @ to include context)") } pub fn prompt(&self, cx: &App) -> String { self.editor.read(cx).text(cx) } - fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context) { - let images = cx - .read_from_clipboard() - .map(|item| { - item.into_entries() - .filter_map(|entry| { - if let ClipboardEntry::Image(image) = entry { - Some(image) - } else { - None - } - }) - .collect::>() - }) - .unwrap_or_default(); - - if images.is_empty() { - return; + fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if inline_assistant_model_supports_images(cx) + && let Some(task) = + paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) + { + task.detach(); } - cx.stop_propagation(); - - self.context_store.update(cx, |store, cx| { - for image in images { - store.add_image_instance(Arc::new(image), cx); - } - }); } fn handle_prompt_editor_events( &mut self, - _: &Entity, + editor: &Entity, event: &EditorEvent, window: &mut Window, cx: &mut Context, ) { match event { EditorEvent::Edited { .. } => { + let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); + + self.mention_set + .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot)); + if let Some(workspace) = window.root::().flatten() { workspace.update(cx, |workspace, cx| { let is_via_ssh = workspace.project().read(cx).is_via_remote_server(); @@ -318,7 +438,7 @@ impl PromptEditor { .log_edit_event("inline assist", is_via_ssh); }); } - let prompt = self.editor.read(cx).text(cx); + let prompt = snapshot.text(); if self .prompt_history_ix .is_none_or(|ix| self.prompt_history[ix] != prompt) @@ -328,6 +448,7 @@ impl PromptEditor { } self.edited_since_done = true; + self.session_state.completion = CompletionState::Pending; cx.notify(); } EditorEvent::Blurred => { @@ -340,23 +461,44 @@ impl PromptEditor { } } - fn toggle_context_picker( - &mut self, - _: &ToggleContextPicker, - window: &mut Window, - cx: &mut Context, - ) { - self.context_picker_menu_handle.toggle(window, cx); + pub fn is_completions_menu_visible(&self, cx: &App) -> bool { + self.editor + .read(cx) + .context_menu() + .borrow() + .as_ref() + .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()) } - pub fn remove_all_context( - &mut self, - _: &RemoveAllContext, - _window: &mut Window, - cx: &mut Context, - ) { - self.context_store.update(cx, |store, cx| store.clear(cx)); - cx.notify(); + pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + let menu_is_open = editor.context_menu().borrow().as_ref().is_some_and(|menu| { + matches!(menu, CodeContextMenu::Completions(_)) && menu.visible() + }); + + let has_at_sign = { + let snapshot = editor.display_snapshot(cx); + let cursor = editor.selections.newest::(&snapshot).head(); + let offset = cursor.to_offset(&snapshot); + if offset.0 > 0 { + snapshot + .buffer_snapshot() + .reversed_chars_at(offset) + .next() + .map(|sign| sign == '@') + .unwrap_or(false) + } else { + false + } + }; + + if menu_is_open && has_at_sign { + return; + } + + editor.insert("@", window, cx); + editor.show_completions(&editor::actions::ShowCompletions, window, cx); + }); } fn cancel( @@ -378,22 +520,207 @@ impl PromptEditor { fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { match self.codegen_status(cx) { CodegenStatus::Idle => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } CodegenStatus::Pending => {} CodegenStatus::Done => { if self.edited_since_done { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } else { cx.emit(PromptEditorEvent::ConfirmRequested { execute: false }); } } CodegenStatus::Error(_) => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } } } + fn fire_started_telemetry(&self, cx: &Context) { + let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() else { + return; + }; + + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.provider.id().to_string(); + + let (kind, language_name) = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + let codegen = codegen.read(cx); + ( + "inline", + codegen.language_name(cx).map(|name| name.to_string()), + ) + } + PromptEditorMode::Terminal { .. } => ("inline_terminal", None), + }; + + telemetry::event!( + "Assistant Started", + session_id = self.session_state.session_id.to_string(), + kind = kind, + phase = "started", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + ); + } + + fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let (model_id, use_streaming_tools) = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) + }; + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let prompt = self.editor.read(cx).text(cx); + + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "positive", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + use_streaming_tools + ); + + self.session_state.completion = CompletionState::Rated; + + cx.notify(); + } + } + } + + fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let (model_telemetry_id, use_streaming_tools) = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) + }; + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let prompt = self.editor.read(cx).text(cx); + + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "negative", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_telemetry_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + use_streaming_tools + ); + + self.session_state.completion = CompletionState::Rated; + + cx.notify(); + } + } + } + + fn toast(&mut self, msg: &str, uuid: Option, cx: &mut Context<'_, PromptEditor>) { + self.workspace + .update(cx, |workspace, cx| { + enum InlinePromptRating {} + workspace.show_toast( + { + let mut toast = Toast::new( + NotificationId::unique::(), + msg.to_string(), + ) + .autohide(); + + if let Some(uuid) = uuid { + toast = toast.on_click("Click to copy rating ID", move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(uuid.to_string())); + }); + }; + + toast + }, + cx, + ); + }) + .ok(); + } + fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { if let Some(ix) = self.prompt_history_ix { if ix > 0 { @@ -431,8 +758,6 @@ impl PromptEditor { editor.move_to_end(&Default::default(), window, cx) }); } - } else if self.context_strip.read(cx).has_context_items(cx) { - self.context_strip.focus_handle(cx).focus(window); } } @@ -469,12 +794,11 @@ impl PromptEditor { IconButton::new("stop", IconName::Stop) .icon_color(Color::Error) .shape(IconButtonShape::Square) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( mode.tooltip_interrupt(), Some(&menu::Cancel), "Changes won't be discarded", - window, cx, ) }) @@ -488,12 +812,11 @@ impl PromptEditor { IconButton::new("restart", IconName::RotateCw) .icon_color(Color::Info) .shape(IconButtonShape::Square) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( mode.tooltip_restart(), Some(&menu::Confirm), "Changes will be discarded", - window, cx, ) }) @@ -503,37 +826,73 @@ impl PromptEditor { .into_any_element(), ] } else { + let show_rating_buttons = cx.has_flag::(); + let rated = matches!(self.session_state.completion, CompletionState::Rated); + let accept = IconButton::new("accept", IconName::Check) .icon_color(Color::Info) .shape(IconButtonShape::Square) - .tooltip(move |window, cx| { - Tooltip::for_action(mode.tooltip_accept(), &menu::Confirm, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action(mode.tooltip_accept(), &menu::Confirm, cx) }) .on_click(cx.listener(|_, _, _, cx| { cx.emit(PromptEditorEvent::ConfirmRequested { execute: false }); })) .into_any_element(); - match &self.mode { - PromptEditorMode::Terminal { .. } => vec![ - accept, - IconButton::new("confirm", IconName::PlayFilled) - .icon_color(Color::Info) + let mut buttons = Vec::new(); + + if show_rating_buttons { + buttons.push( + IconButton::new("thumbs-down", IconName::ThumbsDown) + .icon_color(if rated { Color::Muted } else { Color::Default }) .shape(IconButtonShape::Square) - .tooltip(|window, cx| { - Tooltip::for_action( - "Execute Generated Command", - &menu::SecondaryConfirm, - window, - cx, - ) - }) - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(PromptEditorEvent::ConfirmRequested { execute: true }); + .disabled(rated) + .tooltip(Tooltip::text("Bad result")) + .on_click(cx.listener(|this, _, window, cx| { + this.thumbs_down(&ThumbsDownResult, window, cx); })) .into_any_element(), - ], - PromptEditorMode::Buffer { .. } => vec![accept], + ); + + buttons.push( + IconButton::new("thumbs-up", IconName::ThumbsUp) + .icon_color(if rated { Color::Muted } else { Color::Default }) + .shape(IconButtonShape::Square) + .disabled(rated) + .tooltip(Tooltip::text("Good result")) + .on_click(cx.listener(|this, _, window, cx| { + this.thumbs_up(&ThumbsUpResult, window, cx); + })) + .into_any_element(), + ); + } + + buttons.push(accept); + + match &self.mode { + PromptEditorMode::Terminal { .. } => { + buttons.push( + IconButton::new("confirm", IconName::PlayFilled) + .icon_color(Color::Info) + .shape(IconButtonShape::Square) + .tooltip(|_window, cx| { + Tooltip::for_action( + "Execute Generated Command", + &menu::SecondaryConfirm, + cx, + ) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(PromptEditorEvent::ConfirmRequested { + execute: true, + }); + })) + .into_any_element(), + ); + buttons + } + PromptEditorMode::Buffer { .. } => buttons, } } } @@ -616,13 +975,12 @@ impl PromptEditor { .shape(IconButtonShape::Square) .tooltip({ let focus_handle = self.editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { cx.new(|cx| { let mut tooltip = Tooltip::new("Previous Alternative").key_binding( KeyBinding::for_action_in( &CyclePreviousInlineAssist, &focus_handle, - window, cx, ), ); @@ -658,13 +1016,12 @@ impl PromptEditor { .shape(IconButtonShape::Square) .tooltip({ let focus_handle = self.editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { cx.new(|cx| { let mut tooltip = Tooltip::new("Next Alternative").key_binding( KeyBinding::for_action_in( &CycleNextInlineAssist, &focus_handle, - window, cx, ), ); @@ -711,6 +1068,7 @@ impl PromptEditor { EditorStyle { background: colors.editor_background, local_player: cx.theme().players().local(), + syntax: cx.theme().syntax().clone(), text: text_style, ..Default::default() }, @@ -719,19 +1077,8 @@ impl PromptEditor { .into_any_element() } - fn handle_context_strip_event( - &mut self, - _context_strip: &Entity, - event: &ContextStripEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - ContextStripEvent::PickerDismissed - | ContextStripEvent::BlurredEmpty - | ContextStripEvent::BlurredUp => self.editor.focus_handle(cx).focus(window), - ContextStripEvent::BlurredDown => {} - } + fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { + MarkdownElement::new(markdown, style) } } @@ -767,6 +1114,36 @@ impl InlineAssistId { } } +struct PromptEditorCompletionProviderDelegate; + +fn inline_assistant_model_supports_images(cx: &App) -> bool { + LanguageModelRegistry::read_global(cx) + .inline_assistant_model() + .map_or(false, |m| m.model.supports_images()) +} + +impl PromptCompletionProviderDelegate for PromptEditorCompletionProviderDelegate { + fn supported_modes(&self, _cx: &App) -> Vec { + vec![ + PromptContextType::File, + PromptContextType::Symbol, + PromptContextType::Thread, + PromptContextType::Fetch, + PromptContextType::Rules, + ] + } + + fn supports_images(&self, cx: &App) -> bool { + inline_assistant_model_supports_images(cx) + } + + fn available_commands(&self, _cx: &App) -> Vec { + Vec::new() + } + + fn confirm_command(&self, _cx: &mut App) {} +} + impl PromptEditor { pub fn new_buffer( id: InlineAssistId, @@ -774,16 +1151,16 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, - context_store: Entity, + history_store: Entity, + prompt_store: Option>, + project: WeakEntity, workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, window: &mut Window, cx: &mut Context>, ) -> PromptEditor { let codegen_subscription = cx.observe(&codegen, Self::handle_codegen_changed); - let codegen_buffer = codegen.read(cx).buffer(cx).read(cx).as_singleton(); let mode = PromptEditorMode::Buffer { id, codegen, @@ -807,7 +1184,6 @@ impl PromptEditor { // typing in one will make what you typed appear in all of them. editor.set_show_cursor_when_unfocused(true, cx); editor.set_placeholder_text(&Self::placeholder_text(&mode, window, cx), window, cx); - editor.register_addon(ContextCreasesAddon::new()); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, max_entries_visible: 12, @@ -817,43 +1193,17 @@ impl PromptEditor { editor }); - let prompt_editor_entity = prompt_editor.downgrade(); - prompt_editor.update(cx, |editor, _| { - editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( - workspace.clone(), - context_store.downgrade(), - thread_store.clone(), - text_thread_store.clone(), - prompt_editor_entity, - codegen_buffer.as_ref().map(Entity::downgrade), - )))); - }); + let mention_set = + cx.new(|_cx| MentionSet::new(project, history_store.clone(), prompt_store.clone())); - let context_picker_menu_handle = PopoverMenuHandle::default(); let model_selector_menu_handle = PopoverMenuHandle::default(); - let context_strip = cx.new(|cx| { - ContextStrip::new( - context_store.clone(), - workspace.clone(), - thread_store.clone(), - text_thread_store.clone(), - context_picker_menu_handle.clone(), - SuggestContextKind::Thread, - ModelUsageContext::InlineAssistant, - window, - cx, - ) - }); - - let context_strip_subscription = - cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event); - let mut this: PromptEditor = PromptEditor { editor: prompt_editor.clone(), - context_store, - context_strip, - context_picker_menu_handle, + mention_set, + history_store, + prompt_store, + workspace, model_selector: cx.new(|cx| { AgentModelSelector::new( fs, @@ -870,19 +1220,23 @@ impl PromptEditor { pending_prompt: String::new(), _codegen_subscription: codegen_subscription, editor_subscriptions: Vec::new(), - _context_strip_subscription: context_strip_subscription, show_rate_limit_notice: false, mode, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; + this.assign_completion_provider(cx); this.subscribe_to_editor(window, cx); this } fn handle_codegen_changed( &mut self, - _: Entity, + codegen: Entity, cx: &mut Context>, ) { match self.codegen_status(cx) { @@ -891,10 +1245,15 @@ impl PromptEditor { .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done => { + let completion = codegen.read(cx).active_completion(cx); + self.session_state.completion = CompletionState::Generated { + completion_text: completion, + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); @@ -921,6 +1280,10 @@ impl PromptEditor { } } + pub fn mention_set(&self) -> &Entity { + &self.mention_set + } + pub fn editor_margins(&self) -> &Arc> { match &self.mode { PromptEditorMode::Buffer { editor_margins, .. } => editor_margins, @@ -946,11 +1309,12 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, - context_store: Entity, + history_store: Entity, + prompt_store: Option>, + project: WeakEntity, workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -982,43 +1346,17 @@ impl PromptEditor { editor }); - let prompt_editor_entity = prompt_editor.downgrade(); - prompt_editor.update(cx, |editor, _| { - editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( - workspace.clone(), - context_store.downgrade(), - thread_store.clone(), - text_thread_store.clone(), - prompt_editor_entity, - None, - )))); - }); + let mention_set = + cx.new(|_cx| MentionSet::new(project, history_store.clone(), prompt_store.clone())); - let context_picker_menu_handle = PopoverMenuHandle::default(); let model_selector_menu_handle = PopoverMenuHandle::default(); - let context_strip = cx.new(|cx| { - ContextStrip::new( - context_store.clone(), - workspace.clone(), - thread_store.clone(), - text_thread_store.clone(), - context_picker_menu_handle.clone(), - SuggestContextKind::Thread, - ModelUsageContext::InlineAssistant, - window, - cx, - ) - }); - - let context_strip_subscription = - cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event); - let mut this = Self { editor: prompt_editor.clone(), - context_store, - context_strip, - context_picker_menu_handle, + mention_set, + history_store, + prompt_store, + workspace, model_selector: cx.new(|cx| { AgentModelSelector::new( fs, @@ -1035,12 +1373,16 @@ impl PromptEditor { pending_prompt: String::new(), _codegen_subscription: codegen_subscription, editor_subscriptions: Vec::new(), - _context_strip_subscription: context_strip_subscription, mode, show_rate_limit_notice: false, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; this.count_lines(cx); + this.assign_completion_provider(cx); this.subscribe_to_editor(window, cx); this } @@ -1069,17 +1411,21 @@ impl PromptEditor { } } - fn handle_codegen_changed(&mut self, _: Entity, cx: &mut Context) { + fn handle_codegen_changed(&mut self, codegen: Entity, cx: &mut Context) { match &self.codegen().read(cx).status { CodegenStatus::Idle => { self.editor .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done | CodegenStatus::Error(_) => { + self.session_state.completion = CompletionState::Generated { + completion_text: codegen.read(cx).completion(), + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); @@ -1087,6 +1433,10 @@ impl PromptEditor { } } + pub fn mention_set(&self) -> &Entity { + &self.mention_set + } + pub fn codegen(&self) -> &Entity { match &self.mode { PromptEditorMode::Buffer { .. } => unreachable!(), @@ -1163,3 +1513,59 @@ impl GenerationMode { } } } + +/// Stored information that can be used to resurrect a context crease when creating an editor for a past message. +#[derive(Clone, Debug)] +struct MessageCrease { + range: Range, + icon_path: SharedString, + label: SharedString, +} + +fn extract_message_creases( + editor: &mut Editor, + mention_set: &Entity, + window: &mut Window, + cx: &mut Context<'_, Editor>, +) -> Vec { + let creases = mention_set.read(cx).creases(); + let snapshot = editor.snapshot(window, cx); + snapshot + .crease_snapshot + .creases() + .filter(|(id, _)| creases.contains(id)) + .filter_map(|(_, crease)| { + let metadata = crease.metadata()?.clone(); + Some(MessageCrease { + range: crease.range().to_offset(snapshot.buffer()), + label: metadata.label, + icon_path: metadata.icon_path, + }) + }) + .collect() +} + +fn insert_message_creases( + editor: &mut Editor, + message_creases: &[MessageCrease], + window: &mut Window, + cx: &mut Context<'_, Editor>, +) -> Vec { + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let creases = message_creases + .iter() + .map(|crease| { + let start = buffer_snapshot.anchor_after(crease.range.start); + let end = buffer_snapshot.anchor_before(crease.range.end); + crease_for_mention( + crease.label.clone(), + crease.icon_path.clone(), + start..end, + cx.weak_entity(), + ) + }) + .collect::>(); + let ids = editor.insert_creases(creases.clone(), cx); + editor.fold_creases(creases, false, window, cx); + ids +} diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index eb5a734b4c..5b5a4513c6 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -1,15 +1,18 @@ use std::{cmp::Reverse, sync::Arc}; -use collections::{HashSet, IndexMap}; +use collections::IndexMap; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; -use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task}; +use gpui::{ + Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task, +}; use language_model::{ AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use ui::{ListItem, ListItemSpacing, prelude::*}; +use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*}; +use zed_actions::agent::OpenSettings; type OnModelChanged = Arc, &mut App) + 'static>; type GetActiveModel = Arc Option + 'static>; @@ -19,14 +22,28 @@ pub type LanguageModelSelector = Picker; pub fn language_model_selector( get_active_model: impl Fn(&App) -> Option + 'static, on_model_changed: impl Fn(Arc, &mut App) + 'static, + popover_styles: bool, + focus_handle: FocusHandle, window: &mut Window, cx: &mut Context, ) -> LanguageModelSelector { - let delegate = LanguageModelPickerDelegate::new(get_active_model, on_model_changed, window, cx); - Picker::list(delegate, window, cx) - .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + let delegate = LanguageModelPickerDelegate::new( + get_active_model, + on_model_changed, + popover_styles, + focus_handle, + window, + cx, + ); + + if popover_styles { + Picker::list(delegate, window, cx) + .show_scrollbar(true) + .width(rems(20.)) + .max_height(Some(rems(20.).into())) + } else { + Picker::list(delegate, window, cx).show_scrollbar(true) + } } fn all_models(cx: &App) -> GroupedModels { @@ -45,7 +62,7 @@ fn all_models(cx: &App) -> GroupedModels { }) .collect(); - let other = providers + let all = providers .iter() .flat_map(|provider| { provider @@ -58,7 +75,7 @@ fn all_models(cx: &App) -> GroupedModels { }) .collect(); - GroupedModels::new(other, recommended) + GroupedModels::new(all, recommended) } #[derive(Clone)] @@ -75,12 +92,16 @@ pub struct LanguageModelPickerDelegate { selected_index: usize, _authenticate_all_providers_task: Task<()>, _subscriptions: Vec, + popover_styles: bool, + focus_handle: FocusHandle, } impl LanguageModelPickerDelegate { fn new( get_active_model: impl Fn(&App) -> Option + 'static, on_model_changed: impl Fn(Arc, &mut App) + 'static, + popover_styles: bool, + focus_handle: FocusHandle, window: &mut Window, cx: &mut Context>, ) -> Self { @@ -113,6 +134,8 @@ impl LanguageModelPickerDelegate { } }, )], + popover_styles, + focus_handle, } } @@ -177,7 +200,7 @@ impl LanguageModelPickerDelegate { } _ => { log::error!( - "Failed to authenticate provider: {}: {err}", + "Failed to authenticate provider: {}: {err:#}", provider_name.0 ); } @@ -195,33 +218,24 @@ impl LanguageModelPickerDelegate { struct GroupedModels { recommended: Vec, - other: IndexMap>, + all: IndexMap>, } impl GroupedModels { - pub fn new(other: Vec, recommended: Vec) -> Self { - let recommended_ids = recommended - .iter() - .map(|info| (info.model.provider_id(), info.model.id())) - .collect::>(); - - let mut other_by_provider: IndexMap<_, Vec> = IndexMap::default(); - for model in other { - if recommended_ids.contains(&(model.model.provider_id(), model.model.id())) { - continue; - } - + pub fn new(all: Vec, recommended: Vec) -> Self { + let mut all_by_provider: IndexMap<_, Vec> = IndexMap::default(); + for model in all { let provider = model.model.provider_id(); - if let Some(models) = other_by_provider.get_mut(&provider) { + if let Some(models) = all_by_provider.get_mut(&provider) { models.push(model); } else { - other_by_provider.insert(provider, vec![model]); + all_by_provider.insert(provider, vec![model]); } } Self { recommended, - other: other_by_provider, + all: all_by_provider, } } @@ -237,7 +251,7 @@ impl GroupedModels { ); } - for models in self.other.values() { + for models in self.all.values() { if models.is_empty() { continue; } @@ -252,20 +266,6 @@ impl GroupedModels { } entries } - - fn model_infos(&self) -> Vec { - let other = self - .other - .values() - .flat_map(|model| model.iter()) - .cloned() - .collect::>(); - self.recommended - .iter() - .chain(&other) - .cloned() - .collect::>() - } } enum LanguageModelPickerEntry { @@ -410,8 +410,9 @@ impl PickerDelegate for LanguageModelPickerDelegate { .collect::>(); let available_models = all_models - .model_infos() - .iter() + .all + .values() + .flat_map(|models| models.iter()) .filter(|m| configured_provider_ids.contains(&m.model.provider_id())) .cloned() .collect::>(); @@ -499,17 +500,15 @@ impl PickerDelegate for LanguageModelPickerDelegate { .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .start_slot( - Icon::new(model_info.icon) - .color(model_icon_color) - .size(IconSize::Small), - ) .child( h_flex() .w_full() - .pl_0p5() .gap_1p5() - .w(px(240.)) + .child( + Icon::new(model_info.icon) + .color(model_icon_color) + .size(IconSize::Small), + ) .child(Label::new(model_info.model.name().0).truncate()), ) .end_slot(div().pr_3().when(is_selected, |this| { @@ -530,25 +529,28 @@ impl PickerDelegate for LanguageModelPickerDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { + let focus_handle = self.focus_handle.clone(); + + if !self.popover_styles { + return None; + } + Some( h_flex() .w_full() + .p_1p5() .border_t_1() .border_color(cx.theme().colors().border_variant) - .p_1() - .gap_4() - .justify_between() .child( Button::new("configure", "Configure") - .icon(IconName::Settings) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) .on_click(|_, window, cx| { - window.dispatch_action( - zed_actions::agent::OpenSettings.boxed_clone(), - cx, - ); + window.dispatch_action(OpenSettings.boxed_clone(), cx); }), ) .into_any(), @@ -745,46 +747,52 @@ mod tests { } #[gpui::test] - fn test_exclude_recommended_models(_cx: &mut TestAppContext) { + fn test_recommended_models_also_appear_in_other(_cx: &mut TestAppContext) { let recommended_models = create_models(vec![("zed", "claude")]); let all_models = create_models(vec![ - ("zed", "claude"), // Should be filtered out from "other" + ("zed", "claude"), // Should also appear in "other" ("zed", "gemini"), ("copilot", "o3"), ]); let grouped_models = GroupedModels::new(all_models, recommended_models); - let actual_other_models = grouped_models - .other + let actual_all_models = grouped_models + .all .values() .flatten() .cloned() .collect::>(); - // Recommended models should not appear in "other" - assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/o3"]); + // Recommended models should also appear in "all" + assert_models_eq( + actual_all_models, + vec!["zed/claude", "zed/gemini", "copilot/o3"], + ); } #[gpui::test] - fn test_dont_exclude_models_from_other_providers(_cx: &mut TestAppContext) { + fn test_models_from_different_providers(_cx: &mut TestAppContext) { let recommended_models = create_models(vec![("zed", "claude")]); let all_models = create_models(vec![ - ("zed", "claude"), // Should be filtered out from "other" + ("zed", "claude"), // Should also appear in "other" ("zed", "gemini"), - ("copilot", "claude"), // Should not be filtered out from "other" + ("copilot", "claude"), // Different provider, should appear in "other" ]); let grouped_models = GroupedModels::new(all_models, recommended_models); - let actual_other_models = grouped_models - .other + let actual_all_models = grouped_models + .all .values() .flatten() .cloned() .collect::>(); - // Recommended models should not appear in "other" - assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/claude"]); + // All models should appear in "all" regardless of recommended status + assert_models_eq( + actual_all_models, + vec!["zed/claude", "zed/gemini", "copilot/claude"], + ); } } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs new file mode 100644 index 0000000000..eee28bbfb2 --- /dev/null +++ b/crates/agent_ui/src/mention_set.rs @@ -0,0 +1,1098 @@ +use acp_thread::{MentionUri, selection_name}; +use agent::{HistoryStore, outline}; +use agent_client_protocol as acp; +use agent_servers::{AgentServer, AgentServerDelegate}; +use anyhow::{Context as _, Result, anyhow}; +use assistant_slash_commands::codeblock_fence_for_path; +use collections::{HashMap, HashSet}; +use editor::{ + Anchor, Editor, EditorSnapshot, ExcerptId, FoldPlaceholder, ToOffset, + display_map::{Crease, CreaseId, CreaseMetadata, FoldId}, + scroll::Autoscroll, +}; +use futures::{AsyncReadExt as _, FutureExt as _, future::Shared}; +use gpui::{ + Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Empty, Entity, EntityId, + Image, ImageFormat, Img, SharedString, Task, WeakEntity, pulsating_between, +}; +use http_client::{AsyncBody, HttpClientWithUrl}; +use itertools::Either; +use language::Buffer; +use language_model::LanguageModelImage; +use multi_buffer::MultiBufferRow; +use postage::stream::Stream as _; +use project::{Project, ProjectItem, ProjectPath, Worktree}; +use prompt_store::{PromptId, PromptStore}; +use rope::Point; +use std::{ + cell::RefCell, + ffi::OsStr, + fmt::Write, + ops::{Range, RangeInclusive}, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, + time::Duration, +}; +use text::OffsetRangeExt; +use ui::{ButtonLike, Disclosure, TintColor, Toggleable, prelude::*}; +use util::{ResultExt, debug_panic, rel_path::RelPath}; +use workspace::{Workspace, notifications::NotifyResultExt as _}; + +pub type MentionTask = Shared>>; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Mention { + Text { + content: String, + tracked_buffers: Vec>, + }, + Image(MentionImage), + Link, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MentionImage { + pub data: SharedString, + pub format: ImageFormat, +} + +pub struct MentionSet { + project: WeakEntity, + history_store: Entity, + prompt_store: Option>, + mentions: HashMap, +} + +impl MentionSet { + pub fn new( + project: WeakEntity, + history_store: Entity, + prompt_store: Option>, + ) -> Self { + Self { + project, + history_store, + prompt_store, + mentions: HashMap::default(), + } + } + + pub fn contents( + &self, + full_mention_content: bool, + cx: &mut App, + ) -> Task>> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("Project not found"))); + }; + let mentions = self.mentions.clone(); + cx.spawn(async move |cx| { + let mut contents = HashMap::default(); + for (crease_id, (mention_uri, task)) in mentions { + let content = if full_mention_content + && let MentionUri::Directory { abs_path } = &mention_uri + { + cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))? + .await? + } else { + task.await.map_err(|e| anyhow!("{e}"))? + }; + + contents.insert(crease_id, (mention_uri, content)); + } + Ok(contents) + }) + } + + pub fn remove_invalid(&mut self, snapshot: &EditorSnapshot) { + for (crease_id, crease) in snapshot.crease_snapshot.creases() { + if !crease.range().start.is_valid(snapshot.buffer_snapshot()) { + self.mentions.remove(&crease_id); + } + } + } + + pub fn insert_mention(&mut self, crease_id: CreaseId, uri: MentionUri, task: MentionTask) { + self.mentions.insert(crease_id, (uri, task)); + } + + pub fn remove_mention(&mut self, crease_id: &CreaseId) { + self.mentions.remove(crease_id); + } + + pub fn creases(&self) -> HashSet { + self.mentions.keys().cloned().collect() + } + + pub fn mentions(&self) -> HashSet { + self.mentions.values().map(|(uri, _)| uri.clone()).collect() + } + + pub fn set_mentions(&mut self, mentions: HashMap) { + self.mentions = mentions; + } + + pub fn clear(&mut self) -> impl Iterator { + self.mentions.drain() + } + + pub fn confirm_mention_completion( + &mut self, + crease_text: SharedString, + start: text::Anchor, + content_len: usize, + mention_uri: MentionUri, + supports_images: bool, + editor: Entity, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task<()> { + let Some(project) = self.project.upgrade() else { + return Task::ready(()); + }; + + let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); + let Some(start_anchor) = snapshot.buffer_snapshot().as_singleton_anchor(start) else { + return Task::ready(()); + }; + let excerpt_id = start_anchor.excerpt_id; + let end_anchor = snapshot.buffer_snapshot().anchor_before( + start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1usize, + ); + + let crease = if let MentionUri::File { abs_path } = &mention_uri + && let Some(extension) = abs_path.extension() + && let Some(extension) = extension.to_str() + && Img::extensions().contains(&extension) + && !extension.contains("svg") + { + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + log::error!("project path not found"); + return Task::ready(()); + }; + let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx)); + let image = cx + .spawn(async move |_, cx| { + let image = image_task.await.map_err(|e| e.to_string())?; + let image = image + .update(cx, |image, _| image.image.clone()) + .map_err(|e| e.to_string())?; + Ok(image) + }) + .shared(); + insert_crease_for_mention( + excerpt_id, + start, + content_len, + mention_uri.name().into(), + IconName::Image.path().into(), + Some(image), + editor.clone(), + window, + cx, + ) + } else { + insert_crease_for_mention( + excerpt_id, + start, + content_len, + crease_text, + mention_uri.icon_path(cx), + None, + editor.clone(), + window, + cx, + ) + }; + let Some((crease_id, tx)) = crease else { + return Task::ready(()); + }; + + let task = match mention_uri.clone() { + MentionUri::Fetch { url } => { + self.confirm_mention_for_fetch(url, workspace.read(cx).client().http_client(), cx) + } + MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)), + MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx), + MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx), + MentionUri::File { abs_path } => { + self.confirm_mention_for_file(abs_path, supports_images, cx) + } + MentionUri::Symbol { + abs_path, + line_range, + .. + } => self.confirm_mention_for_symbol(abs_path, line_range, cx), + MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx), + MentionUri::PastedImage => { + debug_panic!("pasted image URI should not be included in completions"); + Task::ready(Err(anyhow!( + "pasted imaged URI should not be included in completions" + ))) + } + MentionUri::Selection { .. } => { + debug_panic!("unexpected selection URI"); + Task::ready(Err(anyhow!("unexpected selection URI"))) + } + }; + let task = cx + .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) + .shared(); + self.mentions.insert(crease_id, (mention_uri, task.clone())); + + // Notify the user if we failed to load the mentioned context + cx.spawn_in(window, async move |this, cx| { + let result = task.await.notify_async_err(cx); + drop(tx); + if result.is_none() { + this.update(cx, |this, cx| { + editor.update(cx, |editor, cx| { + // Remove mention + editor.edit([(start_anchor..end_anchor, "")], cx); + }); + this.mentions.remove(&crease_id); + }) + .ok(); + } + }) + } + + pub fn confirm_mention_for_file( + &self, + abs_path: PathBuf, + supports_images: bool, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let extension = abs_path + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default(); + + if Img::extensions().contains(&extension) && !extension.contains("svg") { + if !supports_images { + return Task::ready(Err(anyhow!("This model does not support images yet"))); + } + let task = project.update(cx, |project, cx| project.open_image(project_path, cx)); + return cx.spawn(async move |_, cx| { + let image = task.await?; + let image = image.update(cx, |image, _| image.image.clone())?; + let format = image.format; + let image = cx + .update(|cx| LanguageModelImage::from_image(image, cx))? + .await; + if let Some(image) = image { + Ok(Mention::Image(MentionImage { + data: image.source, + format, + })) + } else { + Err(anyhow!("Failed to convert image")) + } + }); + } + + let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + cx.spawn(async move |_, cx| { + let buffer = buffer.await?; + let buffer_content = outline::get_buffer_content_or_outline( + buffer.clone(), + Some(&abs_path.to_string_lossy()), + &cx, + ) + .await?; + + Ok(Mention::Text { + content: buffer_content.text, + tracked_buffers: vec![buffer], + }) + }) + } + + fn confirm_mention_for_fetch( + &self, + url: url::Url, + http_client: Arc, + cx: &mut Context, + ) -> Task> { + cx.background_executor().spawn(async move { + let content = fetch_url_content(http_client, url.to_string()).await?; + Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), + }) + }) + } + + fn confirm_mention_for_symbol( + &self, + abs_path: PathBuf, + line_range: RangeInclusive, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + cx.spawn(async move |_, cx| { + let buffer = buffer.await?; + let mention = buffer.update(cx, |buffer, cx| { + let start = Point::new(*line_range.start(), 0).min(buffer.max_point()); + let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point()); + let content = buffer.text_for_range(start..end).collect(); + Mention::Text { + content, + tracked_buffers: vec![cx.entity()], + } + })?; + anyhow::Ok(mention) + }) + } + + fn confirm_mention_for_rule( + &mut self, + id: PromptId, + cx: &mut Context, + ) -> Task> { + let Some(prompt_store) = self.prompt_store.as_ref() else { + return Task::ready(Err(anyhow!("Missing prompt store"))); + }; + let prompt = prompt_store.read(cx).load(id, cx); + cx.spawn(async move |_, _| { + let prompt = prompt.await?; + Ok(Mention::Text { + content: prompt, + tracked_buffers: Vec::new(), + }) + }) + } + + pub fn confirm_mention_for_selection( + &mut self, + source_range: Range, + selections: Vec<(Entity, Range, Range)>, + editor: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(project) = self.project.upgrade() else { + return; + }; + + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + let Some(start) = snapshot.as_singleton_anchor(source_range.start) else { + return; + }; + + let offset = start.to_offset(&snapshot); + + for (buffer, selection_range, range_to_fold) in selections { + let range = snapshot.anchor_after(offset + range_to_fold.start) + ..snapshot.anchor_after(offset + range_to_fold.end); + + let abs_path = buffer + .read(cx) + .project_path(cx) + .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx)); + let snapshot = buffer.read(cx).snapshot(); + + let text = snapshot + .text_for_range(selection_range.clone()) + .collect::(); + let point_range = selection_range.to_point(&snapshot); + let line_range = point_range.start.row..=point_range.end.row; + + let uri = MentionUri::Selection { + abs_path: abs_path.clone(), + line_range: line_range.clone(), + }; + let crease = crease_for_mention( + selection_name(abs_path.as_deref(), &line_range).into(), + uri.icon_path(cx), + range, + editor.downgrade(), + ); + + let crease_id = editor.update(cx, |editor, cx| { + let crease_ids = editor.insert_creases(vec![crease.clone()], cx); + editor.fold_creases(vec![crease], false, window, cx); + crease_ids.first().copied().unwrap() + }); + + self.mentions.insert( + crease_id, + ( + uri, + Task::ready(Ok(Mention::Text { + content: text, + tracked_buffers: vec![buffer], + })) + .shared(), + ), + ); + } + + // Take this explanation with a grain of salt but, with creases being + // inserted, GPUI's recomputes the editor layout in the next frames, so + // directly calling `editor.request_autoscroll` wouldn't work as + // expected. We're leveraging `cx.on_next_frame` to wait 2 frames and + // ensure that the layout has been recalculated so that the autoscroll + // request actually shows the cursor's new position. + cx.on_next_frame(window, move |_, window, cx| { + cx.on_next_frame(window, move |_, _, cx| { + editor.update(cx, |editor, cx| { + editor.request_autoscroll(Autoscroll::fit(), cx) + }); + }); + }); + } + + fn confirm_mention_for_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + + let server = Rc::new(agent::NativeAgentServer::new( + project.read(cx).fs().clone(), + self.history_store.clone(), + )); + let delegate = AgentServerDelegate::new( + project.read(cx).agent_server_store().clone(), + project.clone(), + None, + None, + ); + let connection = server.connect(None, delegate, cx); + cx.spawn(async move |_, cx| { + let (agent, _) = connection.await?; + let agent = agent.downcast::().unwrap(); + let summary = agent + .0 + .update(cx, |agent, cx| agent.thread_summary(id, cx))? + .await?; + anyhow::Ok(Mention::Text { + content: summary.to_string(), + tracked_buffers: Vec::new(), + }) + }) + } + + fn confirm_mention_for_text_thread( + &mut self, + path: PathBuf, + cx: &mut Context, + ) -> Task> { + let text_thread_task = self.history_store.update(cx, |store, cx| { + store.load_text_thread(path.as_path().into(), cx) + }); + cx.spawn(async move |_, cx| { + let text_thread = text_thread_task.await?; + let xml = text_thread.update(cx, |text_thread, cx| text_thread.to_xml(cx))?; + Ok(Mention::Text { + content: xml, + tracked_buffers: Vec::new(), + }) + }) + } +} + +pub(crate) fn paste_images_as_context( + editor: Entity, + mention_set: Entity, + window: &mut Window, + cx: &mut App, +) -> Option> { + let clipboard = cx.read_from_clipboard()?; + Some(window.spawn(cx, async move |cx| { + use itertools::Itertools; + let (mut images, paths) = clipboard + .into_entries() + .filter_map(|entry| match entry { + ClipboardEntry::Image(image) => Some(Either::Left(image)), + ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)), + _ => None, + }) + .partition_map::, Vec<_>, _, _, _>(std::convert::identity); + + if !paths.is_empty() { + images.extend( + cx.background_spawn(async move { + let mut images = vec![]; + for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) { + let Ok(content) = async_fs::read(path).await else { + continue; + }; + let Ok(format) = image::guess_format(&content) else { + continue; + }; + images.push(gpui::Image::from_bytes( + match format { + image::ImageFormat::Png => gpui::ImageFormat::Png, + image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, + image::ImageFormat::WebP => gpui::ImageFormat::Webp, + image::ImageFormat::Gif => gpui::ImageFormat::Gif, + image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, + image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, + image::ImageFormat::Ico => gpui::ImageFormat::Ico, + _ => continue, + }, + content, + )); + } + images + }) + .await, + ); + } + + if images.is_empty() { + return; + } + + let replacement_text = MentionUri::PastedImage.as_link().to_string(); + cx.update(|_window, cx| { + cx.stop_propagation(); + }) + .ok(); + for image in images { + let Ok((excerpt_id, text_anchor, multibuffer_anchor)) = + editor.update_in(cx, |message_editor, window, cx| { + let snapshot = message_editor.snapshot(window, cx); + let (excerpt_id, _, buffer_snapshot) = + snapshot.buffer_snapshot().as_singleton().unwrap(); + + let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len()); + let multibuffer_anchor = snapshot + .buffer_snapshot() + .anchor_in_excerpt(*excerpt_id, text_anchor); + message_editor.edit( + [( + multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), + format!("{replacement_text} "), + )], + cx, + ); + (*excerpt_id, text_anchor, multibuffer_anchor) + }) + else { + break; + }; + + let content_len = replacement_text.len(); + let Some(start_anchor) = multibuffer_anchor else { + continue; + }; + let Ok(end_anchor) = editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) + }) else { + continue; + }; + let image = Arc::new(image); + let Ok(Some((crease_id, tx))) = cx.update(|window, cx| { + insert_crease_for_mention( + excerpt_id, + text_anchor, + content_len, + MentionUri::PastedImage.name().into(), + IconName::Image.path().into(), + Some(Task::ready(Ok(image.clone())).shared()), + editor.clone(), + window, + cx, + ) + }) else { + continue; + }; + let task = cx + .spawn(async move |cx| { + let format = image.format; + let image = cx + .update(|_, cx| LanguageModelImage::from_image(image, cx)) + .map_err(|e| e.to_string())? + .await; + drop(tx); + if let Some(image) = image { + Ok(Mention::Image(MentionImage { + data: image.source, + format, + })) + } else { + Err("Failed to convert image".into()) + } + }) + .shared(); + + mention_set + .update(cx, |mention_set, _cx| { + mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone()) + }) + .ok(); + + if task.await.notify_async_err(cx).is_none() { + editor + .update(cx, |editor, cx| { + editor.edit([(start_anchor..end_anchor, "")], cx); + }) + .ok(); + mention_set + .update(cx, |mention_set, _cx| { + mention_set.remove_mention(&crease_id) + }) + .ok(); + } + } + })) +} + +pub(crate) fn insert_crease_for_mention( + excerpt_id: ExcerptId, + anchor: text::Anchor, + content_len: usize, + crease_label: SharedString, + crease_icon: SharedString, + // abs_path: Option>, + image: Option, String>>>>, + editor: Entity, + window: &mut Window, + cx: &mut App, +) -> Option<(CreaseId, postage::barrier::Sender)> { + let (tx, rx) = postage::barrier::channel(); + + let crease_id = editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + + let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?; + + let start = start.bias_right(&snapshot); + let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); + + let placeholder = FoldPlaceholder { + render: render_mention_fold_button( + crease_label.clone(), + crease_icon.clone(), + start..end, + rx, + image, + cx.weak_entity(), + cx, + ), + merge_adjacent: false, + ..Default::default() + }; + + let crease = Crease::Inline { + range: start..end, + placeholder, + render_toggle: None, + render_trailer: None, + metadata: Some(CreaseMetadata { + label: crease_label, + icon_path: crease_icon, + }), + }; + + let ids = editor.insert_creases(vec![crease.clone()], cx); + editor.fold_creases(vec![crease], false, window, cx); + + Some(ids[0]) + })?; + + Some((crease_id, tx)) +} + +pub(crate) fn crease_for_mention( + label: SharedString, + icon_path: SharedString, + range: Range, + editor_entity: WeakEntity, +) -> Crease { + let placeholder = FoldPlaceholder { + render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity), + merge_adjacent: false, + ..Default::default() + }; + + let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any(); + + Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer) + .with_metadata(CreaseMetadata { icon_path, label }) +} + +fn render_fold_icon_button( + icon_path: SharedString, + label: SharedString, + editor: WeakEntity, +) -> Arc, &mut App) -> AnyElement> { + Arc::new({ + move |fold_id, fold_range, cx| { + let is_in_text_selection = editor + .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx)) + .unwrap_or_default(); + + ButtonLike::new(fold_id) + .style(ButtonStyle::Filled) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_in_text_selection) + .child( + h_flex() + .gap_1() + .child( + Icon::from_path(icon_path.clone()) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(label.clone()) + .size(LabelSize::Small) + .buffer_font(cx) + .single_line(), + ), + ) + .into_any_element() + } + }) +} + +fn fold_toggle( + name: &'static str, +) -> impl Fn( + MultiBufferRow, + bool, + Arc, + &mut Window, + &mut App, +) -> AnyElement { + move |row, is_folded, fold, _window, _cx| { + Disclosure::new((name, row.0 as u64), !is_folded) + .toggle_state(is_folded) + .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) + .into_any_element() + } +} + +fn full_mention_for_directory( + project: &Entity, + abs_path: &Path, + cx: &mut App, +) -> Task> { + fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc, String)> { + let mut files = Vec::new(); + + for entry in worktree.child_entries(path) { + if entry.is_dir() { + files.extend(collect_files_in_path(worktree, &entry.path)); + } else if entry.is_file() { + files.push(( + entry.path.clone(), + worktree + .full_path(&entry.path) + .to_string_lossy() + .to_string(), + )); + } + } + + files + } + + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else { + return Task::ready(Err(anyhow!("project entry not found"))); + }; + let directory_path = entry.path.clone(); + let worktree_id = project_path.worktree_id; + let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else { + return Task::ready(Err(anyhow!("worktree not found"))); + }; + let project = project.clone(); + cx.spawn(async move |cx| { + let file_paths = worktree.read_with(cx, |worktree, _cx| { + collect_files_in_path(worktree, &directory_path) + })?; + let descendants_future = cx.update(|cx| { + futures::future::join_all(file_paths.into_iter().map(|(worktree_path, full_path)| { + let rel_path = worktree_path + .strip_prefix(&directory_path) + .log_err() + .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into()); + + let open_task = project.update(cx, |project, cx| { + project.buffer_store().update(cx, |buffer_store, cx| { + let project_path = ProjectPath { + worktree_id, + path: worktree_path, + }; + buffer_store.open_buffer(project_path, cx) + }) + }); + + cx.spawn(async move |cx| { + let buffer = open_task.await.log_err()?; + let buffer_content = outline::get_buffer_content_or_outline( + buffer.clone(), + Some(&full_path), + &cx, + ) + .await + .ok()?; + + Some((rel_path, full_path, buffer_content.text, buffer)) + }) + })) + })?; + + let contents = cx + .background_spawn(async move { + let (contents, tracked_buffers) = descendants_future + .await + .into_iter() + .flatten() + .map(|(rel_path, full_path, rope, buffer)| { + ((rel_path, full_path, rope), buffer) + }) + .unzip(); + Mention::Text { + content: render_directory_contents(contents), + tracked_buffers, + } + }) + .await; + anyhow::Ok(contents) + }) +} + +fn render_directory_contents(entries: Vec<(Arc, String, String)>) -> String { + let mut output = String::new(); + for (_relative_path, full_path, content) in entries { + let fence = codeblock_fence_for_path(Some(&full_path), None); + write!(output, "\n{fence}\n{content}\n```").unwrap(); + } + output +} + +fn render_mention_fold_button( + label: SharedString, + icon: SharedString, + range: Range, + mut loading_finished: postage::barrier::Receiver, + image_task: Option, String>>>>, + editor: WeakEntity, + cx: &mut App, +) -> Arc, &mut App) -> AnyElement> { + let loading = cx.new(|cx| { + let loading = cx.spawn(async move |this, cx| { + loading_finished.recv().await; + this.update(cx, |this: &mut LoadingContext, cx| { + this.loading = None; + cx.notify(); + }) + .ok(); + }); + LoadingContext { + id: cx.entity_id(), + label, + icon, + range, + editor, + loading: Some(loading), + image: image_task.clone(), + } + }); + Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element()) +} + +struct LoadingContext { + id: EntityId, + label: SharedString, + icon: SharedString, + range: Range, + editor: WeakEntity, + loading: Option>, + image: Option, String>>>>, +} + +impl Render for LoadingContext { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_in_text_selection = self + .editor + .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx)) + .unwrap_or_default(); + ButtonLike::new(("loading-context", self.id)) + .style(ButtonStyle::Filled) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_in_text_selection) + .when_some(self.image.clone(), |el, image_task| { + el.hoverable_tooltip(move |_, cx| { + let image = image_task.peek().cloned().transpose().ok().flatten(); + let image_task = image_task.clone(); + cx.new::(|cx| ImageHover { + image, + _task: cx.spawn(async move |this, cx| { + if let Ok(image) = image_task.clone().await { + this.update(cx, |this, cx| { + if this.image.replace(image).is_none() { + cx.notify(); + } + }) + .ok(); + } + }), + }) + .into() + }) + }) + .child( + h_flex() + .gap_1() + .child( + Icon::from_path(self.icon.clone()) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(self.label.clone()) + .size(LabelSize::Small) + .buffer_font(cx) + .single_line(), + ) + .map(|el| { + if self.loading.is_some() { + el.with_animation( + "loading-context-crease", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any() + } else { + el.into_any() + } + }), + ) + } +} + +struct ImageHover { + image: Option>, + _task: Task<()>, +} + +impl Render for ImageHover { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + if let Some(image) = self.image.clone() { + gpui::img(image).max_w_96().max_h_96().into_any_element() + } else { + gpui::Empty.into_any_element() + } + } +} + +async fn fetch_url_content(http_client: Arc, url: String) -> Result { + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] + enum ContentType { + Html, + Plaintext, + Json, + } + use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown}; + + let url = if !url.starts_with("https://") && !url.starts_with("http://") { + format!("https://{url}") + } else { + url + }; + + let mut response = http_client.get(&url, AsyncBody::default(), true).await?; + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading response body")?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + anyhow::bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let Some(content_type) = response.headers().get("content-type") else { + anyhow::bail!("missing Content-Type header"); + }; + let content_type = content_type + .to_str() + .context("invalid Content-Type header")?; + let content_type = match content_type { + "text/html" => ContentType::Html, + "text/plain" => ContentType::Plaintext, + "application/json" => ContentType::Json, + _ => ContentType::Html, + }; + + match content_type { + ContentType::Html => { + let mut handlers: Vec = vec![ + Rc::new(RefCell::new(markdown::WebpageChromeRemover)), + Rc::new(RefCell::new(markdown::ParagraphHandler)), + Rc::new(RefCell::new(markdown::HeadingHandler)), + Rc::new(RefCell::new(markdown::ListHandler)), + Rc::new(RefCell::new(markdown::TableHandler::new())), + Rc::new(RefCell::new(markdown::StyledTextHandler)), + ]; + if url.contains("wikipedia.org") { + use html_to_markdown::structure::wikipedia; + + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); + handlers.push(Rc::new( + RefCell::new(wikipedia::WikipediaCodeHandler::new()), + )); + } else { + handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); + } + convert_html_to_markdown(&body[..], &mut handlers) + } + ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), + ContentType::Json => { + let json: serde_json::Value = serde_json::from_slice(&body)?; + + Ok(format!( + "```json\n{}\n```", + serde_json::to_string_pretty(&json)? + )) + } + } +} diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs deleted file mode 100644 index a1311f3923..0000000000 --- a/crates/agent_ui/src/message_editor.rs +++ /dev/null @@ -1,172 +0,0 @@ -use agent::{context::AgentContextKey, context_store::ContextStoreEvent}; -use agent_settings::AgentProfileId; -use collections::HashMap; -use editor::display_map::CreaseId; -use editor::{Addon, AnchorRangeExt, Editor}; -use gpui::{App, Entity, Subscription}; -use ui::prelude::*; - -use crate::context_picker::crease_for_mention; -use crate::profile_selector::ProfileProvider; -use agent::{MessageCrease, Thread, context_store::ContextStore}; - -impl ProfileProvider for Entity { - fn profiles_supported(&self, cx: &App) -> bool { - self.read(cx) - .configured_model() - .is_some_and(|model| model.model.supports_tools()) - } - - fn profile_id(&self, cx: &App) -> AgentProfileId { - self.read(cx).profile().id().clone() - } - - fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) { - self.update(cx, |this, cx| { - this.set_profile(profile_id, cx); - }); - } -} - -#[derive(Default)] -pub struct ContextCreasesAddon { - creases: HashMap>, - _subscription: Option, -} - -impl Addon for ContextCreasesAddon { - fn to_any(&self) -> &dyn std::any::Any { - self - } - - fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { - Some(self) - } -} - -impl ContextCreasesAddon { - pub fn new() -> Self { - Self { - creases: HashMap::default(), - _subscription: None, - } - } - - pub fn add_creases( - &mut self, - context_store: &Entity, - key: AgentContextKey, - creases: impl IntoIterator, - cx: &mut Context, - ) { - self.creases.entry(key).or_default().extend(creases); - self._subscription = Some( - cx.subscribe(context_store, |editor, _, event, cx| match event { - ContextStoreEvent::ContextRemoved(key) => { - let Some(this) = editor.addon_mut::() else { - return; - }; - let (crease_ids, replacement_texts): (Vec<_>, Vec<_>) = this - .creases - .remove(key) - .unwrap_or_default() - .into_iter() - .unzip(); - let ranges = editor - .remove_creases(crease_ids, cx) - .into_iter() - .map(|(_, range)| range) - .collect::>(); - editor.unfold_ranges(&ranges, false, false, cx); - editor.edit(ranges.into_iter().zip(replacement_texts), cx); - cx.notify(); - } - }), - ) - } - - pub fn into_inner(self) -> HashMap> { - self.creases - } -} - -pub fn extract_message_creases( - editor: &mut Editor, - cx: &mut Context<'_, Editor>, -) -> Vec { - let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let mut contexts_by_crease_id = editor - .addon_mut::() - .map(std::mem::take) - .unwrap_or_default() - .into_inner() - .into_iter() - .flat_map(|(key, creases)| { - let context = key.0; - creases - .into_iter() - .map(move |(id, _)| (id, context.clone())) - }) - .collect::>(); - // Filter the addon's list of creases based on what the editor reports, - // since the addon might have removed creases in it. - - editor.display_map.update(cx, |display_map, cx| { - display_map - .snapshot(cx) - .crease_snapshot - .creases() - .filter_map(|(id, crease)| { - Some(( - id, - ( - crease.range().to_offset(&buffer_snapshot), - crease.metadata()?.clone(), - ), - )) - }) - .map(|(id, (range, metadata))| { - let context = contexts_by_crease_id.remove(&id); - MessageCrease { - range, - context, - label: metadata.label, - icon_path: metadata.icon_path, - } - }) - .collect() - }) -} - -pub fn insert_message_creases( - editor: &mut Editor, - message_creases: &[MessageCrease], - context_store: &Entity, - window: &mut Window, - cx: &mut Context<'_, Editor>, -) { - let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let creases = message_creases - .iter() - .map(|crease| { - let start = buffer_snapshot.anchor_after(crease.range.start); - let end = buffer_snapshot.anchor_before(crease.range.end); - crease_for_mention( - crease.label.clone(), - crease.icon_path.clone(), - start..end, - cx.weak_entity(), - ) - }) - .collect::>(); - let ids = editor.insert_creases(creases.clone(), cx); - editor.fold_creases(creases, false, window, cx); - if let Some(addon) = editor.addon_mut::() { - for (crease, id) in message_creases.iter().zip(ids) { - if let Some(context) = crease.context.as_ref() { - let key = AgentContextKey(context.clone()); - addon.add_creases(context_store, key, vec![(id, crease.label.clone())], cx); - } - } - } -} diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index d52b2436d6..0182be0912 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -15,8 +15,8 @@ use std::{ sync::{Arc, atomic::AtomicBool}, }; use ui::{ - DocumentationAside, DocumentationEdge, DocumentationSide, HighlightedLabel, LabelSize, - ListItem, ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*, + DocumentationAside, DocumentationEdge, DocumentationSide, HighlightedLabel, KeyBinding, + LabelSize, ListItem, ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; /// Trait for types that can provide and manage agent profiles @@ -81,6 +81,7 @@ impl ProfileSelector { self.provider.clone(), self.profiles.clone(), cx.background_executor().clone(), + self.focus_handle.clone(), cx, ); @@ -144,10 +145,16 @@ impl Render for ProfileSelector { .unwrap_or_else(|| "Unknown".into()); let focus_handle = self.focus_handle.clone(); + let icon = if self.picker_handle.is_deployed() { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + let trigger_button = Button::new("profile-selector", selected_profile) .label_size(LabelSize::Small) .color(Color::Muted) - .icon(IconName::ChevronDown) + .icon(icon) .icon_size(IconSize::XSmall) .icon_position(IconPosition::End) .icon_color(Color::Muted) @@ -156,12 +163,11 @@ impl Render for ProfileSelector { PickerPopoverMenu::new( picker, trigger_button, - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Toggle Profile Menu", &ToggleProfileSelector, &focus_handle, - window, cx, ) }, @@ -202,6 +208,7 @@ pub(crate) struct ProfilePickerDelegate { selected_index: usize, query: String, cancel: Option>, + focus_handle: FocusHandle, } impl ProfilePickerDelegate { @@ -210,6 +217,7 @@ impl ProfilePickerDelegate { provider: Arc, profiles: AvailableProfiles, background: BackgroundExecutor, + focus_handle: FocusHandle, cx: &mut Context, ) -> Self { let candidates = Self::candidates_from(profiles); @@ -226,6 +234,7 @@ impl ProfilePickerDelegate { selected_index: 0, query: String::new(), cancel: None, + focus_handle, }; this.selected_index = this @@ -533,7 +542,7 @@ impl PickerDelegate for ProfilePickerDelegate { let is_active = active_id == candidate.id; Some( - ListItem::new(SharedString::from(candidate.id.0.clone())) + ListItem::new(candidate.id.0.clone()) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) @@ -589,20 +598,26 @@ impl PickerDelegate for ProfilePickerDelegate { _: &mut Window, cx: &mut Context>, ) -> Option { + let focus_handle = self.focus_handle.clone(); + Some( h_flex() .w_full() .border_t_1() .border_color(cx.theme().colors().border_variant) - .p_1() - .gap_4() - .justify_between() + .p_1p5() .child( Button::new("configure", "Configure") - .icon(IconName::Settings) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in( + &ManageProfiles::default(), + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) .on_click(|_, window, cx| { window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); }), @@ -654,20 +669,25 @@ mod tests { is_builtin: true, }]; - let delegate = ProfilePickerDelegate { - fs: FakeFs::new(cx.executor()), - provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))), - background: cx.executor(), - candidates, - string_candidates: Arc::new(Vec::new()), - filtered_entries: Vec::new(), - selected_index: 0, - query: String::new(), - cancel: None, - }; + cx.update(|cx| { + let focus_handle = cx.focus_handle(); - let matches = Vec::new(); // No matches - let _entries = delegate.entries_from_matches(matches); + let delegate = ProfilePickerDelegate { + fs: FakeFs::new(cx.background_executor().clone()), + provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))), + background: cx.background_executor().clone(), + candidates, + string_candidates: Arc::new(Vec::new()), + filtered_entries: Vec::new(), + selected_index: 0, + query: String::new(), + cancel: None, + focus_handle, + }; + + let matches = Vec::new(); // No matches + let _entries = delegate.entries_from_matches(matches); + }); } #[gpui::test] @@ -685,30 +705,35 @@ mod tests { }, ]; - let delegate = ProfilePickerDelegate { - fs: FakeFs::new(cx.executor()), - provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))), - background: cx.executor(), - candidates, - string_candidates: Arc::new(Vec::new()), - filtered_entries: vec![ - ProfilePickerEntry::Profile(ProfileMatchEntry { - candidate_index: 0, - positions: Vec::new(), - }), - ProfilePickerEntry::Profile(ProfileMatchEntry { - candidate_index: 1, - positions: Vec::new(), - }), - ], - selected_index: 0, - query: String::new(), - cancel: None, - }; + cx.update(|cx| { + let focus_handle = cx.focus_handle(); - // Active profile should be found at index 0 - let active_index = delegate.index_of_profile(&AgentProfileId("write".into())); - assert_eq!(active_index, Some(0)); + let delegate = ProfilePickerDelegate { + fs: FakeFs::new(cx.background_executor().clone()), + provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))), + background: cx.background_executor().clone(), + candidates, + string_candidates: Arc::new(Vec::new()), + filtered_entries: vec![ + ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: 0, + positions: Vec::new(), + }), + ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: 1, + positions: Vec::new(), + }), + ], + selected_index: 0, + query: String::new(), + cancel: None, + focus_handle, + }; + + // Active profile should be found at index 0 + let active_index = delegate.index_of_profile(&AgentProfileId("write".into())); + assert_eq!(active_index, Some(0)); + }); } struct TestProfileProvider { diff --git a/crates/agent_ui/src/slash_command.rs b/crates/agent_ui/src/slash_command.rs index c2f26c4f2e..e328ef6725 100644 --- a/crates/agent_ui/src/slash_command.rs +++ b/crates/agent_ui/src/slash_command.rs @@ -127,6 +127,8 @@ impl SlashCommandCompletionProvider { new_text, label: command.label(cx), icon_path: None, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm, source: CompletionSource::Custom, @@ -232,6 +234,8 @@ impl SlashCommandCompletionProvider { icon_path: None, new_text, documentation: None, + match_start: None, + snippet_deduplication_key: None, confirm, insert_text_mode: None, source: CompletionSource::Custom, @@ -337,7 +341,6 @@ impl CompletionProvider for SlashCommandCompletionProvider { position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); diff --git a/crates/agent_ui/src/slash_command_picker.rs b/crates/agent_ui/src/slash_command_picker.rs index a6bb61510c..0c3cf37599 100644 --- a/crates/agent_ui/src/slash_command_picker.rs +++ b/crates/agent_ui/src/slash_command_picker.rs @@ -155,8 +155,8 @@ impl PickerDelegate for SlashCommandDelegate { match command { SlashCommandEntry::Info(info) => { self.active_context_editor - .update(cx, |context_editor, cx| { - context_editor.insert_command(&info.name, window, cx) + .update(cx, |text_thread_editor, cx| { + text_thread_editor.insert_command(&info.name, window, cx) }) .ok(); } diff --git a/crates/agent_ui/src/terminal_codegen.rs b/crates/agent_ui/src/terminal_codegen.rs index 5a4a9d560a..e93d3d3991 100644 --- a/crates/agent_ui/src/terminal_codegen.rs +++ b/crates/agent_ui/src/terminal_codegen.rs @@ -1,37 +1,38 @@ use crate::inline_prompt_editor::CodegenStatus; -use client::telemetry::Telemetry; use futures::{SinkExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; -use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, report_assistant_event, -}; -use std::{sync::Arc, time::Instant}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; +use language_model::{ConfiguredModel, LanguageModelRegistry, LanguageModelRequest}; +use std::time::Instant; use terminal::Terminal; +use uuid::Uuid; pub struct TerminalCodegen { pub status: CodegenStatus, - pub telemetry: Option>, terminal: Entity, generation: Task<()>, pub message_id: Option, transaction: Option, + session_id: Uuid, } impl EventEmitter for TerminalCodegen {} impl TerminalCodegen { - pub fn new(terminal: Entity, telemetry: Option>) -> Self { + pub fn new(terminal: Entity, session_id: Uuid) -> Self { Self { terminal, - telemetry, status: CodegenStatus::Idle, generation: Task::ready(()), message_id: None, transaction: None, + session_id, } } + pub fn session_id(&self) -> Uuid { + self.session_id + } + pub fn start(&mut self, prompt_task: Task, cx: &mut Context) { let Some(ConfiguredModel { model, .. }) = LanguageModelRegistry::read_global(cx).inline_assistant_model() @@ -39,15 +40,15 @@ impl TerminalCodegen { return; }; - let model_api_key = model.api_key(cx); - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + self.status = CodegenStatus::Pending; self.transaction = Some(TerminalTransaction::start(self.terminal.clone())); self.generation = cx.spawn(async move |this, cx| { let prompt = prompt_task.await; - let model_telemetry_id = model.telemetry_id(); - let model_provider_id = model.provider_id(); let response = model.stream_completion_text(prompt, cx).await; let generate = async { let message_id = response @@ -59,7 +60,7 @@ impl TerminalCodegen { let task = cx.background_spawn({ let message_id = message_id.clone(); - let executor = cx.background_executor().clone(); + let anthropic_reporter = anthropic_reporter.clone(); async move { let mut response_latency = None; let request_start = Instant::now(); @@ -79,24 +80,27 @@ impl TerminalCodegen { let result = task.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id.to_string(), - response_latency, - error_message, - language_name: None, - }, - telemetry, - http_client, - model_api_key, - &executor, + + telemetry::event!( + "Assistant Responded", + session_id = session_id.to_string(), + kind = "inline_terminal", + phase = "response", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = Option::<&str>::None, + message_id = message_id, + response_latency = response_latency, + error_message = error_message, ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: language_model::AnthropicEventType::Response, + language_name: None, + message_id, + }); + result?; anyhow::Ok(()) } @@ -135,6 +139,12 @@ impl TerminalCodegen { cx.notify(); } + pub fn completion(&self) -> Option { + self.transaction + .as_ref() + .map(|transaction| transaction.completion.clone()) + } + pub fn stop(&mut self, cx: &mut Context) { self.status = CodegenStatus::Done; self.generation = Task::ready(()); @@ -167,27 +177,32 @@ pub const CLEAR_INPUT: &str = "\x03"; const CARRIAGE_RETURN: &str = "\x0d"; struct TerminalTransaction { + completion: String, terminal: Entity, } impl TerminalTransaction { pub fn start(terminal: Entity) -> Self { - Self { terminal } + Self { + completion: String::new(), + terminal, + } } pub fn push(&mut self, hunk: String, cx: &mut App) { // Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal let input = Self::sanitize_input(hunk); + self.completion.push_str(&input); self.terminal .update(cx, |terminal, _| terminal.input(input.into_bytes())); } - pub fn undo(&self, cx: &mut App) { + pub fn undo(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes())); } - pub fn complete(&self, cx: &mut App) { + pub fn complete(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes())); } diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 4385d24205..84a74242b8 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -1,15 +1,14 @@ -use crate::inline_prompt_editor::{ - CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId, -}; -use crate::terminal_codegen::{CLEAR_INPUT, CodegenEvent, TerminalCodegen}; -use agent::{ +use crate::{ context::load_context, - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, + inline_prompt_editor::{ + CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId, + }, + terminal_codegen::{CLEAR_INPUT, CodegenEvent, TerminalCodegen}, }; +use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; + use cloud_llm_client::CompletionIntent; use collections::{HashMap, VecDeque}; use editor::{MultiBuffer, actions::SelectAll}; @@ -18,24 +17,19 @@ use gpui::{App, Entity, Focusable, Global, Subscription, Task, UpdateGlobal, Wea use language::Buffer; use language_model::{ ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - Role, report_assistant_event, + Role, report_anthropic_event, }; use project::Project; use prompt_store::{PromptBuilder, PromptStore}; use std::sync::Arc; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; use terminal_view::TerminalView; use ui::prelude::*; use util::ResultExt; +use uuid::Uuid; use workspace::{Toast, Workspace, notifications::NotificationId}; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder)); } const DEFAULT_CONTEXT_LINES: usize = 50; @@ -45,7 +39,6 @@ pub struct TerminalInlineAssistant { next_assist_id: TerminalInlineAssistId, assists: HashMap, prompt_history: VecDeque, - telemetry: Option>, fs: Arc, prompt_builder: Arc, } @@ -53,16 +46,11 @@ pub struct TerminalInlineAssistant { impl Global for TerminalInlineAssistant {} impl TerminalInlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: TerminalInlineAssistId::default(), assists: HashMap::default(), prompt_history: VecDeque::default(), - telemetry: Some(telemetry), fs, prompt_builder, } @@ -73,23 +61,22 @@ impl TerminalInlineAssistant { terminal_view: &Entity, workspace: WeakEntity, project: WeakEntity, + thread_store: Entity, prompt_store: Option>, - thread_store: Option>, - text_thread_store: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, ) { let terminal = terminal_view.read(cx).terminal().clone(); let assist_id = self.next_assist_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), cx, ) }); - let context_store = cx.new(|_cx| ContextStore::new(project, thread_store.clone())); - let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone())); + let codegen = cx.new(|_| TerminalCodegen::new(terminal, session_id)); let prompt_editor = cx.new(|cx| { PromptEditor::new_terminal( @@ -97,11 +84,12 @@ impl TerminalInlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen, + session_id, self.fs.clone(), - context_store.clone(), - workspace.clone(), thread_store.clone(), - text_thread_store.clone(), + prompt_store.clone(), + project.clone(), + workspace.clone(), window, cx, ) @@ -120,8 +108,6 @@ impl TerminalInlineAssistant { terminal_view, prompt_editor, workspace.clone(), - context_store, - prompt_store, window, cx, ); @@ -228,6 +214,10 @@ impl TerminalInlineAssistant { assist_id: TerminalInlineAssistId, cx: &mut App, ) -> Result> { + let ConfiguredModel { model, .. } = LanguageModelRegistry::read_global(cx) + .inline_assistant_model() + .context("No inline assistant model")?; + let assist = self.assists.get(&assist_id).context("invalid assist")?; let shell = std::env::var("SHELL").ok(); @@ -244,46 +234,31 @@ impl TerminalInlineAssistant { .ok() .unwrap_or_default(); + let prompt_editor = assist.prompt_editor.clone().context("invalid assist")?; + let prompt = self.prompt_builder.generate_terminal_assistant_prompt( - &assist - .prompt_editor - .clone() - .context("invalid assist")? - .read(cx) - .prompt(cx), + &prompt_editor.read(cx).prompt(cx), shell.as_deref(), working_directory.as_deref(), &latest_output, )?; - let contexts = assist - .context_store - .read(cx) - .context() - .cloned() - .collect::>(); - let context_load_task = assist.workspace.update(cx, |workspace, cx| { - let project = workspace.project(); - load_context(contexts, project, &assist.prompt_store, cx) - })?; - - let ConfiguredModel { model, .. } = LanguageModelRegistry::read_global(cx) - .inline_assistant_model() - .context("No inline assistant model")?; - let temperature = AgentSettings::temperature_for_model(&model, cx); + let mention_set = prompt_editor.read(cx).mention_set().clone(); + let load_context_task = load_context(&mention_set, cx); + Ok(cx.background_spawn(async move { let mut request_message = LanguageModelRequestMessage { role: Role::User, content: vec![], cache: false, + reasoning_details: None, }; - context_load_task - .await - .loaded_context - .add_to_request_message(&mut request_message); + if let Some(context) = load_context_task.await { + context.add_to_request_message(&mut request_message); + } request_message.content.push(prompt.into()); @@ -325,27 +300,45 @@ impl TerminalInlineAssistant { LanguageModelRegistry::read_global(cx).inline_assistant_model() { let codegen = assist.codegen.read(cx); - let executor = cx.background_executor().clone(); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id: codegen.message_id.clone(), - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency: None, - error_message: None, + let session_id = codegen.session_id(); + let message_id = codegen.message_id.clone(); + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + // Fire Zed telemetry + telemetry::event!( + event_type, + kind = "inline_terminal", + phase = phase, + model = model_telemetry_id, + model_provider = model_provider_id, + message_id = message_id, + session_id = session_id, + ); + + report_anthropic_event( + &model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: anthropic_event_type, language_name: None, + message_id, }, - codegen.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - &executor, + cx, ); } @@ -411,8 +404,6 @@ struct TerminalInlineAssist { prompt_editor: Option>>, codegen: Entity, workspace: WeakEntity, - context_store: Entity, - prompt_store: Option>, _subscriptions: Vec, } @@ -422,8 +413,6 @@ impl TerminalInlineAssist { terminal: &Entity, prompt_editor: Entity>, workspace: WeakEntity, - context_store: Entity, - prompt_store: Option>, window: &mut Window, cx: &mut App, ) -> Self { @@ -433,8 +422,6 @@ impl TerminalInlineAssist { prompt_editor: Some(prompt_editor.clone()), codegen: codegen.clone(), workspace, - context_store, - prompt_store, _subscriptions: vec![ window.subscribe(&prompt_editor, cx, |prompt_editor, event, window, cx| { TerminalInlineAssistant::update_global(cx, |this, cx| { diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index e1265e923e..5e3f348c17 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1,5 +1,4 @@ use crate::{ - QuoteSelection, language_model_selector::{LanguageModelSelector, language_model_selector}, ui::BurnModeTooltip, }; @@ -10,8 +9,8 @@ use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections use client::{proto, zed_urls}; use collections::{BTreeSet, HashMap, HashSet, hash_map}; use editor::{ - Anchor, Editor, EditorEvent, MenuEditPredictionsPolicy, MultiBuffer, MultiBufferSnapshot, - RowExt, ToOffset as _, ToPoint, + Anchor, Editor, EditorEvent, MenuEditPredictionsPolicy, MultiBuffer, MultiBufferOffset, + MultiBufferSnapshot, RowExt, ToOffset as _, ToPoint, actions::{MoveToEndOfLine, Newline, ShowCompletions}, display_map::{ BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata, CustomBlockId, FoldId, @@ -23,11 +22,11 @@ use editor::{FoldPlaceholder, display_map::CreaseId}; use fs::Fs; use futures::FutureExt; use gpui::{ - Action, Animation, AnimationExt, AnyElement, AnyView, App, ClipboardEntry, ClipboardItem, - Empty, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, InteractiveElement, - IntoElement, ParentElement, Pixels, Render, RenderImage, SharedString, Size, - StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, actions, div, img, point, - prelude::*, pulsating_between, size, + Action, Animation, AnimationExt, AnyElement, App, ClipboardEntry, ClipboardItem, Empty, Entity, + EventEmitter, FocusHandle, Focusable, FontWeight, Global, InteractiveElement, IntoElement, + ParentElement, Pixels, Render, RenderImage, SharedString, Size, StatefulInteractiveElement, + Styled, Subscription, Task, WeakEntity, actions, div, img, point, prelude::*, + pulsating_between, size, }; use language::{ BufferSnapshot, LspAdapterDelegate, ToOffset, @@ -67,18 +66,18 @@ use workspace::{ }; use workspace::{ Save, Toast, Workspace, - item::{self, FollowableItem, Item, ItemHandle}, + item::{self, FollowableItem, Item}, notifications::NotificationId, pane, searchable::{SearchEvent, SearchableItem}, }; -use zed_actions::agent::ToggleModelSelector; +use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector}; use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker}; -use assistant_context::{ - AssistantContext, CacheStatus, Content, ContextEvent, ContextId, InvokedSlashCommandId, - InvokedSlashCommandStatus, Message, MessageId, MessageMetadata, MessageStatus, - PendingSlashCommandStatus, ThoughtProcessOutputSection, +use assistant_text_thread::{ + CacheStatus, Content, InvokedSlashCommandId, InvokedSlashCommandStatus, Message, MessageId, + MessageMetadata, MessageStatus, PendingSlashCommandStatus, TextThread, TextThreadEvent, + TextThreadId, ThoughtProcessOutputSection, }; actions!( @@ -127,14 +126,14 @@ pub enum ThoughtProcessStatus { } pub trait AgentPanelDelegate { - fn active_context_editor( + fn active_text_thread_editor( &self, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) -> Option>; - fn open_saved_context( + fn open_local_text_thread( &self, workspace: &mut Workspace, path: Arc, @@ -142,10 +141,10 @@ pub trait AgentPanelDelegate { cx: &mut Context, ) -> Task>; - fn open_remote_context( + fn open_remote_text_thread( &self, workspace: &mut Workspace, - context_id: ContextId, + text_thread_id: TextThreadId, window: &mut Window, cx: &mut Context, ) -> Task>>; @@ -178,7 +177,7 @@ struct GlobalAssistantPanelDelegate(Arc); impl Global for GlobalAssistantPanelDelegate {} pub struct TextThreadEditor { - context: Entity, + text_thread: Entity, fs: Arc, slash_commands: Arc, workspace: WeakEntity, @@ -224,8 +223,8 @@ impl TextThreadEditor { .detach(); } - pub fn for_context( - context: Entity, + pub fn for_text_thread( + text_thread: Entity, fs: Arc, workspace: WeakEntity, project: Entity, @@ -234,14 +233,14 @@ impl TextThreadEditor { cx: &mut Context, ) -> Self { let completion_provider = SlashCommandCompletionProvider::new( - context.read(cx).slash_commands().clone(), + text_thread.read(cx).slash_commands().clone(), Some(cx.entity().downgrade()), Some(workspace.clone()), ); let editor = cx.new(|cx| { let mut editor = - Editor::for_buffer(context.read(cx).buffer().clone(), None, window, cx); + Editor::for_buffer(text_thread.read(cx).buffer().clone(), None, window, cx); editor.disable_scrollbars_and_minimap(window, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); editor.set_show_line_numbers(false, cx); @@ -265,18 +264,26 @@ impl TextThreadEditor { }); let _subscriptions = vec![ - cx.observe(&context, |_, _, cx| cx.notify()), - cx.subscribe_in(&context, window, Self::handle_context_event), + cx.observe(&text_thread, |_, _, cx| cx.notify()), + cx.subscribe_in(&text_thread, window, Self::handle_text_thread_event), cx.subscribe_in(&editor, window, Self::handle_editor_event), cx.subscribe_in(&editor, window, Self::handle_editor_search_event), cx.observe_global_in::(window, Self::settings_changed), ]; - let slash_command_sections = context.read(cx).slash_command_output_sections().to_vec(); - let thought_process_sections = context.read(cx).thought_process_output_sections().to_vec(); - let slash_commands = context.read(cx).slash_commands().clone(); + let slash_command_sections = text_thread + .read(cx) + .slash_command_output_sections() + .to_vec(); + let thought_process_sections = text_thread + .read(cx) + .thought_process_output_sections() + .to_vec(); + let slash_commands = text_thread.read(cx).slash_commands().clone(); + let focus_handle = editor.read(cx).focus_handle(cx); + let mut this = Self { - context, + text_thread, slash_commands, editor, lsp_adapter_delegate, @@ -309,6 +316,8 @@ impl TextThreadEditor { ) }); }, + true, // Use popover styles for picker + focus_handle, window, cx, ) @@ -338,8 +347,8 @@ impl TextThreadEditor { }); } - pub fn context(&self) -> &Entity { - &self.context + pub fn text_thread(&self) -> &Entity { + &self.text_thread } pub fn editor(&self) -> &Entity { @@ -351,9 +360,9 @@ impl TextThreadEditor { self.editor.update(cx, |editor, cx| { editor.insert(&format!("/{command_name}\n\n"), window, cx) }); - let command = self.context.update(cx, |context, cx| { - context.reparse(cx); - context.parsed_slash_commands()[0].clone() + let command = self.text_thread.update(cx, |text_thread, cx| { + text_thread.reparse(cx); + text_thread.parsed_slash_commands()[0].clone() }); self.run_command( command.source_range, @@ -376,12 +385,15 @@ impl TextThreadEditor { fn send_to_model(&mut self, window: &mut Window, cx: &mut Context) { self.last_error = None; - if let Some(user_message) = self.context.update(cx, |context, cx| context.assist(cx)) { + if let Some(user_message) = self + .text_thread + .update(cx, |text_thread, cx| text_thread.assist(cx)) + { let new_selection = { let cursor = user_message .start - .to_offset(self.context.read(cx).buffer().read(cx)); - cursor..cursor + .to_offset(self.text_thread.read(cx).buffer().read(cx)); + MultiBufferOffset(cursor)..MultiBufferOffset(cursor) }; self.editor.update(cx, |editor, cx| { editor.change_selections(Default::default(), window, cx, |selections| { @@ -404,8 +416,8 @@ impl TextThreadEditor { self.last_error = None; if self - .context - .update(cx, |context, cx| context.cancel_last_assist(cx)) + .text_thread + .update(cx, |text_thread, cx| text_thread.cancel_last_assist(cx)) { return; } @@ -420,20 +432,22 @@ impl TextThreadEditor { cx: &mut Context, ) { let cursors = self.cursors(cx); - self.context.update(cx, |context, cx| { - let messages = context - .messages_for_offsets(cursors, cx) + self.text_thread.update(cx, |text_thread, cx| { + let messages = text_thread + .messages_for_offsets(cursors.into_iter().map(|cursor| cursor.0), cx) .into_iter() .map(|message| message.id) .collect(); - context.cycle_message_roles(messages, cx) + text_thread.cycle_message_roles(messages, cx) }); } - fn cursors(&self, cx: &mut App) -> Vec { - let selections = self - .editor - .update(cx, |editor, cx| editor.selections.all::(cx)); + fn cursors(&self, cx: &mut App) -> Vec { + let selections = self.editor.update(cx, |editor, cx| { + editor + .selections + .all::(&editor.display_snapshot(cx)) + }); selections .into_iter() .map(|selection| selection.head()) @@ -446,7 +460,10 @@ impl TextThreadEditor { editor.transact(window, cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |s| s.try_cancel()); let snapshot = editor.buffer().read(cx).snapshot(cx); - let newest_cursor = editor.selections.newest::(cx).head(); + let newest_cursor = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); if newest_cursor.column > 0 || snapshot .chars_at(newest_cursor) @@ -466,7 +483,7 @@ impl TextThreadEditor { editor.insert(&format!("/{name}"), window, cx); if command.accepts_arguments() { editor.insert(" ", window, cx); - editor.show_completions(&ShowCompletions::default(), window, cx); + editor.show_completions(&ShowCompletions, window, cx); } }); }); @@ -489,11 +506,11 @@ impl TextThreadEditor { let selections = self.editor.read(cx).selections.disjoint_anchors_arc(); let mut commands_by_range = HashMap::default(); let workspace = self.workspace.clone(); - self.context.update(cx, |context, cx| { - context.reparse(cx); + self.text_thread.update(cx, |text_thread, cx| { + text_thread.reparse(cx); for selection in selections.iter() { if let Some(command) = - context.pending_command_for_position(selection.head().text_anchor, cx) + text_thread.pending_command_for_position(selection.head().text_anchor, cx) { commands_by_range .entry(command.source_range.clone()) @@ -531,14 +548,14 @@ impl TextThreadEditor { cx: &mut Context, ) { if let Some(command) = self.slash_commands.command(name, cx) { - let context = self.context.read(cx); - let sections = context + let text_thread = self.text_thread.read(cx); + let sections = text_thread .slash_command_output_sections() .iter() - .filter(|section| section.is_valid(context.buffer().read(cx))) + .filter(|section| section.is_valid(text_thread.buffer().read(cx))) .cloned() .collect::>(); - let snapshot = context.buffer().read(cx).snapshot(); + let snapshot = text_thread.buffer().read(cx).snapshot(); let output = command.run( arguments, §ions, @@ -548,8 +565,8 @@ impl TextThreadEditor { window, cx, ); - self.context.update(cx, |context, cx| { - context.insert_command_output( + self.text_thread.update(cx, |text_thread, cx| { + text_thread.insert_command_output( command_range, name, output, @@ -560,32 +577,32 @@ impl TextThreadEditor { } } - fn handle_context_event( + fn handle_text_thread_event( &mut self, - _: &Entity, - event: &ContextEvent, + _: &Entity, + event: &TextThreadEvent, window: &mut Window, cx: &mut Context, ) { - let context_editor = cx.entity().downgrade(); + let text_thread_editor = cx.entity().downgrade(); match event { - ContextEvent::MessagesEdited => { + TextThreadEvent::MessagesEdited => { self.update_message_headers(cx); self.update_image_blocks(cx); - self.context.update(cx, |context, cx| { - context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); + self.text_thread.update(cx, |text_thread, cx| { + text_thread.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); }); } - ContextEvent::SummaryChanged => { + TextThreadEvent::SummaryChanged => { cx.emit(EditorEvent::TitleChanged); - self.context.update(cx, |context, cx| { - context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); + self.text_thread.update(cx, |text_thread, cx| { + text_thread.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); }); } - ContextEvent::SummaryGenerated => {} - ContextEvent::PathChanged { .. } => {} - ContextEvent::StartedThoughtProcess(range) => { + TextThreadEvent::SummaryGenerated => {} + TextThreadEvent::PathChanged { .. } => {} + TextThreadEvent::StartedThoughtProcess(range) => { let creases = self.insert_thought_process_output_sections( [( ThoughtProcessOutputSection { @@ -598,14 +615,12 @@ impl TextThreadEditor { ); self.pending_thought_process = Some((creases[0], range.start)); } - ContextEvent::EndedThoughtProcess(end) => { + TextThreadEvent::EndedThoughtProcess(end) => { if let Some((crease_id, start)) = self.pending_thought_process.take() { self.editor.update(cx, |editor, cx| { let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let (excerpt_id, _, _) = multi_buffer_snapshot.as_singleton().unwrap(); - let start_anchor = multi_buffer_snapshot - .anchor_in_excerpt(*excerpt_id, start) - .unwrap(); + let start_anchor = + multi_buffer_snapshot.as_singleton_anchor(start).unwrap(); editor.display_map.update(cx, |display_map, cx| { display_map.unfold_intersecting( @@ -626,7 +641,7 @@ impl TextThreadEditor { ); } } - ContextEvent::StreamedCompletion => { + TextThreadEvent::StreamedCompletion => { self.editor.update(cx, |editor, cx| { if let Some(scroll_position) = self.scroll_position { let snapshot = editor.snapshot(window, cx); @@ -641,7 +656,7 @@ impl TextThreadEditor { } }); } - ContextEvent::ParsedSlashCommandsUpdated { removed, updated } => { + TextThreadEvent::ParsedSlashCommandsUpdated { removed, updated } => { self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); let (&excerpt_id, _, _) = buffer.as_singleton().unwrap(); @@ -657,12 +672,12 @@ impl TextThreadEditor { updated.iter().map(|command| { let workspace = self.workspace.clone(); let confirm_command = Arc::new({ - let context_editor = context_editor.clone(); + let text_thread_editor = text_thread_editor.clone(); let command = command.clone(); move |window: &mut Window, cx: &mut App| { - context_editor - .update(cx, |context_editor, cx| { - context_editor.run_command( + text_thread_editor + .update(cx, |text_thread_editor, cx| { + text_thread_editor.run_command( command.source_range.clone(), &command.name, &command.arguments, @@ -696,13 +711,10 @@ impl TextThreadEditor { } }; - let start = buffer - .anchor_in_excerpt(excerpt_id, command.source_range.start) + let range = buffer + .anchor_range_in_excerpt(excerpt_id, command.source_range.clone()) .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, command.source_range.end) - .unwrap(); - Crease::inline(start..end, placeholder, render_toggle, render_trailer) + Crease::inline(range, placeholder, render_toggle, render_trailer) }), cx, ); @@ -715,17 +727,17 @@ impl TextThreadEditor { ); }) } - ContextEvent::InvokedSlashCommandChanged { command_id } => { + TextThreadEvent::InvokedSlashCommandChanged { command_id } => { self.update_invoked_slash_command(*command_id, window, cx); } - ContextEvent::SlashCommandOutputSectionAdded { section } => { + TextThreadEvent::SlashCommandOutputSectionAdded { section } => { self.insert_slash_command_output_sections([section.clone()], false, window, cx); } - ContextEvent::Operation(_) => {} - ContextEvent::ShowAssistError(error_message) => { + TextThreadEvent::Operation(_) => {} + TextThreadEvent::ShowAssistError(error_message) => { self.last_error = Some(AssistError::Message(error_message.clone())); } - ContextEvent::ShowPaymentRequiredError => { + TextThreadEvent::ShowPaymentRequiredError => { self.last_error = Some(AssistError::PaymentRequired); } } @@ -738,14 +750,14 @@ impl TextThreadEditor { cx: &mut Context, ) { if let Some(invoked_slash_command) = - self.context.read(cx).invoked_slash_command(&command_id) + self.text_thread.read(cx).invoked_slash_command(&command_id) && let InvokedSlashCommandStatus::Finished = invoked_slash_command.status { let run_commands_in_ranges = invoked_slash_command.run_commands_in_ranges.clone(); for range in run_commands_in_ranges { - let commands = self.context.update(cx, |context, cx| { - context.reparse(cx); - context + let commands = self.text_thread.update(cx, |text_thread, cx| { + text_thread.reparse(cx); + text_thread .pending_commands_for_range(range.clone(), cx) .to_vec() }); @@ -766,21 +778,18 @@ impl TextThreadEditor { self.editor.update(cx, |editor, cx| { if let Some(invoked_slash_command) = - self.context.read(cx).invoked_slash_command(&command_id) + self.text_thread.read(cx).invoked_slash_command(&command_id) { if let InvokedSlashCommandStatus::Finished = invoked_slash_command.status { let buffer = editor.buffer().read(cx).snapshot(cx); let (&excerpt_id, _buffer_id, _buffer_snapshot) = buffer.as_singleton().unwrap(); - let start = buffer - .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.start) - .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end) + let range = buffer + .anchor_range_in_excerpt(excerpt_id, invoked_slash_command.range.clone()) .unwrap(); editor.remove_folds_with_type( - &[start..end], + &[range], TypeId::of::(), false, cx, @@ -796,15 +805,12 @@ impl TextThreadEditor { let buffer = editor.buffer().read(cx).snapshot(cx); let (&excerpt_id, _buffer_id, _buffer_snapshot) = buffer.as_singleton().unwrap(); - let context = self.context.downgrade(); - let crease_start = buffer - .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.start) - .unwrap(); - let crease_end = buffer - .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end) + let context = self.text_thread.downgrade(); + let range = buffer + .anchor_range_in_excerpt(excerpt_id, invoked_slash_command.range.clone()) .unwrap(); let crease = Crease::inline( - crease_start..crease_end, + range, invoked_slash_command_fold_placeholder(command_id, context), fold_toggle("invoked-slash-command"), |_row, _folded, _window, _cx| Empty.into_any(), @@ -842,17 +848,14 @@ impl TextThreadEditor { let mut buffer_rows_to_fold = BTreeSet::new(); let mut creases = Vec::new(); for (section, status) in sections { - let start = buffer - .anchor_in_excerpt(excerpt_id, section.range.start) + let range = buffer + .anchor_range_in_excerpt(excerpt_id, section.range) .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, section.range.end) - .unwrap(); - let buffer_row = MultiBufferRow(start.to_point(&buffer).row); + let buffer_row = MultiBufferRow(range.start.to_point(&buffer).row); buffer_rows_to_fold.insert(buffer_row); creases.push( Crease::inline( - start..end, + range, FoldPlaceholder { render: render_thought_process_fold_icon_button( cx.entity().downgrade(), @@ -894,17 +897,14 @@ impl TextThreadEditor { let mut buffer_rows_to_fold = BTreeSet::new(); let mut creases = Vec::new(); for section in sections { - let start = buffer - .anchor_in_excerpt(excerpt_id, section.range.start) + let range = buffer + .anchor_range_in_excerpt(excerpt_id, section.range) .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, section.range.end) - .unwrap(); - let buffer_row = MultiBufferRow(start.to_point(&buffer).row); + let buffer_row = MultiBufferRow(range.start.to_point(&buffer).row); buffer_rows_to_fold.insert(buffer_row); creases.push( Crease::inline( - start..end, + range, FoldPlaceholder { render: render_fold_icon_button( cx.entity().downgrade(), @@ -1035,7 +1035,7 @@ impl TextThreadEditor { let render_block = |message: MessageMetadata| -> RenderBlock { Arc::new({ - let context = self.context.clone(); + let text_thread = self.text_thread.clone(); move |cx| { let message_id = MessageId(message.timestamp); @@ -1099,20 +1099,19 @@ impl TextThreadEditor { .child(label) .children(spinner), ) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Toggle message role", None, "Available roles: You (User), Agent, System", - window, cx, ) }) .on_click({ - let context = context.clone(); + let text_thread = text_thread.clone(); move |_, _window, cx| { - context.update(cx, |context, cx| { - context.cycle_message_roles( + text_thread.update(cx, |text_thread, cx| { + text_thread.cycle_message_roles( HashSet::from_iter(Some(message_id)), cx, ) @@ -1140,12 +1139,11 @@ impl TextThreadEditor { .size(IconSize::XSmall) .color(Color::Hint), ) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Context Cached", None, "Large messages cached to optimize performance", - window, cx, ) }) @@ -1175,11 +1173,11 @@ impl TextThreadEditor { .icon_position(IconPosition::Start) .tooltip(Tooltip::text("View Details")) .on_click({ - let context = context.clone(); + let text_thread = text_thread.clone(); let error = error.clone(); move |_, _window, cx| { - context.update(cx, |_, cx| { - cx.emit(ContextEvent::ShowAssistError( + text_thread.update(cx, |_, cx| { + cx.emit(TextThreadEvent::ShowAssistError( error.clone(), )); }); @@ -1222,7 +1220,7 @@ impl TextThreadEditor { }; let mut new_blocks = vec![]; let mut block_index_to_message = vec![]; - for message in self.context.read(cx).messages(cx) { + for message in self.text_thread.read(cx).messages(cx) { if blocks_to_remove.remove(&message.id).is_some() { // This is an old message that we might modify. let Some((meta, block_id)) = old_blocks.get_mut(&message.id) else { @@ -1263,13 +1261,21 @@ impl TextThreadEditor { ) -> Option<(String, bool)> { const CODE_FENCE_DELIMITER: &str = "```"; - let context_editor = context_editor_view.read(cx).editor.clone(); - context_editor.update(cx, |context_editor, cx| { - if context_editor.selections.newest::(cx).is_empty() { - let snapshot = context_editor.buffer().read(cx).snapshot(cx); + let text_thread_editor = context_editor_view.read(cx).editor.clone(); + text_thread_editor.update(cx, |text_thread_editor, cx| { + let display_map = text_thread_editor.display_snapshot(cx); + if text_thread_editor + .selections + .newest::(&display_map) + .is_empty() + { + let snapshot = text_thread_editor.buffer().read(cx).snapshot(cx); let (_, _, snapshot) = snapshot.as_singleton()?; - let head = context_editor.selections.newest::(cx).head(); + let head = text_thread_editor + .selections + .newest::(&display_map) + .head(); let offset = snapshot.point_to_offset(head); let surrounding_code_block_range = find_surrounding_code_block(snapshot, offset)?; @@ -1286,8 +1292,8 @@ impl TextThreadEditor { (!text.is_empty()).then_some((text, true)) } else { - let selection = context_editor.selections.newest_adjusted(cx); - let buffer = context_editor.buffer().read(cx).snapshot(cx); + let selection = text_thread_editor.selections.newest_adjusted(&display_map); + let buffer = text_thread_editor.buffer().read(cx).snapshot(cx); let selected_text = buffer.text_for_range(selection.range()).collect::(); (!selected_text.is_empty()).then_some((selected_text, false)) @@ -1305,7 +1311,7 @@ impl TextThreadEditor { return; }; let Some(context_editor_view) = - agent_panel_delegate.active_context_editor(workspace, window, cx) + agent_panel_delegate.active_text_thread_editor(workspace, window, cx) else { return; }; @@ -1333,7 +1339,7 @@ impl TextThreadEditor { let result = maybe!({ let agent_panel_delegate = ::try_global(cx)?; let context_editor_view = - agent_panel_delegate.active_context_editor(workspace, window, cx)?; + agent_panel_delegate.active_text_thread_editor(workspace, window, cx)?; Self::get_selection_or_code_block(&context_editor_view, cx) }); let Some((text, is_code_block)) = result else { @@ -1370,7 +1376,7 @@ impl TextThreadEditor { return; }; let Some(context_editor_view) = - agent_panel_delegate.active_context_editor(workspace, window, cx) + agent_panel_delegate.active_text_thread_editor(workspace, window, cx) else { return; }; @@ -1456,7 +1462,7 @@ impl TextThreadEditor { pub fn quote_selection( workspace: &mut Workspace, - _: &QuoteSelection, + _: &AddSelectionToThread, window: &mut Window, cx: &mut Context, ) { @@ -1474,7 +1480,7 @@ impl TextThreadEditor { let selections = editor.update(cx, |editor, cx| { editor .selections - .all_adjusted(cx) + .all_adjusted(&editor.display_snapshot(cx)) .into_iter() .filter_map(|s| { (!s.is_empty()) @@ -1506,7 +1512,10 @@ impl TextThreadEditor { self.editor.update(cx, |editor, cx| { editor.insert("\n", window, cx); for (text, crease_title) in creases { - let point = editor.selections.newest::(cx).head(); + let point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); let start_row = MultiBufferRow(point.row); editor.insert(&text, window, cx); @@ -1576,9 +1585,15 @@ impl TextThreadEditor { fn get_clipboard_contents( &mut self, cx: &mut Context, - ) -> (String, CopyMetadata, Vec>) { + ) -> ( + String, + CopyMetadata, + Vec>, + ) { let (mut selection, creases) = self.editor.update(cx, |editor, cx| { - let mut selection = editor.selections.newest_adjusted(cx); + let mut selection = editor + .selections + .newest_adjusted(&editor.display_snapshot(cx)); let snapshot = editor.buffer().read(cx).snapshot(cx); selection.goal = SelectionGoal::None; @@ -1626,32 +1641,32 @@ impl TextThreadEditor { ) }); - let context = self.context.read(cx); + let text_thread = self.text_thread.read(cx); let mut text = String::new(); // If selection is empty, we want to copy the entire line if selection.range().is_empty() { - let snapshot = context.buffer().read(cx).snapshot(); + let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); let point = snapshot.offset_to_point(selection.range().start); selection.start = snapshot.point_to_offset(Point::new(point.row, 0)); selection.end = snapshot .point_to_offset(cmp::min(Point::new(point.row + 1, 0), snapshot.max_point())); - for chunk in context.buffer().read(cx).text_for_range(selection.range()) { + for chunk in snapshot.text_for_range(selection.range()) { text.push_str(chunk); } } else { - for message in context.messages(cx) { - if message.offset_range.start >= selection.range().end { + for message in text_thread.messages(cx) { + if message.offset_range.start >= selection.range().end.0 { break; - } else if message.offset_range.end >= selection.range().start { - let range = cmp::max(message.offset_range.start, selection.range().start) - ..cmp::min(message.offset_range.end, selection.range().end); + } else if message.offset_range.end >= selection.range().start.0 { + let range = cmp::max(message.offset_range.start, selection.range().start.0) + ..cmp::min(message.offset_range.end, selection.range().end.0); if !range.is_empty() { - for chunk in context.buffer().read(cx).text_for_range(range) { + for chunk in text_thread.buffer().read(cx).text_for_range(range) { text.push_str(chunk); } - if message.offset_range.end < selection.range().end { + if message.offset_range.end < selection.range().end.0 { text.push('\n'); } } @@ -1667,9 +1682,101 @@ impl TextThreadEditor { window: &mut Window, cx: &mut Context, ) { + let editor_clipboard_selections = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + ClipboardEntry::String(text) => { + text.metadata_json::>() + } + _ => None, + }); + + let has_file_context = editor_clipboard_selections + .as_ref() + .is_some_and(|selections| { + selections + .iter() + .any(|sel| sel.file_path.is_some() && sel.line_range.is_some()) + }); + + if has_file_context { + if let Some(clipboard_item) = cx.read_from_clipboard() { + if let Some(ClipboardEntry::String(clipboard_text)) = + clipboard_item.entries().first() + { + if let Some(selections) = editor_clipboard_selections { + cx.stop_propagation(); + + let text = clipboard_text.text(); + self.editor.update(cx, |editor, cx| { + let mut current_offset = 0; + let weak_editor = cx.entity().downgrade(); + + for selection in selections { + if let (Some(file_path), Some(line_range)) = + (selection.file_path, selection.line_range) + { + let selected_text = + &text[current_offset..current_offset + selection.len]; + let fence = assistant_slash_commands::codeblock_fence_for_path( + file_path.to_str(), + Some(line_range.clone()), + ); + let formatted_text = format!("{fence}{selected_text}\n```"); + + let insert_point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + let start_row = MultiBufferRow(insert_point.row); + + editor.insert(&formatted_text, window, cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor_before = snapshot.anchor_after(insert_point); + let anchor_after = editor + .selections + .newest_anchor() + .head() + .bias_left(&snapshot); + + editor.insert("\n", window, cx); + + let crease_text = acp_thread::selection_name( + Some(file_path.as_ref()), + &line_range, + ); + + let fold_placeholder = quote_selection_fold_placeholder( + crease_text, + weak_editor.clone(), + ); + let crease = Crease::inline( + anchor_before..anchor_after, + fold_placeholder, + render_quote_selection_output_toggle, + |_, _, _, _| Empty.into_any(), + ); + editor.insert_creases(vec![crease], cx); + editor.fold_at(start_row, window, cx); + + current_offset += selection.len; + if !selection.is_entire_line && current_offset < text.len() { + current_offset += 1; + } + } + } + }); + return; + } + } + } + } + cx.stop_propagation(); - let images = if let Some(item) = cx.read_from_clipboard() { + let mut images = if let Some(item) = cx.read_from_clipboard() { item.into_entries() .filter_map(|entry| { if let ClipboardEntry::Image(image) = entry { @@ -1683,6 +1790,40 @@ impl TextThreadEditor { Vec::new() }; + if let Some(paths) = cx.read_from_clipboard() { + for path in paths + .into_entries() + .filter_map(|entry| { + if let ClipboardEntry::ExternalPaths(paths) = entry { + Some(paths.paths().to_owned()) + } else { + None + } + }) + .flatten() + { + let Ok(content) = std::fs::read(path) else { + continue; + }; + let Ok(format) = image::guess_format(&content) else { + continue; + }; + images.push(gpui::Image::from_bytes( + match format { + image::ImageFormat::Png => gpui::ImageFormat::Png, + image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, + image::ImageFormat::WebP => gpui::ImageFormat::Webp, + image::ImageFormat::Gif => gpui::ImageFormat::Gif, + image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, + image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, + image::ImageFormat::Ico => gpui::ImageFormat::Ico, + _ => continue, + }, + content, + )); + } + } + let metadata = if let Some(item) = cx.read_from_clipboard() { item.entries().first().and_then(|entry| { if let ClipboardEntry::String(text) = entry { @@ -1697,7 +1838,10 @@ impl TextThreadEditor { if images.is_empty() { self.editor.update(cx, |editor, cx| { - let paste_position = editor.selections.newest::(cx).head(); + let paste_position = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); editor.paste(action, window, cx); if let Some(metadata) = metadata { @@ -1744,19 +1888,22 @@ impl TextThreadEditor { editor.transact(window, cx, |editor, _window, cx| { let edits = editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|selection| (selection.start..selection.end, "\n")); editor.edit(edits, cx); let snapshot = editor.buffer().read(cx).snapshot(cx); - for selection in editor.selections.all::(cx) { + for selection in editor + .selections + .all::(&editor.display_snapshot(cx)) + { image_positions.push(snapshot.anchor_before(selection.end)); } }); }); - self.context.update(cx, |context, cx| { + self.text_thread.update(cx, |text_thread, cx| { for image in images { let Some(render_image) = image.to_image_data(cx.svg_renderer()).log_err() else { @@ -1766,7 +1913,7 @@ impl TextThreadEditor { let image_task = LanguageModelImage::from_image(Arc::new(image), cx).shared(); for image_position in image_positions.iter() { - context.insert_content( + text_thread.insert_content( Content::Image { anchor: image_position.text_anchor, image_id, @@ -1787,7 +1934,7 @@ impl TextThreadEditor { let excerpt_id = *buffer.as_singleton().unwrap().0; let old_blocks = std::mem::take(&mut self.image_blocks); let new_blocks = self - .context + .text_thread .read(cx) .contents(cx) .map( @@ -1835,36 +1982,36 @@ impl TextThreadEditor { } fn split(&mut self, _: &Split, _window: &mut Window, cx: &mut Context) { - self.context.update(cx, |context, cx| { + self.text_thread.update(cx, |text_thread, cx| { let selections = self.editor.read(cx).selections.disjoint_anchors_arc(); for selection in selections.as_ref() { let buffer = self.editor.read(cx).buffer().read(cx).snapshot(cx); let range = selection .map(|endpoint| endpoint.to_offset(&buffer)) .range(); - context.split_message(range, cx); + text_thread.split_message(range.start.0..range.end.0, cx); } }); } fn save(&mut self, _: &Save, _window: &mut Window, cx: &mut Context) { - self.context.update(cx, |context, cx| { - context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx) + self.text_thread.update(cx, |text_thread, cx| { + text_thread.save(Some(Duration::from_millis(500)), self.fs.clone(), cx) }); } pub fn title(&self, cx: &App) -> SharedString { - self.context.read(cx).summary().or_default() + self.text_thread.read(cx).summary().or_default() } pub fn regenerate_summary(&mut self, cx: &mut Context) { - self.context - .update(cx, |context, cx| context.summarize(true, cx)); + self.text_thread + .update(cx, |text_thread, cx| text_thread.summarize(true, cx)); } fn render_remaining_tokens(&self, cx: &App) -> Option> { let (token_count_color, token_count, max_token_count, tooltip) = - match token_state(&self.context, cx)? { + match token_state(&self.text_thread, cx)? { TokenState::NoTokensLeft { max_token_count, token_count, @@ -1912,7 +2059,7 @@ impl TextThreadEditor { fn render_send_button(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); - let (style, tooltip) = match token_state(&self.context, cx) { + let (style, tooltip) = match token_state(&self.text_thread, cx) { Some(TokenState::NoTokensLeft { .. }) => ( ButtonStyle::Tinted(TintColor::Error), Some(Tooltip::text("Token limit reached")(window, cx)), @@ -1945,7 +2092,7 @@ impl TextThreadEditor { }) .layer(ElevationIndex::ModalSurface) .key_binding( - KeyBinding::for_action_in(&Assist, &focus_handle, window, cx) + KeyBinding::for_action_in(&Assist, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(move |_event, window, cx| { @@ -1977,21 +2124,17 @@ impl TextThreadEditor { cx.entity().downgrade(), IconButton::new("trigger", IconName::Plus) .icon_size(IconSize::Small) - .icon_color(Color::Muted), - move |window, cx| { - Tooltip::with_meta( - "Add Context", - None, - "Type / to insert via keyboard", - window, - cx, - ) + .icon_color(Color::Muted) + .selected_icon_color(Color::Accent) + .selected_style(ButtonStyle::Filled), + move |_window, cx| { + Tooltip::with_meta("Add Context", None, "Type / to insert via keyboard", cx) }, ) } fn render_burn_mode_toggle(&self, cx: &mut Context) -> Option { - let context = self.context().read(cx); + let text_thread = self.text_thread().read(cx); let active_model = LanguageModelRegistry::read_global(cx) .default_model() .map(|default| default.model)?; @@ -1999,7 +2142,7 @@ impl TextThreadEditor { return None; } - let active_completion_mode = context.completion_mode(); + let active_completion_mode = text_thread.completion_mode(); let burn_mode_enabled = active_completion_mode == CompletionMode::Burn; let icon = if burn_mode_enabled { IconName::ZedBurnModeOn @@ -2014,8 +2157,8 @@ impl TextThreadEditor { .toggle_state(burn_mode_enabled) .selected_icon_color(Color::Error) .on_click(cx.listener(move |this, _event, _window, cx| { - this.context().update(cx, |context, _cx| { - context.set_completion_mode(match active_completion_mode { + this.text_thread().update(cx, |text_thread, _cx| { + text_thread.set_completion_mode(match active_completion_mode { CompletionMode::Burn => CompletionMode::Normal, CompletionMode::Normal => CompletionMode::Burn, }); @@ -2052,41 +2195,32 @@ impl TextThreadEditor { }; let focus_handle = self.editor().focus_handle(cx); + let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() { + (Color::Accent, IconName::ChevronUp) + } else { + (Color::Muted, IconName::ChevronDown) + }; PickerPopoverMenu::new( self.language_model_selector.clone(), ButtonLike::new("active-model") - .style(ButtonStyle::Subtle) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child( h_flex() .gap_0p5() - .child( - Icon::new(provider_icon) - .color(Color::Muted) - .size(IconSize::XSmall), - ) + .child(Icon::new(provider_icon).color(color).size(IconSize::XSmall)) .child( Label::new(model_name) - .color(Color::Muted) + .color(color) .size(LabelSize::Small) .ml_0p5(), ) - .child( - Icon::new(IconName::ChevronDown) - .color(Color::Muted) - .size(IconSize::XSmall), - ), + .child(Icon::new(icon).color(color).size(IconSize::XSmall)), ), - move |window, cx| { - Tooltip::for_action_in( - "Change Model", - &ToggleModelSelector, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) }, - gpui::Corner::BottomLeft, + gpui::Corner::BottomRight, cx, ) .with_handle(self.language_model_selector_menu_handle.clone()) @@ -2514,7 +2648,11 @@ impl Item for TextThreadEditor { Some(self.title(cx).to_string().into()) } - fn as_searchable(&self, handle: &Entity) -> Option> { + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { Some(Box::new(handle.clone())) } @@ -2549,11 +2687,11 @@ impl Item for TextThreadEditor { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.clone().into()) } else { None } @@ -2576,11 +2714,13 @@ impl SearchableItem for TextThreadEditor { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |editor, cx| editor.update_matches(matches, window, cx)); + self.editor.update(cx, |editor, cx| { + editor.update_matches(matches, active_match_index, window, cx) + }); } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { @@ -2651,10 +2791,10 @@ impl FollowableItem for TextThreadEditor { } fn to_state_proto(&self, window: &Window, cx: &App) -> Option { - let context = self.context.read(cx); + let text_thread = self.text_thread.read(cx); Some(proto::view::Variant::ContextEditor( proto::view::ContextEditor { - context_id: context.id().to_proto(), + context_id: text_thread.id().to_proto(), editor: if let Some(proto::view::Variant::Editor(proto)) = self.editor.read(cx).to_state_proto(window, cx) { @@ -2680,22 +2820,22 @@ impl FollowableItem for TextThreadEditor { unreachable!() }; - let context_id = ContextId::from_proto(state.context_id); + let text_thread_id = TextThreadId::from_proto(state.context_id); let editor_state = state.editor?; let project = workspace.read(cx).project().clone(); let agent_panel_delegate = ::try_global(cx)?; - let context_editor_task = workspace.update(cx, |workspace, cx| { - agent_panel_delegate.open_remote_context(workspace, context_id, window, cx) + let text_thread_editor_task = workspace.update(cx, |workspace, cx| { + agent_panel_delegate.open_remote_text_thread(workspace, text_thread_id, window, cx) }); Some(window.spawn(cx, async move |cx| { - let context_editor = context_editor_task.await?; - context_editor - .update_in(cx, |context_editor, window, cx| { - context_editor.remote_id = Some(id); - context_editor.editor.update(cx, |editor, cx| { + let text_thread_editor = text_thread_editor_task.await?; + text_thread_editor + .update_in(cx, |text_thread_editor, window, cx| { + text_thread_editor.remote_id = Some(id); + text_thread_editor.editor.update(cx, |editor, cx| { editor.apply_update_proto( &project, proto::update_view::Variant::Editor(proto::update_view::Editor { @@ -2712,7 +2852,7 @@ impl FollowableItem for TextThreadEditor { }) })? .await?; - Ok(context_editor) + Ok(text_thread_editor) })) } @@ -2759,7 +2899,7 @@ impl FollowableItem for TextThreadEditor { } fn dedup(&self, existing: &Self, _window: &Window, cx: &App) -> Option { - if existing.context.read(cx).id() == self.context.read(cx).id() { + if existing.text_thread.read(cx).id() == self.text_thread.read(cx).id() { Some(item::Dedup::KeepExisting) } else { None @@ -2771,17 +2911,17 @@ enum PendingSlashCommand {} fn invoked_slash_command_fold_placeholder( command_id: InvokedSlashCommandId, - context: WeakEntity, + text_thread: WeakEntity, ) -> FoldPlaceholder { FoldPlaceholder { constrain_width: false, merge_adjacent: false, render: Arc::new(move |fold_id, _, cx| { - let Some(context) = context.upgrade() else { + let Some(text_thread) = text_thread.upgrade() else { return Empty.into_any(); }; - let Some(command) = context.read(cx).invoked_slash_command(&command_id) else { + let Some(command) = text_thread.read(cx).invoked_slash_command(&command_id) else { return Empty.into_any(); }; @@ -2822,14 +2962,15 @@ enum TokenState { }, } -fn token_state(context: &Entity, cx: &App) -> Option { +fn token_state(text_thread: &Entity, cx: &App) -> Option { const WARNING_TOKEN_THRESHOLD: f32 = 0.8; let model = LanguageModelRegistry::read_global(cx) .default_model()? .model; - let token_count = context.read(cx).token_count()?; - let max_token_count = model.max_token_count_for_mode(context.read(cx).completion_mode().into()); + let token_count = text_thread.read(cx).token_count()?; + let max_token_count = + model.max_token_count_for_mode(text_thread.read(cx).completion_mode().into()); let token_state = if max_token_count.saturating_sub(token_count) == 0 { TokenState::NoTokensLeft { max_token_count, @@ -2928,7 +3069,7 @@ pub fn make_lsp_adapter_delegate( #[cfg(test)] mod tests { use super::*; - use editor::SelectionEffects; + use editor::{MultiBufferOffset, SelectionEffects}; use fs::FakeFs; use gpui::{App, TestAppContext, VisualTestContext}; use indoc::indoc; @@ -2941,7 +3082,7 @@ mod tests { #[gpui::test] async fn test_copy_paste_whole_message(cx: &mut TestAppContext) { - let (context, context_editor, mut cx) = setup_context_editor_text(vec![ + let (context, text_thread_editor, mut cx) = setup_text_thread_editor_text(vec![ (Role::User, "What is the Zed editor?"), ( Role::Assistant, @@ -2951,8 +3092,8 @@ mod tests { ],cx).await; // Select & Copy whole user message - assert_copy_paste_context_editor( - &context_editor, + assert_copy_paste_text_thread_editor( + &text_thread_editor, message_range(&context, 0, &mut cx), indoc! {" What is the Zed editor? @@ -2963,8 +3104,8 @@ mod tests { ); // Select & Copy whole assistant message - assert_copy_paste_context_editor( - &context_editor, + assert_copy_paste_text_thread_editor( + &text_thread_editor, message_range(&context, 1, &mut cx), indoc! {" What is the Zed editor? @@ -2978,7 +3119,7 @@ mod tests { #[gpui::test] async fn test_copy_paste_no_selection(cx: &mut TestAppContext) { - let (context, context_editor, mut cx) = setup_context_editor_text( + let (context, text_thread_editor, mut cx) = setup_text_thread_editor_text( vec![ (Role::User, "user1"), (Role::Assistant, "assistant1"), @@ -2991,8 +3132,8 @@ mod tests { // Copy and paste first assistant message let message_2_range = message_range(&context, 1, &mut cx); - assert_copy_paste_context_editor( - &context_editor, + assert_copy_paste_text_thread_editor( + &text_thread_editor, message_2_range.start..message_2_range.start, indoc! {" user1 @@ -3005,8 +3146,8 @@ mod tests { // Copy and cut second assistant message let message_3_range = message_range(&context, 2, &mut cx); - assert_copy_paste_context_editor( - &context_editor, + assert_copy_paste_text_thread_editor( + &text_thread_editor, message_3_range.start..message_3_range.start, indoc! {" user1 @@ -3093,29 +3234,29 @@ mod tests { } } - async fn setup_context_editor_text( + async fn setup_text_thread_editor_text( messages: Vec<(Role, &str)>, cx: &mut TestAppContext, ) -> ( - Entity, + Entity, Entity, VisualTestContext, ) { cx.update(init_test); let fs = FakeFs::new(cx.executor()); - let context = create_context_with_messages(messages, cx); + let text_thread = create_text_thread_with_messages(messages, cx); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window, cx); - let context_editor = window + let text_thread_editor = window .update(&mut cx, |_, window, cx| { cx.new(|cx| { - TextThreadEditor::for_context( - context.clone(), + TextThreadEditor::for_text_thread( + text_thread.clone(), fs, workspace.downgrade(), project, @@ -3127,93 +3268,93 @@ mod tests { }) .unwrap(); - (context, context_editor, cx) + (text_thread, text_thread_editor, cx) } fn message_range( - context: &Entity, + text_thread: &Entity, message_ix: usize, cx: &mut TestAppContext, - ) -> Range { - context.update(cx, |context, cx| { - context + ) -> Range { + let range = text_thread.update(cx, |text_thread, cx| { + text_thread .messages(cx) .nth(message_ix) .unwrap() .anchor_range - .to_offset(&context.buffer().read(cx).snapshot()) - }) + .to_offset(&text_thread.buffer().read(cx).snapshot()) + }); + MultiBufferOffset(range.start)..MultiBufferOffset(range.end) } - fn assert_copy_paste_context_editor( - context_editor: &Entity, + fn assert_copy_paste_text_thread_editor( + text_thread_editor: &Entity, range: Range, expected_text: &str, cx: &mut VisualTestContext, ) { - context_editor.update_in(cx, |context_editor, window, cx| { - context_editor.editor.update(cx, |editor, cx| { + text_thread_editor.update_in(cx, |text_thread_editor, window, cx| { + text_thread_editor.editor.update(cx, |editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([range]) }); }); - context_editor.copy(&Default::default(), window, cx); + text_thread_editor.copy(&Default::default(), window, cx); - context_editor.editor.update(cx, |editor, cx| { + text_thread_editor.editor.update(cx, |editor, cx| { editor.move_to_end(&Default::default(), window, cx); }); - context_editor.paste(&Default::default(), window, cx); + text_thread_editor.paste(&Default::default(), window, cx); - context_editor.editor.update(cx, |editor, cx| { + text_thread_editor.editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), expected_text); }); }); } - fn create_context_with_messages( + fn create_text_thread_with_messages( mut messages: Vec<(Role, &str)>, cx: &mut TestAppContext, - ) -> Entity { + ) -> Entity { let registry = Arc::new(LanguageRegistry::test(cx.executor())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); cx.new(|cx| { - let mut context = AssistantContext::local( + let mut text_thread = TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, ); - let mut message_1 = context.messages(cx).next().unwrap(); + let mut message_1 = text_thread.messages(cx).next().unwrap(); let (role, text) = messages.remove(0); loop { if role == message_1.role { - context.buffer().update(cx, |buffer, cx| { + text_thread.buffer().update(cx, |buffer, cx| { buffer.edit([(message_1.offset_range, text)], None, cx); }); break; } let mut ids = HashSet::default(); ids.insert(message_1.id); - context.cycle_message_roles(ids, cx); - message_1 = context.messages(cx).next().unwrap(); + text_thread.cycle_message_roles(ids, cx); + message_1 = text_thread.messages(cx).next().unwrap(); } let mut last_message_id = message_1.id; for (role, text) in messages { - context.insert_message_after(last_message_id, role, MessageStatus::Done, cx); - let message = context.messages(cx).last().unwrap(); + text_thread.insert_message_after(last_message_id, role, MessageStatus::Done, cx); + let message = text_thread.messages(cx).last().unwrap(); last_message_id = message.id; - context.buffer().update(cx, |buffer, cx| { + text_thread.buffer().update(cx, |buffer, cx| { buffer.edit([(message.offset_range, text)], None, cx); }) } - context + text_thread }) } @@ -3222,11 +3363,7 @@ mod tests { prompt_store::init(cx); LanguageModelRegistry::test(cx); cx.set_global(settings_store); - language::init(cx); - agent_settings::init(cx); - Project::init_settings(cx); + theme::init(theme::LoadThemes::JustBase, cx); - workspace::init_settings(cx); - editor::init_settings(cx); } } diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 5363949b90..e604df416e 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -2,8 +2,8 @@ mod acp_onboarding_modal; mod agent_notification; mod burn_mode_tooltip; mod claude_code_onboarding_modal; -mod context_pill; mod end_trial_upsell; +mod hold_for_default; mod onboarding_modal; mod unavailable_editing_tooltip; mod usage_callout; @@ -12,8 +12,8 @@ pub use acp_onboarding_modal::*; pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use claude_code_onboarding_modal::*; -pub use context_pill::*; pub use end_trial_upsell::*; +pub use hold_for_default::*; pub use onboarding_modal::*; pub use unavailable_editing_tooltip::*; pub use usage_callout::*; diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index af2a022f14..34ca0bb32a 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -106,9 +106,6 @@ impl Render for AgentNotification { .font(ui_font) .border_color(cx.theme().colors().border) .rounded_xl() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(AgentNotificationEvent::Accepted); - })) .child( h_flex() .items_start() diff --git a/crates/agent_ui/src/ui/burn_mode_tooltip.rs b/crates/agent_ui/src/ui/burn_mode_tooltip.rs index f95dc1250e..ccd7d4bf31 100644 --- a/crates/agent_ui/src/ui/burn_mode_tooltip.rs +++ b/crates/agent_ui/src/ui/burn_mode_tooltip.rs @@ -18,7 +18,7 @@ impl BurnModeTooltip { } impl Render for BurnModeTooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let (icon, color) = if self.selected { (IconName::ZedBurnModeOn, Color::Error) } else { @@ -45,8 +45,7 @@ impl Render for BurnModeTooltip { .child(Label::new("Burn Mode")) .when(self.selected, |title| title.child(turned_on)); - let keybinding = KeyBinding::for_action(&ToggleBurnMode, window, cx) - .map(|kb| kb.size(rems_from_px(12.))); + let keybinding = KeyBinding::for_action(&ToggleBurnMode, cx).size(rems_from_px(12.)); tooltip_container(cx, |this, _| { this @@ -54,7 +53,7 @@ impl Render for BurnModeTooltip { h_flex() .justify_between() .child(title) - .children(keybinding) + .child(keybinding) ) .child( div() diff --git a/crates/agent_ui/src/ui/context_pill.rs b/crates/agent_ui/src/ui/context_pill.rs deleted file mode 100644 index f85a064554..0000000000 --- a/crates/agent_ui/src/ui/context_pill.rs +++ /dev/null @@ -1,854 +0,0 @@ -use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; - -use file_icons::FileIcons; -use futures::FutureExt as _; -use gpui::{ - Animation, AnimationExt as _, AnyView, ClickEvent, Entity, Image, MouseButton, Task, - pulsating_between, -}; -use language_model::LanguageModelImage; -use project::Project; -use prompt_store::PromptStore; -use rope::Point; -use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container}; - -use agent::context::{ - AgentContextHandle, ContextId, ContextKind, DirectoryContextHandle, FetchedUrlContext, - FileContextHandle, ImageContext, ImageStatus, RulesContextHandle, SelectionContextHandle, - SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, -}; -use util::paths::PathStyle; - -#[derive(IntoElement)] -pub enum ContextPill { - Added { - context: AddedContext, - dupe_name: bool, - focused: bool, - on_click: Option>, - on_remove: Option>, - }, - Suggested { - name: SharedString, - icon_path: Option, - kind: ContextKind, - focused: bool, - on_click: Option>, - }, -} - -impl ContextPill { - pub fn added( - context: AddedContext, - dupe_name: bool, - focused: bool, - on_remove: Option>, - ) -> Self { - Self::Added { - context, - dupe_name, - on_remove, - focused, - on_click: None, - } - } - - pub fn suggested( - name: SharedString, - icon_path: Option, - kind: ContextKind, - focused: bool, - ) -> Self { - Self::Suggested { - name, - icon_path, - kind, - focused, - on_click: None, - } - } - - pub fn on_click(mut self, listener: Rc) -> Self { - match &mut self { - ContextPill::Added { on_click, .. } => { - *on_click = Some(listener); - } - ContextPill::Suggested { on_click, .. } => { - *on_click = Some(listener); - } - } - self - } - - pub fn id(&self) -> ElementId { - match self { - Self::Added { context, .. } => context.handle.element_id("context-pill".into()), - Self::Suggested { .. } => "suggested-context-pill".into(), - } - } - - pub fn icon(&self) -> Icon { - match self { - Self::Suggested { - icon_path: Some(icon_path), - .. - } => Icon::from_path(icon_path), - Self::Suggested { kind, .. } => Icon::new(kind.icon()), - Self::Added { context, .. } => context.icon(), - } - } -} - -impl RenderOnce for ContextPill { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let color = cx.theme().colors(); - - let base_pill = h_flex() - .id(self.id()) - .pl_1() - .pb(px(1.)) - .border_1() - .rounded_sm() - .gap_1() - .child(self.icon().size(IconSize::XSmall).color(Color::Muted)); - - match &self { - ContextPill::Added { - context, - dupe_name, - on_remove, - focused, - on_click, - } => { - let status_is_error = matches!(context.status, ContextStatus::Error { .. }); - let status_is_warning = matches!(context.status, ContextStatus::Warning { .. }); - - base_pill - .pr(if on_remove.is_some() { px(2.) } else { px(4.) }) - .map(|pill| { - if status_is_error { - pill.bg(cx.theme().status().error_background) - .border_color(cx.theme().status().error_border) - } else if status_is_warning { - pill.bg(cx.theme().status().warning_background) - .border_color(cx.theme().status().warning_border) - } else if *focused { - pill.bg(color.element_background) - .border_color(color.border_focused) - } else { - pill.bg(color.element_background) - .border_color(color.border.opacity(0.5)) - } - }) - .child( - h_flex() - .id("context-data") - .gap_1() - .child( - div().max_w_64().child( - Label::new(context.name.clone()) - .size(LabelSize::Small) - .truncate(), - ), - ) - .when_some(context.parent.as_ref(), |element, parent_name| { - if *dupe_name { - element.child( - Label::new(parent_name.clone()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - } else { - element - } - }) - .when_some(context.tooltip.as_ref(), |element, tooltip| { - element.tooltip(Tooltip::text(tooltip.clone())) - }) - .map(|element| match &context.status { - ContextStatus::Ready => element - .when_some( - context.render_hover.as_ref(), - |element, render_hover| { - let render_hover = render_hover.clone(); - element.hoverable_tooltip(move |window, cx| { - render_hover(window, cx) - }) - }, - ) - .into_any(), - ContextStatus::Loading { message } => element - .tooltip(ui::Tooltip::text(message.clone())) - .with_animation( - "pulsating-ctx-pill", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.opacity(delta), - ) - .into_any_element(), - ContextStatus::Warning { message } - | ContextStatus::Error { message } => element - .tooltip(ui::Tooltip::text(message.clone())) - .into_any_element(), - }), - ) - .when_some(on_remove.as_ref(), |element, on_remove| { - element.child( - IconButton::new( - context.handle.element_id("remove".into()), - IconName::Close, - ) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Remove Context")) - .on_click({ - let on_remove = on_remove.clone(); - move |event, window, cx| on_remove(event, window, cx) - }), - ) - }) - .when_some(on_click.as_ref(), |element, on_click| { - let on_click = on_click.clone(); - element.cursor_pointer().on_click(move |event, window, cx| { - on_click(event, window, cx); - cx.stop_propagation(); - }) - }) - .into_any_element() - } - ContextPill::Suggested { - name, - icon_path: _, - kind: _, - focused, - on_click, - } => base_pill - .cursor_pointer() - .pr_1() - .border_dashed() - .map(|pill| { - if *focused { - pill.border_color(color.border_focused) - .bg(color.element_background.opacity(0.5)) - } else { - pill.border_color(color.border) - } - }) - .hover(|style| style.bg(color.element_hover.opacity(0.5))) - .child( - div().max_w_64().child( - Label::new(name.clone()) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ), - ) - .tooltip(|window, cx| { - Tooltip::with_meta("Suggested Context", None, "Click to add it", window, cx) - }) - .when_some(on_click.as_ref(), |element, on_click| { - let on_click = on_click.clone(); - element.on_click(move |event, window, cx| { - on_click(event, window, cx); - cx.stop_propagation(); - }) - }) - .into_any(), - } - } -} - -pub enum ContextStatus { - Ready, - Loading { message: SharedString }, - Error { message: SharedString }, - Warning { message: SharedString }, -} - -#[derive(RegisterComponent)] -pub struct AddedContext { - pub handle: AgentContextHandle, - pub kind: ContextKind, - pub name: SharedString, - pub parent: Option, - pub tooltip: Option, - pub icon_path: Option, - pub status: ContextStatus, - pub render_hover: Option AnyView + 'static>>, -} - -impl AddedContext { - pub fn icon(&self) -> Icon { - match &self.status { - ContextStatus::Warning { .. } => Icon::new(IconName::Warning).color(Color::Warning), - ContextStatus::Error { .. } => Icon::new(IconName::XCircle).color(Color::Error), - _ => { - if let Some(icon_path) = &self.icon_path { - Icon::from_path(icon_path) - } else { - Icon::new(self.kind.icon()) - } - } - } - } - /// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a - /// `None` if `DirectoryContext` or `RulesContext` no longer exist. - /// - /// TODO: `None` cases are unremovable from `ContextStore` and so are a very minor memory leak. - pub fn new_pending( - handle: AgentContextHandle, - prompt_store: Option<&Entity>, - project: &Project, - model: Option<&Arc>, - cx: &App, - ) -> Option { - match handle { - AgentContextHandle::File(handle) => { - Self::pending_file(handle, project.path_style(cx), cx) - } - AgentContextHandle::Directory(handle) => Self::pending_directory(handle, project, cx), - AgentContextHandle::Symbol(handle) => { - Self::pending_symbol(handle, project.path_style(cx), cx) - } - AgentContextHandle::Selection(handle) => { - Self::pending_selection(handle, project.path_style(cx), cx) - } - AgentContextHandle::FetchedUrl(handle) => Some(Self::fetched_url(handle)), - AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)), - AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)), - AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx), - AgentContextHandle::Image(handle) => { - Some(Self::image(handle, model, project.path_style(cx), cx)) - } - } - } - - fn pending_file( - handle: FileContextHandle, - path_style: PathStyle, - cx: &App, - ) -> Option { - let full_path = handle - .buffer - .read(cx) - .file()? - .full_path(cx) - .to_string_lossy() - .to_string(); - Some(Self::file(handle, &full_path, path_style, cx)) - } - - fn file( - handle: FileContextHandle, - full_path: &str, - path_style: PathStyle, - cx: &App, - ) -> AddedContext { - let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style); - AddedContext { - kind: ContextKind::File, - name, - parent, - tooltip: Some(SharedString::new(full_path)), - icon_path: FileIcons::get_icon(Path::new(full_path), cx), - status: ContextStatus::Ready, - render_hover: None, - handle: AgentContextHandle::File(handle), - } - } - - fn pending_directory( - handle: DirectoryContextHandle, - project: &Project, - cx: &App, - ) -> Option { - let worktree = project.worktree_for_entry(handle.entry_id, cx)?.read(cx); - let entry = worktree.entry_for_id(handle.entry_id)?; - let full_path = worktree - .full_path(&entry.path) - .to_string_lossy() - .to_string(); - Some(Self::directory(handle, &full_path, project.path_style(cx))) - } - - fn directory( - handle: DirectoryContextHandle, - full_path: &str, - path_style: PathStyle, - ) -> AddedContext { - let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style); - AddedContext { - kind: ContextKind::Directory, - name, - parent, - tooltip: Some(SharedString::new(full_path)), - icon_path: None, - status: ContextStatus::Ready, - render_hover: None, - handle: AgentContextHandle::Directory(handle), - } - } - - fn pending_symbol( - handle: SymbolContextHandle, - path_style: PathStyle, - cx: &App, - ) -> Option { - let excerpt = ContextFileExcerpt::new( - &handle.full_path(cx)?.to_string_lossy(), - handle.enclosing_line_range(cx), - path_style, - cx, - ); - Some(AddedContext { - kind: ContextKind::Symbol, - name: handle.symbol.clone(), - parent: Some(excerpt.file_name_and_range.clone()), - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: { - let handle = handle.clone(); - Some(Rc::new(move |_, cx| { - excerpt.hover_view(handle.text(cx), cx).into() - })) - }, - handle: AgentContextHandle::Symbol(handle), - }) - } - - fn pending_selection( - handle: SelectionContextHandle, - path_style: PathStyle, - cx: &App, - ) -> Option { - let excerpt = ContextFileExcerpt::new( - &handle.full_path(cx)?.to_string_lossy(), - handle.line_range(cx), - path_style, - cx, - ); - Some(AddedContext { - kind: ContextKind::Selection, - name: excerpt.file_name_and_range.clone(), - parent: excerpt.parent_name.clone(), - tooltip: None, - icon_path: excerpt.icon_path.clone(), - status: ContextStatus::Ready, - render_hover: { - let handle = handle.clone(); - Some(Rc::new(move |_, cx| { - excerpt.hover_view(handle.text(cx), cx).into() - })) - }, - handle: AgentContextHandle::Selection(handle), - }) - } - - fn fetched_url(context: FetchedUrlContext) -> AddedContext { - AddedContext { - kind: ContextKind::FetchedUrl, - name: context.url.clone(), - parent: None, - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: None, - handle: AgentContextHandle::FetchedUrl(context), - } - } - - fn pending_thread(handle: ThreadContextHandle, cx: &App) -> AddedContext { - AddedContext { - kind: ContextKind::Thread, - name: handle.title(cx), - parent: None, - tooltip: None, - icon_path: None, - status: if handle.thread.read(cx).is_generating_detailed_summary() { - ContextStatus::Loading { - message: "Summarizing…".into(), - } - } else { - ContextStatus::Ready - }, - render_hover: { - let thread = handle.thread.clone(); - Some(Rc::new(move |_, cx| { - let text = thread.read(cx).latest_detailed_summary_or_text(); - ContextPillHover::new_text(text, cx).into() - })) - }, - handle: AgentContextHandle::Thread(handle), - } - } - - fn pending_text_thread(handle: TextThreadContextHandle, cx: &App) -> AddedContext { - AddedContext { - kind: ContextKind::TextThread, - name: handle.title(cx), - parent: None, - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: { - let context = handle.context.clone(); - Some(Rc::new(move |_, cx| { - let text = context.read(cx).to_xml(cx); - ContextPillHover::new_text(text.into(), cx).into() - })) - }, - handle: AgentContextHandle::TextThread(handle), - } - } - - fn pending_rules( - handle: RulesContextHandle, - prompt_store: Option<&Entity>, - cx: &App, - ) -> Option { - let title = prompt_store - .as_ref()? - .read(cx) - .metadata(handle.prompt_id.into())? - .title - .unwrap_or_else(|| "Unnamed Rule".into()); - Some(AddedContext { - kind: ContextKind::Rules, - name: title, - parent: None, - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: None, - handle: AgentContextHandle::Rules(handle), - }) - } - - fn image( - context: ImageContext, - model: Option<&Arc>, - path_style: PathStyle, - cx: &App, - ) -> AddedContext { - let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() { - let (name, parent) = - extract_file_name_and_directory_from_full_path(full_path, path_style); - let icon_path = FileIcons::get_icon(Path::new(full_path), cx); - (name, parent, icon_path) - } else { - ("Image".into(), None, None) - }; - - let status = match context.status(model) { - ImageStatus::Loading => ContextStatus::Loading { - message: "Loading…".into(), - }, - ImageStatus::Error => ContextStatus::Error { - message: "Failed to load Image".into(), - }, - ImageStatus::Warning => ContextStatus::Warning { - message: format!( - "{} doesn't support attaching Images as Context", - model.map(|m| m.name().0).unwrap_or_else(|| "Model".into()) - ) - .into(), - }, - ImageStatus::Ready => ContextStatus::Ready, - }; - - AddedContext { - kind: ContextKind::Image, - name, - parent, - tooltip: None, - icon_path, - status, - render_hover: Some(Rc::new({ - let image = context.original_image.clone(); - move |_, cx| { - let image = image.clone(); - ContextPillHover::new(cx, move |_, _| { - gpui::img(image.clone()) - .max_w_96() - .max_h_96() - .into_any_element() - }) - .into() - } - })), - handle: AgentContextHandle::Image(context), - } - } -} - -fn extract_file_name_and_directory_from_full_path( - path: &str, - path_style: PathStyle, -) -> (SharedString, Option) { - let (parent, file_name) = path_style.split(path); - let parent = parent.and_then(|parent| { - let parent = parent.trim_end_matches(path_style.separator()); - let (_, parent) = path_style.split(parent); - if parent.is_empty() { - None - } else { - Some(SharedString::new(parent)) - } - }); - (SharedString::new(file_name), parent) -} - -#[derive(Debug, Clone)] -struct ContextFileExcerpt { - pub file_name_and_range: SharedString, - pub full_path_and_range: SharedString, - pub parent_name: Option, - pub icon_path: Option, -} - -impl ContextFileExcerpt { - pub fn new(full_path: &str, line_range: Range, path_style: PathStyle, cx: &App) -> Self { - let (parent, file_name) = path_style.split(full_path); - let line_range_text = format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1); - let mut full_path_and_range = full_path.to_owned(); - full_path_and_range.push_str(&line_range_text); - let mut file_name_and_range = file_name.to_owned(); - file_name_and_range.push_str(&line_range_text); - - let parent_name = parent.and_then(|parent| { - let parent = parent.trim_end_matches(path_style.separator()); - let (_, parent) = path_style.split(parent); - if parent.is_empty() { - None - } else { - Some(SharedString::new(parent)) - } - }); - - let icon_path = FileIcons::get_icon(Path::new(full_path), cx); - - ContextFileExcerpt { - file_name_and_range: file_name_and_range.into(), - full_path_and_range: full_path_and_range.into(), - parent_name, - icon_path, - } - } - - fn hover_view(&self, text: SharedString, cx: &mut App) -> Entity { - let icon_path = self.icon_path.clone(); - let full_path_and_range = self.full_path_and_range.clone(); - ContextPillHover::new(cx, move |_, cx| { - v_flex() - .child( - h_flex() - .gap_0p5() - .w_full() - .max_w_full() - .border_b_1() - .border_color(cx.theme().colors().border.opacity(0.6)) - .children( - icon_path - .clone() - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)), - ) - .child( - // TODO: make this truncate on the left. - Label::new(full_path_and_range.clone()) - .size(LabelSize::Small) - .ml_1(), - ), - ) - .child( - div() - .id("context-pill-hover-contents") - .overflow_scroll() - .max_w_128() - .max_h_96() - .child(Label::new(text.clone()).buffer_font(cx)), - ) - .into_any_element() - }) - } -} - -struct ContextPillHover { - render_hover: Box AnyElement>, -} - -impl ContextPillHover { - fn new( - cx: &mut App, - render_hover: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, - ) -> Entity { - cx.new(|_| Self { - render_hover: Box::new(render_hover), - }) - } - - fn new_text(content: SharedString, cx: &mut App) -> Entity { - Self::new(cx, move |_, _| { - div() - .id("context-pill-hover-contents") - .overflow_scroll() - .max_w_128() - .max_h_96() - .child(content.clone()) - .into_any_element() - }) - } -} - -impl Render for ContextPillHover { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, move |this, cx| { - this.occlude() - .on_mouse_move(|_, _, cx| cx.stop_propagation()) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child((self.render_hover)(window, cx)) - }) - } -} - -impl Component for AddedContext { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn sort_name() -> &'static str { - "AddedContext" - } - - fn preview(_window: &mut Window, cx: &mut App) -> Option { - let mut next_context_id = ContextId::zero(); - let image_ready = ( - "Ready", - AddedContext::image( - ImageContext { - context_id: next_context_id.post_inc(), - project_path: None, - full_path: None, - original_image: Arc::new(Image::empty()), - image_task: Task::ready(Some(LanguageModelImage::empty())).shared(), - }, - None, - PathStyle::local(), - cx, - ), - ); - - let image_loading = ( - "Loading", - AddedContext::image( - ImageContext { - context_id: next_context_id.post_inc(), - project_path: None, - full_path: None, - original_image: Arc::new(Image::empty()), - image_task: cx - .background_spawn(async move { - smol::Timer::after(Duration::from_secs(60 * 5)).await; - Some(LanguageModelImage::empty()) - }) - .shared(), - }, - None, - PathStyle::local(), - cx, - ), - ); - - let image_error = ( - "Error", - AddedContext::image( - ImageContext { - context_id: next_context_id.post_inc(), - project_path: None, - full_path: None, - original_image: Arc::new(Image::empty()), - image_task: Task::ready(None).shared(), - }, - None, - PathStyle::local(), - cx, - ), - ); - - Some( - v_flex() - .gap_6() - .children( - vec![image_ready, image_loading, image_error] - .into_iter() - .map(|(text, context)| { - single_example( - text, - ContextPill::added(context, false, false, None).into_any_element(), - ) - }), - ) - .into_any(), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::App; - use language_model::{LanguageModel, fake_provider::FakeLanguageModel}; - use std::sync::Arc; - - #[gpui::test] - fn test_image_context_warning_for_unsupported_model(cx: &mut App) { - let model: Arc = Arc::new(FakeLanguageModel::default()); - assert!(!model.supports_images()); - - let image_context = ImageContext { - context_id: ContextId::zero(), - project_path: None, - original_image: Arc::new(Image::empty()), - image_task: Task::ready(Some(LanguageModelImage::empty())).shared(), - full_path: None, - }; - - let added_context = - AddedContext::image(image_context, Some(&model), PathStyle::local(), cx); - - assert!(matches!( - added_context.status, - ContextStatus::Warning { .. } - )); - - assert!(matches!(added_context.kind, ContextKind::Image)); - assert_eq!(added_context.name.as_ref(), "Image"); - assert!(added_context.parent.is_none()); - assert!(added_context.icon_path.is_none()); - } - - #[gpui::test] - fn test_image_context_ready_for_no_model(cx: &mut App) { - let image_context = ImageContext { - context_id: ContextId::zero(), - project_path: None, - original_image: Arc::new(Image::empty()), - image_task: Task::ready(Some(LanguageModelImage::empty())).shared(), - full_path: None, - }; - - let added_context = AddedContext::image(image_context, None, PathStyle::local(), cx); - - assert!( - matches!(added_context.status, ContextStatus::Ready), - "Expected ready status when no model provided" - ); - - assert!(matches!(added_context.kind, ContextKind::Image)); - assert_eq!(added_context.name.as_ref(), "Image"); - assert!(added_context.parent.is_none()); - assert!(added_context.icon_path.is_none()); - } -} diff --git a/crates/agent_ui/src/ui/hold_for_default.rs b/crates/agent_ui/src/ui/hold_for_default.rs new file mode 100644 index 0000000000..409e5d5970 --- /dev/null +++ b/crates/agent_ui/src/ui/hold_for_default.rs @@ -0,0 +1,40 @@ +use gpui::{App, IntoElement, Modifiers, RenderOnce, Window}; +use ui::{prelude::*, render_modifiers}; + +#[derive(IntoElement)] +pub struct HoldForDefault { + is_default: bool, +} + +impl HoldForDefault { + pub fn new(is_default: bool) -> Self { + Self { is_default } + } +} + +impl RenderOnce for HoldForDefault { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .pt_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .gap_0p5() + .text_sm() + .text_color(Color::Muted.color(cx)) + .child("Hold") + .child(h_flex().flex_shrink_0().children(render_modifiers( + &Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(TextSize::Default.rems(cx).into()), + true, + ))) + .child(div().map(|this| { + if self.is_default { + this.child("to unset as default") + } else { + this.child("to set as default") + } + })) + } +} diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml new file mode 100644 index 0000000000..f24ef47471 --- /dev/null +++ b/crates/agent_ui_v2/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "agent_ui_v2" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_ui_v2.rs" +doctest = false + +[dependencies] +agent.workspace = true +agent_servers.workspace = true +agent_settings.workspace = true +agent_ui.workspace = true +anyhow.workspace = true +assistant_text_thread.workspace = true +chrono.workspace = true +db.workspace = true +editor.workspace = true +feature_flags.workspace = true +fs.workspace = true +fuzzy.workspace = true +gpui.workspace = true +menu.workspace = true +project.workspace = true +prompt_store.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +text.workspace = true +time.workspace = true +time_format.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true diff --git a/crates/agent_ui_v2/LICENSE-GPL b/crates/agent_ui_v2/LICENSE-GPL new file mode 120000 index 0000000000..e0f9dbd5d6 --- /dev/null +++ b/crates/agent_ui_v2/LICENSE-GPL @@ -0,0 +1 @@ +LICENSE-GPL \ No newline at end of file diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs new file mode 100644 index 0000000000..72886f87ec --- /dev/null +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -0,0 +1,287 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; +use agent_servers::AgentServer; +use agent_settings::AgentSettings; +use agent_ui::acp::AcpThreadView; +use fs::Fs; +use gpui::{ + Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*, +}; +use project::Project; +use prompt_store::PromptStore; +use serde::{Deserialize, Serialize}; +use settings::DockSide; +use settings::Settings as _; +use std::rc::Rc; +use std::sync::Arc; +use ui::{Tab, Tooltip, prelude::*}; +use workspace::{ + Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition}, + utility_pane::UtilityPaneSlot, +}; + +pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0); + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum SerializedHistoryEntryId { + AcpThread(String), + TextThread(String), +} + +impl From for SerializedHistoryEntryId { + fn from(id: HistoryEntryId) -> Self { + match id { + HistoryEntryId::AcpThread(session_id) => { + SerializedHistoryEntryId::AcpThread(session_id.0.to_string()) + } + HistoryEntryId::TextThread(path) => { + SerializedHistoryEntryId::TextThread(path.to_string_lossy().to_string()) + } + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SerializedAgentThreadPane { + pub expanded: bool, + pub width: Option, + pub thread_id: Option, +} + +pub enum AgentsUtilityPaneEvent { + StateChanged, +} + +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} + +struct ActiveThreadView { + view: Entity, + thread_id: HistoryEntryId, + _notify: Subscription, +} + +pub struct AgentThreadPane { + focus_handle: gpui::FocusHandle, + expanded: bool, + width: Option, + thread_view: Option, + workspace: WeakEntity, +} + +impl AgentThreadPane { + pub fn new(workspace: WeakEntity, cx: &mut ui::Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + focus_handle, + expanded: false, + width: None, + thread_view: None, + workspace, + } + } + + pub fn thread_id(&self) -> Option { + self.thread_view.as_ref().map(|tv| tv.thread_id.clone()) + } + + pub fn serialize(&self) -> SerializedAgentThreadPane { + SerializedAgentThreadPane { + expanded: self.expanded, + width: self.width, + thread_id: self.thread_id().map(SerializedHistoryEntryId::from), + } + } + + pub fn open_thread( + &mut self, + entry: HistoryEntry, + fs: Arc, + workspace: WeakEntity, + project: Entity, + history_store: Entity, + prompt_store: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let thread_id = entry.id(); + + let resume_thread = match &entry { + HistoryEntry::AcpThread(thread) => Some(thread.clone()), + HistoryEntry::TextThread(_) => None, + }; + + let agent: Rc = Rc::new(NativeAgentServer::new(fs, history_store.clone())); + + let thread_view = cx.new(|cx| { + AcpThreadView::new( + agent, + resume_thread, + None, + workspace, + project, + history_store, + prompt_store, + true, + window, + cx, + ) + }); + + let notify = cx.observe(&thread_view, |_, _, cx| { + cx.notify(); + }); + + self.thread_view = Some(ActiveThreadView { + view: thread_view, + thread_id, + _notify: notify, + }); + + cx.notify(); + } + + fn title(&self, cx: &App) -> SharedString { + if let Some(active_thread_view) = &self.thread_view { + let thread_view = active_thread_view.view.read(cx); + if let Some(thread) = thread_view.thread() { + let title = thread.read(cx).title(); + if !title.is_empty() { + return title; + } + } + thread_view.title(cx) + } else { + "Thread".into() + } + } + + fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let position = self.position(window, cx); + let slot = match position { + UtilityPanePosition::Left => UtilityPaneSlot::Left, + UtilityPanePosition::Right => UtilityPaneSlot::Right, + }; + + let workspace = self.workspace.clone(); + let toggle_icon = self.toggle_icon(cx); + let title = self.title(cx); + + let pane_toggle_button = |workspace: WeakEntity| { + IconButton::new("toggle_utility_pane", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) + .on_click(move |_, window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane(slot, window, cx) + }) + .ok(); + }) + }; + + h_flex() + .id("utility-pane-header") + .w_full() + .h(Tab::container_height(cx)) + .px_1p5() + .gap(DynamicSpacing::Base06.rems(cx)) + .when(slot == UtilityPaneSlot::Right, |this| { + this.flex_row_reverse() + }) + .flex_none() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(pane_toggle_button(workspace)) + .child( + h_flex() + .size_full() + .min_w_0() + .gap_1() + .map(|this| { + if slot == UtilityPaneSlot::Right { + this.flex_row_reverse().justify_start() + } else { + this.justify_between() + } + }) + .child(Label::new(title).truncate()) + .child( + IconButton::new("close_btn", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Close Agent Pane")) + .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| { + cx.emit(ClosePane); + this.thread_view = None; + cx.notify() + })), + ), + ) + } +} + +impl Focusable for AgentThreadPane { + fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle { + if let Some(thread_view) = &self.thread_view { + thread_view.view.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } +} + +impl UtilityPane for AgentThreadPane { + fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition { + match AgentSettings::get_global(cx).agents_panel_dock { + DockSide::Left => UtilityPanePosition::Left, + DockSide::Right => UtilityPanePosition::Right, + } + } + + fn toggle_icon(&self, _cx: &App) -> IconName { + IconName::Thread + } + + fn expanded(&self, _cx: &App) -> bool { + self.expanded + } + + fn set_expanded(&mut self, expanded: bool, cx: &mut Context) { + self.expanded = expanded; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } + + fn width(&self, _cx: &App) -> Pixels { + self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH) + } + + fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } +} + +impl Render for AgentThreadPane { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = if let Some(thread_view) = &self.thread_view { + div().size_full().child(thread_view.view.clone()) + } else { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Label::new("Select a thread to view details").size(LabelSize::Default)) + }; + + div() + .size_full() + .flex() + .flex_col() + .child(self.render_header(window, cx)) + .child(content) + } +} diff --git a/crates/agent_ui_v2/src/agent_ui_v2.rs b/crates/agent_ui_v2/src/agent_ui_v2.rs new file mode 100644 index 0000000000..92a4144e30 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_ui_v2.rs @@ -0,0 +1,4 @@ +mod agent_thread_pane; +mod thread_history; + +pub mod agents_panel; diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs new file mode 100644 index 0000000000..254b8d2999 --- /dev/null +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -0,0 +1,437 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore}; +use agent_settings::AgentSettings; +use anyhow::Result; +use assistant_text_thread::TextThreadStore; +use db::kvp::KEY_VALUE_STORE; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use fs::Fs; +use gpui::{ + Action, AsyncWindowContext, Entity, EventEmitter, Focusable, Pixels, Subscription, Task, + WeakEntity, actions, prelude::*, +}; +use project::Project; +use prompt_store::{PromptBuilder, PromptStore}; +use serde::{Deserialize, Serialize}; +use settings::{Settings as _, update_settings_file}; +use std::sync::Arc; +use ui::{App, Context, IconName, IntoElement, ParentElement, Render, Styled, Window}; +use util::ResultExt; +use workspace::{ + Panel, Workspace, + dock::{ClosePane, DockPosition, PanelEvent, UtilityPane}, + utility_pane::{UtilityPaneSlot, utility_slot_for_dock_position}, +}; + +use crate::agent_thread_pane::{ + AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId, +}; +use crate::thread_history::{AcpThreadHistory, ThreadHistoryEvent}; + +const AGENTS_PANEL_KEY: &str = "agents_panel"; + +#[derive(Serialize, Deserialize, Debug)] +struct SerializedAgentsPanel { + width: Option, + pane: Option, +} + +actions!( + agents, + [ + /// Toggle the visibility of the agents panel. + ToggleAgentsPanel + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + workspace.register_action(|workspace, _: &ToggleAgentsPanel, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }); + }) + .detach(); +} + +pub struct AgentsPanel { + focus_handle: gpui::FocusHandle, + workspace: WeakEntity, + project: Entity, + agent_thread_pane: Option>, + history: Entity, + history_store: Entity, + prompt_store: Option>, + fs: Arc, + width: Option, + pending_serialization: Task>, + _subscriptions: Vec, +} + +impl AgentsPanel { + pub fn load( + workspace: WeakEntity, + cx: AsyncWindowContext, + ) -> Task, anyhow::Error>> { + cx.spawn(async move |cx| { + let serialized_panel = cx + .background_spawn(async move { + KEY_VALUE_STORE + .read_kvp(AGENTS_PANEL_KEY) + .ok() + .flatten() + .and_then(|panel| { + serde_json::from_str::(&panel).ok() + }) + }) + .await; + + let (fs, project, prompt_builder) = workspace.update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + let project = workspace.project().clone(); + let prompt_builder = PromptBuilder::load(fs.clone(), false, cx); + (fs, project, prompt_builder) + })?; + + let text_thread_store = workspace + .update(cx, |_, cx| { + TextThreadStore::new( + project.clone(), + prompt_builder.clone(), + Default::default(), + cx, + ) + })? + .await?; + + let prompt_store = workspace + .update(cx, |_, cx| PromptStore::global(cx))? + .await + .log_err(); + + workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + let mut panel = Self::new( + workspace.clone(), + fs, + project, + prompt_store, + text_thread_store, + window, + cx, + ); + if let Some(serialized_panel) = serialized_panel { + panel.width = serialized_panel.width; + if let Some(serialized_pane) = serialized_panel.pane { + panel.restore_utility_pane(serialized_pane, window, cx); + } + } + panel + }) + }) + }) + } + + fn new( + workspace: WeakEntity, + fs: Arc, + project: Entity, + prompt_store: Option>, + text_thread_store: Entity, + window: &mut Window, + cx: &mut ui::Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); + + let this = cx.weak_entity(); + let subscriptions = vec![ + cx.subscribe_in(&history, window, Self::handle_history_event), + cx.on_flags_ready(move |_, cx| { + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }), + ]; + + Self { + focus_handle, + workspace, + project, + agent_thread_pane: None, + history, + history_store, + prompt_store, + fs, + width: None, + pending_serialization: Task::ready(None), + _subscriptions: subscriptions, + } + } + + fn restore_utility_pane( + &mut self, + serialized_pane: SerializedAgentThreadPane, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread_id) = &serialized_pane.thread_id else { + return; + }; + + let entry = self + .history_store + .read(cx) + .entries() + .find(|e| match (&e.id(), thread_id) { + ( + HistoryEntryId::AcpThread(session_id), + SerializedHistoryEntryId::AcpThread(id), + ) => session_id.to_string() == *id, + (HistoryEntryId::TextThread(path), SerializedHistoryEntryId::TextThread(id)) => { + path.to_string_lossy() == *id + } + _ => false, + }); + + if let Some(entry) = entry { + self.open_thread( + entry, + serialized_pane.expanded, + serialized_pane.width, + window, + cx, + ); + } + } + + fn handle_utility_pane_event( + &mut self, + _utility_pane: Entity, + event: &AgentsUtilityPaneEvent, + cx: &mut Context, + ) { + match event { + AgentsUtilityPaneEvent::StateChanged => { + self.serialize(cx); + cx.notify(); + } + } + } + + fn handle_close_pane_event( + &mut self, + _utility_pane: Entity, + _event: &ClosePane, + cx: &mut Context, + ) { + self.agent_thread_pane = None; + self.serialize(cx); + cx.notify(); + } + + fn handle_history_event( + &mut self, + _history: &Entity, + event: &ThreadHistoryEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + ThreadHistoryEvent::Open(entry) => { + self.open_thread(entry.clone(), true, None, window, cx); + } + } + } + + fn open_thread( + &mut self, + entry: HistoryEntry, + expanded: bool, + width: Option, + window: &mut Window, + cx: &mut Context, + ) { + let entry_id = entry.id(); + + if let Some(existing_pane) = &self.agent_thread_pane { + if existing_pane.read(cx).thread_id() == Some(entry_id) { + existing_pane.update(cx, |pane, cx| { + pane.set_expanded(true, cx); + }); + return; + } + } + + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); + let project = self.project.clone(); + let history_store = self.history_store.clone(); + let prompt_store = self.prompt_store.clone(); + + let agent_thread_pane = cx.new(|cx| { + let mut pane = AgentThreadPane::new(workspace.clone(), cx); + pane.open_thread( + entry, + fs, + workspace.clone(), + project, + history_store, + prompt_store, + window, + cx, + ); + if let Some(width) = width { + pane.set_width(Some(width), cx); + } + pane.set_expanded(expanded, cx); + pane + }); + + let state_subscription = cx.subscribe(&agent_thread_pane, Self::handle_utility_pane_event); + let close_subscription = cx.subscribe(&agent_thread_pane, Self::handle_close_pane_event); + + self._subscriptions.push(state_subscription); + self._subscriptions.push(close_subscription); + + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, agent_thread_pane.clone(), cx); + }); + } + + self.agent_thread_pane = Some(agent_thread_pane); + self.serialize(cx); + cx.notify(); + } + + fn utility_slot(&self, window: &Window, cx: &App) -> UtilityPaneSlot { + let position = self.position(window, cx); + utility_slot_for_dock_position(position) + } + + fn re_register_utility_pane(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(pane) = &self.agent_thread_pane { + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + let pane = pane.clone(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, pane, cx); + }); + } + } + } + + fn serialize(&mut self, cx: &mut Context) { + let width = self.width; + let pane = self + .agent_thread_pane + .as_ref() + .map(|pane| pane.read(cx).serialize()); + + self.pending_serialization = cx.background_spawn(async move { + KEY_VALUE_STORE + .write_kvp( + AGENTS_PANEL_KEY.into(), + serde_json::to_string(&SerializedAgentsPanel { width, pane }).unwrap(), + ) + .await + .log_err() + }); + } +} + +impl EventEmitter for AgentsPanel {} + +impl Focusable for AgentsPanel { + fn focus_handle(&self, _cx: &ui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Panel for AgentsPanel { + fn persistent_name() -> &'static str { + "AgentsPanel" + } + + fn panel_key() -> &'static str { + AGENTS_PANEL_KEY + } + + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + match AgentSettings::get_global(cx).agents_panel_dock { + settings::DockSide::Left => DockPosition::Left, + settings::DockSide::Right => DockPosition::Right, + } + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position != DockPosition::Bottom + } + + fn set_position( + &mut self, + position: DockPosition, + window: &mut Window, + cx: &mut Context, + ) { + update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.agent.get_or_insert_default().agents_panel_dock = Some(match position { + DockPosition::Left => settings::DockSide::Left, + DockPosition::Right | DockPosition::Bottom => settings::DockSide::Right, + }); + }); + self.re_register_utility_pane(window, cx); + } + + fn size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or(settings.default_width) + } + DockPosition::Bottom => self.width.unwrap_or(settings.default_height), + } + } + + fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => {} + } + self.serialize(cx); + cx.notify(); + } + + fn icon(&self, _window: &Window, cx: &App) -> Option { + (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgentTwo) + } + + fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { + Some("Agents Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleAgentsPanel) + } + + fn activation_priority(&self) -> u32 { + 4 + } + + fn enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).enabled(cx) && cx.has_flag::() + } +} + +impl Render for AgentsPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div().size_full().child(self.history.clone()) + } +} diff --git a/crates/agent_ui_v2/src/thread_history.rs b/crates/agent_ui_v2/src/thread_history.rs new file mode 100644 index 0000000000..8f66268149 --- /dev/null +++ b/crates/agent_ui_v2/src/thread_history.rs @@ -0,0 +1,735 @@ +use agent::{HistoryEntry, HistoryStore}; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, Window, actions, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, + prelude::*, +}; + +actions!( + agents, + [ + /// Removes all thread history. + RemoveHistory, + /// Removes the currently selected thread. + RemoveSelectedThread, + ] +); + +pub struct AcpThreadHistory { + pub(crate) history_store: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _update_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: HistoryEntry, + format: EntryTimeFormat, + }, + SearchResult { + entry: HistoryEntry, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&HistoryEntry> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +#[allow(dead_code)] +pub enum ThreadHistoryEvent { + Open(HistoryEntry), +} + +impl EventEmitter for AcpThreadHistory {} + +impl AcpThreadHistory { + pub fn new( + history_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_store_subscription = cx.observe(&history_store, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history_store, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_store_subscription], + _update_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self + .history_store + .update(cx, |store, _| store.entries().collect()); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._update_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + let history_entry_id = history_entry.id(); + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.id() == history_entry_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_date = entry + .updated_at() + .with_timezone(&Local) + .naive_local() + .date(); + let entry_bucket = TimeBucket::from_dates(today, entry_date); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, entry.title())); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&HistoryEntry> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.is_empty() { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + pub fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + pub fn select_next( + &mut self, + _: &menu::SelectNext, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + + let task = match entry { + HistoryEntry::AcpThread(thread) => self + .history_store + .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), + HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { + this.delete_text_thread(text_thread.path.clone(), cx) + }), + }; + task.detach_and_log_err(cx); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.history_store.update(cx, |store, cx| { + store.delete_threads(cx).detach_and_log_err(cx) + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &HistoryEntry, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let timestamp = entry.updated_at().timestamp(); + let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(entry.title(), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(thread_timestamp) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for AcpThreadHistory { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for AcpThreadHistory { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history_store.read(cx).is_empty(cx); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); + + let date = today; + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); + + let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); + + let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + // All: not in this week or last week + let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); + + // Test year boundary cases + let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + + let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); + assert_eq!( + TimeBucket::from_dates(new_year, date), + TimeBucket::Yesterday + ); + + let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); + assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); + } +} diff --git a/crates/ai_onboarding/Cargo.toml b/crates/ai_onboarding/Cargo.toml index 95a45b1a6f..8fb0570e5c 100644 --- a/crates/ai_onboarding/Cargo.toml +++ b/crates/ai_onboarding/Cargo.toml @@ -24,5 +24,4 @@ serde.workspace = true smallvec.workspace = true telemetry.workspace = true ui.workspace = true -workspace-hack.workspace = true zed_actions.workspace = true diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index d953ae6121..20bb0a5f68 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -84,10 +84,32 @@ impl ZedAiOnboarding { self } + fn render_dismiss_button(&self) -> Option { + self.dismiss_onboarding.as_ref().map(|dismiss_callback| { + let callback = dismiss_callback.clone(); + + h_flex() + .absolute() + .top_0() + .right_0() + .child( + IconButton::new("dismiss_onboarding", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(move |_, window, cx| { + telemetry::event!("Banner Dismissed", source = "AI Onboarding",); + callback(window, cx) + }), + ) + .into_any_element() + }) + } + fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement { let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn); v_flex() + .relative() .gap_1() .child(Headline::new("Welcome to Zed AI")) .child( @@ -109,6 +131,7 @@ impl ZedAiOnboarding { } }), ) + .children(self.render_dismiss_button()) .into_any_element() } @@ -180,27 +203,7 @@ impl ZedAiOnboarding { ) .child(PlanDefinitions.free_plan(is_v2)), ) - .when_some( - self.dismiss_onboarding.as_ref(), - |this, dismiss_callback| { - let callback = dismiss_callback.clone(); - - this.child( - h_flex().absolute().top_0().right_0().child( - IconButton::new("dismiss_onboarding", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(move |_, window, cx| { - telemetry::event!( - "Banner Dismissed", - source = "AI Onboarding", - ); - callback(window, cx) - }), - ), - ) - }, - ) + .children(self.render_dismiss_button()) .child( v_flex() .mt_2() @@ -245,26 +248,7 @@ impl ZedAiOnboarding { .mb_2(), ) .child(PlanDefinitions.pro_trial(is_v2, false)) - .when_some( - self.dismiss_onboarding.as_ref(), - |this, dismiss_callback| { - let callback = dismiss_callback.clone(); - this.child( - h_flex().absolute().top_0().right_0().child( - IconButton::new("dismiss_onboarding", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(move |_, window, cx| { - telemetry::event!( - "Banner Dismissed", - source = "AI Onboarding", - ); - callback(window, cx) - }), - ), - ) - }, - ) + .children(self.render_dismiss_button()) .into_any_element() } @@ -278,26 +262,7 @@ impl ZedAiOnboarding { .mb_2(), ) .child(PlanDefinitions.pro_plan(is_v2, false)) - .when_some( - self.dismiss_onboarding.as_ref(), - |this, dismiss_callback| { - let callback = dismiss_callback.clone(); - this.child( - h_flex().absolute().top_0().right_0().child( - IconButton::new("dismiss_onboarding", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(move |_, window, cx| { - telemetry::event!( - "Banner Dismissed", - source = "AI Onboarding", - ); - callback(window, cx) - }), - ), - ) - }, - ) + .children(self.render_dismiss_button()) .into_any_element() } } diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml index c8103e5bfb..a9c7208b0c 100644 --- a/crates/anthropic/Cargo.toml +++ b/crates/anthropic/Cargo.toml @@ -26,4 +26,3 @@ serde_json.workspace = true settings.workspace = true strum.workspace = true thiserror.workspace = true -workspace-hack.workspace = true diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 93334fd950..e976b7f5dc 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -12,6 +12,8 @@ pub use settings::{AnthropicAvailableModel as AvailableModel, ModelMode}; use strum::{EnumIter, EnumString}; use thiserror::Error; +pub mod batches; + pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -67,6 +69,13 @@ pub enum Model { alias = "claude-opus-4-1-thinking-latest" )] ClaudeOpus4_1Thinking, + #[serde(rename = "claude-opus-4-5", alias = "claude-opus-4-5-latest")] + ClaudeOpus4_5, + #[serde( + rename = "claude-opus-4-5-thinking", + alias = "claude-opus-4-5-thinking-latest" + )] + ClaudeOpus4_5Thinking, #[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")] ClaudeSonnet4, #[serde( @@ -91,6 +100,13 @@ pub enum Model { Claude3_7SonnetThinking, #[serde(rename = "claude-3-5-sonnet", alias = "claude-3-5-sonnet-latest")] Claude3_5Sonnet, + #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] + ClaudeHaiku4_5, + #[serde( + rename = "claude-haiku-4-5-thinking", + alias = "claude-haiku-4-5-thinking-latest" + )] + ClaudeHaiku4_5Thinking, #[serde(rename = "claude-3-5-haiku", alias = "claude-3-5-haiku-latest")] Claude3_5Haiku, #[serde(rename = "claude-3-opus", alias = "claude-3-opus-latest")] @@ -124,6 +140,14 @@ impl Model { } pub fn from_id(id: &str) -> Result { + if id.starts_with("claude-opus-4-5-thinking") { + return Ok(Self::ClaudeOpus4_5Thinking); + } + + if id.starts_with("claude-opus-4-5") { + return Ok(Self::ClaudeOpus4_5); + } + if id.starts_with("claude-opus-4-1-thinking") { return Ok(Self::ClaudeOpus4_1Thinking); } @@ -168,6 +192,14 @@ impl Model { return Ok(Self::Claude3_5Sonnet); } + if id.starts_with("claude-haiku-4-5-thinking") { + return Ok(Self::ClaudeHaiku4_5Thinking); + } + + if id.starts_with("claude-haiku-4-5") { + return Ok(Self::ClaudeHaiku4_5); + } + if id.starts_with("claude-3-5-haiku") { return Ok(Self::Claude3_5Haiku); } @@ -193,6 +225,8 @@ impl Model { Self::ClaudeOpus4_1 => "claude-opus-4-1-latest", Self::ClaudeOpus4Thinking => "claude-opus-4-thinking-latest", Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-thinking-latest", + Self::ClaudeOpus4_5 => "claude-opus-4-5-latest", + Self::ClaudeOpus4_5Thinking => "claude-opus-4-5-thinking-latest", Self::ClaudeSonnet4 => "claude-sonnet-4-latest", Self::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking-latest", Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-latest", @@ -200,6 +234,8 @@ impl Model { Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest", Self::Claude3_7Sonnet => "claude-3-7-sonnet-latest", Self::Claude3_7SonnetThinking => "claude-3-7-sonnet-thinking-latest", + Self::ClaudeHaiku4_5 => "claude-haiku-4-5-latest", + Self::ClaudeHaiku4_5Thinking => "claude-haiku-4-5-thinking-latest", Self::Claude3_5Haiku => "claude-3-5-haiku-latest", Self::Claude3Opus => "claude-3-opus-latest", Self::Claude3Sonnet => "claude-3-sonnet-20240229", @@ -213,10 +249,12 @@ impl Model { match self { Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking => "claude-opus-4-20250514", Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-20250805", + Self::ClaudeOpus4_5 | Self::ClaudeOpus4_5Thinking => "claude-opus-4-5-20251101", Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => "claude-sonnet-4-20250514", Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking => "claude-sonnet-4-5-20250929", Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest", Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => "claude-3-7-sonnet-latest", + Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking => "claude-haiku-4-5-20251001", Self::Claude3_5Haiku => "claude-3-5-haiku-latest", Self::Claude3Opus => "claude-3-opus-latest", Self::Claude3Sonnet => "claude-3-sonnet-20240229", @@ -231,6 +269,8 @@ impl Model { Self::ClaudeOpus4_1 => "Claude Opus 4.1", Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking", Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking", + Self::ClaudeOpus4_5 => "Claude Opus 4.5", + Self::ClaudeOpus4_5Thinking => "Claude Opus 4.5 Thinking", Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking", Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", @@ -238,6 +278,8 @@ impl Model { Self::Claude3_7Sonnet => "Claude 3.7 Sonnet", Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", Self::Claude3_7SonnetThinking => "Claude 3.7 Sonnet Thinking", + Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", + Self::ClaudeHaiku4_5Thinking => "Claude Haiku 4.5 Thinking", Self::Claude3_5Haiku => "Claude 3.5 Haiku", Self::Claude3Opus => "Claude 3 Opus", Self::Claude3Sonnet => "Claude 3 Sonnet", @@ -254,11 +296,15 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking | Self::Claude3_5Sonnet + | Self::ClaudeHaiku4_5 + | Self::ClaudeHaiku4_5Thinking | Self::Claude3_5Haiku | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking @@ -281,11 +327,15 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking | Self::Claude3_5Sonnet + | Self::ClaudeHaiku4_5 + | Self::ClaudeHaiku4_5Thinking | Self::Claude3_5Haiku | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking @@ -302,6 +352,8 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -310,6 +362,7 @@ impl Model { | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking | Self::Claude3_5Haiku => 8_192, + Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking => 64_000, Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3Haiku => 4_096, Self::Custom { max_output_tokens, .. @@ -323,6 +376,8 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -330,6 +385,8 @@ impl Model { | Self::Claude3_5Sonnet | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking + | Self::ClaudeHaiku4_5 + | Self::ClaudeHaiku4_5Thinking | Self::Claude3_5Haiku | Self::Claude3Opus | Self::Claude3Sonnet @@ -345,18 +402,22 @@ impl Model { match self { Self::ClaudeOpus4 | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_5 | Self::ClaudeSonnet4 | Self::ClaudeSonnet4_5 | Self::Claude3_5Sonnet | Self::Claude3_7Sonnet + | Self::ClaudeHaiku4_5 | Self::Claude3_5Haiku | Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3Haiku => AnthropicModelMode::Default, Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5Thinking + | Self::ClaudeHaiku4_5Thinking | Self::Claude3_7SonnetThinking => AnthropicModelMode::Thinking { budget_tokens: Some(4_096), }, @@ -364,19 +425,28 @@ impl Model { } } - pub const DEFAULT_BETA_HEADERS: &[&str] = &["prompt-caching-2024-07-31"]; - - pub fn beta_headers(&self) -> String { - let mut headers = Self::DEFAULT_BETA_HEADERS - .iter() - .map(|header| header.to_string()) - .collect::>(); + pub fn beta_headers(&self) -> Option { + let mut headers = vec![]; match self { + Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeSonnet4_5 + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5Thinking + | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5Thinking => { + // Fine-grained tool streaming for newer models + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); + } Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => { // Try beta token-efficient tool use (supported in Claude 3.7 Sonnet only) // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/token-efficient-tool-use headers.push("token-efficient-tools-2025-02-19".to_string()); + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); } Self::Custom { extra_beta_headers, .. @@ -391,7 +461,11 @@ impl Model { _ => {} } - headers.join(",") + if headers.is_empty() { + None + } else { + Some(headers.join(",")) + } } pub fn tool_model_id(&self) -> &str { @@ -407,60 +481,112 @@ impl Model { } } -pub async fn complete( +/// Generate completion with streaming. +pub async fn stream_completion( client: &dyn HttpClient, api_url: &str, api_key: &str, request: Request, - beta_headers: String, + beta_headers: Option, +) -> Result>, AnthropicError> { + stream_completion_with_rate_limit_info(client, api_url, api_key, request, beta_headers) + .await + .map(|output| output.0) +} + +/// Generate completion without streaming. +pub async fn non_streaming_completion( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: Request, + beta_headers: Option, ) -> Result { + let (mut response, rate_limits) = + send_request(client, api_url, api_key, &request, beta_headers).await?; + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(handle_error_response(response, rate_limits).await) + } +} + +async fn send_request( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: impl Serialize, + beta_headers: Option, +) -> Result<(http::Response, RateLimitInfo), AnthropicError> { let uri = format!("{api_url}/v1/messages"); - let request_builder = HttpRequest::builder() + + let mut request_builder = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Anthropic-Version", "2023-06-01") - .header("Anthropic-Beta", beta_headers) .header("X-Api-Key", api_key.trim()) .header("Content-Type", "application/json"); + if let Some(beta_headers) = beta_headers { + request_builder = request_builder.header("Anthropic-Beta", beta_headers); + } + let serialized_request = serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; let request = request_builder .body(AsyncBody::from(serialized_request)) .map_err(AnthropicError::BuildRequestBody)?; - let mut response = client + let response = client .send(request) .await .map_err(AnthropicError::HttpSend)?; - let status_code = response.status(); + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + Ok((response, rate_limits)) +} + +async fn handle_error_response( + mut response: http::Response, + rate_limits: RateLimitInfo, +) -> AnthropicError { + if response.status().as_u16() == 529 { + return AnthropicError::ServerOverloaded { + retry_after: rate_limits.retry_after, + }; + } + + if let Some(retry_after) = rate_limits.retry_after { + return AnthropicError::RateLimit { retry_after }; + } + let mut body = String::new(); - response + let read_result = response .body_mut() .read_to_string(&mut body) .await - .map_err(AnthropicError::ReadResponse)?; + .map_err(AnthropicError::ReadResponse); - if status_code.is_success() { - Ok(serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse)?) - } else { - Err(AnthropicError::HttpResponseError { - status_code, - message: body, - }) + if let Err(err) = read_result { + return err; } -} -pub async fn stream_completion( - client: &dyn HttpClient, - api_url: &str, - api_key: &str, - request: Request, - beta_headers: String, -) -> Result>, AnthropicError> { - stream_completion_with_rate_limit_info(client, api_url, api_key, request, beta_headers) - .await - .map(|output| output.0) + match serde_json::from_str::(&body) { + Ok(Event::Error { error }) => AnthropicError::ApiError(error), + Ok(_) | Err(_) => AnthropicError::HttpResponseError { + status_code: response.status(), + message: body, + }, + } } /// An individual rate limit. @@ -554,7 +680,7 @@ pub async fn stream_completion_with_rate_limit_info( api_url: &str, api_key: &str, request: Request, - beta_headers: String, + beta_headers: Option, ) -> Result< ( BoxStream<'static, Result>, @@ -566,26 +692,10 @@ pub async fn stream_completion_with_rate_limit_info( base: request, stream: true, }; - let uri = format!("{api_url}/v1/messages"); - let request_builder = HttpRequest::builder() - .method(Method::POST) - .uri(uri) - .header("Anthropic-Version", "2023-06-01") - .header("Anthropic-Beta", beta_headers) - .header("X-Api-Key", api_key.trim()) - .header("Content-Type", "application/json"); - let serialized_request = - serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; - let request = request_builder - .body(AsyncBody::from(serialized_request)) - .map_err(AnthropicError::BuildRequestBody)?; + let (response, rate_limits) = + send_request(client, api_url, api_key, &request, beta_headers).await?; - let mut response = client - .send(request) - .await - .map_err(AnthropicError::HttpSend)?; - let rate_limits = RateLimitInfo::from_headers(response.headers()); if response.status().is_success() { let reader = BufReader::new(response.into_body()); let stream = reader @@ -604,27 +714,8 @@ pub async fn stream_completion_with_rate_limit_info( }) .boxed(); Ok((stream, Some(rate_limits))) - } else if response.status().as_u16() == 529 { - Err(AnthropicError::ServerOverloaded { - retry_after: rate_limits.retry_after, - }) - } else if let Some(retry_after) = rate_limits.retry_after { - Err(AnthropicError::RateLimit { retry_after }) } else { - let mut body = String::new(); - response - .body_mut() - .read_to_string(&mut body) - .await - .map_err(AnthropicError::ReadResponse)?; - - match serde_json::from_str::(&body) { - Ok(Event::Error { error }) => Err(AnthropicError::ApiError(error)), - Ok(_) | Err(_) => Err(AnthropicError::HttpResponseError { - status_code: response.status(), - message: body, - }), - } + Err(handle_error_response(response, rate_limits).await) } } diff --git a/crates/anthropic/src/batches.rs b/crates/anthropic/src/batches.rs new file mode 100644 index 0000000000..5fb594348d --- /dev/null +++ b/crates/anthropic/src/batches.rs @@ -0,0 +1,190 @@ +use anyhow::Result; +use futures::AsyncReadExt; +use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use serde::{Deserialize, Serialize}; + +use crate::{AnthropicError, ApiError, RateLimitInfo, Request, Response}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchRequest { + pub custom_id: String, + pub params: Request, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateBatchRequest { + pub requests: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageBatchRequestCounts { + pub processing: u64, + pub succeeded: u64, + pub errored: u64, + pub canceled: u64, + pub expired: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageBatch { + pub id: String, + #[serde(rename = "type")] + pub batch_type: String, + pub processing_status: String, + pub request_counts: MessageBatchRequestCounts, + pub ended_at: Option, + pub created_at: String, + pub expires_at: String, + pub archived_at: Option, + pub cancel_initiated_at: Option, + pub results_url: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum BatchResult { + #[serde(rename = "succeeded")] + Succeeded { message: Response }, + #[serde(rename = "errored")] + Errored { error: ApiError }, + #[serde(rename = "canceled")] + Canceled, + #[serde(rename = "expired")] + Expired, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchIndividualResponse { + pub custom_id: String, + pub result: BatchResult, +} + +pub async fn create_batch( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: CreateBatchRequest, +) -> Result { + let uri = format!("{api_url}/v1/messages/batches"); + + let request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()) + .header("Content-Type", "application/json"); + + let serialized_request = + serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; + let http_request = request_builder + .body(AsyncBody::from(serialized_request)) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} + +pub async fn retrieve_batch( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + message_batch_id: &str, +) -> Result { + let uri = format!("{api_url}/v1/messages/batches/{message_batch_id}"); + + let request_builder = HttpRequest::builder() + .method(Method::GET) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()); + + let http_request = request_builder + .body(AsyncBody::default()) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} + +pub async fn retrieve_batch_results( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + message_batch_id: &str, +) -> Result, AnthropicError> { + let uri = format!("{api_url}/v1/messages/batches/{message_batch_id}/results"); + + let request_builder = HttpRequest::builder() + .method(Method::GET) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()); + + let http_request = request_builder + .body(AsyncBody::default()) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + let mut results = Vec::new(); + for line in body.lines() { + if line.trim().is_empty() { + continue; + } + let result: BatchIndividualResponse = + serde_json::from_str(line).map_err(AnthropicError::DeserializeResponse)?; + results.push(result); + } + + Ok(results) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 6aec7e6d7e..298d1a7369 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -20,7 +20,6 @@ smol.workspace = true log.workspace = true tempfile.workspace = true util.workspace = true -workspace-hack.workspace = true zeroize.workspace = true [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index dfe8a96ee6..ab4474aa62 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -20,7 +20,7 @@ use futures::{ }; use gpui::{AsyncApp, BackgroundExecutor, Task}; use smol::fs; -use util::{ResultExt as _, debug_panic, maybe, paths::PathExt}; +use util::{ResultExt as _, debug_panic, maybe, paths::PathExt, shell::ShellKind}; /// Path to the program used for askpass /// @@ -199,13 +199,15 @@ impl PasswordProxy { let current_exec = std::env::current_exe().context("Failed to determine current zed executable path.")?; - let askpass_program = ASKPASS_PROGRAM - .get_or_init(|| current_exec) - .try_shell_safe() - .context("Failed to shell-escape Askpass program path.")? - .to_string(); + // TODO: inferred from the use of powershell.exe in askpass_helper_script + let shell_kind = if cfg!(windows) { + ShellKind::PowerShell + } else { + ShellKind::Posix + }; + let askpass_program = ASKPASS_PROGRAM.get_or_init(|| current_exec); // Create an askpass script that communicates back to this process. - let askpass_script = generate_askpass_script(&askpass_program, &askpass_socket); + let askpass_script = generate_askpass_script(shell_kind, askpass_program, &askpass_socket)?; let _task = executor.spawn(async move { maybe!(async move { let listener = @@ -247,10 +249,15 @@ impl PasswordProxy { 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?; + make_file_executable(&askpass_script_path) + .await + .with_context(|| { + format!("marking askpass script executable at {askpass_script_path:?}") + })?; + // todo(shell): There might be no powershell on the system #[cfg(target_os = "windows")] let askpass_helper = format!( - "powershell.exe -ExecutionPolicy Bypass -File {}", + "powershell.exe -ExecutionPolicy Bypass -File \"{}\"", askpass_script_path.display() ); @@ -328,23 +335,51 @@ pub fn set_askpass_program(path: std::path::PathBuf) { #[inline] #[cfg(not(target_os = "windows"))] -fn generate_askpass_script(askpass_program: &str, askpass_socket: &std::path::Path) -> String { - format!( +fn generate_askpass_script( + shell_kind: ShellKind, + askpass_program: &std::path::Path, + askpass_socket: &std::path::Path, +) -> Result { + let askpass_program = shell_kind.prepend_command_prefix( + askpass_program + .to_str() + .context("Askpass program is on a non-utf8 path")?, + ); + let askpass_program = shell_kind + .try_quote_prefix_aware(&askpass_program) + .context("Failed to shell-escape Askpass program path")?; + let askpass_socket = askpass_socket + .try_shell_safe(shell_kind) + .context("Failed to shell-escape Askpass socket path")?; + let print_args = "printf '%s\\0' \"$@\""; + let shebang = "#!/bin/sh"; + Ok(format!( "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n", - askpass_socket = askpass_socket.display(), - print_args = "printf '%s\\0' \"$@\"", - shebang = "#!/bin/sh", - ) + )) } #[inline] #[cfg(target_os = "windows")] -fn generate_askpass_script(askpass_program: &str, askpass_socket: &std::path::Path) -> String { - format!( +fn generate_askpass_script( + shell_kind: ShellKind, + askpass_program: &std::path::Path, + askpass_socket: &std::path::Path, +) -> Result { + let askpass_program = shell_kind.prepend_command_prefix( + askpass_program + .to_str() + .context("Askpass program is on a non-utf8 path")?, + ); + let askpass_program = shell_kind + .try_quote_prefix_aware(&askpass_program) + .context("Failed to shell-escape Askpass program path")?; + let askpass_socket = askpass_socket + .try_shell_safe(shell_kind) + .context("Failed to shell-escape Askpass socket path")?; + Ok(format!( r#" $ErrorActionPreference = 'Stop'; - ($args -join [char]0) | & "{askpass_program}" --askpass={askpass_socket} 2> $null + ($args -join [char]0) | {askpass_program} --askpass={askpass_socket} 2> $null "#, - askpass_socket = askpass_socket.display(), - ) + )) } diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 130394a30b..a56cd109f1 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -15,4 +15,3 @@ workspace = true anyhow.workspace = true gpui.workspace = true rust-embed.workspace = true -workspace-hack.workspace = true diff --git a/crates/assistant_slash_command/Cargo.toml b/crates/assistant_slash_command/Cargo.toml index 0908cd6165..1fc3e8448c 100644 --- a/crates/assistant_slash_command/Cargo.toml +++ b/crates/assistant_slash_command/Cargo.toml @@ -27,7 +27,6 @@ serde_json.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/assistant_slash_command/src/assistant_slash_command.rs b/crates/assistant_slash_command/src/assistant_slash_command.rs index 4b85fa2edf..2e6bb7325e 100644 --- a/crates/assistant_slash_command/src/assistant_slash_command.rs +++ b/crates/assistant_slash_command/src/assistant_slash_command.rs @@ -9,6 +9,7 @@ use anyhow::Result; use futures::StreamExt; use futures::stream::{self, BoxStream}; use gpui::{App, SharedString, Task, WeakEntity, Window}; +use language::CodeLabelBuilder; use language::HighlightId; use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt}; pub use language_model::Role; @@ -328,15 +329,15 @@ impl SlashCommandLine { } pub fn create_label_for_command(command_name: &str, arguments: &[&str], cx: &App) -> CodeLabel { - let mut label = CodeLabel::default(); + let mut label = CodeLabelBuilder::default(); label.push_str(command_name, None); + label.respan_filter_range(None); label.push_str(" ", None); label.push_str( &arguments.join(" "), cx.theme().syntax().highlight_id("comment").map(HighlightId), ); - label.filter_range = 0..command_name.len(); - label + label.build() } #[cfg(test)] diff --git a/crates/assistant_slash_commands/Cargo.toml b/crates/assistant_slash_commands/Cargo.toml index 5844d21a51..b2a70449f4 100644 --- a/crates/assistant_slash_commands/Cargo.toml +++ b/crates/assistant_slash_commands/Cargo.toml @@ -22,7 +22,6 @@ feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true -globset.workspace = true gpui.workspace = true html_to_markdown.workspace = true http_client.workspace = true @@ -38,7 +37,6 @@ ui.workspace = true util.workspace = true workspace.workspace = true worktree.workspace = true -workspace-hack.workspace = true [dev-dependencies] fs = { workspace = true, features = ["test-support"] } diff --git a/crates/assistant_slash_commands/src/diagnostics_command.rs b/crates/assistant_slash_commands/src/diagnostics_command.rs index 3a9c330615..3b3e3f7b89 100644 --- a/crates/assistant_slash_commands/src/diagnostics_command.rs +++ b/crates/assistant_slash_commands/src/diagnostics_command.rs @@ -233,18 +233,11 @@ fn collect_diagnostics( options: Options, cx: &mut App, ) -> Task>> { - let error_source = if let Some(path_matcher) = &options.path_matcher { - debug_assert_eq!(path_matcher.sources().len(), 1); - Some(path_matcher.sources().first().cloned().unwrap_or_default()) - } else { - None - }; - let path_style = project.read(cx).path_style(cx); let glob_is_exact_file_match = if let Some(path) = options .path_matcher .as_ref() - .and_then(|pm| pm.sources().first()) + .and_then(|pm| pm.sources().next()) { project .read(cx) @@ -266,6 +259,13 @@ fn collect_diagnostics( .collect(); cx.spawn(async move |cx| { + let error_source = if let Some(path_matcher) = &options.path_matcher { + debug_assert_eq!(path_matcher.sources().count(), 1); + Some(path_matcher.sources().next().unwrap_or_default()) + } else { + None + }; + let mut output = SlashCommandOutput::default(); if let Some(error_source) = error_source.as_ref() { @@ -277,7 +277,7 @@ fn collect_diagnostics( let mut project_summary = DiagnosticSummary::default(); for (project_path, path, summary) in diagnostic_summaries { if let Some(path_matcher) = &options.path_matcher - && !path_matcher.is_match(&path.as_std_path()) + && !path_matcher.is_match(&path) { continue; } diff --git a/crates/assistant_slash_commands/src/file_command.rs b/crates/assistant_slash_commands/src/file_command.rs index 0968a297b8..ae4e8363b4 100644 --- a/crates/assistant_slash_commands/src/file_command.rs +++ b/crates/assistant_slash_commands/src/file_command.rs @@ -7,7 +7,7 @@ use futures::Stream; use futures::channel::mpsc; use fuzzy::PathMatch; use gpui::{App, Entity, Task, WeakEntity}; -use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate}; +use language::{BufferSnapshot, CodeLabelBuilder, HighlightId, LineEnding, LspAdapterDelegate}; use project::{PathMatchCandidateSet, Project}; use serde::{Deserialize, Serialize}; use smol::stream::StreamExt; @@ -168,7 +168,7 @@ impl SlashCommand for FileSlashCommand { .display(path_style) .to_string(); - let mut label = CodeLabel::default(); + let mut label = CodeLabelBuilder::default(); let file_name = path_match.path.file_name()?; let label_text = if path_match.is_dir { format!("{}/ ", file_name) @@ -178,10 +178,10 @@ impl SlashCommand for FileSlashCommand { label.push_str(label_text.as_str(), None); label.push_str(&text, comment_id); - label.filter_range = 0..file_name.len(); + label.respan_filter_range(Some(file_name)); Some(ArgumentCompletion { - label, + label: label.build(), new_text: text, after_completion: AfterCompletion::Compose, replace_previous_arguments: false, @@ -226,10 +226,10 @@ fn collect_files( let Ok(matchers) = glob_inputs .iter() .map(|glob_input| { - custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()]) + util::paths::PathMatcher::new(&[glob_input.to_owned()], project.read(cx).path_style(cx)) .with_context(|| format!("invalid path {glob_input}")) }) - .collect::>>() + .collect::>>() else { return futures::stream::once(async { anyhow::bail!("invalid path"); @@ -250,6 +250,7 @@ fn collect_files( let worktree_id = snapshot.id(); let path_style = snapshot.path_style(); let mut directory_stack: Vec> = Vec::new(); + let mut folded_directory_path: Option> = None; let mut folded_directory_names: Arc = RelPath::empty().into(); let mut is_top_level_directory = true; @@ -277,6 +278,16 @@ fn collect_files( )))?; } + if let Some(folded_path) = &folded_directory_path { + if !entry.path.starts_with(folded_path) { + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; + if directory_stack.is_empty() { + is_top_level_directory = true; + } + } + } + let filename = entry.path.file_name().unwrap_or_default().to_string(); if entry.is_dir() { @@ -292,13 +303,17 @@ fn collect_files( folded_directory_names = folded_directory_names.join(RelPath::unix(&filename).unwrap()); } + folded_directory_path = Some(entry.path.clone()); continue; } } else { // Skip empty directories folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; continue; } + + // Render the directory (either folded or normal) if folded_directory_names.is_empty() { let label = if is_top_level_directory { is_top_level_directory = false; @@ -334,6 +349,8 @@ fn collect_files( }, )))?; directory_stack.push(entry.path.clone()); + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; } events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { @@ -447,87 +464,6 @@ pub fn build_entry_output_section( } } -/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix -/// check. Only subpaths pass the prefix check, rather than any prefix. -mod custom_path_matcher { - use globset::{Glob, GlobSet, GlobSetBuilder}; - use std::fmt::Debug as _; - use util::{paths::SanitizedPath, rel_path::RelPath}; - - #[derive(Clone, Debug, Default)] - pub struct PathMatcher { - sources: Vec, - sources_with_trailing_slash: Vec, - glob: GlobSet, - } - - impl std::fmt::Display for PathMatcher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.sources.fmt(f) - } - } - - impl PartialEq for PathMatcher { - fn eq(&self, other: &Self) -> bool { - self.sources.eq(&other.sources) - } - } - - impl Eq for PathMatcher {} - - impl PathMatcher { - pub fn new(globs: &[String]) -> Result { - let globs = globs - .iter() - .map(|glob| Glob::new(&SanitizedPath::new(glob).to_string())) - .collect::, _>>()?; - let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); - let sources_with_trailing_slash = globs - .iter() - .map(|glob| glob.glob().to_string() + "/") - .collect(); - let mut glob_builder = GlobSetBuilder::new(); - for single_glob in globs { - glob_builder.add(single_glob); - } - let glob = glob_builder.build()?; - Ok(PathMatcher { - glob, - sources, - sources_with_trailing_slash, - }) - } - - pub fn is_match(&self, other: &RelPath) -> bool { - self.sources - .iter() - .zip(self.sources_with_trailing_slash.iter()) - .any(|(source, with_slash)| { - let as_bytes = other.as_unix_str().as_bytes(); - let with_slash = if source.ends_with('/') { - source.as_bytes() - } else { - with_slash.as_bytes() - }; - - as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes()) - }) - || self.glob.is_match(other.as_std_path()) - || self.check_with_end_separator(other) - } - - fn check_with_end_separator(&self, path: &RelPath) -> bool { - let path_str = path.as_unix_str(); - let separator = "/"; - if path_str.ends_with(separator) { - false - } else { - self.glob.is_match(path_str.to_string() + separator) - } - } - } -} - pub fn append_buffer_to_output( buffer: &BufferSnapshot, path: Option<&str>, @@ -577,8 +513,6 @@ mod test { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); // release_channel::init(SemanticVersion::default(), cx); - language::init(cx); - Project::init_settings(cx); }); } diff --git a/crates/assistant_slash_commands/src/selection_command.rs b/crates/assistant_slash_commands/src/selection_command.rs index c8692dec71..ce6c0b9314 100644 --- a/crates/assistant_slash_commands/src/selection_command.rs +++ b/crates/assistant_slash_commands/src/selection_command.rs @@ -79,7 +79,7 @@ impl SlashCommand for SelectionCommand { editor.update(cx, |editor, cx| { let selection_ranges = editor .selections - .all_adjusted(cx) + .all_adjusted(&editor.display_snapshot(cx)) .iter() .map(|selection| selection.range()) .collect::>(); diff --git a/crates/assistant_slash_commands/src/tab_command.rs b/crates/assistant_slash_commands/src/tab_command.rs index 9fd38128ca..a4c0ad412c 100644 --- a/crates/assistant_slash_commands/src/tab_command.rs +++ b/crates/assistant_slash_commands/src/tab_command.rs @@ -7,7 +7,7 @@ use collections::{HashMap, HashSet}; use editor::Editor; use futures::future::join_all; use gpui::{Task, WeakEntity}; -use language::{BufferSnapshot, CodeLabel, HighlightId, LspAdapterDelegate}; +use language::{BufferSnapshot, CodeLabel, CodeLabelBuilder, HighlightId, LspAdapterDelegate}; use std::sync::{Arc, atomic::AtomicBool}; use ui::{ActiveTheme, App, Window, prelude::*}; use util::{ResultExt, paths::PathStyle}; @@ -308,10 +308,10 @@ fn create_tab_completion_label( comment_id: Option, ) -> CodeLabel { let (parent_path, file_name) = path_style.split(path); - let mut label = CodeLabel::default(); + let mut label = CodeLabelBuilder::default(); label.push_str(file_name, None); label.push_str(" ", None); label.push_str(parent_path.unwrap_or_default(), comment_id); - label.filter_range = 0..file_name.len(); - label + label.respan_filter_range(Some(file_name)); + label.build() } diff --git a/crates/assistant_context/Cargo.toml b/crates/assistant_text_thread/Cargo.toml similarity index 91% rename from crates/assistant_context/Cargo.toml rename to crates/assistant_text_thread/Cargo.toml index 3e2761a846..5ad429758e 100644 --- a/crates/assistant_context/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "assistant_context" +name = "assistant_text_thread" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,7 +9,7 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/assistant_context.rs" +path = "src/assistant_text_thread.rs" [features] test-support = [] @@ -29,6 +29,7 @@ fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +itertools.workspace = true language.workspace = true language_model.workspace = true log.workspace = true @@ -45,13 +46,12 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true smol.workspace = true -telemetry_events.workspace = true +telemetry.workspace = true text.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true workspace.workspace = true -workspace-hack.workspace = true zed_env_vars.workspace = true [dev-dependencies] diff --git a/crates/agent2/LICENSE-GPL b/crates/assistant_text_thread/LICENSE-GPL similarity index 100% rename from crates/agent2/LICENSE-GPL rename to crates/assistant_text_thread/LICENSE-GPL diff --git a/crates/assistant_text_thread/src/assistant_text_thread.rs b/crates/assistant_text_thread/src/assistant_text_thread.rs new file mode 100644 index 0000000000..7eab9800d5 --- /dev/null +++ b/crates/assistant_text_thread/src/assistant_text_thread.rs @@ -0,0 +1,15 @@ +#[cfg(test)] +mod assistant_text_thread_tests; +mod text_thread; +mod text_thread_store; + +pub use crate::text_thread::*; +pub use crate::text_thread_store::*; + +use client::Client; +use gpui::App; +use std::sync::Arc; + +pub fn init(client: Arc, _: &mut App) { + text_thread_store::init(&client.into()); +} diff --git a/crates/assistant_context/src/assistant_context_tests.rs b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs similarity index 73% rename from crates/assistant_context/src/assistant_context_tests.rs rename to crates/assistant_text_thread/src/assistant_text_thread_tests.rs index 413e32dfcb..7232a03c21 100644 --- a/crates/assistant_context/src/assistant_context_tests.rs +++ b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs @@ -1,6 +1,6 @@ use crate::{ - AssistantContext, CacheStatus, ContextEvent, ContextId, ContextOperation, ContextSummary, - InvokedSlashCommandId, MessageCacheMetadata, MessageId, MessageStatus, + CacheStatus, InvokedSlashCommandId, MessageCacheMetadata, MessageId, MessageStatus, TextThread, + TextThreadEvent, TextThreadId, TextThreadOperation, TextThreadSummary, }; use anyhow::Result; use assistant_slash_command::{ @@ -22,7 +22,6 @@ use language_model::{ }; use parking_lot::Mutex; use pretty_assertions::assert_eq; -use project::Project; use prompt_store::PromptBuilder; use rand::prelude::*; use serde_json::json; @@ -47,31 +46,30 @@ fn test_inserting_and_removing_messages(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, ) }); - let buffer = context.read(cx).buffer.clone(); + let buffer = text_thread.read(cx).buffer().clone(); - let message_1 = context.read(cx).message_anchors[0].clone(); + let message_1 = text_thread.read(cx).message_anchors[0].clone(); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![(message_1.id, Role::User, 0..0)] ); - let message_2 = context.update(cx, |context, cx| { + let message_2 = text_thread.update(cx, |context, cx| { context .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) .unwrap() }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..1), (message_2.id, Role::Assistant, 1..1) @@ -82,20 +80,20 @@ fn test_inserting_and_removing_messages(cx: &mut App) { buffer.edit([(0..0, "1"), (1..1, "2")], None, cx) }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..3) ] ); - let message_3 = context.update(cx, |context, cx| { + let message_3 = text_thread.update(cx, |context, cx| { context .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) .unwrap() }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..4), @@ -103,13 +101,13 @@ fn test_inserting_and_removing_messages(cx: &mut App) { ] ); - let message_4 = context.update(cx, |context, cx| { + let message_4 = text_thread.update(cx, |context, cx| { context .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) .unwrap() }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..4), @@ -122,7 +120,7 @@ fn test_inserting_and_removing_messages(cx: &mut App) { buffer.edit([(4..4, "C"), (5..5, "D")], None, cx) }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..4), @@ -134,7 +132,7 @@ fn test_inserting_and_removing_messages(cx: &mut App) { // Deleting across message boundaries merges the messages. buffer.update(cx, |buffer, cx| buffer.edit([(1..4, "")], None, cx)); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..3), (message_3.id, Role::User, 3..4), @@ -144,7 +142,7 @@ fn test_inserting_and_removing_messages(cx: &mut App) { // Undoing the deletion should also undo the merge. buffer.update(cx, |buffer, cx| buffer.undo(cx)); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..4), @@ -156,7 +154,7 @@ fn test_inserting_and_removing_messages(cx: &mut App) { // Redoing the deletion should also redo the merge. buffer.update(cx, |buffer, cx| buffer.redo(cx)); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..3), (message_3.id, Role::User, 3..4), @@ -164,13 +162,13 @@ fn test_inserting_and_removing_messages(cx: &mut App) { ); // Ensure we can still insert after a merged message. - let message_5 = context.update(cx, |context, cx| { + let message_5 = text_thread.update(cx, |context, cx| { context .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) .unwrap() }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..3), (message_5.id, Role::System, 3..4), @@ -186,21 +184,20 @@ fn test_message_splitting(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, ) }); - let buffer = context.read(cx).buffer.clone(); + let buffer = text_thread.read(cx).buffer().clone(); - let message_1 = context.read(cx).message_anchors[0].clone(); + let message_1 = text_thread.read(cx).message_anchors[0].clone(); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![(message_1.id, Role::User, 0..0)] ); @@ -208,26 +205,28 @@ fn test_message_splitting(cx: &mut App) { buffer.edit([(0..0, "aaa\nbbb\nccc\nddd\n")], None, cx) }); - let (_, message_2) = context.update(cx, |context, cx| context.split_message(3..3, cx)); + let (_, message_2) = + text_thread.update(cx, |text_thread, cx| text_thread.split_message(3..3, cx)); let message_2 = message_2.unwrap(); // We recycle newlines in the middle of a split message assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_2.id, Role::User, 4..16), ] ); - let (_, message_3) = context.update(cx, |context, cx| context.split_message(3..3, cx)); + let (_, message_3) = + text_thread.update(cx, |text_thread, cx| text_thread.split_message(3..3, cx)); let message_3 = message_3.unwrap(); // We don't recycle newlines at the end of a split message assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_3.id, Role::User, 4..5), @@ -235,11 +234,12 @@ fn test_message_splitting(cx: &mut App) { ] ); - let (_, message_4) = context.update(cx, |context, cx| context.split_message(9..9, cx)); + let (_, message_4) = + text_thread.update(cx, |text_thread, cx| text_thread.split_message(9..9, cx)); let message_4 = message_4.unwrap(); assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_3.id, Role::User, 4..5), @@ -248,11 +248,12 @@ fn test_message_splitting(cx: &mut App) { ] ); - let (_, message_5) = context.update(cx, |context, cx| context.split_message(9..9, cx)); + let (_, message_5) = + text_thread.update(cx, |text_thread, cx| text_thread.split_message(9..9, cx)); let message_5 = message_5.unwrap(); assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\nddd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_3.id, Role::User, 4..5), @@ -263,12 +264,12 @@ fn test_message_splitting(cx: &mut App) { ); let (message_6, message_7) = - context.update(cx, |context, cx| context.split_message(14..16, cx)); + text_thread.update(cx, |text_thread, cx| text_thread.split_message(14..16, cx)); let message_6 = message_6.unwrap(); let message_7 = message_7.unwrap(); assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\ndd\nd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_3.id, Role::User, 4..5), @@ -287,42 +288,41 @@ fn test_messages_for_offsets(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, ) }); - let buffer = context.read(cx).buffer.clone(); + let buffer = text_thread.read(cx).buffer().clone(); - let message_1 = context.read(cx).message_anchors[0].clone(); + let message_1 = text_thread.read(cx).message_anchors[0].clone(); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![(message_1.id, Role::User, 0..0)] ); buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "aaa")], None, cx)); - let message_2 = context - .update(cx, |context, cx| { - context.insert_message_after(message_1.id, Role::User, MessageStatus::Done, cx) + let message_2 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_1.id, Role::User, MessageStatus::Done, cx) }) .unwrap(); buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "bbb")], None, cx)); - let message_3 = context - .update(cx, |context, cx| { - context.insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + let message_3 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) }) .unwrap(); buffer.update(cx, |buffer, cx| buffer.edit([(8..8, "ccc")], None, cx)); assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_2.id, Role::User, 4..8), @@ -331,22 +331,22 @@ fn test_messages_for_offsets(cx: &mut App) { ); assert_eq!( - message_ids_for_offsets(&context, &[0, 4, 9], cx), + message_ids_for_offsets(&text_thread, &[0, 4, 9], cx), [message_1.id, message_2.id, message_3.id] ); assert_eq!( - message_ids_for_offsets(&context, &[0, 1, 11], cx), + message_ids_for_offsets(&text_thread, &[0, 1, 11], cx), [message_1.id, message_3.id] ); - let message_4 = context - .update(cx, |context, cx| { - context.insert_message_after(message_3.id, Role::User, MessageStatus::Done, cx) + let message_4 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_3.id, Role::User, MessageStatus::Done, cx) }) .unwrap(); assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_2.id, Role::User, 4..8), @@ -355,12 +355,12 @@ fn test_messages_for_offsets(cx: &mut App) { ] ); assert_eq!( - message_ids_for_offsets(&context, &[0, 4, 8, 12], cx), + message_ids_for_offsets(&text_thread, &[0, 4, 8, 12], cx), [message_1.id, message_2.id, message_3.id, message_4.id] ); fn message_ids_for_offsets( - context: &Entity, + context: &Entity, offsets: &[usize], cx: &App, ) -> Vec { @@ -398,11 +398,10 @@ async fn test_slash_commands(cx: &mut TestAppContext) { let registry = Arc::new(LanguageRegistry::test(cx.executor())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -417,19 +416,19 @@ async fn test_slash_commands(cx: &mut TestAppContext) { } let context_ranges = Rc::new(RefCell::new(ContextRanges::default())); - context.update(cx, |_, cx| { - cx.subscribe(&context, { + text_thread.update(cx, |_, cx| { + cx.subscribe(&text_thread, { let context_ranges = context_ranges.clone(); - move |context, _, event, _| { + move |text_thread, _, event, _| { let mut context_ranges = context_ranges.borrow_mut(); match event { - ContextEvent::InvokedSlashCommandChanged { command_id } => { - let command = context.invoked_slash_command(command_id).unwrap(); + TextThreadEvent::InvokedSlashCommandChanged { command_id } => { + let command = text_thread.invoked_slash_command(command_id).unwrap(); context_ranges .command_outputs .insert(*command_id, command.range.clone()); } - ContextEvent::ParsedSlashCommandsUpdated { removed, updated } => { + TextThreadEvent::ParsedSlashCommandsUpdated { removed, updated } => { for range in removed { context_ranges.parsed_commands.remove(range); } @@ -439,7 +438,7 @@ async fn test_slash_commands(cx: &mut TestAppContext) { .insert(command.source_range.clone()); } } - ContextEvent::SlashCommandOutputSectionAdded { section } => { + TextThreadEvent::SlashCommandOutputSectionAdded { section } => { context_ranges.output_sections.insert(section.range.clone()); } _ => {} @@ -449,7 +448,7 @@ async fn test_slash_commands(cx: &mut TestAppContext) { .detach(); }); - let buffer = context.read_with(cx, |context, _| context.buffer.clone()); + let buffer = text_thread.read_with(cx, |text_thread, _| text_thread.buffer().clone()); // Insert a slash command buffer.update(cx, |buffer, cx| { @@ -508,9 +507,9 @@ async fn test_slash_commands(cx: &mut TestAppContext) { ); let (command_output_tx, command_output_rx) = mpsc::unbounded(); - context.update(cx, |context, cx| { - let command_source_range = context.parsed_slash_commands[0].source_range.clone(); - context.insert_command_output( + text_thread.update(cx, |text_thread, cx| { + let command_source_range = text_thread.parsed_slash_commands[0].source_range.clone(); + text_thread.insert_command_output( command_source_range, "file", Task::ready(Ok(command_output_rx.boxed())), @@ -670,25 +669,24 @@ async fn test_serialization(cx: &mut TestAppContext) { let registry = Arc::new(LanguageRegistry::test(cx.executor())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, ) }); - let buffer = context.read_with(cx, |context, _| context.buffer.clone()); - let message_0 = context.read_with(cx, |context, _| context.message_anchors[0].id); - let message_1 = context.update(cx, |context, cx| { - context + let buffer = text_thread.read_with(cx, |text_thread, _| text_thread.buffer().clone()); + let message_0 = text_thread.read_with(cx, |text_thread, _| text_thread.message_anchors[0].id); + let message_1 = text_thread.update(cx, |text_thread, cx| { + text_thread .insert_message_after(message_0, Role::Assistant, MessageStatus::Done, cx) .unwrap() }); - let message_2 = context.update(cx, |context, cx| { - context + let message_2 = text_thread.update(cx, |text_thread, cx| { + text_thread .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) .unwrap() }); @@ -696,15 +694,15 @@ async fn test_serialization(cx: &mut TestAppContext) { buffer.edit([(0..0, "a"), (1..1, "b\nc")], None, cx); buffer.finalize_last_transaction(); }); - let _message_3 = context.update(cx, |context, cx| { - context + let _message_3 = text_thread.update(cx, |text_thread, cx| { + text_thread .insert_message_after(message_2.id, Role::System, MessageStatus::Done, cx) .unwrap() }); buffer.update(cx, |buffer, cx| buffer.undo(cx)); assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "a\nb\nc\n"); assert_eq!( - cx.read(|cx| messages(&context, cx)), + cx.read(|cx| messages(&text_thread, cx)), [ (message_0, Role::User, 0..2), (message_1.id, Role::Assistant, 2..6), @@ -712,21 +710,20 @@ async fn test_serialization(cx: &mut TestAppContext) { ] ); - let serialized_context = context.read_with(cx, |context, cx| context.serialize(cx)); + let serialized_context = text_thread.read_with(cx, |text_thread, cx| text_thread.serialize(cx)); let deserialized_context = cx.new(|cx| { - AssistantContext::deserialize( + TextThread::deserialize( serialized_context, Path::new("").into(), registry.clone(), prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); let deserialized_buffer = - deserialized_context.read_with(cx, |context, _| context.buffer.clone()); + deserialized_context.read_with(cx, |text_thread, _| text_thread.buffer().clone()); assert_eq!( deserialized_buffer.read_with(cx, |buffer, _| buffer.text()), "a\nb\nc\n" @@ -741,7 +738,7 @@ async fn test_serialization(cx: &mut TestAppContext) { ); } -#[gpui::test(iterations = 100)] +#[gpui::test(iterations = 25)] async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: StdRng) { cx.update(init_test); @@ -762,22 +759,21 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std let registry = Arc::new(LanguageRegistry::test(cx.background_executor.clone())); let network = Arc::new(Mutex::new(Network::new(rng.clone()))); - let mut contexts = Vec::new(); + let mut text_threads = Vec::new(); let num_peers = rng.random_range(min_peers..=max_peers); - let context_id = ContextId::new(); + let context_id = TextThreadId::new(); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); for i in 0..num_peers { let context = cx.new(|cx| { - AssistantContext::new( + TextThread::new( context_id.clone(), - i as ReplicaId, + ReplicaId::new(i as u16), language::Capability::ReadWrite, registry.clone(), prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -786,18 +782,18 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std cx.subscribe(&context, { let network = network.clone(); move |_, event, _| { - if let ContextEvent::Operation(op) = event { + if let TextThreadEvent::Operation(op) = event { network .lock() - .broadcast(i as ReplicaId, vec![op.to_proto()]); + .broadcast(ReplicaId::new(i as u16), vec![op.to_proto()]); } } }) .detach(); }); - contexts.push(context); - network.lock().add_peer(i as ReplicaId); + text_threads.push(context); + network.lock().add_peer(ReplicaId::new(i as u16)); } let mut mutation_count = operations; @@ -806,30 +802,30 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std || !network.lock().is_idle() || network.lock().contains_disconnected_peers() { - let context_index = rng.random_range(0..contexts.len()); - let context = &contexts[context_index]; + let context_index = rng.random_range(0..text_threads.len()); + let text_thread = &text_threads[context_index]; match rng.random_range(0..100) { 0..=29 if mutation_count > 0 => { log::info!("Context {}: edit buffer", context_index); - context.update(cx, |context, cx| { - context - .buffer + text_thread.update(cx, |text_thread, cx| { + text_thread + .buffer() .update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx)); }); mutation_count -= 1; } 30..=44 if mutation_count > 0 => { - context.update(cx, |context, cx| { - let range = context.buffer.read(cx).random_byte_range(0, &mut rng); + text_thread.update(cx, |text_thread, cx| { + let range = text_thread.buffer().read(cx).random_byte_range(0, &mut rng); log::info!("Context {}: split message at {:?}", context_index, range); - context.split_message(range, cx); + text_thread.split_message(range, cx); }); mutation_count -= 1; } 45..=59 if mutation_count > 0 => { - context.update(cx, |context, cx| { - if let Some(message) = context.messages(cx).choose(&mut rng) { + text_thread.update(cx, |text_thread, cx| { + if let Some(message) = text_thread.messages(cx).choose(&mut rng) { let role = *[Role::User, Role::Assistant, Role::System] .choose(&mut rng) .unwrap(); @@ -839,13 +835,13 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std message.id, role ); - context.insert_message_after(message.id, role, MessageStatus::Done, cx); + text_thread.insert_message_after(message.id, role, MessageStatus::Done, cx); } }); mutation_count -= 1; } 60..=74 if mutation_count > 0 => { - context.update(cx, |context, cx| { + text_thread.update(cx, |text_thread, cx| { let command_text = "/".to_string() + slash_commands .command_names() @@ -854,7 +850,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std .clone() .as_ref(); - let command_range = context.buffer.update(cx, |buffer, cx| { + let command_range = text_thread.buffer().update(cx, |buffer, cx| { let offset = buffer.random_byte_range(0, &mut rng).start; buffer.edit( [(offset..offset, format!("\n{}\n", command_text))], @@ -877,10 +873,9 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std let num_sections = rng.random_range(0..=3); let mut section_start = 0; for _ in 0..num_sections { - let mut section_end = rng.random_range(section_start..=output_text.len()); - while !output_text.is_char_boundary(section_end) { - section_end += 1; - } + let section_end = output_text.floor_char_boundary( + rng.random_range(section_start..=output_text.len()), + ); events.push(Ok(SlashCommandEvent::StartSection { icon: IconName::Ai, label: "section".into(), @@ -908,9 +903,15 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std events.len() ); - let command_range = context.buffer.read(cx).anchor_after(command_range.start) - ..context.buffer.read(cx).anchor_after(command_range.end); - context.insert_command_output( + let command_range = text_thread + .buffer() + .read(cx) + .anchor_after(command_range.start) + ..text_thread + .buffer() + .read(cx) + .anchor_after(command_range.end); + text_thread.insert_command_output( command_range, "/command", Task::ready(Ok(stream::iter(events).boxed())), @@ -922,8 +923,8 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std mutation_count -= 1; } 75..=84 if mutation_count > 0 => { - context.update(cx, |context, cx| { - if let Some(message) = context.messages(cx).choose(&mut rng) { + text_thread.update(cx, |text_thread, cx| { + if let Some(message) = text_thread.messages(cx).choose(&mut rng) { let new_status = match rng.random_range(0..3) { 0 => MessageStatus::Done, 1 => MessageStatus::Pending, @@ -935,7 +936,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std message.id, new_status ); - context.update_metadata(message.id, cx, |metadata| { + text_thread.update_metadata(message.id, cx, |metadata| { metadata.status = new_status; }); } @@ -943,13 +944,13 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std mutation_count -= 1; } _ => { - let replica_id = context_index as ReplicaId; + let replica_id = ReplicaId::new(context_index as u16); if network.lock().is_disconnected(replica_id) { - network.lock().reconnect_peer(replica_id, 0); + network.lock().reconnect_peer(replica_id, ReplicaId::new(0)); let (ops_to_send, ops_to_receive) = cx.read(|cx| { - let host_context = &contexts[0].read(cx); - let guest_context = context.read(cx); + let host_context = &text_threads[0].read(cx); + let guest_context = text_thread.read(cx); ( guest_context.serialize_ops(&host_context.version(cx), cx), host_context.serialize_ops(&guest_context.version(cx), cx), @@ -959,7 +960,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std let ops_to_receive = ops_to_receive .await .into_iter() - .map(ContextOperation::from_proto) + .map(TextThreadOperation::from_proto) .collect::>>() .unwrap(); log::info!( @@ -970,8 +971,10 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std ); network.lock().broadcast(replica_id, ops_to_send); - context.update(cx, |context, cx| context.apply_ops(ops_to_receive, cx)); - } else if rng.random_bool(0.1) && replica_id != 0 { + text_thread.update(cx, |text_thread, cx| { + text_thread.apply_ops(ops_to_receive, cx) + }); + } else if rng.random_bool(0.1) && replica_id != ReplicaId::new(0) { log::info!("Context {}: disconnecting", context_index); network.lock().disconnect_peer(replica_id); } else if network.lock().has_unreceived(replica_id) { @@ -979,43 +982,43 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std let ops = network.lock().receive(replica_id); let ops = ops .into_iter() - .map(ContextOperation::from_proto) + .map(TextThreadOperation::from_proto) .collect::>>() .unwrap(); - context.update(cx, |context, cx| context.apply_ops(ops, cx)); + text_thread.update(cx, |text_thread, cx| text_thread.apply_ops(ops, cx)); } } } } cx.read(|cx| { - let first_context = contexts[0].read(cx); - for context in &contexts[1..] { - let context = context.read(cx); - assert!(context.pending_ops.is_empty(), "pending ops: {:?}", context.pending_ops); + let first_context = text_threads[0].read(cx); + for text_thread in &text_threads[1..] { + let text_thread = text_thread.read(cx); + assert!(text_thread.pending_ops.is_empty(), "pending ops: {:?}", text_thread.pending_ops); assert_eq!( - context.buffer.read(cx).text(), - first_context.buffer.read(cx).text(), - "Context {} text != Context 0 text", - context.buffer.read(cx).replica_id() + text_thread.buffer().read(cx).text(), + first_context.buffer().read(cx).text(), + "Context {:?} text != Context 0 text", + text_thread.buffer().read(cx).replica_id() ); assert_eq!( - context.message_anchors, + text_thread.message_anchors, first_context.message_anchors, - "Context {} messages != Context 0 messages", - context.buffer.read(cx).replica_id() + "Context {:?} messages != Context 0 messages", + text_thread.buffer().read(cx).replica_id() ); assert_eq!( - context.messages_metadata, + text_thread.messages_metadata, first_context.messages_metadata, - "Context {} message metadata != Context 0 message metadata", - context.buffer.read(cx).replica_id() + "Context {:?} message metadata != Context 0 message metadata", + text_thread.buffer().read(cx).replica_id() ); assert_eq!( - context.slash_command_output_sections, + text_thread.slash_command_output_sections, first_context.slash_command_output_sections, - "Context {} slash command output sections != Context 0 slash command output sections", - context.buffer.read(cx).replica_id() + "Context {:?} slash command output sections != Context 0 slash command output sections", + text_thread.buffer().read(cx).replica_id() ); } }); @@ -1027,17 +1030,16 @@ fn test_mark_cache_anchors(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, ) }); - let buffer = context.read(cx).buffer.clone(); + let buffer = text_thread.read(cx).buffer().clone(); // Create a test cache configuration let cache_configuration = &Some(LanguageModelCacheConfiguration { @@ -1046,14 +1048,14 @@ fn test_mark_cache_anchors(cx: &mut App) { min_total_token: 10, }); - let message_1 = context.read(cx).message_anchors[0].clone(); + let message_1 = text_thread.read(cx).message_anchors[0].clone(); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .filter(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor)) .count(), @@ -1062,41 +1064,41 @@ fn test_mark_cache_anchors(cx: &mut App) { ); buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "aaa")], None, cx)); - let message_2 = context - .update(cx, |context, cx| { - context.insert_message_after(message_1.id, Role::User, MessageStatus::Pending, cx) + let message_2 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_1.id, Role::User, MessageStatus::Pending, cx) }) .unwrap(); buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "bbbbbbb")], None, cx)); - let message_3 = context - .update(cx, |context, cx| { - context.insert_message_after(message_2.id, Role::User, MessageStatus::Pending, cx) + let message_3 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_2.id, Role::User, MessageStatus::Pending, cx) }) .unwrap(); buffer.update(cx, |buffer, cx| buffer.edit([(12..12, "cccccc")], None, cx)); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!(buffer.read(cx).text(), "aaa\nbbbbbbb\ncccccc"); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .filter(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor)) .count(), 0, "Messages should not be marked for cache before going over the token minimum." ); - context.update(cx, |context, _| { - context.token_count = Some(20); + text_thread.update(cx, |text_thread, _| { + text_thread.token_count = Some(20); }); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, true, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, true, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .map(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor)) .collect::>(), @@ -1104,28 +1106,33 @@ fn test_mark_cache_anchors(cx: &mut App) { "Last message should not be an anchor on speculative request." ); - context - .update(cx, |context, cx| { - context.insert_message_after(message_3.id, Role::Assistant, MessageStatus::Pending, cx) + text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after( + message_3.id, + Role::Assistant, + MessageStatus::Pending, + cx, + ) }) .unwrap(); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .map(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor)) .collect::>(), vec![false, true, true, false], "Most recent message should also be cached if not a speculative request." ); - context.update(cx, |context, cx| { - context.update_cache_status_for_completion(cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.update_cache_status_for_completion(cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .map(|(_, cache)| cache .as_ref() @@ -1141,11 +1148,11 @@ fn test_mark_cache_anchors(cx: &mut App) { ); buffer.update(cx, |buffer, cx| buffer.edit([(14..14, "d")], None, cx)); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .map(|(_, cache)| cache .as_ref() @@ -1160,11 +1167,11 @@ fn test_mark_cache_anchors(cx: &mut App) { "Modifying a message should invalidate it's cache but leave previous messages." ); buffer.update(cx, |buffer, cx| buffer.edit([(2..2, "e")], None, cx)); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .map(|(_, cache)| cache .as_ref() @@ -1182,31 +1189,36 @@ fn test_mark_cache_anchors(cx: &mut App) { #[gpui::test] async fn test_summarization(cx: &mut TestAppContext) { - let (context, fake_model) = setup_context_editor_with_fake_model(cx); + let (text_thread, fake_model) = setup_context_editor_with_fake_model(cx); // Initial state should be pending - context.read_with(cx, |context, _| { - assert!(matches!(context.summary(), ContextSummary::Pending)); - assert_eq!(context.summary().or_default(), ContextSummary::DEFAULT); + text_thread.read_with(cx, |text_thread, _| { + assert!(matches!(text_thread.summary(), TextThreadSummary::Pending)); + assert_eq!( + text_thread.summary().or_default(), + TextThreadSummary::DEFAULT + ); }); - let message_1 = context.read_with(cx, |context, _cx| context.message_anchors[0].clone()); - context.update(cx, |context, cx| { + let message_1 = text_thread.read_with(cx, |text_thread, _cx| { + text_thread.message_anchors[0].clone() + }); + text_thread.update(cx, |context, cx| { context .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) .unwrap(); }); // Send a message - context.update(cx, |context, cx| { - context.assist(cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.assist(cx); }); simulate_successful_response(&fake_model, cx); // Should start generating summary when there are >= 2 messages - context.read_with(cx, |context, _| { - assert!(!context.summary().content().unwrap().done); + text_thread.read_with(cx, |text_thread, _| { + assert!(!text_thread.summary().content().unwrap().done); }); cx.run_until_parked(); @@ -1216,61 +1228,61 @@ async fn test_summarization(cx: &mut TestAppContext) { cx.run_until_parked(); // Summary should be set - context.read_with(cx, |context, _| { - assert_eq!(context.summary().or_default(), "Brief Introduction"); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(text_thread.summary().or_default(), "Brief Introduction"); }); // We should be able to manually set a summary - context.update(cx, |context, cx| { - context.set_custom_summary("Brief Intro".into(), cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.set_custom_summary("Brief Intro".into(), cx); }); - context.read_with(cx, |context, _| { - assert_eq!(context.summary().or_default(), "Brief Intro"); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(text_thread.summary().or_default(), "Brief Intro"); }); } #[gpui::test] async fn test_thread_summary_error_set_manually(cx: &mut TestAppContext) { - let (context, fake_model) = setup_context_editor_with_fake_model(cx); + let (text_thread, fake_model) = setup_context_editor_with_fake_model(cx); - test_summarize_error(&fake_model, &context, cx); + test_summarize_error(&fake_model, &text_thread, cx); // Now we should be able to set a summary - context.update(cx, |context, cx| { - context.set_custom_summary("Brief Intro".into(), cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.set_custom_summary("Brief Intro".into(), cx); }); - context.read_with(cx, |context, _| { - assert_eq!(context.summary().or_default(), "Brief Intro"); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(text_thread.summary().or_default(), "Brief Intro"); }); } #[gpui::test] async fn test_thread_summary_error_retry(cx: &mut TestAppContext) { - let (context, fake_model) = setup_context_editor_with_fake_model(cx); + let (text_thread, fake_model) = setup_context_editor_with_fake_model(cx); - test_summarize_error(&fake_model, &context, cx); + test_summarize_error(&fake_model, &text_thread, cx); // Sending another message should not trigger another summarize request - context.update(cx, |context, cx| { - context.assist(cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.assist(cx); }); simulate_successful_response(&fake_model, cx); - context.read_with(cx, |context, _| { + text_thread.read_with(cx, |text_thread, _| { // State is still Error, not Generating - assert!(matches!(context.summary(), ContextSummary::Error)); + assert!(matches!(text_thread.summary(), TextThreadSummary::Error)); }); // But the summarize request can be invoked manually - context.update(cx, |context, cx| { - context.summarize(true, cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.summarize(true, cx); }); - context.read_with(cx, |context, _| { - assert!(!context.summary().content().unwrap().done); + text_thread.read_with(cx, |text_thread, _| { + assert!(!text_thread.summary().content().unwrap().done); }); cx.run_until_parked(); @@ -1278,32 +1290,34 @@ async fn test_thread_summary_error_retry(cx: &mut TestAppContext) { fake_model.end_last_completion_stream(); cx.run_until_parked(); - context.read_with(cx, |context, _| { - assert_eq!(context.summary().or_default(), "A successful summary"); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(text_thread.summary().or_default(), "A successful summary"); }); } fn test_summarize_error( model: &Arc, - context: &Entity, + text_thread: &Entity, cx: &mut TestAppContext, ) { - let message_1 = context.read_with(cx, |context, _cx| context.message_anchors[0].clone()); - context.update(cx, |context, cx| { - context + let message_1 = text_thread.read_with(cx, |text_thread, _cx| { + text_thread.message_anchors[0].clone() + }); + text_thread.update(cx, |text_thread, cx| { + text_thread .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) .unwrap(); }); // Send a message - context.update(cx, |context, cx| { - context.assist(cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.assist(cx); }); simulate_successful_response(model, cx); - context.read_with(cx, |context, _| { - assert!(!context.summary().content().unwrap().done); + text_thread.read_with(cx, |text_thread, _| { + assert!(!text_thread.summary().content().unwrap().done); }); // Simulate summary request ending @@ -1312,15 +1326,18 @@ fn test_summarize_error( cx.run_until_parked(); // State is set to Error and default message - context.read_with(cx, |context, _| { - assert_eq!(*context.summary(), ContextSummary::Error); - assert_eq!(context.summary().or_default(), ContextSummary::DEFAULT); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(*text_thread.summary(), TextThreadSummary::Error); + assert_eq!( + text_thread.summary().or_default(), + TextThreadSummary::DEFAULT + ); }); } fn setup_context_editor_with_fake_model( cx: &mut TestAppContext, -) -> (Entity, Arc) { +) -> (Entity, Arc) { let registry = Arc::new(LanguageRegistry::test(cx.executor())); let fake_provider = Arc::new(FakeLanguageModelProvider::default()); @@ -1340,10 +1357,9 @@ fn setup_context_editor_with_fake_model( let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); let context = cx.new(|cx| { - AssistantContext::local( + TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -1360,7 +1376,7 @@ fn simulate_successful_response(fake_model: &FakeLanguageModel, cx: &mut TestApp cx.run_until_parked(); } -fn messages(context: &Entity, cx: &App) -> Vec<(MessageId, Role, Range)> { +fn messages(context: &Entity, cx: &App) -> Vec<(MessageId, Role, Range)> { context .read(cx) .messages(cx) @@ -1369,7 +1385,7 @@ fn messages(context: &Entity, cx: &App) -> Vec<(MessageId, Rol } fn messages_cache( - context: &Entity, + context: &Entity, cx: &App, ) -> Vec<(MessageId, Option)> { context @@ -1384,9 +1400,6 @@ fn init_test(cx: &mut App) { prompt_store::init(cx); LanguageModelRegistry::test(cx); cx.set_global(settings_store); - language::init(cx); - agent_settings::init(cx); - Project::init_settings(cx); } #[derive(Clone)] diff --git a/crates/assistant_context/src/assistant_context.rs b/crates/assistant_text_thread/src/text_thread.rs similarity index 89% rename from crates/assistant_context/src/assistant_context.rs rename to crates/assistant_text_thread/src/text_thread.rs index 6c06cc2c8e..5ec72eb081 100644 --- a/crates/assistant_context/src/assistant_context.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -1,7 +1,3 @@ -#[cfg(test)] -mod assistant_context_tests; -mod context_store; - use agent_settings::{AgentSettings, SUMMARIZE_THREAD_PROMPT}; use anyhow::{Context as _, Result, bail}; use assistant_slash_command::{ @@ -9,25 +5,28 @@ use assistant_slash_command::{ SlashCommandResult, SlashCommandWorkingSet, }; use assistant_slash_commands::FileCommandMetadata; -use client::{self, Client, ModelRequestUsage, RequestUsage, proto, telemetry::Telemetry}; +use client::{self, ModelRequestUsage, RequestUsage, proto}; use clock::ReplicaId; -use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; +use cloud_llm_client::{CompletionIntent, UsageLimit}; use collections::{HashMap, HashSet}; use fs::{Fs, RenameOptions}; + use futures::{FutureExt, StreamExt, future::Shared}; use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, RenderImage, SharedString, Subscription, - Task, + Task, WeakEntity, }; +use itertools::Itertools as _; use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset}; use language_model::{ - LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent, - LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + AnthropicCompletionType, AnthropicEventData, AnthropicEventType, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionEvent, LanguageModelImage, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolUseId, MessageContent, PaymentRequiredError, Role, StopReason, - report_assistant_event, + report_anthropic_event, }; use open_ai::Model as OpenAiModel; -use paths::contexts_dir; +use paths::text_threads_dir; use project::Project; use prompt_store::PromptBuilder; use serde::{Deserialize, Serialize}; @@ -42,22 +41,16 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use text::{BufferSnapshot, ToPoint}; use ui::IconName; use util::{ResultExt, TryFutureExt, post_inc}; use uuid::Uuid; -pub use crate::context_store::*; - -pub fn init(client: Arc, _: &mut App) { - context_store::init(&client.into()); -} - #[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -pub struct ContextId(String); +pub struct TextThreadId(String); -impl ContextId { +impl TextThreadId { pub fn new() -> Self { Self(Uuid::new_v4().to_string()) } @@ -130,7 +123,7 @@ impl MessageStatus { } #[derive(Clone, Debug)] -pub enum ContextOperation { +pub enum TextThreadOperation { InsertMessage { anchor: MessageAnchor, metadata: MessageMetadata, @@ -142,7 +135,7 @@ pub enum ContextOperation { version: clock::Global, }, UpdateSummary { - summary: ContextSummaryContent, + summary: TextThreadSummaryContent, version: clock::Global, }, SlashCommandStarted { @@ -170,7 +163,7 @@ pub enum ContextOperation { BufferOperation(language::Operation), } -impl ContextOperation { +impl TextThreadOperation { pub fn from_proto(op: proto::ContextOperation) -> Result { match op.variant.context("invalid variant")? { proto::context_operation::Variant::InsertMessage(insert) => { @@ -212,7 +205,7 @@ impl ContextOperation { version: language::proto::deserialize_version(&update.version), }), proto::context_operation::Variant::UpdateSummary(update) => Ok(Self::UpdateSummary { - summary: ContextSummaryContent { + summary: TextThreadSummaryContent { text: update.summary, done: update.done, timestamp: language::proto::deserialize_timestamp( @@ -453,7 +446,7 @@ impl ContextOperation { } #[derive(Debug, Clone)] -pub enum ContextEvent { +pub enum TextThreadEvent { ShowAssistError(SharedString), ShowPaymentRequiredError, MessagesEdited, @@ -476,24 +469,24 @@ pub enum ContextEvent { SlashCommandOutputSectionAdded { section: SlashCommandOutputSection, }, - Operation(ContextOperation), + Operation(TextThreadOperation), } #[derive(Clone, Debug, Eq, PartialEq)] -pub enum ContextSummary { +pub enum TextThreadSummary { Pending, - Content(ContextSummaryContent), + Content(TextThreadSummaryContent), Error, } -#[derive(Default, Clone, Debug, Eq, PartialEq)] -pub struct ContextSummaryContent { +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TextThreadSummaryContent { pub text: String, pub done: bool, pub timestamp: clock::Lamport, } -impl ContextSummary { +impl TextThreadSummary { pub const DEFAULT: &str = "New Text Thread"; pub fn or_default(&self) -> SharedString { @@ -505,44 +498,48 @@ impl ContextSummary { .map_or_else(|| message.into(), |content| content.text.clone().into()) } - pub fn content(&self) -> Option<&ContextSummaryContent> { + pub fn content(&self) -> Option<&TextThreadSummaryContent> { match self { - ContextSummary::Content(content) => Some(content), - ContextSummary::Pending | ContextSummary::Error => None, + TextThreadSummary::Content(content) => Some(content), + TextThreadSummary::Pending | TextThreadSummary::Error => None, } } - fn content_as_mut(&mut self) -> Option<&mut ContextSummaryContent> { + fn content_as_mut(&mut self) -> Option<&mut TextThreadSummaryContent> { match self { - ContextSummary::Content(content) => Some(content), - ContextSummary::Pending | ContextSummary::Error => None, + TextThreadSummary::Content(content) => Some(content), + TextThreadSummary::Pending | TextThreadSummary::Error => None, } } - fn content_or_set_empty(&mut self) -> &mut ContextSummaryContent { + fn content_or_set_empty(&mut self) -> &mut TextThreadSummaryContent { match self { - ContextSummary::Content(content) => content, - ContextSummary::Pending | ContextSummary::Error => { - let content = ContextSummaryContent::default(); - *self = ContextSummary::Content(content); + TextThreadSummary::Content(content) => content, + TextThreadSummary::Pending | TextThreadSummary::Error => { + let content = TextThreadSummaryContent { + text: "".to_string(), + done: false, + timestamp: clock::Lamport::MIN, + }; + *self = TextThreadSummary::Content(content); self.content_as_mut().unwrap() } } } pub fn is_pending(&self) -> bool { - matches!(self, ContextSummary::Pending) + matches!(self, TextThreadSummary::Pending) } fn timestamp(&self) -> Option { match self { - ContextSummary::Content(content) => Some(content.timestamp), - ContextSummary::Pending | ContextSummary::Error => None, + TextThreadSummary::Content(content) => Some(content.timestamp), + TextThreadSummary::Pending | TextThreadSummary::Error => None, } } } -impl PartialOrd for ContextSummary { +impl PartialOrd for TextThreadSummary { fn partial_cmp(&self, other: &Self) -> Option { self.timestamp().partial_cmp(&other.timestamp()) } @@ -664,35 +661,34 @@ struct PendingCompletion { #[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] pub struct InvokedSlashCommandId(clock::Lamport); -pub struct AssistantContext { - id: ContextId, +pub struct TextThread { + id: TextThreadId, timestamp: clock::Lamport, version: clock::Global, - pending_ops: Vec, - operations: Vec, + pub(crate) pending_ops: Vec, + operations: Vec, buffer: Entity, - parsed_slash_commands: Vec, + pub(crate) parsed_slash_commands: Vec, invoked_slash_commands: HashMap, - edits_since_last_parse: language::Subscription, + edits_since_last_parse: language::Subscription, slash_commands: Arc, - slash_command_output_sections: Vec>, + pub(crate) slash_command_output_sections: Vec>, thought_process_output_sections: Vec>, - message_anchors: Vec, + pub(crate) message_anchors: Vec, contents: Vec, - messages_metadata: HashMap, - summary: ContextSummary, + pub(crate) messages_metadata: HashMap, + summary: TextThreadSummary, summary_task: Task>, completion_count: usize, pending_completions: Vec, - token_count: Option, + pub(crate) token_count: Option, pending_token_count: Task>, pending_save: Task>, pending_cache_warming_task: Task>, path: Option>, _subscriptions: Vec, - telemetry: Option>, language_registry: Arc, - project: Option>, + project: Option>, prompt_builder: Arc, completion_mode: agent_settings::CompletionMode, } @@ -707,26 +703,24 @@ impl ContextAnnotation for ParsedSlashCommand { } } -impl EventEmitter for AssistantContext {} +impl EventEmitter for TextThread {} -impl AssistantContext { +impl TextThread { pub fn local( language_registry: Arc, - project: Option>, - telemetry: Option>, + project: Option>, prompt_builder: Arc, slash_commands: Arc, cx: &mut Context, ) -> Self { Self::new( - ContextId::new(), + TextThreadId::new(), ReplicaId::default(), language::Capability::ReadWrite, language_registry, prompt_builder, slash_commands, project, - telemetry, cx, ) } @@ -740,14 +734,13 @@ impl AssistantContext { } pub fn new( - id: ContextId, + id: TextThreadId, replica_id: ReplicaId, capability: language::Capability, language_registry: Arc, prompt_builder: Arc, slash_commands: Arc, - project: Option>, - telemetry: Option>, + project: Option>, cx: &mut Context, ) -> Self { let buffer = cx.new(|_cx| { @@ -776,7 +769,7 @@ impl AssistantContext { slash_command_output_sections: Vec::new(), thought_process_output_sections: Vec::new(), edits_since_last_parse: edits_since_last_slash_command_parse, - summary: ContextSummary::Pending, + summary: TextThreadSummary::Pending, summary_task: Task::ready(None), completion_count: Default::default(), pending_completions: Default::default(), @@ -788,7 +781,6 @@ impl AssistantContext { completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, path: None, buffer, - telemetry, project, language_registry, slash_commands, @@ -796,12 +788,12 @@ impl AssistantContext { }; let first_message_id = MessageId(clock::Lamport { - replica_id: 0, + replica_id: ReplicaId::LOCAL, value: 0, }); let message = MessageAnchor { id: first_message_id, - start: language::Anchor::MIN, + start: language::Anchor::min_for_buffer(this.buffer.read(cx).remote_id()), }; this.messages_metadata.insert( first_message_id, @@ -819,12 +811,12 @@ impl AssistantContext { this } - pub(crate) fn serialize(&self, cx: &App) -> SavedContext { + pub(crate) fn serialize(&self, cx: &App) -> SavedTextThread { let buffer = self.buffer.read(cx); - SavedContext { + SavedTextThread { id: Some(self.id.clone()), zed: "context".into(), - version: SavedContext::VERSION.into(), + version: SavedTextThread::VERSION.into(), text: buffer.text(), messages: self .messages(cx) @@ -872,16 +864,15 @@ impl AssistantContext { } pub fn deserialize( - saved_context: SavedContext, + saved_context: SavedTextThread, path: Arc, language_registry: Arc, prompt_builder: Arc, slash_commands: Arc, - project: Option>, - telemetry: Option>, + project: Option>, cx: &mut Context, ) -> Self { - let id = saved_context.id.clone().unwrap_or_else(ContextId::new); + let id = saved_context.id.clone().unwrap_or_else(TextThreadId::new); let mut this = Self::new( id, ReplicaId::default(), @@ -890,7 +881,6 @@ impl AssistantContext { prompt_builder, slash_commands, project, - telemetry, cx, ); this.path = Some(path); @@ -902,7 +892,7 @@ impl AssistantContext { this } - pub fn id(&self) -> &ContextId { + pub fn id(&self) -> &TextThreadId { &self.id } @@ -910,9 +900,9 @@ impl AssistantContext { self.timestamp.replica_id } - pub fn version(&self, cx: &App) -> ContextVersion { - ContextVersion { - context: self.version.clone(), + pub fn version(&self, cx: &App) -> TextThreadVersion { + TextThreadVersion { + text_thread: self.version.clone(), buffer: self.buffer.read(cx).version(), } } @@ -934,7 +924,7 @@ impl AssistantContext { pub fn serialize_ops( &self, - since: &ContextVersion, + since: &TextThreadVersion, cx: &App, ) -> Task> { let buffer_ops = self @@ -945,7 +935,7 @@ impl AssistantContext { let mut context_ops = self .operations .iter() - .filter(|op| !since.context.observed(op.timestamp())) + .filter(|op| !since.text_thread.observed(op.timestamp())) .cloned() .collect::>(); context_ops.extend(self.pending_ops.iter().cloned()); @@ -969,13 +959,13 @@ impl AssistantContext { pub fn apply_ops( &mut self, - ops: impl IntoIterator, + ops: impl IntoIterator, cx: &mut Context, ) { let mut buffer_ops = Vec::new(); for op in ops { match op { - ContextOperation::BufferOperation(buffer_op) => buffer_ops.push(buffer_op), + TextThreadOperation::BufferOperation(buffer_op) => buffer_ops.push(buffer_op), op @ _ => self.pending_ops.push(op), } } @@ -984,7 +974,7 @@ impl AssistantContext { self.flush_ops(cx); } - fn flush_ops(&mut self, cx: &mut Context) { + fn flush_ops(&mut self, cx: &mut Context) { let mut changed_messages = HashSet::default(); let mut summary_generated = false; @@ -997,7 +987,7 @@ impl AssistantContext { let timestamp = op.timestamp(); match op.clone() { - ContextOperation::InsertMessage { + TextThreadOperation::InsertMessage { anchor, metadata, .. } => { if self.messages_metadata.contains_key(&anchor.id) { @@ -1007,7 +997,7 @@ impl AssistantContext { self.insert_message(anchor, metadata, cx); } } - ContextOperation::UpdateMessage { + TextThreadOperation::UpdateMessage { message_id, metadata: new_metadata, .. @@ -1018,7 +1008,7 @@ impl AssistantContext { changed_messages.insert(message_id); } } - ContextOperation::UpdateSummary { + TextThreadOperation::UpdateSummary { summary: new_summary, .. } => { @@ -1027,11 +1017,11 @@ impl AssistantContext { .timestamp() .is_none_or(|current_timestamp| new_summary.timestamp > current_timestamp) { - self.summary = ContextSummary::Content(new_summary); + self.summary = TextThreadSummary::Content(new_summary); summary_generated = true; } } - ContextOperation::SlashCommandStarted { + TextThreadOperation::SlashCommandStarted { id, output_range, name, @@ -1048,9 +1038,9 @@ impl AssistantContext { timestamp: id.0, }, ); - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id: id }); } - ContextOperation::SlashCommandOutputSectionAdded { section, .. } => { + TextThreadOperation::SlashCommandOutputSectionAdded { section, .. } => { let buffer = self.buffer.read(cx); if let Err(ix) = self .slash_command_output_sections @@ -1058,10 +1048,10 @@ impl AssistantContext { { self.slash_command_output_sections .insert(ix, section.clone()); - cx.emit(ContextEvent::SlashCommandOutputSectionAdded { section }); + cx.emit(TextThreadEvent::SlashCommandOutputSectionAdded { section }); } } - ContextOperation::ThoughtProcessOutputSectionAdded { section, .. } => { + TextThreadOperation::ThoughtProcessOutputSectionAdded { section, .. } => { let buffer = self.buffer.read(cx); if let Err(ix) = self .thought_process_output_sections @@ -1071,7 +1061,7 @@ impl AssistantContext { .insert(ix, section.clone()); } } - ContextOperation::SlashCommandFinished { + TextThreadOperation::SlashCommandFinished { id, error_message, timestamp, @@ -1090,10 +1080,10 @@ impl AssistantContext { slash_command.status = InvokedSlashCommandStatus::Finished; } } - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id: id }); } } - ContextOperation::BufferOperation(_) => unreachable!(), + TextThreadOperation::BufferOperation(_) => unreachable!(), } self.version.observe(timestamp); @@ -1103,43 +1093,43 @@ impl AssistantContext { if !changed_messages.is_empty() { self.message_roles_updated(changed_messages, cx); - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); cx.notify(); } if summary_generated { - cx.emit(ContextEvent::SummaryChanged); - cx.emit(ContextEvent::SummaryGenerated); + cx.emit(TextThreadEvent::SummaryChanged); + cx.emit(TextThreadEvent::SummaryGenerated); cx.notify(); } } - fn can_apply_op(&self, op: &ContextOperation, cx: &App) -> bool { + fn can_apply_op(&self, op: &TextThreadOperation, cx: &App) -> bool { if !self.version.observed_all(op.version()) { return false; } match op { - ContextOperation::InsertMessage { anchor, .. } => self + TextThreadOperation::InsertMessage { anchor, .. } => self .buffer .read(cx) .version .observed(anchor.start.timestamp), - ContextOperation::UpdateMessage { message_id, .. } => { + TextThreadOperation::UpdateMessage { message_id, .. } => { self.messages_metadata.contains_key(message_id) } - ContextOperation::UpdateSummary { .. } => true, - ContextOperation::SlashCommandStarted { output_range, .. } => { + TextThreadOperation::UpdateSummary { .. } => true, + TextThreadOperation::SlashCommandStarted { output_range, .. } => { self.has_received_operations_for_anchor_range(output_range.clone(), cx) } - ContextOperation::SlashCommandOutputSectionAdded { section, .. } => { + TextThreadOperation::SlashCommandOutputSectionAdded { section, .. } => { self.has_received_operations_for_anchor_range(section.range.clone(), cx) } - ContextOperation::ThoughtProcessOutputSectionAdded { section, .. } => { + TextThreadOperation::ThoughtProcessOutputSectionAdded { section, .. } => { self.has_received_operations_for_anchor_range(section.range.clone(), cx) } - ContextOperation::SlashCommandFinished { .. } => true, - ContextOperation::BufferOperation(_) => { + TextThreadOperation::SlashCommandFinished { .. } => true, + TextThreadOperation::BufferOperation(_) => { panic!("buffer operations should always be applied") } } @@ -1151,18 +1141,16 @@ impl AssistantContext { cx: &App, ) -> bool { let version = &self.buffer.read(cx).version; - let observed_start = range.start == language::Anchor::MIN - || range.start == language::Anchor::MAX - || version.observed(range.start.timestamp); - let observed_end = range.end == language::Anchor::MIN - || range.end == language::Anchor::MAX - || version.observed(range.end.timestamp); + let observed_start = + range.start.is_min() || range.start.is_max() || version.observed(range.start.timestamp); + let observed_end = + range.end.is_min() || range.end.is_max() || version.observed(range.end.timestamp); observed_start && observed_end } - fn push_op(&mut self, op: ContextOperation, cx: &mut Context) { + fn push_op(&mut self, op: TextThreadOperation, cx: &mut Context) { self.operations.push(op.clone()); - cx.emit(ContextEvent::Operation(op)); + cx.emit(TextThreadEvent::Operation(op)); } pub fn buffer(&self) -> &Entity { @@ -1173,10 +1161,6 @@ impl AssistantContext { self.language_registry.clone() } - pub fn project(&self) -> Option> { - self.project.clone() - } - pub fn prompt_builder(&self) -> Arc { self.prompt_builder.clone() } @@ -1185,7 +1169,7 @@ impl AssistantContext { self.path.as_ref() } - pub fn summary(&self) -> &ContextSummary { + pub fn summary(&self) -> &TextThreadSummary { &self.summary } @@ -1246,13 +1230,13 @@ impl AssistantContext { language::BufferEvent::Operation { operation, is_local: true, - } => cx.emit(ContextEvent::Operation(ContextOperation::BufferOperation( - operation.clone(), - ))), + } => cx.emit(TextThreadEvent::Operation( + TextThreadOperation::BufferOperation(operation.clone()), + )), language::BufferEvent::Edited => { self.count_remaining_tokens(cx); self.reparse(cx); - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); } _ => {} } @@ -1422,6 +1406,7 @@ impl AssistantContext { role: Role::User, content: vec!["Respond only with OK, nothing else.".into()], cache: false, + reasoning_details: None, }); req }; @@ -1518,7 +1503,7 @@ impl AssistantContext { if !updated_parsed_slash_commands.is_empty() || !removed_parsed_slash_command_ranges.is_empty() { - cx.emit(ContextEvent::ParsedSlashCommandsUpdated { + cx.emit(TextThreadEvent::ParsedSlashCommandsUpdated { removed: removed_parsed_slash_command_ranges, updated: updated_parsed_slash_commands, }); @@ -1592,7 +1577,7 @@ impl AssistantContext { && (!command.range.start.is_valid(buffer) || !command.range.end.is_valid(buffer)) { command.status = InvokedSlashCommandStatus::Finished; - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id }); invalidated_command_ids.push(command_id); } } @@ -1601,7 +1586,7 @@ impl AssistantContext { let version = self.version.clone(); let timestamp = self.next_timestamp(); self.push_op( - ContextOperation::SlashCommandFinished { + TextThreadOperation::SlashCommandFinished { id: command_id, timestamp, error_message: None, @@ -1857,14 +1842,17 @@ impl AssistantContext { } if ensure_trailing_newline - && buffer.contains_str_at(command_range_end, "\n") + && buffer + .chars_at(command_range_end) + .next() + .is_some_and(|c| c == '\n') { - let newline_offset = insert_position.saturating_sub(1); - if buffer.contains_str_at(newline_offset, "\n") + if let Some((prev_char, '\n')) = + buffer.reversed_chars_at(insert_position).next_tuple() && last_section_range.is_none_or(|last_section_range| { !last_section_range .to_offset(buffer) - .contains(&newline_offset) + .contains(&(insert_position - prev_char.len_utf8())) }) { deletions.push((command_range_end..command_range_end + 1, "")); @@ -1906,9 +1894,9 @@ impl AssistantContext { } } - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id }); this.push_op( - ContextOperation::SlashCommandFinished { + TextThreadOperation::SlashCommandFinished { id: command_id, timestamp, error_message, @@ -1931,9 +1919,9 @@ impl AssistantContext { timestamp: command_id.0, }, ); - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id }); self.push_op( - ContextOperation::SlashCommandStarted { + TextThreadOperation::SlashCommandStarted { id: command_id, output_range: command_range, name: name.to_string(), @@ -1957,13 +1945,13 @@ impl AssistantContext { }; self.slash_command_output_sections .insert(insertion_ix, section.clone()); - cx.emit(ContextEvent::SlashCommandOutputSectionAdded { + cx.emit(TextThreadEvent::SlashCommandOutputSectionAdded { section: section.clone(), }); let version = self.version.clone(); let timestamp = self.next_timestamp(); self.push_op( - ContextOperation::SlashCommandOutputSectionAdded { + TextThreadOperation::SlashCommandOutputSectionAdded { timestamp, section, version, @@ -1992,7 +1980,7 @@ impl AssistantContext { let version = self.version.clone(); let timestamp = self.next_timestamp(); self.push_op( - ContextOperation::ThoughtProcessOutputSectionAdded { + TextThreadOperation::ThoughtProcessOutputSectionAdded { timestamp, section, version, @@ -2079,16 +2067,22 @@ impl AssistantContext { }); match event { - LanguageModelCompletionEvent::StatusUpdate(status_update) => { - if let CompletionRequestStatus::UsageUpdated { amount, limit } = status_update { - this.update_model_request_usage( - amount as u32, - limit, - cx, - ); - } + LanguageModelCompletionEvent::Started | + LanguageModelCompletionEvent::Queued {..} | + LanguageModelCompletionEvent::ToolUseLimitReached { .. } => {} + LanguageModelCompletionEvent::UsageUpdated { amount, limit } => { + this.update_model_request_usage( + amount as u32, + limit, + cx, + ); } LanguageModelCompletionEvent::StartMessage { .. } => {} + LanguageModelCompletionEvent::ReasoningDetails(_) => { + // ReasoningDetails are metadata (signatures, encrypted data, format info) + // used for request/response validation, not UI content. + // The displayable thinking text is already handled by the Thinking event. + } LanguageModelCompletionEvent::Stop(reason) => { stop_reason = reason; } @@ -2111,7 +2105,7 @@ impl AssistantContext { let end = buffer .anchor_before(message_old_end_offset + chunk_len); context_event = Some( - ContextEvent::StartedThoughtProcess(start..end), + TextThreadEvent::StartedThoughtProcess(start..end), ); } else { // This ensures that all the thinking chunks are inserted inside the thinking tag @@ -2129,7 +2123,7 @@ impl AssistantContext { if let Some(start) = thought_process_stack.pop() { let end = buffer.anchor_before(message_old_end_offset); context_event = - Some(ContextEvent::EndedThoughtProcess(end)); + Some(TextThreadEvent::EndedThoughtProcess(end)); thought_process_output_section = Some(ThoughtProcessOutputSection { range: start..end, @@ -2159,7 +2153,7 @@ impl AssistantContext { cx.emit(context_event); } - cx.emit(ContextEvent::StreamedCompletion); + cx.emit(TextThreadEvent::StreamedCompletion); Some(()) })?; @@ -2180,7 +2174,7 @@ impl AssistantContext { this.update(cx, |this, cx| { let error_message = if let Some(error) = result.as_ref().err() { if error.is::() { - cx.emit(ContextEvent::ShowPaymentRequiredError); + cx.emit(TextThreadEvent::ShowPaymentRequiredError); this.update_metadata(assistant_message_id, cx, |metadata| { metadata.status = MessageStatus::Canceled; }); @@ -2191,7 +2185,7 @@ impl AssistantContext { .map(|err| err.to_string()) .collect::>() .join("\n"); - cx.emit(ContextEvent::ShowAssistError(SharedString::from( + cx.emit(TextThreadEvent::ShowAssistError(SharedString::from( error_message.clone(), ))); this.update_metadata(assistant_message_id, cx, |metadata| { @@ -2212,24 +2206,26 @@ impl AssistantContext { .read(cx) .language() .map(|language| language.name()); - report_assistant_event( - AssistantEventData { - conversation_id: Some(this.id.0.clone()), - kind: AssistantKind::Panel, - phase: AssistantPhase::Response, - message_id: None, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - this.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - cx.background_executor(), + + telemetry::event!( + "Assistant Responded", + conversation_id = this.id.0.clone(), + kind = "panel", + phase = "response", + model = model.telemetry_id(), + model_provider = model.provider_id().to_string(), + response_latency, + error_message, + language_name = language_name.as_ref().map(|name| name.to_proto()), ); + report_anthropic_event(&model, AnthropicEventData { + completion_type: AnthropicCompletionType::Panel, + event: AnthropicEventType::Response, + language_name: language_name.map(|name| name.to_proto()), + message_id: None, + }, cx); + if let Ok(stop_reason) = result { match stop_reason { StopReason::ToolUse => {} @@ -2312,6 +2308,7 @@ impl AssistantContext { role: message.role, content: Vec::new(), cache: message.cache.as_ref().is_some_and(|cache| cache.is_anchor), + reasoning_details: None, }; while let Some(content) = contents.peek() { @@ -2408,13 +2405,13 @@ impl AssistantContext { if let Some(metadata) = self.messages_metadata.get_mut(&id) { f(metadata); metadata.timestamp = timestamp; - let operation = ContextOperation::UpdateMessage { + let operation = TextThreadOperation::UpdateMessage { message_id: id, metadata: metadata.clone(), version, }; self.push_op(operation, cx); - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); cx.notify(); } } @@ -2478,7 +2475,7 @@ impl AssistantContext { }; self.insert_message(anchor.clone(), metadata.clone(), cx); self.push_op( - ContextOperation::InsertMessage { + TextThreadOperation::InsertMessage { anchor: anchor.clone(), metadata, version, @@ -2501,7 +2498,7 @@ impl AssistantContext { Err(ix) => ix, }; self.contents.insert(insertion_ix, content); - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); } pub fn contents<'a>(&'a self, cx: &'a App) -> impl 'a + Iterator { @@ -2576,7 +2573,7 @@ impl AssistantContext { }; self.insert_message(suffix.clone(), suffix_metadata.clone(), cx); self.push_op( - ContextOperation::InsertMessage { + TextThreadOperation::InsertMessage { anchor: suffix.clone(), metadata: suffix_metadata, version, @@ -2626,7 +2623,7 @@ impl AssistantContext { }; self.insert_message(selection.clone(), selection_metadata.clone(), cx); self.push_op( - ContextOperation::InsertMessage { + TextThreadOperation::InsertMessage { anchor: selection.clone(), metadata: selection_metadata, version, @@ -2638,7 +2635,7 @@ impl AssistantContext { }; if !edited_buffer { - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); } new_messages } else { @@ -2652,7 +2649,7 @@ impl AssistantContext { new_metadata: MessageMetadata, cx: &mut Context, ) { - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); self.messages_metadata.insert(new_anchor.id, new_metadata); @@ -2683,20 +2680,21 @@ impl AssistantContext { role: Role::User, content: vec![SUMMARIZE_THREAD_PROMPT.into()], cache: false, + reasoning_details: None, }); // If there is no summary, it is set with `done: false` so that "Loading Summary…" can // be displayed. match self.summary { - ContextSummary::Pending | ContextSummary::Error => { - self.summary = ContextSummary::Content(ContextSummaryContent { + TextThreadSummary::Pending | TextThreadSummary::Error => { + self.summary = TextThreadSummary::Content(TextThreadSummaryContent { text: "".to_string(), done: false, - timestamp: clock::Lamport::default(), + timestamp: clock::Lamport::MIN, }); replace_old = true; } - ContextSummary::Content(_) => {} + TextThreadSummary::Content(_) => {} } self.summary_task = cx.spawn(async move |this, cx| { @@ -2718,13 +2716,13 @@ impl AssistantContext { } summary.text.extend(lines.next()); summary.timestamp = timestamp; - let operation = ContextOperation::UpdateSummary { + let operation = TextThreadOperation::UpdateSummary { summary: summary.clone(), version, }; this.push_op(operation, cx); - cx.emit(ContextEvent::SummaryChanged); - cx.emit(ContextEvent::SummaryGenerated); + cx.emit(TextThreadEvent::SummaryChanged); + cx.emit(TextThreadEvent::SummaryGenerated); })?; // Stop if the LLM generated multiple lines. @@ -2748,13 +2746,13 @@ impl AssistantContext { if let Some(summary) = this.summary.content_as_mut() { summary.done = true; summary.timestamp = timestamp; - let operation = ContextOperation::UpdateSummary { + let operation = TextThreadOperation::UpdateSummary { summary: summary.clone(), version, }; this.push_op(operation, cx); - cx.emit(ContextEvent::SummaryChanged); - cx.emit(ContextEvent::SummaryGenerated); + cx.emit(TextThreadEvent::SummaryChanged); + cx.emit(TextThreadEvent::SummaryGenerated); } })?; @@ -2764,8 +2762,8 @@ impl AssistantContext { if let Err(err) = result { this.update(cx, |this, cx| { - this.summary = ContextSummary::Error; - cx.emit(ContextEvent::SummaryChanged); + this.summary = TextThreadSummary::Error; + cx.emit(TextThreadEvent::SummaryChanged); }) .log_err(); log::error!("Error generating context summary: {}", err); @@ -2850,7 +2848,8 @@ impl AssistantContext { messages.next(); } } - let message_end_anchor = message_end.unwrap_or(language::Anchor::MAX); + let message_end_anchor = + message_end.unwrap_or(language::Anchor::max_for_buffer(buffer.remote_id())); let message_end = message_end_anchor.to_offset(buffer); return Some(Message { @@ -2871,7 +2870,7 @@ impl AssistantContext { &mut self, debounce: Option, fs: Arc, - cx: &mut Context, + cx: &mut Context, ) { if self.replica_id() != ReplicaId::default() { // Prevent saving a remote context for now. @@ -2902,7 +2901,7 @@ impl AssistantContext { let mut discriminant = 1; let mut new_path; loop { - new_path = contexts_dir().join(&format!( + new_path = text_threads_dir().join(&format!( "{} - {}.zed.json", summary.trim(), discriminant @@ -2914,7 +2913,7 @@ impl AssistantContext { } } - fs.create_dir(contexts_dir().as_ref()).await?; + fs.create_dir(text_threads_dir().as_ref()).await?; // rename before write ensures that only one file exists if let Some(old_path) = old_path.as_ref() @@ -2926,6 +2925,7 @@ impl AssistantContext { RenameOptions { overwrite: true, ignore_if_exists: true, + create_parents: false, }, ) .await?; @@ -2936,7 +2936,7 @@ impl AssistantContext { let new_path: Arc = new_path.clone().into(); move |this, cx| { this.path = Some(new_path.clone()); - cx.emit(ContextEvent::PathChanged { old_path, new_path }); + cx.emit(TextThreadEvent::PathChanged { old_path, new_path }); } }) .ok(); @@ -2955,11 +2955,11 @@ impl AssistantContext { summary.timestamp = timestamp; summary.done = true; summary.text = custom_summary; - cx.emit(ContextEvent::SummaryChanged); + cx.emit(TextThreadEvent::SummaryChanged); } fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut App) { - let Some(project) = &self.project else { + let Some(project) = self.project.as_ref().and_then(|project| project.upgrade()) else { return; }; project.read(cx).user_store().update(cx, |user_store, cx| { @@ -2975,23 +2975,23 @@ impl AssistantContext { } #[derive(Debug, Default)] -pub struct ContextVersion { - context: clock::Global, +pub struct TextThreadVersion { + text_thread: clock::Global, buffer: clock::Global, } -impl ContextVersion { +impl TextThreadVersion { pub fn from_proto(proto: &proto::ContextVersion) -> Self { Self { - context: language::proto::deserialize_version(&proto.context_version), + text_thread: language::proto::deserialize_version(&proto.context_version), buffer: language::proto::deserialize_version(&proto.buffer_version), } } - pub fn to_proto(&self, context_id: ContextId) -> proto::ContextVersion { + pub fn to_proto(&self, context_id: TextThreadId) -> proto::ContextVersion { proto::ContextVersion { context_id: context_id.to_proto(), - context_version: language::proto::serialize_version(&self.context), + context_version: language::proto::serialize_version(&self.text_thread), buffer_version: language::proto::serialize_version(&self.buffer), } } @@ -3059,8 +3059,8 @@ pub struct SavedMessage { } #[derive(Serialize, Deserialize)] -pub struct SavedContext { - pub id: Option, +pub struct SavedTextThread { + pub id: Option, pub zed: String, pub version: String, pub text: String, @@ -3072,7 +3072,7 @@ pub struct SavedContext { pub thought_process_output_sections: Vec>, } -impl SavedContext { +impl SavedTextThread { pub const VERSION: &'static str = "0.4.0"; pub fn from_json(json: &str) -> Result { @@ -3082,9 +3082,9 @@ impl SavedContext { .context("version not found")? { serde_json::Value::String(version) => match version.as_str() { - SavedContext::VERSION => { - Ok(serde_json::from_value::(saved_context_json)?) - } + SavedTextThread::VERSION => Ok(serde_json::from_value::( + saved_context_json, + )?), SavedContextV0_3_0::VERSION => { let saved_context = serde_json::from_value::(saved_context_json)?; @@ -3109,18 +3109,18 @@ impl SavedContext { fn into_ops( self, buffer: &Entity, - cx: &mut Context, - ) -> Vec { + cx: &mut Context, + ) -> Vec { let mut operations = Vec::new(); let mut version = clock::Global::new(); let mut next_timestamp = clock::Lamport::new(ReplicaId::default()); let mut first_message_metadata = None; for message in self.messages { - if message.id == MessageId(clock::Lamport::default()) { + if message.id == MessageId(clock::Lamport::MIN) { first_message_metadata = Some(message.metadata); } else { - operations.push(ContextOperation::InsertMessage { + operations.push(TextThreadOperation::InsertMessage { anchor: MessageAnchor { id: message.id, start: buffer.read(cx).anchor_before(message.start), @@ -3140,8 +3140,8 @@ impl SavedContext { if let Some(metadata) = first_message_metadata { let timestamp = next_timestamp.tick(); - operations.push(ContextOperation::UpdateMessage { - message_id: MessageId(clock::Lamport::default()), + operations.push(TextThreadOperation::UpdateMessage { + message_id: MessageId(clock::Lamport::MIN), metadata: MessageMetadata { role: metadata.role, status: metadata.status, @@ -3156,7 +3156,7 @@ impl SavedContext { let buffer = buffer.read(cx); for section in self.slash_command_output_sections { let timestamp = next_timestamp.tick(); - operations.push(ContextOperation::SlashCommandOutputSectionAdded { + operations.push(TextThreadOperation::SlashCommandOutputSectionAdded { timestamp, section: SlashCommandOutputSection { range: buffer.anchor_after(section.range.start) @@ -3173,7 +3173,7 @@ impl SavedContext { for section in self.thought_process_output_sections { let timestamp = next_timestamp.tick(); - operations.push(ContextOperation::ThoughtProcessOutputSectionAdded { + operations.push(TextThreadOperation::ThoughtProcessOutputSectionAdded { timestamp, section: ThoughtProcessOutputSection { range: buffer.anchor_after(section.range.start) @@ -3186,8 +3186,8 @@ impl SavedContext { } let timestamp = next_timestamp.tick(); - operations.push(ContextOperation::UpdateSummary { - summary: ContextSummaryContent { + operations.push(TextThreadOperation::UpdateSummary { + summary: TextThreadSummaryContent { text: self.summary, done: true, timestamp, @@ -3217,7 +3217,7 @@ struct SavedMessageMetadataPreV0_4_0 { #[derive(Serialize, Deserialize)] struct SavedContextV0_3_0 { - id: Option, + id: Option, zed: String, version: String, text: String, @@ -3230,11 +3230,11 @@ struct SavedContextV0_3_0 { impl SavedContextV0_3_0 { const VERSION: &'static str = "0.3.0"; - fn upgrade(self) -> SavedContext { - SavedContext { + fn upgrade(self) -> SavedTextThread { + SavedTextThread { id: self.id, zed: self.zed, - version: SavedContext::VERSION.into(), + version: SavedTextThread::VERSION.into(), text: self.text, messages: self .messages @@ -3266,7 +3266,7 @@ impl SavedContextV0_3_0 { #[derive(Serialize, Deserialize)] struct SavedContextV0_2_0 { - id: Option, + id: Option, zed: String, version: String, text: String, @@ -3278,7 +3278,7 @@ struct SavedContextV0_2_0 { impl SavedContextV0_2_0 { const VERSION: &'static str = "0.2.0"; - fn upgrade(self) -> SavedContext { + fn upgrade(self) -> SavedTextThread { SavedContextV0_3_0 { id: self.id, zed: self.zed, @@ -3295,7 +3295,7 @@ impl SavedContextV0_2_0 { #[derive(Serialize, Deserialize)] struct SavedContextV0_1_0 { - id: Option, + id: Option, zed: String, version: String, text: String, @@ -3309,7 +3309,7 @@ struct SavedContextV0_1_0 { impl SavedContextV0_1_0 { const VERSION: &'static str = "0.1.0"; - fn upgrade(self) -> SavedContext { + fn upgrade(self) -> SavedTextThread { SavedContextV0_2_0 { id: self.id, zed: self.zed, @@ -3324,7 +3324,7 @@ impl SavedContextV0_1_0 { } #[derive(Debug, Clone)] -pub struct SavedContextMetadata { +pub struct SavedTextThreadMetadata { pub title: SharedString, pub path: Arc, pub mtime: chrono::DateTime, diff --git a/crates/assistant_context/src/context_store.rs b/crates/assistant_text_thread/src/text_thread_store.rs similarity index 65% rename from crates/assistant_context/src/context_store.rs rename to crates/assistant_text_thread/src/text_thread_store.rs index 5fac44e31f..483baa7313 100644 --- a/crates/assistant_context/src/context_store.rs +++ b/crates/assistant_text_thread/src/text_thread_store.rs @@ -1,19 +1,19 @@ use crate::{ - AssistantContext, ContextEvent, ContextId, ContextOperation, ContextVersion, SavedContext, - SavedContextMetadata, + SavedTextThread, SavedTextThreadMetadata, TextThread, TextThreadEvent, TextThreadId, + TextThreadOperation, TextThreadVersion, }; use anyhow::{Context as _, Result}; use assistant_slash_command::{SlashCommandId, SlashCommandWorkingSet}; -use client::{Client, TypedEnvelope, proto, telemetry::Telemetry}; +use client::{Client, TypedEnvelope, proto}; use clock::ReplicaId; use collections::HashMap; use context_server::ContextServerId; use fs::{Fs, RemoveOptions}; use futures::StreamExt; use fuzzy::StringMatchCandidate; -use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, Task, WeakEntity}; use language::LanguageRegistry; -use paths::contexts_dir; +use paths::text_threads_dir; use project::{ Project, context_server_store::{ContextServerStatus, ContextServerStore}, @@ -27,65 +27,58 @@ use util::{ResultExt, TryFutureExt}; use zed_env_vars::ZED_STATELESS; pub(crate) fn init(client: &AnyProtoClient) { - client.add_entity_message_handler(ContextStore::handle_advertise_contexts); - client.add_entity_request_handler(ContextStore::handle_open_context); - client.add_entity_request_handler(ContextStore::handle_create_context); - client.add_entity_message_handler(ContextStore::handle_update_context); - client.add_entity_request_handler(ContextStore::handle_synchronize_contexts); + client.add_entity_message_handler(TextThreadStore::handle_advertise_contexts); + client.add_entity_request_handler(TextThreadStore::handle_open_context); + client.add_entity_request_handler(TextThreadStore::handle_create_context); + client.add_entity_message_handler(TextThreadStore::handle_update_context); + client.add_entity_request_handler(TextThreadStore::handle_synchronize_contexts); } #[derive(Clone)] -pub struct RemoteContextMetadata { - pub id: ContextId, +pub struct RemoteTextThreadMetadata { + pub id: TextThreadId, pub summary: Option, } -pub struct ContextStore { - contexts: Vec, - contexts_metadata: Vec, +pub struct TextThreadStore { + text_threads: Vec, + text_threads_metadata: Vec, context_server_slash_command_ids: HashMap>, - host_contexts: Vec, + host_text_threads: Vec, fs: Arc, languages: Arc, slash_commands: Arc, - telemetry: Arc, _watch_updates: Task>, client: Arc, - project: Entity, + project: WeakEntity, project_is_shared: bool, client_subscription: Option, _project_subscriptions: Vec, prompt_builder: Arc, } -pub enum ContextStoreEvent { - ContextCreated(ContextId), +enum TextThreadHandle { + Weak(WeakEntity), + Strong(Entity), } -impl EventEmitter for ContextStore {} - -enum ContextHandle { - Weak(WeakEntity), - Strong(Entity), -} - -impl ContextHandle { - fn upgrade(&self) -> Option> { +impl TextThreadHandle { + fn upgrade(&self) -> Option> { match self { - ContextHandle::Weak(weak) => weak.upgrade(), - ContextHandle::Strong(strong) => Some(strong.clone()), + TextThreadHandle::Weak(weak) => weak.upgrade(), + TextThreadHandle::Strong(strong) => Some(strong.clone()), } } - fn downgrade(&self) -> WeakEntity { + fn downgrade(&self) -> WeakEntity { match self { - ContextHandle::Weak(weak) => weak.clone(), - ContextHandle::Strong(strong) => strong.downgrade(), + TextThreadHandle::Weak(weak) => weak.clone(), + TextThreadHandle::Strong(strong) => strong.downgrade(), } } } -impl ContextStore { +impl TextThreadStore { pub fn new( project: Entity, prompt_builder: Arc, @@ -94,21 +87,19 @@ impl ContextStore { ) -> Task>> { let fs = project.read(cx).fs().clone(); let languages = project.read(cx).languages().clone(); - let telemetry = project.read(cx).client().telemetry().clone(); cx.spawn(async move |cx| { const CONTEXT_WATCH_DURATION: Duration = Duration::from_millis(100); - let (mut events, _) = fs.watch(contexts_dir(), CONTEXT_WATCH_DURATION).await; + let (mut events, _) = fs.watch(text_threads_dir(), CONTEXT_WATCH_DURATION).await; let this = cx.new(|cx: &mut Context| { let mut this = Self { - contexts: Vec::new(), - contexts_metadata: Vec::new(), + text_threads: Vec::new(), + text_threads_metadata: Vec::new(), context_server_slash_command_ids: HashMap::default(), - host_contexts: Vec::new(), + host_text_threads: Vec::new(), fs, languages, slash_commands, - telemetry, _watch_updates: cx.spawn(async move |this, cx| { async move { while events.next().await.is_some() { @@ -125,10 +116,10 @@ impl ContextStore { ], project_is_shared: false, client: project.read(cx).client(), - project: project.clone(), + project: project.downgrade(), prompt_builder, }; - this.handle_project_shared(project.clone(), cx); + this.handle_project_shared(cx); this.synchronize_contexts(cx); this.register_context_server_handlers(cx); this.reload(cx).detach_and_log_err(cx); @@ -142,17 +133,16 @@ impl ContextStore { #[cfg(any(test, feature = "test-support"))] pub fn fake(project: Entity, cx: &mut Context) -> Self { Self { - contexts: Default::default(), - contexts_metadata: Default::default(), + text_threads: Default::default(), + text_threads_metadata: Default::default(), context_server_slash_command_ids: Default::default(), - host_contexts: Default::default(), + host_text_threads: Default::default(), fs: project.read(cx).fs().clone(), languages: project.read(cx).languages().clone(), slash_commands: Arc::default(), - telemetry: project.read(cx).client().telemetry().clone(), _watch_updates: Task::ready(None), client: project.read(cx).client(), - project, + project: project.downgrade(), project_is_shared: false, client_subscription: None, _project_subscriptions: Default::default(), @@ -166,13 +156,13 @@ impl ContextStore { mut cx: AsyncApp, ) -> Result<()> { this.update(&mut cx, |this, cx| { - this.host_contexts = envelope + this.host_text_threads = envelope .payload .contexts .into_iter() - .map(|context| RemoteContextMetadata { - id: ContextId::from_proto(context.context_id), - summary: context.summary, + .map(|text_thread| RemoteTextThreadMetadata { + id: TextThreadId::from_proto(text_thread.context_id), + summary: text_thread.summary, }) .collect(); cx.notify(); @@ -184,25 +174,27 @@ impl ContextStore { envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let context_id = ContextId::from_proto(envelope.payload.context_id); + let context_id = TextThreadId::from_proto(envelope.payload.context_id); let operations = this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; + anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "only the host contexts can be opened" ); - let context = this - .loaded_context_for_id(&context_id, cx) + let text_thread = this + .loaded_text_thread_for_id(&context_id, cx) .context("context not found")?; anyhow::ensure!( - context.read(cx).replica_id() == ReplicaId::default(), + text_thread.read(cx).replica_id() == ReplicaId::default(), "context must be opened via the host" ); anyhow::Ok( - context + text_thread .read(cx) - .serialize_ops(&ContextVersion::default(), cx), + .serialize_ops(&TextThreadVersion::default(), cx), ) })??; let operations = operations.await; @@ -217,20 +209,20 @@ impl ContextStore { mut cx: AsyncApp, ) -> Result { let (context_id, operations) = this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "can only create contexts as the host" ); - let context = this.create(cx); - let context_id = context.read(cx).id().clone(); - cx.emit(ContextStoreEvent::ContextCreated(context_id.clone())); + let text_thread = this.create(cx); + let context_id = text_thread.read(cx).id().clone(); anyhow::Ok(( context_id, - context + text_thread .read(cx) - .serialize_ops(&ContextVersion::default(), cx), + .serialize_ops(&TextThreadVersion::default(), cx), )) })??; let operations = operations.await; @@ -246,11 +238,11 @@ impl ContextStore { mut cx: AsyncApp, ) -> Result<()> { this.update(&mut cx, |this, cx| { - let context_id = ContextId::from_proto(envelope.payload.context_id); - if let Some(context) = this.loaded_context_for_id(&context_id, cx) { + let context_id = TextThreadId::from_proto(envelope.payload.context_id); + if let Some(text_thread) = this.loaded_text_thread_for_id(&context_id, cx) { let operation_proto = envelope.payload.operation.context("invalid operation")?; - let operation = ContextOperation::from_proto(operation_proto)?; - context.update(cx, |context, cx| context.apply_ops([operation], cx)); + let operation = TextThreadOperation::from_proto(operation_proto)?; + text_thread.update(cx, |text_thread, cx| text_thread.apply_ops([operation], cx)); } Ok(()) })? @@ -262,19 +254,20 @@ impl ContextStore { mut cx: AsyncApp, ) -> Result { this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "only the host can synchronize contexts" ); let mut local_versions = Vec::new(); for remote_version_proto in envelope.payload.contexts { - let remote_version = ContextVersion::from_proto(&remote_version_proto); - let context_id = ContextId::from_proto(remote_version_proto.context_id); - if let Some(context) = this.loaded_context_for_id(&context_id, cx) { - let context = context.read(cx); - let operations = context.serialize_ops(&remote_version, cx); - local_versions.push(context.version(cx).to_proto(context_id.clone())); + let remote_version = TextThreadVersion::from_proto(&remote_version_proto); + let context_id = TextThreadId::from_proto(remote_version_proto.context_id); + if let Some(text_thread) = this.loaded_text_thread_for_id(&context_id, cx) { + let text_thread = text_thread.read(cx); + let operations = text_thread.serialize_ops(&remote_version, cx); + local_versions.push(text_thread.version(cx).to_proto(context_id.clone())); let client = this.client.clone(); let project_id = envelope.payload.project_id; cx.background_spawn(async move { @@ -300,23 +293,27 @@ impl ContextStore { })? } - fn handle_project_shared(&mut self, _: Entity, cx: &mut Context) { - let is_shared = self.project.read(cx).is_shared(); + fn handle_project_shared(&mut self, cx: &mut Context) { + let Some(project) = self.project.upgrade() else { + return; + }; + + let is_shared = project.read(cx).is_shared(); let was_shared = mem::replace(&mut self.project_is_shared, is_shared); if is_shared == was_shared { return; } if is_shared { - self.contexts.retain_mut(|context| { - if let Some(strong_context) = context.upgrade() { - *context = ContextHandle::Strong(strong_context); + self.text_threads.retain_mut(|text_thread| { + if let Some(strong_context) = text_thread.upgrade() { + *text_thread = TextThreadHandle::Strong(strong_context); true } else { false } }); - let remote_id = self.project.read(cx).remote_id().unwrap(); + let remote_id = project.read(cx).remote_id().unwrap(); self.client_subscription = self .client .subscribe_to_entity(remote_id) @@ -330,13 +327,13 @@ impl ContextStore { fn handle_project_event( &mut self, - project: Entity, + _project: Entity, event: &project::Event, cx: &mut Context, ) { match event { project::Event::RemoteIdChanged(_) => { - self.handle_project_shared(project, cx); + self.handle_project_shared(cx); } project::Event::Reshared => { self.advertise_contexts(cx); @@ -345,12 +342,12 @@ impl ContextStore { self.synchronize_contexts(cx); } project::Event::DisconnectedFromHost => { - self.contexts.retain_mut(|context| { - if let Some(strong_context) = context.upgrade() { - *context = ContextHandle::Weak(context.downgrade()); - strong_context.update(cx, |context, cx| { - if context.replica_id() != ReplicaId::default() { - context.set_capability(language::Capability::ReadOnly, cx); + self.text_threads.retain_mut(|text_thread| { + if let Some(strong_context) = text_thread.upgrade() { + *text_thread = TextThreadHandle::Weak(text_thread.downgrade()); + strong_context.update(cx, |text_thread, cx| { + if text_thread.replica_id() != ReplicaId::default() { + text_thread.set_capability(language::Capability::ReadOnly, cx); } }); true @@ -358,37 +355,40 @@ impl ContextStore { false } }); - self.host_contexts.clear(); + self.host_text_threads.clear(); cx.notify(); } _ => {} } } - pub fn unordered_contexts(&self) -> impl Iterator { - self.contexts_metadata.iter() + pub fn unordered_text_threads(&self) -> impl Iterator { + self.text_threads_metadata.iter() } - pub fn create(&mut self, cx: &mut Context) -> Entity { + pub fn host_text_threads(&self) -> impl Iterator { + self.host_text_threads.iter() + } + + pub fn create(&mut self, cx: &mut Context) -> Entity { let context = cx.new(|cx| { - AssistantContext::local( + TextThread::local( self.languages.clone(), Some(self.project.clone()), - Some(self.telemetry.clone()), self.prompt_builder.clone(), self.slash_commands.clone(), cx, ) }); - self.register_context(&context, cx); + self.register_text_thread(&context, cx); context } - pub fn create_remote_context( - &mut self, - cx: &mut Context, - ) -> Task>> { - let project = self.project.read(cx); + pub fn create_remote(&mut self, cx: &mut Context) -> Task>> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("project was dropped"))); + }; + let project = project.read(cx); let Some(project_id) = project.remote_id() else { return Task::ready(Err(anyhow::anyhow!("project was not remote"))); }; @@ -397,16 +397,16 @@ impl ContextStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); + let prompt_builder = self.prompt_builder.clone(); let slash_commands = self.slash_commands.clone(); let request = self.client.request(proto::CreateContext { project_id }); cx.spawn(async move |this, cx| { let response = request.await?; - let context_id = ContextId::from_proto(response.context_id); + let context_id = TextThreadId::from_proto(response.context_id); let context_proto = response.context.context("invalid context")?; - let context = cx.new(|cx| { - AssistantContext::new( + let text_thread = cx.new(|cx| { + TextThread::new( context_id.clone(), replica_id, capability, @@ -414,7 +414,6 @@ impl ContextStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -423,41 +422,40 @@ impl ContextStore { context_proto .operations .into_iter() - .map(ContextOperation::from_proto) + .map(TextThreadOperation::from_proto) .collect::>>() }) .await?; - context.update(cx, |context, cx| context.apply_ops(operations, cx))?; + text_thread.update(cx, |context, cx| context.apply_ops(operations, cx))?; this.update(cx, |this, cx| { - if let Some(existing_context) = this.loaded_context_for_id(&context_id, cx) { + if let Some(existing_context) = this.loaded_text_thread_for_id(&context_id, cx) { existing_context } else { - this.register_context(&context, cx); + this.register_text_thread(&text_thread, cx); this.synchronize_contexts(cx); - context + text_thread } }) }) } - pub fn open_local_context( + pub fn open_local( &mut self, path: Arc, cx: &Context, - ) -> Task>> { - if let Some(existing_context) = self.loaded_context_for_path(&path, cx) { + ) -> Task>> { + if let Some(existing_context) = self.loaded_text_thread_for_path(&path, cx) { return Task::ready(Ok(existing_context)); } let fs = self.fs.clone(); let languages = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let load = cx.background_spawn({ let path = path.clone(); async move { let saved_context = fs.load(&path).await?; - SavedContext::from_json(&saved_context) + SavedTextThread::from_json(&saved_context) } }); let prompt_builder = self.prompt_builder.clone(); @@ -466,33 +464,28 @@ impl ContextStore { cx.spawn(async move |this, cx| { let saved_context = load.await?; let context = cx.new(|cx| { - AssistantContext::deserialize( + TextThread::deserialize( saved_context, path.clone(), languages, prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; this.update(cx, |this, cx| { - if let Some(existing_context) = this.loaded_context_for_path(&path, cx) { + if let Some(existing_context) = this.loaded_text_thread_for_path(&path, cx) { existing_context } else { - this.register_context(&context, cx); + this.register_text_thread(&context, cx); context } }) }) } - pub fn delete_local_context( - &mut self, - path: Arc, - cx: &mut Context, - ) -> Task> { + pub fn delete_local(&mut self, path: Arc, cx: &mut Context) -> Task> { let fs = self.fs.clone(); cx.spawn(async move |this, cx| { @@ -506,57 +499,60 @@ impl ContextStore { .await?; this.update(cx, |this, cx| { - this.contexts.retain(|context| { - context + this.text_threads.retain(|text_thread| { + text_thread .upgrade() - .and_then(|context| context.read(cx).path()) + .and_then(|text_thread| text_thread.read(cx).path()) != Some(&path) }); - this.contexts_metadata - .retain(|context| context.path.as_ref() != path.as_ref()); + this.text_threads_metadata + .retain(|text_thread| text_thread.path.as_ref() != path.as_ref()); })?; Ok(()) }) } - fn loaded_context_for_path(&self, path: &Path, cx: &App) -> Option> { - self.contexts.iter().find_map(|context| { - let context = context.upgrade()?; - if context.read(cx).path().map(Arc::as_ref) == Some(path) { - Some(context) + fn loaded_text_thread_for_path(&self, path: &Path, cx: &App) -> Option> { + self.text_threads.iter().find_map(|text_thread| { + let text_thread = text_thread.upgrade()?; + if text_thread.read(cx).path().map(Arc::as_ref) == Some(path) { + Some(text_thread) } else { None } }) } - pub fn loaded_context_for_id( + pub fn loaded_text_thread_for_id( &self, - id: &ContextId, + id: &TextThreadId, cx: &App, - ) -> Option> { - self.contexts.iter().find_map(|context| { - let context = context.upgrade()?; - if context.read(cx).id() == id { - Some(context) + ) -> Option> { + self.text_threads.iter().find_map(|text_thread| { + let text_thread = text_thread.upgrade()?; + if text_thread.read(cx).id() == id { + Some(text_thread) } else { None } }) } - pub fn open_remote_context( + pub fn open_remote( &mut self, - context_id: ContextId, + text_thread_id: TextThreadId, cx: &mut Context, - ) -> Task>> { - let project = self.project.read(cx); + ) -> Task>> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("project was dropped"))); + }; + let project = project.read(cx); let Some(project_id) = project.remote_id() else { return Task::ready(Err(anyhow::anyhow!("project was not remote"))); }; - if let Some(context) = self.loaded_context_for_id(&context_id, cx) { + if let Some(context) = self.loaded_text_thread_for_id(&text_thread_id, cx) { return Task::ready(Ok(context)); } @@ -564,26 +560,24 @@ impl ContextStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let request = self.client.request(proto::OpenContext { project_id, - context_id: context_id.to_proto(), + context_id: text_thread_id.to_proto(), }); let prompt_builder = self.prompt_builder.clone(); let slash_commands = self.slash_commands.clone(); cx.spawn(async move |this, cx| { let response = request.await?; let context_proto = response.context.context("invalid context")?; - let context = cx.new(|cx| { - AssistantContext::new( - context_id.clone(), + let text_thread = cx.new(|cx| { + TextThread::new( + text_thread_id.clone(), replica_id, capability, language_registry, prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -592,51 +586,56 @@ impl ContextStore { context_proto .operations .into_iter() - .map(ContextOperation::from_proto) + .map(TextThreadOperation::from_proto) .collect::>>() }) .await?; - context.update(cx, |context, cx| context.apply_ops(operations, cx))?; + text_thread.update(cx, |context, cx| context.apply_ops(operations, cx))?; this.update(cx, |this, cx| { - if let Some(existing_context) = this.loaded_context_for_id(&context_id, cx) { + if let Some(existing_context) = this.loaded_text_thread_for_id(&text_thread_id, cx) + { existing_context } else { - this.register_context(&context, cx); + this.register_text_thread(&text_thread, cx); this.synchronize_contexts(cx); - context + text_thread } }) }) } - fn register_context(&mut self, context: &Entity, cx: &mut Context) { + fn register_text_thread(&mut self, text_thread: &Entity, cx: &mut Context) { let handle = if self.project_is_shared { - ContextHandle::Strong(context.clone()) + TextThreadHandle::Strong(text_thread.clone()) } else { - ContextHandle::Weak(context.downgrade()) + TextThreadHandle::Weak(text_thread.downgrade()) }; - self.contexts.push(handle); + self.text_threads.push(handle); self.advertise_contexts(cx); - cx.subscribe(context, Self::handle_context_event).detach(); + cx.subscribe(text_thread, Self::handle_context_event) + .detach(); } fn handle_context_event( &mut self, - context: Entity, - event: &ContextEvent, + text_thread: Entity, + event: &TextThreadEvent, cx: &mut Context, ) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; match event { - ContextEvent::SummaryChanged => { + TextThreadEvent::SummaryChanged => { self.advertise_contexts(cx); } - ContextEvent::PathChanged { old_path, new_path } => { + TextThreadEvent::PathChanged { old_path, new_path } => { if let Some(old_path) = old_path.as_ref() { - for metadata in &mut self.contexts_metadata { + for metadata in &mut self.text_threads_metadata { if &metadata.path == old_path { metadata.path = new_path.clone(); break; @@ -644,8 +643,8 @@ impl ContextStore { } } } - ContextEvent::Operation(operation) => { - let context_id = context.read(cx).id().to_proto(); + TextThreadEvent::Operation(operation) => { + let context_id = text_thread.read(cx).id().to_proto(); let operation = operation.to_proto(); self.client .send(proto::UpdateContext { @@ -660,25 +659,27 @@ impl ContextStore { } fn advertise_contexts(&self, cx: &App) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; - // For now, only the host can advertise their open contexts. - if self.project.read(cx).is_via_collab() { + if project.read(cx).is_via_collab() { return; } let contexts = self - .contexts + .text_threads .iter() .rev() - .filter_map(|context| { - let context = context.upgrade()?.read(cx); - if context.replica_id() == ReplicaId::default() { + .filter_map(|text_thread| { + let text_thread = text_thread.upgrade()?.read(cx); + if text_thread.replica_id() == ReplicaId::default() { Some(proto::ContextMetadata { - context_id: context.id().to_proto(), - summary: context + context_id: text_thread.id().to_proto(), + summary: text_thread .summary() .content() .map(|summary| summary.text.clone()), @@ -697,17 +698,20 @@ impl ContextStore { } fn synchronize_contexts(&mut self, cx: &mut Context) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; - let contexts = self - .contexts + let text_threads = self + .text_threads .iter() - .filter_map(|context| { - let context = context.upgrade()?.read(cx); - if context.replica_id() != ReplicaId::default() { - Some(context.version(cx).to_proto(context.id().clone())) + .filter_map(|text_thread| { + let text_thread = text_thread.upgrade()?.read(cx); + if text_thread.replica_id() != ReplicaId::default() { + Some(text_thread.version(cx).to_proto(text_thread.id().clone())) } else { None } @@ -717,26 +721,27 @@ impl ContextStore { let client = self.client.clone(); let request = self.client.request(proto::SynchronizeContexts { project_id, - contexts, + contexts: text_threads, }); cx.spawn(async move |this, cx| { let response = request.await?; - let mut context_ids = Vec::new(); + let mut text_thread_ids = Vec::new(); let mut operations = Vec::new(); this.read_with(cx, |this, cx| { for context_version_proto in response.contexts { - let context_version = ContextVersion::from_proto(&context_version_proto); - let context_id = ContextId::from_proto(context_version_proto.context_id); - if let Some(context) = this.loaded_context_for_id(&context_id, cx) { - context_ids.push(context_id); - operations.push(context.read(cx).serialize_ops(&context_version, cx)); + let text_thread_version = TextThreadVersion::from_proto(&context_version_proto); + let text_thread_id = TextThreadId::from_proto(context_version_proto.context_id); + if let Some(text_thread) = this.loaded_text_thread_for_id(&text_thread_id, cx) { + text_thread_ids.push(text_thread_id); + operations + .push(text_thread.read(cx).serialize_ops(&text_thread_version, cx)); } } })?; let operations = futures::future::join_all(operations).await; - for (context_id, operations) in context_ids.into_iter().zip(operations) { + for (context_id, operations) in text_thread_ids.into_iter().zip(operations) { for operation in operations { client.send(proto::UpdateContext { project_id, @@ -751,8 +756,8 @@ impl ContextStore { .detach_and_log_err(cx); } - pub fn search(&self, query: String, cx: &App) -> Task> { - let metadata = self.contexts_metadata.clone(); + pub fn search(&self, query: String, cx: &App) -> Task> { + let metadata = self.text_threads_metadata.clone(); let executor = cx.background_executor().clone(); cx.background_spawn(async move { if query.is_empty() { @@ -782,20 +787,16 @@ impl ContextStore { }) } - pub fn host_contexts(&self) -> &[RemoteContextMetadata] { - &self.host_contexts - } - fn reload(&mut self, cx: &mut Context) -> Task> { let fs = self.fs.clone(); cx.spawn(async move |this, cx| { if *ZED_STATELESS { return Ok(()); } - fs.create_dir(contexts_dir()).await?; + fs.create_dir(text_threads_dir()).await?; - let mut paths = fs.read_dir(contexts_dir()).await?; - let mut contexts = Vec::::new(); + let mut paths = fs.read_dir(text_threads_dir()).await?; + let mut contexts = Vec::::new(); while let Some(path) = paths.next().await { let path = path?; if path.extension() != Some(OsStr::new("json")) { @@ -821,7 +822,7 @@ impl ContextStore { .lines() .next() { - contexts.push(SavedContextMetadata { + contexts.push(SavedTextThreadMetadata { title: title.to_string().into(), path: path.into(), mtime: metadata.mtime.timestamp_for_user().into(), @@ -829,17 +830,20 @@ impl ContextStore { } } } - contexts.sort_unstable_by_key(|context| Reverse(context.mtime)); + contexts.sort_unstable_by_key(|text_thread| Reverse(text_thread.mtime)); this.update(cx, |this, cx| { - this.contexts_metadata = contexts; + this.text_threads_metadata = contexts; cx.notify(); }) }) } fn register_context_server_handlers(&self, cx: &mut Context) { - let context_server_store = self.project.read(cx).context_server_store(); + let Some(project) = self.project.upgrade() else { + return; + }; + let context_server_store = project.read(cx).context_server_store(); cx.subscribe(&context_server_store, Self::handle_context_server_event) .detach(); diff --git a/crates/assistant_tool/Cargo.toml b/crates/assistant_tool/Cargo.toml deleted file mode 100644 index c95695052a..0000000000 --- a/crates/assistant_tool/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "assistant_tool" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/assistant_tool.rs" - -[dependencies] -action_log.workspace = true -anyhow.workspace = true -collections.workspace = true -derive_more.workspace = true -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 -serde.workspace = true -serde_json.workspace = true -text.workspace = true -util.workspace = true -workspace.workspace = true -workspace-hack.workspace = true - -[dev-dependencies] -buffer_diff = { workspace = true, features = ["test-support"] } -collections = { workspace = true, features = ["test-support"] } -clock = { workspace = true, features = ["test-support"] } -ctor.workspace = true -gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true -language = { workspace = true, features = ["test-support"] } -language_model = { workspace = true, features = ["test-support"] } -log.workspace = true -pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } -rand.workspace = true -settings = { workspace = true, features = ["test-support"] } -text = { workspace = true, features = ["test-support"] } -util = { workspace = true, features = ["test-support"] } -zlog.workspace = true diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs deleted file mode 100644 index 9c5825d0f0..0000000000 --- a/crates/assistant_tool/src/assistant_tool.rs +++ /dev/null @@ -1,269 +0,0 @@ -pub mod outline; -mod tool_registry; -mod tool_schema; -mod tool_working_set; - -use std::fmt; -use std::fmt::Debug; -use std::fmt::Formatter; -use std::ops::Deref; -use std::sync::Arc; - -use action_log::ActionLog; -use anyhow::Result; -use gpui::AnyElement; -use gpui::AnyWindowHandle; -use gpui::Context; -use gpui::IntoElement; -use gpui::Window; -use gpui::{App, Entity, SharedString, Task, WeakEntity}; -use icons::IconName; -use language_model::LanguageModel; -use language_model::LanguageModelImage; -use language_model::LanguageModelRequest; -use language_model::LanguageModelToolSchemaFormat; -use project::Project; -use workspace::Workspace; - -pub use crate::tool_registry::*; -pub use crate::tool_schema::*; -pub use crate::tool_working_set::*; - -pub fn init(cx: &mut App) { - ToolRegistry::default_global(cx); -} - -#[derive(Debug, Clone)] -pub enum ToolUseStatus { - InputStillStreaming, - NeedsConfirmation, - Pending, - Running, - Finished(SharedString), - Error(SharedString), -} - -impl ToolUseStatus { - pub fn text(&self) -> SharedString { - match self { - ToolUseStatus::NeedsConfirmation => "".into(), - ToolUseStatus::InputStillStreaming => "".into(), - ToolUseStatus::Pending => "".into(), - ToolUseStatus::Running => "".into(), - ToolUseStatus::Finished(out) => out.clone(), - ToolUseStatus::Error(out) => out.clone(), - } - } - - pub fn error(&self) -> Option { - match self { - ToolUseStatus::Error(out) => Some(out.clone()), - _ => None, - } - } -} - -#[derive(Debug)] -pub struct ToolResultOutput { - pub content: ToolResultContent, - pub output: Option, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ToolResultContent { - Text(String), - Image(LanguageModelImage), -} - -impl ToolResultContent { - pub fn len(&self) -> usize { - match self { - ToolResultContent::Text(str) => str.len(), - ToolResultContent::Image(image) => image.len(), - } - } - - pub fn is_empty(&self) -> bool { - match self { - ToolResultContent::Text(str) => str.is_empty(), - ToolResultContent::Image(image) => image.is_empty(), - } - } - - pub fn as_str(&self) -> Option<&str> { - match self { - ToolResultContent::Text(str) => Some(str), - ToolResultContent::Image(_) => None, - } - } -} - -impl From for ToolResultOutput { - fn from(value: String) -> Self { - ToolResultOutput { - content: ToolResultContent::Text(value), - output: None, - } - } -} - -impl Deref for ToolResultOutput { - type Target = ToolResultContent; - - fn deref(&self) -> &Self::Target { - &self.content - } -} - -/// The result of running a tool, containing both the asynchronous output -/// and an optional card view that can be rendered immediately. -pub struct ToolResult { - /// The asynchronous task that will eventually resolve to the tool's output - pub output: Task>, - /// An optional view to present the output of the tool. - pub card: Option, -} - -pub trait ToolCard: 'static + Sized { - fn render( - &mut self, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement; -} - -#[derive(Clone)] -pub struct AnyToolCard { - entity: gpui::AnyEntity, - render: fn( - entity: gpui::AnyEntity, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut App, - ) -> AnyElement, -} - -impl From> for AnyToolCard { - fn from(entity: Entity) -> Self { - fn downcast_render( - entity: gpui::AnyEntity, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut App, - ) -> AnyElement { - let entity = entity.downcast::().unwrap(); - entity.update(cx, |entity, cx| { - entity - .render(status, window, workspace, cx) - .into_any_element() - }) - } - - Self { - entity: entity.into(), - render: downcast_render::, - } - } -} - -impl AnyToolCard { - pub fn render( - &self, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut App, - ) -> AnyElement { - (self.render)(self.entity.clone(), status, window, workspace, cx) - } -} - -impl From>> for ToolResult { - /// Convert from a task to a ToolResult with no card - fn from(output: Task>) -> Self { - Self { output, card: None } - } -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] -pub enum ToolSource { - /// A native tool built-in to Zed. - Native, - /// A tool provided by a context server. - ContextServer { id: SharedString }, -} - -/// A tool that can be used by a language model. -pub trait Tool: 'static + Send + Sync { - /// Returns the name of the tool. - fn name(&self) -> String; - - /// Returns the description of the tool. - fn description(&self) -> String; - - /// Returns the icon for the tool. - fn icon(&self) -> IconName; - - /// Returns the source of the tool. - fn source(&self) -> ToolSource { - ToolSource::Native - } - - /// Returns true if the tool needs the users's confirmation - /// before having permission to run. - fn needs_confirmation( - &self, - input: &serde_json::Value, - project: &Entity, - cx: &App, - ) -> bool; - - /// Returns true if the tool may perform edits. - fn may_perform_edits(&self) -> bool; - - /// Returns the JSON schema that describes the tool's input. - fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result { - Ok(serde_json::Value::Object(serde_json::Map::default())) - } - - /// Returns markdown to be displayed in the UI for this tool. - fn ui_text(&self, input: &serde_json::Value) -> String; - - /// Returns markdown to be displayed in the UI for this tool, while the input JSON is still streaming - /// (so information may be missing). - fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String { - self.ui_text(input) - } - - /// Runs the tool with the provided input. - fn run( - self: Arc, - input: serde_json::Value, - request: Arc, - project: Entity, - action_log: Entity, - model: Arc, - window: Option, - cx: &mut App, - ) -> ToolResult; - - fn deserialize_card( - self: Arc, - _output: serde_json::Value, - _project: Entity, - _window: &mut Window, - _cx: &mut App, - ) -> Option { - None - } -} - -impl Debug for dyn Tool { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.debug_struct("Tool").field("name", &self.name()).finish() - } -} diff --git a/crates/assistant_tool/src/tool_registry.rs b/crates/assistant_tool/src/tool_registry.rs deleted file mode 100644 index 26b4821a6d..0000000000 --- a/crates/assistant_tool/src/tool_registry.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::sync::Arc; - -use collections::HashMap; -use derive_more::{Deref, DerefMut}; -use gpui::Global; -use gpui::{App, ReadGlobal}; -use parking_lot::RwLock; - -use crate::Tool; - -#[derive(Default, Deref, DerefMut)] -struct GlobalToolRegistry(Arc); - -impl Global for GlobalToolRegistry {} - -#[derive(Default)] -struct ToolRegistryState { - tools: HashMap, Arc>, -} - -#[derive(Default)] -pub struct ToolRegistry { - state: RwLock, -} - -impl ToolRegistry { - /// Returns the global [`ToolRegistry`]. - pub fn global(cx: &App) -> Arc { - GlobalToolRegistry::global(cx).0.clone() - } - - /// Returns the global [`ToolRegistry`]. - /// - /// Inserts a default [`ToolRegistry`] if one does not yet exist. - pub fn default_global(cx: &mut App) -> Arc { - cx.default_global::().0.clone() - } - - pub fn new() -> Arc { - Arc::new(Self { - state: RwLock::new(ToolRegistryState { - tools: HashMap::default(), - }), - }) - } - - /// Registers the provided [`Tool`]. - pub fn register_tool(&self, tool: impl Tool) { - let mut state = self.state.write(); - let tool_name: Arc = tool.name().into(); - state.tools.insert(tool_name, Arc::new(tool)); - } - - /// Unregisters the provided [`Tool`]. - pub fn unregister_tool(&self, tool: impl Tool) { - self.unregister_tool_by_name(tool.name().as_str()) - } - - /// Unregisters the tool with the given name. - pub fn unregister_tool_by_name(&self, tool_name: &str) { - let mut state = self.state.write(); - state.tools.remove(tool_name); - } - - /// Returns the list of tools in the registry. - pub fn tools(&self) -> Vec> { - self.state.read().tools.values().cloned().collect() - } - - /// Returns the [`Tool`] with the given name. - pub fn tool(&self, name: &str) -> Option> { - self.state.read().tools.get(name).cloned() - } -} diff --git a/crates/assistant_tool/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs deleted file mode 100644 index 61f57affc7..0000000000 --- a/crates/assistant_tool/src/tool_working_set.rs +++ /dev/null @@ -1,415 +0,0 @@ -use std::{borrow::Borrow, sync::Arc}; - -use crate::{Tool, ToolRegistry, ToolSource}; -use collections::{HashMap, HashSet, IndexMap}; -use gpui::{App, SharedString}; -use util::debug_panic; - -#[derive(Copy, Clone, PartialEq, Eq, Hash, Default)] -pub struct ToolId(usize); - -/// A unique identifier for a tool within a working set. -#[derive(Clone, PartialEq, Eq, Hash, Default)] -pub struct UniqueToolName(SharedString); - -impl Borrow for UniqueToolName { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl From for UniqueToolName { - fn from(value: String) -> Self { - UniqueToolName(SharedString::new(value)) - } -} - -impl Into for UniqueToolName { - fn into(self) -> String { - self.0.into() - } -} - -impl std::fmt::Debug for UniqueToolName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl std::fmt::Display for UniqueToolName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.as_ref()) - } -} - -/// A working set of tools for use in one instance of the Assistant Panel. -#[derive(Default)] -pub struct ToolWorkingSet { - context_server_tools_by_id: HashMap>, - context_server_tools_by_name: HashMap>, - next_tool_id: ToolId, -} - -impl ToolWorkingSet { - pub fn tool(&self, name: &str, cx: &App) -> Option> { - self.context_server_tools_by_name - .get(name) - .cloned() - .or_else(|| ToolRegistry::global(cx).tool(name)) - } - - pub fn tools(&self, cx: &App) -> Vec<(UniqueToolName, Arc)> { - let mut tools = ToolRegistry::global(cx) - .tools() - .into_iter() - .map(|tool| (UniqueToolName(tool.name().into()), tool)) - .collect::>(); - tools.extend(self.context_server_tools_by_name.clone()); - tools - } - - pub fn tools_by_source(&self, cx: &App) -> IndexMap>> { - let mut tools_by_source = IndexMap::default(); - - for (_, tool) in self.tools(cx) { - tools_by_source - .entry(tool.source()) - .or_insert_with(Vec::new) - .push(tool); - } - - for tools in tools_by_source.values_mut() { - tools.sort_by_key(|tool| tool.name()); - } - - tools_by_source.sort_unstable_keys(); - - tools_by_source - } - - pub fn insert(&mut self, tool: Arc, cx: &App) -> ToolId { - let tool_id = self.register_tool(tool); - self.tools_changed(cx); - tool_id - } - - pub fn extend(&mut self, tools: impl Iterator>, cx: &App) -> Vec { - let ids = tools.map(|tool| self.register_tool(tool)).collect(); - self.tools_changed(cx); - ids - } - - pub fn remove(&mut self, tool_ids_to_remove: &[ToolId], cx: &App) { - self.context_server_tools_by_id - .retain(|id, _| !tool_ids_to_remove.contains(id)); - self.tools_changed(cx); - } - - fn register_tool(&mut self, tool: Arc) -> ToolId { - let tool_id = self.next_tool_id; - self.next_tool_id.0 += 1; - self.context_server_tools_by_id - .insert(tool_id, tool.clone()); - tool_id - } - - fn tools_changed(&mut self, cx: &App) { - self.context_server_tools_by_name = resolve_context_server_tool_name_conflicts( - &self - .context_server_tools_by_id - .values() - .cloned() - .collect::>(), - &ToolRegistry::global(cx).tools(), - ); - } -} - -fn resolve_context_server_tool_name_conflicts( - context_server_tools: &[Arc], - native_tools: &[Arc], -) -> HashMap> { - fn resolve_tool_name(tool: &Arc) -> String { - let mut tool_name = tool.name(); - tool_name.truncate(MAX_TOOL_NAME_LENGTH); - tool_name - } - - const MAX_TOOL_NAME_LENGTH: usize = 64; - - let mut duplicated_tool_names = HashSet::default(); - let mut seen_tool_names = HashSet::default(); - seen_tool_names.extend(native_tools.iter().map(|tool| tool.name())); - for tool in context_server_tools { - let tool_name = resolve_tool_name(tool); - if seen_tool_names.contains(&tool_name) { - debug_assert!( - tool.source() != ToolSource::Native, - "Expected MCP tool but got a native tool: {}", - tool_name - ); - duplicated_tool_names.insert(tool_name); - } else { - seen_tool_names.insert(tool_name); - } - } - - if duplicated_tool_names.is_empty() { - return context_server_tools - .iter() - .map(|tool| (resolve_tool_name(tool).into(), tool.clone())) - .collect(); - } - - context_server_tools - .iter() - .filter_map(|tool| { - let mut tool_name = resolve_tool_name(tool); - if !duplicated_tool_names.contains(&tool_name) { - return Some((tool_name.into(), tool.clone())); - } - match tool.source() { - ToolSource::Native => { - debug_panic!("Expected MCP tool but got a native tool: {}", tool_name); - // Built-in tools always keep their original name - Some((tool_name.into(), tool.clone())) - } - ToolSource::ContextServer { id } => { - // Context server tools are prefixed with the context server ID, and truncated if necessary - tool_name.insert(0, '_'); - if tool_name.len() + id.len() > MAX_TOOL_NAME_LENGTH { - let len = MAX_TOOL_NAME_LENGTH - tool_name.len(); - let mut id = id.to_string(); - id.truncate(len); - tool_name.insert_str(0, &id); - } else { - tool_name.insert_str(0, &id); - } - - tool_name.truncate(MAX_TOOL_NAME_LENGTH); - - if seen_tool_names.contains(&tool_name) { - log::error!("Cannot resolve tool name conflict for tool {}", tool.name()); - None - } else { - Some((tool_name.into(), tool.clone())) - } - } - } - }) - .collect() -} -#[cfg(test)] -mod tests { - use gpui::{AnyWindowHandle, Entity, Task, TestAppContext}; - use language_model::{LanguageModel, LanguageModelRequest}; - use project::Project; - - use crate::{ActionLog, ToolResult}; - - use super::*; - - #[gpui::test] - fn test_unique_tool_names(cx: &mut TestAppContext) { - fn assert_tool( - tool_working_set: &ToolWorkingSet, - unique_name: &str, - expected_name: &str, - expected_source: ToolSource, - cx: &App, - ) { - let tool = tool_working_set.tool(unique_name, cx).unwrap(); - assert_eq!(tool.name(), expected_name); - assert_eq!(tool.source(), expected_source); - } - - let tool_registry = cx.update(ToolRegistry::default_global); - tool_registry.register_tool(TestTool::new("tool1", ToolSource::Native)); - tool_registry.register_tool(TestTool::new("tool2", ToolSource::Native)); - - let mut tool_working_set = ToolWorkingSet::default(); - cx.update(|cx| { - tool_working_set.extend( - vec![ - Arc::new(TestTool::new( - "tool2", - ToolSource::ContextServer { id: "mcp-1".into() }, - )) as Arc, - Arc::new(TestTool::new( - "tool2", - ToolSource::ContextServer { id: "mcp-2".into() }, - )) as Arc, - ] - .into_iter(), - cx, - ); - }); - - cx.update(|cx| { - assert_tool(&tool_working_set, "tool1", "tool1", ToolSource::Native, cx); - assert_tool(&tool_working_set, "tool2", "tool2", ToolSource::Native, cx); - assert_tool( - &tool_working_set, - "mcp-1_tool2", - "tool2", - ToolSource::ContextServer { id: "mcp-1".into() }, - cx, - ); - assert_tool( - &tool_working_set, - "mcp-2_tool2", - "tool2", - ToolSource::ContextServer { id: "mcp-2".into() }, - cx, - ); - }) - } - - #[gpui::test] - fn test_resolve_context_server_tool_name_conflicts() { - assert_resolve_context_server_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - ], - vec![TestTool::new( - "tool3", - ToolSource::ContextServer { id: "mcp-1".into() }, - )], - vec!["tool3"], - ); - - assert_resolve_context_server_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - ], - vec![ - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }), - ], - vec!["mcp-1_tool3", "mcp-2_tool3"], - ); - - assert_resolve_context_server_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - TestTool::new("tool3", ToolSource::Native), - ], - vec![ - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }), - ], - vec!["mcp-1_tool3", "mcp-2_tool3"], - ); - - // Test deduplication of tools with very long names, in this case the mcp server name should be truncated - assert_resolve_context_server_tool_name_conflicts( - vec![TestTool::new( - "tool-with-very-very-very-long-name", - ToolSource::Native, - )], - vec![TestTool::new( - "tool-with-very-very-very-long-name", - ToolSource::ContextServer { - id: "mcp-with-very-very-very-long-name".into(), - }, - )], - vec!["mcp-with-very-very-very-long-_tool-with-very-very-very-long-name"], - ); - - fn assert_resolve_context_server_tool_name_conflicts( - builtin_tools: Vec, - context_server_tools: Vec, - expected: Vec<&'static str>, - ) { - let context_server_tools: Vec> = context_server_tools - .into_iter() - .map(|t| Arc::new(t) as Arc) - .collect(); - let builtin_tools: Vec> = builtin_tools - .into_iter() - .map(|t| Arc::new(t) as Arc) - .collect(); - let tools = - resolve_context_server_tool_name_conflicts(&context_server_tools, &builtin_tools); - assert_eq!(tools.len(), expected.len()); - for (i, (name, _)) in tools.into_iter().enumerate() { - assert_eq!( - name.0.as_ref(), - expected[i], - "Expected '{}' got '{}' at index {}", - expected[i], - name, - i - ); - } - } - } - - struct TestTool { - name: String, - source: ToolSource, - } - - impl TestTool { - fn new(name: impl Into, source: ToolSource) -> Self { - Self { - name: name.into(), - source, - } - } - } - - impl Tool for TestTool { - fn name(&self) -> String { - self.name.clone() - } - - fn icon(&self) -> icons::IconName { - icons::IconName::Ai - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn needs_confirmation( - &self, - _input: &serde_json::Value, - _project: &Entity, - _cx: &App, - ) -> bool { - true - } - - fn source(&self) -> ToolSource { - self.source.clone() - } - - fn description(&self) -> String { - "Test tool".to_string() - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Test tool".to_string() - } - - fn run( - self: Arc, - _input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - _cx: &mut App, - ) -> ToolResult { - ToolResult { - output: Task::ready(Err(anyhow::anyhow!("No content"))), - card: None, - } - } - } -} diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml deleted file mode 100644 index 9b9b8196d1..0000000000 --- a/crates/assistant_tools/Cargo.toml +++ /dev/null @@ -1,92 +0,0 @@ -[package] -name = "assistant_tools" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/assistant_tools.rs" - -[features] -eval = [] - -[dependencies] -action_log.workspace = true -agent_settings.workspace = true -anyhow.workspace = true -assistant_tool.workspace = true -buffer_diff.workspace = true -chrono.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -collections.workspace = true -component.workspace = true -derive_more.workspace = true -diffy = "0.4.2" -editor.workspace = true -feature_flags.workspace = true -futures.workspace = true -gpui.workspace = true -handlebars = { workspace = true, features = ["rust-embed"] } -html_to_markdown.workspace = true -http_client.workspace = true -indoc.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true -log.workspace = true -lsp.workspace = true -markdown.workspace = true -open.workspace = true -paths.workspace = true -portable-pty.workspace = true -project.workspace = true -prompt_store.workspace = true -regex.workspace = true -rust-embed.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -smallvec.workspace = true -streaming_diff.workspace = true -strsim.workspace = true -task.workspace = true -terminal.workspace = true -terminal_view.workspace = true -theme.workspace = true -ui.workspace = true -util.workspace = true -watch.workspace = true -web_search.workspace = true -workspace-hack.workspace = true -workspace.workspace = true - -[dev-dependencies] -lsp = { workspace = true, features = ["test-support"] } -client = { workspace = true, features = ["test-support"] } -clock = { workspace = true, features = ["test-support"] } -collections = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -gpui_tokio.workspace = true -fs = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } -language_model = { workspace = true, features = ["test-support"] } -language_models.workspace = true -project = { workspace = true, features = ["test-support"] } -rand.workspace = true -pretty_assertions.workspace = true -reqwest_client.workspace = true -settings = { workspace = true, features = ["test-support"] } -smol.workspace = true -task = { workspace = true, features = ["test-support"]} -tempfile.workspace = true -theme.workspace = true -tree-sitter-rust.workspace = true -workspace = { workspace = true, features = ["test-support"] } -unindent.workspace = true -zlog.workspace = true diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs deleted file mode 100644 index 17e2ba12f7..0000000000 --- a/crates/assistant_tools/src/assistant_tools.rs +++ /dev/null @@ -1,167 +0,0 @@ -mod copy_path_tool; -mod create_directory_tool; -mod delete_path_tool; -mod diagnostics_tool; -pub mod edit_agent; -mod edit_file_tool; -mod fetch_tool; -mod find_path_tool; -mod grep_tool; -mod list_directory_tool; -mod move_path_tool; -mod now_tool; -mod open_tool; -mod project_notifications_tool; -mod read_file_tool; -mod schema; -pub mod templates; -mod terminal_tool; -mod thinking_tool; -mod ui; -mod web_search_tool; - -use assistant_tool::ToolRegistry; -use copy_path_tool::CopyPathTool; -use gpui::{App, Entity}; -use http_client::HttpClientWithUrl; -use language_model::LanguageModelRegistry; -use move_path_tool::MovePathTool; -use std::sync::Arc; -use web_search_tool::WebSearchTool; - -pub(crate) use templates::*; - -use crate::create_directory_tool::CreateDirectoryTool; -use crate::delete_path_tool::DeletePathTool; -use crate::diagnostics_tool::DiagnosticsTool; -use crate::edit_file_tool::EditFileTool; -use crate::fetch_tool::FetchTool; -use crate::list_directory_tool::ListDirectoryTool; -use crate::now_tool::NowTool; -use crate::thinking_tool::ThinkingTool; - -pub use edit_file_tool::{EditFileMode, EditFileToolInput}; -pub use find_path_tool::*; -pub use grep_tool::{GrepTool, GrepToolInput}; -pub use open_tool::OpenTool; -pub use project_notifications_tool::ProjectNotificationsTool; -pub use read_file_tool::{ReadFileTool, ReadFileToolInput}; -pub use terminal_tool::TerminalTool; - -pub fn init(http_client: Arc, cx: &mut App) { - assistant_tool::init(cx); - - let registry = ToolRegistry::global(cx); - registry.register_tool(TerminalTool); - registry.register_tool(CreateDirectoryTool); - registry.register_tool(CopyPathTool); - registry.register_tool(DeletePathTool); - registry.register_tool(MovePathTool); - registry.register_tool(DiagnosticsTool); - registry.register_tool(ListDirectoryTool); - registry.register_tool(NowTool); - registry.register_tool(OpenTool); - registry.register_tool(ProjectNotificationsTool); - registry.register_tool(FindPathTool); - registry.register_tool(ReadFileTool); - registry.register_tool(GrepTool); - registry.register_tool(ThinkingTool); - registry.register_tool(FetchTool::new(http_client)); - registry.register_tool(EditFileTool); - - register_web_search_tool(&LanguageModelRegistry::global(cx), cx); - cx.subscribe( - &LanguageModelRegistry::global(cx), - move |registry, event, cx| { - if let language_model::Event::DefaultModelChanged = event { - register_web_search_tool(®istry, cx); - } - }, - ) - .detach(); -} - -fn register_web_search_tool(registry: &Entity, cx: &mut App) { - let using_zed_provider = registry - .read(cx) - .default_model() - .is_some_and(|default| default.is_provided_by_zed()); - if using_zed_provider { - ToolRegistry::global(cx).register_tool(WebSearchTool); - } else { - ToolRegistry::global(cx).unregister_tool(WebSearchTool); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use agent_settings::AgentSettings; - use client::Client; - use clock::FakeSystemClock; - use http_client::FakeHttpClient; - use schemars::JsonSchema; - use serde::Serialize; - use settings::Settings; - - #[test] - fn test_json_schema() { - #[derive(Serialize, JsonSchema)] - struct GetWeatherTool { - location: String, - } - - let schema = schema::json_schema_for::( - language_model::LanguageModelToolSchemaFormat::JsonSchema, - ) - .unwrap(); - - assert_eq!( - schema, - serde_json::json!({ - "type": "object", - "properties": { - "location": { - "type": "string" - } - }, - "required": ["location"], - "additionalProperties": false - }) - ); - } - - #[gpui::test] - fn test_builtin_tool_schema_compatibility(cx: &mut App) { - settings::init(cx); - AgentSettings::register(cx); - - let client = Client::new( - Arc::new(FakeSystemClock::new()), - FakeHttpClient::with_200_response(), - cx, - ); - language_model::init(client.clone(), cx); - crate::init(client.http_client(), cx); - - for tool in ToolRegistry::global(cx).tools() { - let actual_schema = tool - .input_schema(language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset) - .unwrap(); - let mut expected_schema = actual_schema.clone(); - assistant_tool::adapt_schema_to_format( - &mut expected_schema, - language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset, - ) - .unwrap(); - - let error_message = format!( - "Tool schema for `{}` is not compatible with `language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset` (Gemini Models).\n\ - Are you using `schema::json_schema_for(format)` to generate the schema?", - tool.name(), - ); - - assert_eq!(actual_schema, expected_schema, "{}", error_message) - } - } -} diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs deleted file mode 100644 index 572eddcb10..0000000000 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::AnyWindowHandle; -use gpui::{App, AppContext, Entity, Task}; -use language_model::LanguageModel; -use language_model::{LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct CopyPathToolInput { - /// The source path of the file or directory to copy. - /// If a directory is specified, its contents will be copied recursively (like `cp -r`). - /// - /// - /// If the project has the following files: - /// - /// - directory1/a/something.txt - /// - directory2/a/things.txt - /// - directory3/a/other.txt - /// - /// You can copy the first file by providing a source_path of "directory1/a/something.txt" - /// - pub source_path: String, - - /// The destination path where the file or directory should be copied to. - /// - /// - /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", - /// provide a destination_path of "directory2/b/copy.txt" - /// - pub destination_path: String, -} - -pub struct CopyPathTool; - -impl Tool for CopyPathTool { - fn name(&self) -> String { - "copy_path".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("./copy_path_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolCopy - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let src = MarkdownInlineCode(&input.source_path); - let dest = MarkdownInlineCode(&input.destination_path); - format!("Copy {src} to {dest}") - } - Err(_) => "Copy path".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let copy_task = project.update(cx, |project, cx| { - match project - .find_project_path(&input.source_path, cx) - .and_then(|project_path| project.entry_for_path(&project_path, cx)) - { - Some(entity) => match project.find_project_path(&input.destination_path, cx) { - Some(project_path) => project.copy_entry(entity.id, project_path, cx), - None => Task::ready(Err(anyhow!( - "Destination path {} was outside the project.", - input.destination_path - ))), - }, - None => Task::ready(Err(anyhow!( - "Source path {} was not found in the project.", - input.source_path - ))), - } - }); - - cx.background_spawn(async move { - let _ = copy_task.await.with_context(|| { - format!( - "Copying {} to {}", - input.source_path, input.destination_path - ) - })?; - Ok(format!("Copied {} to {}", input.source_path, input.destination_path).into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/copy_path_tool/description.md b/crates/assistant_tools/src/copy_path_tool/description.md deleted file mode 100644 index a5105e6f18..0000000000 --- a/crates/assistant_tools/src/copy_path_tool/description.md +++ /dev/null @@ -1,6 +0,0 @@ -Copies a file or directory in the project, and returns confirmation that the copy succeeded. -Directory contents will be copied recursively (like `cp -r`). - -This tool should be used when it's desirable to create a copy of a file or directory without modifying the original. -It's much more efficient than doing this by separately reading and then writing the file or directory's contents, -so this tool should be preferred over that approach whenever copying is the goal. diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs deleted file mode 100644 index 85eea463dc..0000000000 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ /dev/null @@ -1,100 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::AnyWindowHandle; -use gpui::{App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct CreateDirectoryToolInput { - /// The path of the new directory. - /// - /// - /// If the project has the following structure: - /// - /// - directory1/ - /// - directory2/ - /// - /// You can create a new directory by providing a path of "directory1/new_directory" - /// - pub path: String, -} - -pub struct CreateDirectoryTool; - -impl Tool for CreateDirectoryTool { - fn name(&self) -> String { - "create_directory".into() - } - - fn description(&self) -> String { - include_str!("./create_directory_tool/description.md").into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn icon(&self) -> IconName { - IconName::ToolFolder - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - format!("Create directory {}", MarkdownInlineCode(&input.path)) - } - Err(_) => "Create directory".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let project_path = match project.read(cx).find_project_path(&input.path, cx) { - Some(project_path) => project_path, - None => { - return Task::ready(Err(anyhow!("Path to create was outside the project"))).into(); - } - }; - let destination_path: Arc = input.path.as_str().into(); - - cx.spawn(async move |cx| { - project - .update(cx, |project, cx| { - project.create_entry(project_path.clone(), true, cx) - })? - .await - .with_context(|| format!("Creating directory {destination_path}"))?; - - Ok(format!("Created directory {destination_path}").into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/create_directory_tool/description.md b/crates/assistant_tools/src/create_directory_tool/description.md deleted file mode 100644 index 52056518c2..0000000000 --- a/crates/assistant_tools/src/create_directory_tool/description.md +++ /dev/null @@ -1,3 +0,0 @@ -Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. - -This tool creates a directory and all necessary parent directories (similar to `mkdir -p`). It should be used whenever you need to create new directories within the project. diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs deleted file mode 100644 index 7c85f1ed75..0000000000 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ /dev/null @@ -1,144 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use futures::{SinkExt, StreamExt, channel::mpsc}; -use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::{Project, ProjectPath}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use ui::IconName; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct DeletePathToolInput { - /// The path of the file or directory to delete. - /// - /// - /// If the project has the following files: - /// - /// - directory1/a/something.txt - /// - directory2/a/things.txt - /// - directory3/a/other.txt - /// - /// You can delete the first file by providing a path of "directory1/a/something.txt" - /// - pub path: String, -} - -pub struct DeletePathTool; - -impl Tool for DeletePathTool { - fn name(&self) -> String { - "delete_path".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - true - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("./delete_path_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolDeleteFile - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => format!("Delete “`{}`”", input.path), - Err(_) => "Delete path".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let path_str = match serde_json::from_value::(input) { - Ok(input) => input.path, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let Some(project_path) = project.read(cx).find_project_path(&path_str, cx) else { - return Task::ready(Err(anyhow!( - "Couldn't delete {path_str} because that path isn't in this project." - ))) - .into(); - }; - - let Some(worktree) = project - .read(cx) - .worktree_for_id(project_path.worktree_id, cx) - else { - return Task::ready(Err(anyhow!( - "Couldn't delete {path_str} because that path isn't in this project." - ))) - .into(); - }; - - let worktree_snapshot = worktree.read(cx).snapshot(); - let (mut paths_tx, mut paths_rx) = mpsc::channel(256); - cx.background_spawn({ - let project_path = project_path.clone(); - async move { - for entry in - worktree_snapshot.traverse_from_path(true, false, false, &project_path.path) - { - if !entry.path.starts_with(&project_path.path) { - break; - } - paths_tx - .send(ProjectPath { - worktree_id: project_path.worktree_id, - path: entry.path.clone(), - }) - .await?; - } - anyhow::Ok(()) - } - }) - .detach(); - - cx.spawn(async move |cx| { - while let Some(path) = paths_rx.next().await { - if let Ok(buffer) = project - .update(cx, |project, cx| project.open_buffer(path, cx))? - .await - { - action_log.update(cx, |action_log, cx| { - action_log.will_delete_buffer(buffer.clone(), cx) - })?; - } - } - - let deletion_task = project - .update(cx, |project, cx| { - project.delete_file(project_path, false, cx) - })? - .with_context(|| { - format!("Couldn't delete {path_str} because that path isn't in this project.") - })?; - deletion_task - .await - .with_context(|| format!("Deleting {path_str}"))?; - Ok(format!("Deleted {path_str}").into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/delete_path_tool/description.md b/crates/assistant_tools/src/delete_path_tool/description.md deleted file mode 100644 index dfd4388bf0..0000000000 --- a/crates/assistant_tools/src/delete_path_tool/description.md +++ /dev/null @@ -1 +0,0 @@ -Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion. diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs deleted file mode 100644 index 75bd683512..0000000000 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ /dev/null @@ -1,171 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language::{DiagnosticSeverity, OffsetRangeExt}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{fmt::Write, sync::Arc}; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct DiagnosticsToolInput { - /// The path to get diagnostics for. If not provided, returns a project-wide summary. - /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. - /// - /// - /// If the project has the following root directories: - /// - /// - lorem - /// - ipsum - /// - /// If you wanna access diagnostics for `dolor.txt` in `ipsum`, you should use the path `ipsum/dolor.txt`. - /// - #[serde(deserialize_with = "deserialize_path")] - pub path: Option, -} - -fn deserialize_path<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let opt = Option::::deserialize(deserializer)?; - // The model passes an empty string sometimes - Ok(opt.filter(|s| !s.is_empty())) -} - -pub struct DiagnosticsTool; - -impl Tool for DiagnosticsTool { - fn name(&self) -> String { - "diagnostics".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./diagnostics_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolDiagnostics - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - if let Some(path) = serde_json::from_value::(input.clone()) - .ok() - .and_then(|input| match input.path { - Some(path) if !path.is_empty() => Some(path), - _ => None, - }) - { - format!("Check diagnostics for {}", MarkdownInlineCode(&path)) - } else { - "Check project diagnostics".to_string() - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - match serde_json::from_value::(input) - .ok() - .and_then(|input| input.path) - { - Some(path) if !path.is_empty() => { - let Some(project_path) = project.read(cx).find_project_path(&path, cx) else { - return Task::ready(Err(anyhow!("Could not find path {path} in project",))) - .into(); - }; - - let buffer = - project.update(cx, |project, cx| project.open_buffer(project_path, cx)); - - cx.spawn(async move |cx| { - let mut output = String::new(); - let buffer = buffer.await?; - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - for (_, group) in snapshot.diagnostic_groups(None) { - let entry = &group.entries[group.primary_ix]; - let range = entry.range.to_point(&snapshot); - let severity = match entry.diagnostic.severity { - DiagnosticSeverity::ERROR => "error", - DiagnosticSeverity::WARNING => "warning", - _ => continue, - }; - - writeln!( - output, - "{} at line {}: {}", - severity, - range.start.row + 1, - entry.diagnostic.message - )?; - } - - if output.is_empty() { - Ok("File doesn't have errors or warnings!".to_string().into()) - } else { - Ok(output.into()) - } - }) - .into() - } - _ => { - let project = project.read(cx); - let mut output = String::new(); - let mut has_diagnostics = false; - - for (project_path, _, summary) in project.diagnostic_summaries(true, cx) { - if summary.error_count > 0 || summary.warning_count > 0 { - let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) - else { - continue; - }; - - has_diagnostics = true; - output.push_str(&format!( - "{}: {} error(s), {} warning(s)\n", - worktree.read(cx).absolutize(&project_path.path).display(), - summary.error_count, - summary.warning_count - )); - } - } - - if has_diagnostics { - Task::ready(Ok(output.into())).into() - } else { - Task::ready(Ok("No errors or warnings found in the project." - .to_string() - .into())) - .into() - } - } - } - } -} diff --git a/crates/assistant_tools/src/diagnostics_tool/description.md b/crates/assistant_tools/src/diagnostics_tool/description.md deleted file mode 100644 index 90dc00f1e4..0000000000 --- a/crates/assistant_tools/src/diagnostics_tool/description.md +++ /dev/null @@ -1,21 +0,0 @@ -Get errors and warnings for the project or a specific file. - -This tool can be invoked after a series of edits to determine if further edits are necessary, or if the user asks to fix errors or warnings in their codebase. - -When a path is provided, shows all diagnostics for that specific file. -When no path is provided, shows a summary of error and warning counts for all files in the project. - - -To get diagnostics for a specific file: -{ - "path": "src/main.rs" -} - -To get a project-wide diagnostic summary: -{} - - - -- If you think you can fix a diagnostic, make 1-2 attempts and then give up. -- Don't remove code you've generated just because you can't fix an error. The user can help you fix it. - diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs deleted file mode 100644 index f88978650a..0000000000 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ /dev/null @@ -1,2423 +0,0 @@ -use crate::{ - Templates, - edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}, - schema::json_schema_for, - ui::{COLLAPSED_LINES, ToolOutputPreview}, -}; -use action_log::ActionLog; -use agent_settings; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ - AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, -}; -use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use editor::{ - Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey, multibuffer_context_lines, -}; -use futures::StreamExt; -use gpui::{ - Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task, - TextStyleRefinement, WeakEntity, pulsating_between, -}; -use indoc::formatdoc; -use language::{ - Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope, - TextBuffer, - language_settings::{self, FormatOnSave, SoftWrap}, -}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use paths; -use project::{ - Project, ProjectPath, - lsp_store::{FormatTrigger, LspFormatTarget}, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::{ - cmp::Reverse, - collections::HashSet, - ffi::OsStr, - ops::Range, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; -use theme::ThemeSettings; -use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*}; -use util::{ResultExt, rel_path::RelPath}; -use workspace::Workspace; - -pub struct EditFileTool; - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct EditFileToolInput { - /// A one-line, user-friendly markdown description of the edit. This will be - /// shown in the UI and also passed to another model to perform the edit. - /// - /// Be terse, but also descriptive in what you want to achieve with this - /// edit. Avoid generic instructions. - /// - /// NEVER mention the file path in this description. - /// - /// Fix API endpoint URLs - /// Update copyright year in `page_footer` - /// - /// Make sure to include this field before all the others in the input object - /// so that we can display it immediately. - pub display_description: String, - - /// The full path of the file to create or modify in the project. - /// - /// WARNING: When specifying which file path need changing, you MUST - /// start each path with one of the project's root directories. - /// - /// The following examples assume we have two root directories in the project: - /// - /a/b/backend - /// - /c/d/frontend - /// - /// - /// `backend/src/main.rs` - /// - /// Notice how the file path starts with `backend`. Without that, the path - /// would be ambiguous and the call would fail! - /// - /// - /// - /// `frontend/db.js` - /// - pub path: PathBuf, - - /// The mode of operation on the file. Possible values: - /// - 'edit': Make granular edits to an existing file. - /// - 'create': Create a new file if it doesn't exist. - /// - 'overwrite': Replace the entire contents of an existing file. - /// - /// When a file already exists or you just created it, prefer editing - /// it as opposed to recreating it from scratch. - pub mode: EditFileMode, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum EditFileMode { - Edit, - Create, - Overwrite, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct EditFileToolOutput { - pub original_path: PathBuf, - pub new_text: String, - pub old_text: Arc, - pub raw_output: Option, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -struct PartialInput { - #[serde(default)] - path: String, - #[serde(default)] - display_description: String, -} - -const DEFAULT_UI_TEXT: &str = "Editing file"; - -impl Tool for EditFileTool { - fn name(&self) -> String { - "edit_file".into() - } - - fn needs_confirmation( - &self, - input: &serde_json::Value, - project: &Entity, - cx: &App, - ) -> bool { - if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { - return false; - } - - let Ok(input) = serde_json::from_value::(input.clone()) else { - // If it's not valid JSON, it's going to error and confirming won't do anything. - return false; - }; - - // If any path component matches the local settings folder, then this could affect - // the editor in ways beyond the project source, so prompt. - let local_settings_folder = paths::local_settings_folder_name(); - let path = Path::new(&input.path); - if path - .components() - .any(|c| c.as_os_str() == >::as_ref(local_settings_folder)) - { - return true; - } - - // It's also possible that the global config dir is configured to be inside the project, - // so check for that edge case too. - if let Ok(canonical_path) = std::fs::canonicalize(&input.path) - && canonical_path.starts_with(paths::config_dir()) - { - return true; - } - - // Check if path is inside the global config directory - // First check if it's already inside project - if not, try to canonicalize - let project_path = project.read(cx).find_project_path(&input.path, cx); - - // If the path is inside the project, and it's not one of the above edge cases, - // then no confirmation is necessary. Otherwise, confirmation is necessary. - project_path.is_none() - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("edit_file_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolPencil - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let path = Path::new(&input.path); - let mut description = input.display_description.clone(); - - // Add context about why confirmation may be needed - let local_settings_folder = paths::local_settings_folder_name(); - if path - .components() - .any(|c| c.as_os_str() == >::as_ref(local_settings_folder)) - { - description.push_str(" (local settings)"); - } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) - && canonical_path.starts_with(paths::config_dir()) - { - description.push_str(" (global settings)"); - } - - description - } - Err(_) => "Editing file".to_string(), - } - } - - fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String { - if let Some(input) = serde_json::from_value::(input.clone()).ok() { - let description = input.display_description.trim(); - if !description.is_empty() { - return description.to_string(); - } - - let path = input.path.trim(); - if !path.is_empty() { - return path.to_string(); - } - } - - DEFAULT_UI_TEXT.to_string() - } - - fn run( - self: Arc, - input: serde_json::Value, - request: Arc, - project: Entity, - action_log: Entity, - model: Arc, - window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let project_path = match resolve_path(&input, project.clone(), cx) { - Ok(path) => path, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let card = window.and_then(|window| { - window - .update(cx, |_, window, cx| { - cx.new(|cx| { - EditFileToolCard::new(input.path.clone(), project.clone(), window, cx) - }) - }) - .ok() - }); - - let card_clone = card.clone(); - let action_log_clone = action_log.clone(); - let task = cx.spawn(async move |cx: &mut AsyncApp| { - let edit_format = EditFormat::from_model(model.clone())?; - let edit_agent = EditAgent::new( - model, - project.clone(), - action_log_clone, - Templates::new(), - edit_format, - ); - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(project_path.clone(), cx) - })? - .await?; - - let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let old_text = cx - .background_spawn({ - let old_snapshot = old_snapshot.clone(); - async move { Arc::new(old_snapshot.text()) } - }) - .await; - - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?; - } - - let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) { - edit_agent.edit( - buffer.clone(), - input.display_description.clone(), - &request, - cx, - ) - } else { - edit_agent.overwrite( - buffer.clone(), - input.display_description.clone(), - &request, - cx, - ) - }; - - let mut hallucinated_old_text = false; - let mut ambiguous_ranges = Vec::new(); - while let Some(event) = events.next().await { - match event { - EditAgentOutputEvent::Edited { .. } => { - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.update_diff(cx))?; - } - } - EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true, - EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges, - EditAgentOutputEvent::ResolvingEditRange(range) => { - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.reveal_range(range, cx))?; - } - } - } - } - let agent_output = output.await?; - - // If format_on_save is enabled, format the buffer - let format_on_save_enabled = buffer - .read_with(cx, |buffer, cx| { - let settings = language_settings::language_settings( - buffer.language().map(|l| l.name()), - buffer.file(), - cx, - ); - !matches!(settings.format_on_save, FormatOnSave::Off) - }) - .unwrap_or(false); - - if format_on_save_enabled { - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - })?; - let format_task = project.update(cx, |project, cx| { - project.format( - HashSet::from_iter([buffer.clone()]), - LspFormatTarget::Buffers, - false, // Don't push to history since the tool did it. - FormatTrigger::Save, - cx, - ) - })?; - format_task.await.log_err(); - } - - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? - .await?; - - // Notify the action log that we've edited the buffer (*after* formatting has completed). - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - })?; - - let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let (new_text, diff) = cx - .background_spawn({ - let new_snapshot = new_snapshot.clone(); - let old_text = old_text.clone(); - async move { - let new_text = new_snapshot.text(); - let diff = language::unified_diff(&old_text, &new_text); - - (new_text, diff) - } - }) - .await; - - let output = EditFileToolOutput { - original_path: project_path.path.as_std_path().to_owned(), - new_text, - old_text, - raw_output: Some(agent_output), - }; - - if let Some(card) = card_clone { - card.update(cx, |card, cx| { - card.update_diff(cx); - card.finalize(cx) - }) - .log_err(); - } - - let input_path = input.path.display(); - if diff.is_empty() { - anyhow::ensure!( - !hallucinated_old_text, - formatdoc! {" - Some edits were produced but none of them could be applied. - Read the relevant sections of {input_path} again so that - I can perform the requested edits. - "} - ); - anyhow::ensure!( - ambiguous_ranges.is_empty(), - { - let line_numbers = ambiguous_ranges - .iter() - .map(|range| range.start.to_string()) - .collect::>() - .join(", "); - formatdoc! {" - matches more than one position in the file (lines: {line_numbers}). Read the - relevant sections of {input_path} again and extend so - that I can perform the requested edits. - "} - } - ); - Ok(ToolResultOutput { - content: ToolResultContent::Text("No edits were made.".into()), - output: serde_json::to_value(output).ok(), - }) - } else { - Ok(ToolResultOutput { - content: ToolResultContent::Text(format!( - "Edited {}:\n\n```diff\n{}\n```", - input_path, diff - )), - output: serde_json::to_value(output).ok(), - }) - } - }); - - ToolResult { - output: task, - card: card.map(AnyToolCard::from), - } - } - - fn deserialize_card( - self: Arc, - output: serde_json::Value, - project: Entity, - window: &mut Window, - cx: &mut App, - ) -> Option { - let output = match serde_json::from_value::(output) { - Ok(output) => output, - Err(_) => return None, - }; - - let card = cx.new(|cx| { - EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx) - }); - - cx.spawn({ - let path: Arc = output.original_path.into(); - let language_registry = project.read(cx).languages().clone(); - let card = card.clone(); - async move |cx| { - let buffer = - build_buffer(output.new_text, path.clone(), &language_registry, cx).await?; - let buffer_diff = - build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx) - .await?; - card.update(cx, |card, cx| { - card.multibuffer.update(cx, |multibuffer, cx| { - let snapshot = buffer.read(cx).snapshot(); - let diff = buffer_diff.read(cx); - let diff_hunk_ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)) - .collect::>(); - - multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&buffer, cx), - buffer, - diff_hunk_ranges, - multibuffer_context_lines(cx), - cx, - ); - multibuffer.add_diff(buffer_diff, cx); - let end = multibuffer.len(cx); - card.total_lines = - Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1); - }); - - cx.notify(); - })?; - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - - Some(card.into()) - } -} - -/// Validate that the file path is valid, meaning: -/// -/// - For `edit` and `overwrite`, the path must point to an existing file. -/// - For `create`, the file must not already exist, but it's parent dir must exist. -fn resolve_path( - input: &EditFileToolInput, - project: Entity, - cx: &mut App, -) -> Result { - let project = project.read(cx); - - match input.mode { - EditFileMode::Edit | EditFileMode::Overwrite => { - let path = project - .find_project_path(&input.path, cx) - .context("Can't edit file: path not found")?; - - let entry = project - .entry_for_path(&path, cx) - .context("Can't edit file: path not found")?; - - anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); - Ok(path) - } - - EditFileMode::Create => { - if let Some(path) = project.find_project_path(&input.path, cx) { - anyhow::ensure!( - project.entry_for_path(&path, cx).is_none(), - "Can't create file: file already exists" - ); - } - - let parent_path = input - .path - .parent() - .context("Can't create file: incorrect path")?; - - let parent_project_path = project.find_project_path(&parent_path, cx); - - let parent_entry = parent_project_path - .as_ref() - .and_then(|path| project.entry_for_path(path, cx)) - .context("Can't create file: parent directory doesn't exist")?; - - anyhow::ensure!( - parent_entry.is_dir(), - "Can't create file: parent is not a directory" - ); - - let file_name = input - .path - .file_name() - .and_then(|file_name| file_name.to_str()) - .context("Can't create file: invalid filename")?; - - let new_file_path = parent_project_path.map(|parent| ProjectPath { - path: parent.path.join(RelPath::unix(file_name).unwrap()), - ..parent - }); - - new_file_path.context("Can't create file") - } - } -} - -pub struct EditFileToolCard { - path: PathBuf, - editor: Entity, - multibuffer: Entity, - project: Entity, - buffer: Option>, - base_text: Option>, - buffer_diff: Option>, - revealed_ranges: Vec>, - diff_task: Option>>, - preview_expanded: bool, - error_expanded: Option>, - full_height_expanded: bool, - total_lines: Option, -} - -impl EditFileToolCard { - pub fn new(path: PathBuf, project: Entity, window: &mut Window, cx: &mut App) -> Self { - let expand_edit_card = agent_settings::AgentSettings::get_global(cx).expand_edit_card; - let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly)); - - let editor = cx.new(|cx| { - let mut editor = Editor::new( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sized_by_content: true, - }, - multibuffer.clone(), - Some(project.clone()), - window, - cx, - ); - editor.set_show_gutter(false, cx); - editor.disable_inline_diagnostics(); - editor.disable_expand_excerpt_buttons(cx); - // Keep horizontal scrollbar so user can scroll horizontally if needed - editor.set_show_vertical_scrollbar(false, cx); - editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); - editor.set_soft_wrap_mode(SoftWrap::None, cx); - editor.scroll_manager.set_forbid_vertical_scroll(true); - editor.set_show_indent_guides(false, cx); - editor.set_read_only(true); - editor.set_show_breakpoints(false, cx); - editor.set_show_code_actions(false, cx); - editor.set_show_git_diff_gutter(false, cx); - editor.set_expand_all_diff_hunks(cx); - editor - }); - Self { - path, - project, - editor, - multibuffer, - buffer: None, - base_text: None, - buffer_diff: None, - revealed_ranges: Vec::new(), - diff_task: None, - preview_expanded: true, - error_expanded: None, - full_height_expanded: expand_edit_card, - total_lines: None, - } - } - - pub fn initialize(&mut self, buffer: Entity, cx: &mut App) { - let buffer_snapshot = buffer.read(cx).snapshot(); - let base_text = buffer_snapshot.text(); - let language_registry = buffer.read(cx).language_registry(); - let text_snapshot = buffer.read(cx).text_snapshot(); - - // Create a buffer diff with the current text as the base - let buffer_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&text_snapshot, cx); - let _ = diff.set_base_text( - buffer_snapshot.clone(), - language_registry, - text_snapshot, - cx, - ); - diff - }); - - self.buffer = Some(buffer); - self.base_text = Some(base_text.into()); - self.buffer_diff = Some(buffer_diff.clone()); - - // Add the diff to the multibuffer - self.multibuffer - .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx)); - } - - pub fn is_loading(&self) -> bool { - self.total_lines.is_none() - } - - pub fn update_diff(&mut self, cx: &mut Context) { - let Some(buffer) = self.buffer.as_ref() else { - return; - }; - let Some(buffer_diff) = self.buffer_diff.as_ref() else { - return; - }; - - let buffer = buffer.clone(); - let buffer_diff = buffer_diff.clone(); - let base_text = self.base_text.clone(); - self.diff_task = Some(cx.spawn(async move |this, cx| { - let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?; - let diff_snapshot = BufferDiff::update_diff( - buffer_diff.clone(), - text_snapshot.clone(), - base_text, - false, - false, - None, - None, - cx, - ) - .await?; - buffer_diff.update(cx, |diff, cx| { - diff.set_snapshot(diff_snapshot, &text_snapshot, cx) - })?; - this.update(cx, |this, cx| this.update_visible_ranges(cx)) - })); - } - - pub fn reveal_range(&mut self, range: Range, cx: &mut Context) { - self.revealed_ranges.push(range); - self.update_visible_ranges(cx); - } - - fn update_visible_ranges(&mut self, cx: &mut Context) { - let Some(buffer) = self.buffer.as_ref() else { - return; - }; - - let ranges = self.excerpt_ranges(cx); - self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.set_excerpts_for_path( - PathKey::for_buffer(buffer, cx), - buffer.clone(), - ranges, - multibuffer_context_lines(cx), - cx, - ); - let end = multibuffer.len(cx); - Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1) - }); - cx.notify(); - } - - fn excerpt_ranges(&self, cx: &App) -> Vec> { - let Some(buffer) = self.buffer.as_ref() else { - return Vec::new(); - }; - let Some(diff) = self.buffer_diff.as_ref() else { - return Vec::new(); - }; - - let buffer = buffer.read(cx); - let diff = diff.read(cx); - let mut ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) - .collect::>(); - ranges.extend( - self.revealed_ranges - .iter() - .map(|range| range.to_point(buffer)), - ); - ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); - - // Merge adjacent ranges - let mut ranges = ranges.into_iter().peekable(); - let mut merged_ranges = Vec::new(); - while let Some(mut range) = ranges.next() { - while let Some(next_range) = ranges.peek() { - if range.end >= next_range.start { - range.end = range.end.max(next_range.end); - ranges.next(); - } else { - break; - } - } - - merged_ranges.push(range); - } - merged_ranges - } - - pub fn finalize(&mut self, cx: &mut Context) -> Result<()> { - let ranges = self.excerpt_ranges(cx); - let buffer = self.buffer.take().context("card was already finalized")?; - let base_text = self - .base_text - .take() - .context("card was already finalized")?; - let language_registry = self.project.read(cx).languages().clone(); - - // Replace the buffer in the multibuffer with the snapshot - let buffer = cx.new(|cx| { - let language = buffer.read(cx).language().cloned(); - let buffer = TextBuffer::new_normalized( - 0, - cx.entity_id().as_non_zero_u64().into(), - buffer.read(cx).line_ending(), - buffer.read(cx).as_rope().clone(), - ); - let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); - buffer.set_language(language, cx); - buffer - }); - - let buffer_diff = cx.spawn({ - let buffer = buffer.clone(); - async move |_this, cx| { - build_buffer_diff(base_text, &buffer, &language_registry, cx).await - } - }); - - cx.spawn(async move |this, cx| { - let buffer_diff = buffer_diff.await?; - this.update(cx, |this, cx| { - this.multibuffer.update(cx, |multibuffer, cx| { - let path_key = PathKey::for_buffer(&buffer, cx); - multibuffer.clear(cx); - multibuffer.set_excerpts_for_path( - path_key, - buffer, - ranges, - multibuffer_context_lines(cx), - cx, - ); - multibuffer.add_diff(buffer_diff.clone(), cx); - }); - - cx.notify(); - }) - }) - .detach_and_log_err(cx); - Ok(()) - } -} - -impl ToolCard for EditFileToolCard { - fn render( - &mut self, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let error_message = match status { - ToolUseStatus::Error(err) => Some(err), - _ => None, - }; - - let running_or_pending = match status { - ToolUseStatus::Running | ToolUseStatus::Pending => Some(()), - _ => None, - }; - - let should_show_loading = running_or_pending.is_some() && !self.full_height_expanded; - - let path_label_button = h_flex() - .id(("edit-tool-path-label-button", self.editor.entity_id())) - .w_full() - .max_w_full() - .px_1() - .gap_0p5() - .cursor_pointer() - .rounded_sm() - .opacity(0.8) - .hover(|label| { - label - .opacity(1.) - .bg(cx.theme().colors().element_hover.opacity(0.5)) - }) - .tooltip(Tooltip::text("Jump to File")) - .child( - h_flex() - .child( - Icon::new(IconName::ToolPencil) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - div() - .text_size(rems(0.8125)) - .child(self.path.display().to_string()) - .ml_1p5() - .mr_0p5(), - ) - .child( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Small) - .color(Color::Ignored), - ), - ) - .on_click({ - let path = self.path.clone(); - move |_, window, cx| { - workspace - .update(cx, { - |workspace, cx| { - let Some(project_path) = - workspace.project().read(cx).find_project_path(&path, cx) - else { - return; - }; - let open_task = - workspace.open_path(project_path, None, true, window, cx); - window - .spawn(cx, async move |cx| { - let item = open_task.await?; - if let Some(active_editor) = item.downcast::() { - active_editor - .update_in(cx, |editor, window, cx| { - let snapshot = - editor.buffer().read(cx).snapshot(cx); - let first_hunk = editor - .diff_hunks_in_ranges( - &[editor::Anchor::min() - ..editor::Anchor::max()], - &snapshot, - ) - .next(); - if let Some(first_hunk) = first_hunk { - let first_hunk_start = - first_hunk.multi_buffer_range().start; - editor.change_selections( - Default::default(), - window, - cx, - |selections| { - selections.select_anchor_ranges([ - first_hunk_start - ..first_hunk_start, - ]); - }, - ) - } - }) - .log_err(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - }) - .ok(); - } - }) - .into_any_element(); - - let codeblock_header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - - let codeblock_header = h_flex() - .flex_none() - .p_1() - .gap_1() - .justify_between() - .rounded_t_md() - .when(error_message.is_none(), |header| { - header.bg(codeblock_header_bg) - }) - .child(path_label_button) - .when(should_show_loading, |header| { - header.pr_1p5().child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_rotate_animation(2), - ) - }) - .when_some(error_message, |header, error_message| { - header.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .child( - Disclosure::new( - ("edit-file-error-disclosure", self.editor.entity_id()), - self.error_expanded.is_some(), - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - let error_message = error_message.clone(); - - move |this, _event, _window, cx| { - if this.error_expanded.is_some() { - this.error_expanded.take(); - } else { - this.error_expanded = Some(cx.new(|cx| { - Markdown::new(error_message.clone(), None, None, cx) - })) - } - cx.notify(); - } - })), - ), - ) - }) - .when(error_message.is_none() && !self.is_loading(), |header| { - header.child( - Disclosure::new( - ("edit-file-disclosure", self.editor.entity_id()), - self.preview_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener( - move |this, _event, _window, _cx| { - this.preview_expanded = !this.preview_expanded; - }, - )), - ) - }); - - let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| { - let line_height = editor - .style() - .map(|style| style.text.line_height_in_pixels(window.rem_size())) - .unwrap_or_default(); - - editor.set_text_style_refinement(TextStyleRefinement { - font_size: Some( - TextSize::Small - .rems(cx) - .to_pixels(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) - .into(), - ), - ..TextStyleRefinement::default() - }); - let element = editor.render(window, cx); - (element.into_any_element(), line_height) - }); - - let border_color = cx.theme().colors().border.opacity(0.6); - - let waiting_for_diff = { - let styles = [ - ("w_4_5", (0.1, 0.85), 2000), - ("w_1_4", (0.2, 0.75), 2200), - ("w_2_4", (0.15, 0.64), 1900), - ("w_3_5", (0.25, 0.72), 2300), - ("w_2_5", (0.3, 0.56), 1800), - ]; - - let mut container = v_flex() - .p_3() - .gap_1() - .border_t_1() - .rounded_b_md() - .border_color(border_color) - .bg(cx.theme().colors().editor_background); - - for (width_method, pulse_range, duration_ms) in styles.iter() { - let (min_opacity, max_opacity) = *pulse_range; - let placeholder = match *width_method { - "w_4_5" => div().w_3_4(), - "w_1_4" => div().w_1_4(), - "w_2_4" => div().w_2_4(), - "w_3_5" => div().w_3_5(), - "w_2_5" => div().w_2_5(), - _ => div().w_1_2(), - } - .id("loading_div") - .h_1() - .rounded_full() - .bg(cx.theme().colors().element_active) - .with_animation( - "loading_pulsate", - Animation::new(Duration::from_millis(*duration_ms)) - .repeat() - .with_easing(pulsating_between(min_opacity, max_opacity)), - |label, delta| label.opacity(delta), - ); - - container = container.child(placeholder); - } - - container - }; - - v_flex() - .mb_2() - .border_1() - .when(error_message.is_some(), |card| card.border_dashed()) - .border_color(border_color) - .rounded_md() - .overflow_hidden() - .child(codeblock_header) - .when_some(self.error_expanded.as_ref(), |card, error_markdown| { - card.child( - v_flex() - .p_2() - .gap_1() - .border_t_1() - .border_dashed() - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .rounded_b_md() - .child( - Label::new("Error") - .size(LabelSize::XSmall) - .color(Color::Error), - ) - .child( - div() - .rounded_md() - .text_ui_sm(cx) - .bg(cx.theme().colors().editor_background) - .child(MarkdownElement::new( - error_markdown.clone(), - markdown_style(window, cx), - )), - ), - ) - }) - .when(self.is_loading() && error_message.is_none(), |card| { - card.child(waiting_for_diff) - }) - .when(self.preview_expanded && !self.is_loading(), |card| { - let editor_view = v_flex() - .relative() - .h_full() - .when(!self.full_height_expanded, |editor_container| { - editor_container.max_h(COLLAPSED_LINES as f32 * editor_line_height) - }) - .overflow_hidden() - .border_t_1() - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .child(editor); - - card.child( - ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id()) - .with_total_lines(self.total_lines.unwrap_or(0) as usize) - .toggle_state(self.full_height_expanded) - .with_collapsed_fade() - .on_toggle({ - let this = cx.entity().downgrade(); - move |is_expanded, _window, cx| { - if let Some(this) = this.upgrade() { - this.update(cx, |this, _cx| { - this.full_height_expanded = is_expanded; - }); - } - } - }), - ) - }) - } -} - -fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let theme_settings = ThemeSettings::get_global(cx); - let ui_font_size = TextSize::Default.rems(cx); - let mut text_style = window.text_style(); - - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.ui_font.family.clone()), - font_fallbacks: theme_settings.ui_font.fallbacks.clone(), - font_features: Some(theme_settings.ui_font.features.clone()), - font_size: Some(ui_font_size.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - MarkdownStyle { - base_text_style: text_style.clone(), - selection_background_color: cx.theme().colors().element_selection_background, - ..Default::default() - } -} - -async fn build_buffer( - mut text: String, - path: Arc, - language_registry: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let line_ending = LineEnding::detect(&text); - LineEnding::normalize(&mut text); - let text = Rope::from(text); - let language = cx - .update(|_cx| language_registry.load_language_for_file_path(&path))? - .await - .ok(); - let buffer = cx.new(|cx| { - let buffer = TextBuffer::new_normalized( - 0, - cx.entity_id().as_non_zero_u64().into(), - line_ending, - text, - ); - let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); - buffer.set_language(language, cx); - buffer - })?; - Ok(buffer) -} - -async fn build_buffer_diff( - old_text: Arc, - buffer: &Entity, - language_registry: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let buffer = cx.update(|cx| buffer.read(cx).snapshot())?; - - let old_text_rope = cx - .background_spawn({ - let old_text = old_text.clone(); - async move { Rope::from(old_text.as_str()) } - }) - .await; - let base_buffer = cx - .update(|cx| { - Buffer::build_snapshot( - old_text_rope, - buffer.language().cloned(), - Some(language_registry.clone()), - cx, - ) - })? - .await; - - let diff_snapshot = cx - .update(|cx| { - BufferDiffSnapshot::new_with_base_buffer( - buffer.text.clone(), - Some(old_text), - base_buffer, - cx, - ) - })? - .await; - - let secondary_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&buffer, cx); - diff.set_snapshot(diff_snapshot.clone(), &buffer, cx); - diff - })?; - - cx.new(|cx| { - let mut diff = BufferDiff::new(&buffer.text, cx); - diff.set_snapshot(diff_snapshot, &buffer, cx); - diff.set_secondary_diff(secondary_diff); - diff - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use ::fs::Fs; - use client::TelemetrySettings; - use gpui::{TestAppContext, UpdateGlobal}; - use language_model::fake_provider::FakeLanguageModel; - use serde_json::json; - use settings::SettingsStore; - use std::fs; - use util::{path, rel_path::rel_path}; - - #[gpui::test] - async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Some edit".into(), - path: "root/nonexistent_file.txt".into(), - mode: EditFileMode::Edit, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap_err().to_string(), - "Can't edit file: path not found" - ); - } - - #[gpui::test] - async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { - let mode = &EditFileMode::Create; - - let result = test_resolve_path(mode, "root/new.txt", cx); - assert_resolved_path_eq(result.await, "new.txt"); - - let result = test_resolve_path(mode, "new.txt", cx); - assert_resolved_path_eq(result.await, "new.txt"); - - let result = test_resolve_path(mode, "dir/new.txt", cx); - assert_resolved_path_eq(result.await, "dir/new.txt"); - - let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't create file: file already exists" - ); - - let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't create file: parent directory doesn't exist" - ); - } - - #[gpui::test] - async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { - let mode = &EditFileMode::Edit; - - let path_with_root = "root/dir/subdir/existing.txt"; - let path_without_root = "dir/subdir/existing.txt"; - let result = test_resolve_path(mode, path_with_root, cx); - assert_resolved_path_eq(result.await, path_without_root); - - let result = test_resolve_path(mode, path_without_root, cx); - assert_resolved_path_eq(result.await, path_without_root); - - let result = test_resolve_path(mode, "root/nonexistent.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't edit file: path not found" - ); - - let result = test_resolve_path(mode, "root/dir", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't edit file: path is a directory" - ); - } - - async fn test_resolve_path( - mode: &EditFileMode, - path: &str, - cx: &mut TestAppContext, - ) -> anyhow::Result { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "dir": { - "subdir": { - "existing.txt": "hello" - } - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - let input = EditFileToolInput { - display_description: "Some edit".into(), - path: path.into(), - mode: mode.clone(), - }; - - cx.update(|cx| resolve_path(&input, project, cx)) - } - - #[track_caller] - fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { - let actual = path.expect("Should return valid path").path; - assert_eq!(actual.as_ref(), rel_path(expected)); - } - - #[test] - fn still_streaming_ui_text_with_path() { - let input = json!({ - "path": "src/main.rs", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); - } - - #[test] - fn still_streaming_ui_text_with_description() { - let input = json!({ - "path": "", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_with_path_and_description() { - let input = json!({ - "path": "src/main.rs", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_no_path_or_description() { - let input = json!({ - "path": "", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - - #[test] - fn still_streaming_ui_text_with_null() { - let input = serde_json::Value::Null; - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); - }); - } - - fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { - cx.update(|cx| { - paths::set_custom_data_dir(data_dir.to_str().unwrap()); - // Set custom data directory (config will be under data_dir/config) - - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); - }); - } - - #[gpui::test] - async fn test_format_on_save(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // Set up a Rust language with LSP formatting support - let rust_language = Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: language::LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - )); - - // Register the language and fake LSP - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_language); - - let mut fake_language_servers = language_registry.register_fake_lsp( - "Rust", - language::FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - document_formatting_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - // Create the file - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - - // Open the buffer to trigger LSP initialization - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/src/main.rs"), cx) - }) - .await - .unwrap(); - - // Register the buffer with language servers - let _handle = project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - - const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; - const FORMATTED_CONTENT: &str = - "This file was formatted by the fake formatter in the test.\n"; - - // Get the fake language server and set up formatting handler - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.set_request_handler::({ - |_, _| async move { - Ok(Some(vec![lsp::TextEdit { - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), - new_text: FORMATTED_CONTENT.to_string(), - }])) - } - }); - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // First, test with format_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On); - settings.project.all_languages.defaults.formatter = - Some(language::language_settings::SelectedFormatter::Auto); - }); - }); - }); - - // Have the model stream unformatted content - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the unformatted content - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Read the file to verify it was formatted automatically - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - new_content.replace("\r\n", "\n"), - FORMATTED_CONTENT, - "Code should be formatted when format_on_save is enabled" - ); - - let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); - - assert_eq!( - stale_buffer_count, 0, - "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ - This causes the agent to think the file was modified externally when it was just formatted.", - stale_buffer_count - ); - - // Next, test with format_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.all_languages.defaults.format_on_save = - Some(FormatOnSave::Off); - }); - }); - }); - - // Stream unformatted edits again - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the unformatted content - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Verify the file was not formatted - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - new_content.replace("\r\n", "\n"), - UNFORMATTED_CONTENT, - "Code should not be formatted when format_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - - // Create a simple file with trailing whitespace - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // First, test with remove_trailing_whitespace_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings - .project - .all_languages - .defaults - .remove_trailing_whitespace_on_save = Some(true); - }); - }); - }); - - const CONTENT_WITH_TRAILING_WHITESPACE: &str = - "fn main() { \n println!(\"Hello!\"); \n}\n"; - - // Have the model stream content that contains trailing whitespace - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the content with trailing whitespace - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Read the file to verify trailing whitespace was removed automatically - assert_eq!( - // Ignore carriage returns on Windows - fs.load(path!("/root/src/main.rs").as_ref()) - .await - .unwrap() - .replace("\r\n", "\n"), - "fn main() {\n println!(\"Hello!\");\n}\n", - "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" - ); - - // Next, test with remove_trailing_whitespace_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings - .project - .all_languages - .defaults - .remove_trailing_whitespace_on_save = Some(false); - }); - }); - }); - - // Stream edits again with trailing whitespace - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the content with trailing whitespace - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Verify the file still has trailing whitespace - // Read the file again - it should still have trailing whitespace - let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - final_content.replace("\r\n", "\n"), - CONTENT_WITH_TRAILING_WHITESPACE, - "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_needs_confirmation(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; - - // Test 1: Path with .zed component should require confirmation - let input_with_zed = json!({ - "display_description": "Edit settings", - "path": ".zed/settings.json", - "mode": "edit" - }); - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_with_zed, &project, cx), - "Path with .zed component should require confirmation" - ); - }); - - // Test 2: Absolute path should require confirmation - let input_absolute = json!({ - "display_description": "Edit file", - "path": "/etc/hosts", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_absolute, &project, cx), - "Absolute path should require confirmation" - ); - }); - - // Test 3: Relative path without .zed should not require confirmation - let input_relative = json!({ - "display_description": "Edit file", - "path": "root/src/main.rs", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_relative, &project, cx), - "Relative path without .zed should not require confirmation" - ); - }); - - // Test 4: Path with .zed in the middle should require confirmation - let input_zed_middle = json!({ - "display_description": "Edit settings", - "path": "root/.zed/tasks.json", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_zed_middle, &project, cx), - "Path with .zed in any component should require confirmation" - ); - }); - - // Test 5: When always_allow_tool_actions is enabled, no confirmation needed - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = true; - agent_settings::AgentSettings::override_global(settings, cx); - - assert!( - !tool.needs_confirmation(&input_with_zed, &project, cx), - "When always_allow_tool_actions is true, no confirmation should be needed" - ); - assert!( - !tool.needs_confirmation(&input_absolute, &project, cx), - "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" - ); - }); - } - - #[gpui::test] - async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { - // Set up a custom config directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - - // Test ui_text shows context for various paths - let test_cases = vec![ - ( - json!({ - "display_description": "Update config", - "path": ".zed/settings.json", - "mode": "edit" - }), - "Update config (local settings)", - ".zed path should show local settings context", - ), - ( - json!({ - "display_description": "Fix bug", - "path": "src/.zed/local.json", - "mode": "edit" - }), - "Fix bug (local settings)", - "Nested .zed path should show local settings context", - ), - ( - json!({ - "display_description": "Update readme", - "path": "README.md", - "mode": "edit" - }), - "Update readme", - "Normal path should not show additional context", - ), - ( - json!({ - "display_description": "Edit config", - "path": "config.zed", - "mode": "edit" - }), - "Edit config", - ".zed as extension should not show context", - ), - ]; - - for (input, expected_text, description) in test_cases { - cx.update(|_cx| { - let ui_text = tool.ui_text(&input); - assert_eq!(ui_text, expected_text, "Failed for case: {}", description); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create a project in /project directory - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test file outside project requires confirmation - let input_outside = json!({ - "display_description": "Edit file", - "path": "/outside/file.txt", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_outside, &project, cx), - "File outside project should require confirmation" - ); - }); - - // Test file inside project doesn't require confirmation - let input_inside = json!({ - "display_description": "Edit file", - "path": "project/file.txt", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_inside, &project, cx), - "File inside project should not require confirmation" - ); - }); - } - - #[gpui::test] - async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { - // Set up a custom data directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/home/user/myproject", json!({})).await; - let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; - - // Get the actual local settings folder name - let local_settings_folder = paths::local_settings_folder_name(); - - // Test various config path patterns - let test_cases = vec![ - ( - format!("{local_settings_folder}/settings.json"), - true, - "Top-level local settings file".to_string(), - ), - ( - format!("myproject/{local_settings_folder}/settings.json"), - true, - "Local settings in project path".to_string(), - ), - ( - format!("src/{local_settings_folder}/config.toml"), - true, - "Local settings in subdirectory".to_string(), - ), - ( - ".zed.backup/file.txt".to_string(), - true, - ".zed.backup is outside project".to_string(), - ), - ( - "my.zed/file.txt".to_string(), - true, - "my.zed is outside project".to_string(), - ), - ( - "myproject/src/file.zed".to_string(), - false, - ".zed as file extension".to_string(), - ), - ( - "myproject/normal/path/file.rs".to_string(), - false, - "Normal file without config paths".to_string(), - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { - // Set up a custom data directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create test files in the global config directory - let global_config_dir = paths::config_dir(); - fs::create_dir_all(&global_config_dir).unwrap(); - let global_settings_path = global_config_dir.join("settings.json"); - fs::write(&global_settings_path, "{}").unwrap(); - - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test global config paths - let test_cases = vec![ - ( - global_settings_path.to_str().unwrap().to_string(), - true, - "Global settings file should require confirmation", - ), - ( - global_config_dir - .join("keymap.json") - .to_str() - .unwrap() - .to_string(), - true, - "Global keymap file should require confirmation", - ), - ( - "project/normal_file.rs".to_string(), - false, - "Normal project file should not require confirmation", - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {}", - description - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create multiple worktree directories - fs.insert_tree( - "/workspace/frontend", - json!({ - "src": { - "main.js": "console.log('frontend');" - } - }), - ) - .await; - fs.insert_tree( - "/workspace/backend", - json!({ - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - fs.insert_tree( - "/workspace/shared", - json!({ - ".zed": { - "settings.json": "{}" - } - }), - ) - .await; - - // Create project with multiple worktrees - let project = Project::test( - fs.clone(), - [ - path!("/workspace/frontend").as_ref(), - path!("/workspace/backend").as_ref(), - path!("/workspace/shared").as_ref(), - ], - cx, - ) - .await; - - // Test files in different worktrees - let test_cases = vec![ - ("frontend/src/main.js", false, "File in first worktree"), - ("backend/src/main.rs", false, "File in second worktree"), - ( - "shared/.zed/settings.json", - true, - ".zed file in third worktree", - ), - ("/etc/hosts", true, "Absolute path outside all worktrees"), - ( - "../outside/file.txt", - true, - "Relative path outside worktrees", - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - ".zed": { - "settings.json": "{}" - }, - "src": { - ".zed": { - "local.json": "{}" - } - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test edge cases - let test_cases = vec![ - // Empty path - find_project_path returns Some for empty paths - ("", false, "Empty path is treated as project root"), - // Root directory - ("/", true, "Root directory should be outside project"), - ("project/../other", true, "Path with .. is outside project"), - ( - "project/./src/file.rs", - false, - "Path with . should work normally", - ), - // Windows-style paths (if on Windows) - #[cfg(target_os = "windows")] - ("C:\\Windows\\System32\\hosts", true, "Windows system path"), - #[cfg(target_os = "windows")] - ("project\\src\\main.rs", false, "Windows-style project path"), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - - // Test UI text for various scenarios - let test_cases = vec![ - ( - json!({ - "display_description": "Update config", - "path": ".zed/settings.json", - "mode": "edit" - }), - "Update config (local settings)", - ".zed path should show local settings context", - ), - ( - json!({ - "display_description": "Fix bug", - "path": "src/.zed/local.json", - "mode": "edit" - }), - "Fix bug (local settings)", - "Nested .zed path should show local settings context", - ), - ( - json!({ - "display_description": "Update readme", - "path": "README.md", - "mode": "edit" - }), - "Update readme", - "Normal path should not show additional context", - ), - ( - json!({ - "display_description": "Edit config", - "path": "config.zed", - "mode": "edit" - }), - "Edit config", - ".zed as extension should not show context", - ), - ]; - - for (input, expected_text, description) in test_cases { - cx.update(|_cx| { - let ui_text = tool.ui_text(&input); - assert_eq!(ui_text, expected_text, "Failed for case: {}", description); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "existing.txt": "content", - ".zed": { - "settings.json": "{}" - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test different EditFileMode values - let modes = vec![ - EditFileMode::Edit, - EditFileMode::Create, - EditFileMode::Overwrite, - ]; - - for mode in modes { - // Test .zed path with different modes - let input_zed = json!({ - "display_description": "Edit settings", - "path": "project/.zed/settings.json", - "mode": mode - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_zed, &project, cx), - ".zed path should require confirmation regardless of mode: {:?}", - mode - ); - }); - - // Test outside path with different modes - let input_outside = json!({ - "display_description": "Edit file", - "path": "/outside/file.txt", - "mode": mode - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_outside, &project, cx), - "Outside path should require confirmation regardless of mode: {:?}", - mode - ); - }); - - // Test normal path with different modes - let input_normal = json!({ - "display_description": "Edit file", - "path": "project/normal.txt", - "mode": mode - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_normal, &project, cx), - "Normal path should not require confirmation regardless of mode: {:?}", - mode - ); - }); - } - } - - #[gpui::test] - async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { - // Set up with custom directories for deterministic testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Enable always_allow_tool_actions - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = true; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // Test that all paths that normally require confirmation are bypassed - let global_settings_path = paths::config_dir().join("settings.json"); - fs::create_dir_all(paths::config_dir()).unwrap(); - fs::write(&global_settings_path, "{}").unwrap(); - - let test_cases = vec![ - ".zed/settings.json", - "project/.zed/config.toml", - global_settings_path.to_str().unwrap(), - "/etc/hosts", - "/absolute/path/file.txt", - "../outside/project.txt", - ]; - - for path in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input, &project, cx), - "Path {} should not require confirmation when always_allow_tool_actions is true", - path - ); - }); - } - - // Disable always_allow_tool_actions and verify confirmation is required again - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = false; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // Verify .zed path requires confirmation again - let input = json!({ - "display_description": "Edit file", - "path": ".zed/settings.json", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input, &project, cx), - ".zed path should require confirmation when always_allow_tool_actions is false" - ); - }); - } -} diff --git a/crates/assistant_tools/src/edit_file_tool/description.md b/crates/assistant_tools/src/edit_file_tool/description.md deleted file mode 100644 index 27f8e49dd6..0000000000 --- a/crates/assistant_tools/src/edit_file_tool/description.md +++ /dev/null @@ -1,8 +0,0 @@ -This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead. - -Before using this tool: - -1. Use the `read_file` tool to understand the file's contents and context - -2. Verify the directory path is correct (only applicable when creating new files): - - Use the `list_directory` tool to verify the parent directory exists and is the correct location diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs deleted file mode 100644 index cc22c9fc09..0000000000 --- a/crates/assistant_tools/src/fetch_tool.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::rc::Rc; -use std::sync::Arc; -use std::{borrow::Cow, cell::RefCell}; - -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow, bail}; -use assistant_tool::{Tool, ToolResult}; -use futures::AsyncReadExt as _; -use gpui::{AnyWindowHandle, App, AppContext as _, Entity, Task}; -use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown}; -use http_client::{AsyncBody, HttpClientWithUrl}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::IconName; -use util::markdown::MarkdownEscaped; - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -enum ContentType { - Html, - Plaintext, - Json, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct FetchToolInput { - /// The URL to fetch. - url: String, -} - -pub struct FetchTool { - http_client: Arc, -} - -impl FetchTool { - pub fn new(http_client: Arc) -> Self { - Self { http_client } - } - - async fn build_message(http_client: Arc, url: &str) -> Result { - let url = if !url.starts_with("https://") && !url.starts_with("http://") { - Cow::Owned(format!("https://{url}")) - } else { - Cow::Borrowed(url) - }; - - let mut response = http_client.get(&url, AsyncBody::default(), true).await?; - - let mut body = Vec::new(); - response - .body_mut() - .read_to_end(&mut body) - .await - .context("error reading response body")?; - - if response.status().is_client_error() { - let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); - } - - let Some(content_type) = response.headers().get("content-type") else { - bail!("missing Content-Type header"); - }; - let content_type = content_type - .to_str() - .context("invalid Content-Type header")?; - let content_type = match content_type { - "text/html" | "application/xhtml+xml" => ContentType::Html, - "application/json" => ContentType::Json, - _ => ContentType::Plaintext, - }; - - match content_type { - ContentType::Html => { - let mut handlers: Vec = vec![ - Rc::new(RefCell::new(markdown::WebpageChromeRemover)), - Rc::new(RefCell::new(markdown::ParagraphHandler)), - Rc::new(RefCell::new(markdown::HeadingHandler)), - Rc::new(RefCell::new(markdown::ListHandler)), - Rc::new(RefCell::new(markdown::TableHandler::new())), - Rc::new(RefCell::new(markdown::StyledTextHandler)), - ]; - if url.contains("wikipedia.org") { - use html_to_markdown::structure::wikipedia; - - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); - handlers.push(Rc::new( - RefCell::new(wikipedia::WikipediaCodeHandler::new()), - )); - } else { - handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); - } - - convert_html_to_markdown(&body[..], &mut handlers) - } - ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), - ContentType::Json => { - let json: serde_json::Value = serde_json::from_slice(&body)?; - - Ok(format!( - "```json\n{}\n```", - serde_json::to_string_pretty(&json)? - )) - } - } - } -} - -impl Tool for FetchTool { - fn name(&self) -> String { - "fetch".to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - true - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./fetch_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolWeb - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => format!("Fetch {}", MarkdownEscaped(&input.url)), - Err(_) => "Fetch URL".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let text = cx.background_spawn({ - let http_client = self.http_client.clone(); - async move { Self::build_message(http_client, &input.url).await } - }); - - cx.foreground_executor() - .spawn(async move { - let text = text.await?; - if text.trim().is_empty() { - bail!("no textual content found"); - } - - Ok(text.into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/fetch_tool/description.md b/crates/assistant_tools/src/fetch_tool/description.md deleted file mode 100644 index 007ba6c608..0000000000 --- a/crates/assistant_tools/src/fetch_tool/description.md +++ /dev/null @@ -1 +0,0 @@ -Fetches a URL and returns the content as Markdown. diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs deleted file mode 100644 index 0bc478251c..0000000000 --- a/crates/assistant_tools/src/find_path_tool.rs +++ /dev/null @@ -1,472 +0,0 @@ -use crate::{schema::json_schema_for, ui::ToolCallCardHeader}; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{ - Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, -}; -use editor::Editor; -use futures::channel::oneshot::{self, Receiver}; -use gpui::{ - AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window, -}; -use language; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; -use std::{cmp, path::PathBuf, sync::Arc}; -use ui::{Disclosure, Tooltip, prelude::*}; -use util::{ResultExt, paths::PathMatcher}; -use workspace::Workspace; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct FindPathToolInput { - /// The glob to match against every path in the project. - /// - /// - /// If the project has the following root directories: - /// - /// - directory1/a/something.txt - /// - directory2/a/things.txt - /// - directory3/a/other.txt - /// - /// You can get back the first two paths by providing a glob of "*thing*.txt" - /// - pub glob: String, - - /// Optional starting position for paginated results (0-based). - /// When not provided, starts from the beginning. - #[serde(default)] - pub offset: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -struct FindPathToolOutput { - glob: String, - paths: Vec, -} - -const RESULTS_PER_PAGE: usize = 50; - -pub struct FindPathTool; - -impl Tool for FindPathTool { - fn name(&self) -> String { - "find_path".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./find_path_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolSearch - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => format!("Find paths matching “`{}`”", input.glob), - Err(_) => "Search paths".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let (offset, glob) = match serde_json::from_value::(input) { - Ok(input) => (input.offset, input.glob), - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let (sender, receiver) = oneshot::channel(); - - let card = cx.new(|cx| FindPathToolCard::new(glob.clone(), receiver, cx)); - - let search_paths_task = search_paths(&glob, project, cx); - - let task = cx.background_spawn(async move { - let matches = search_paths_task.await?; - let paginated_matches: &[PathBuf] = &matches[cmp::min(offset, matches.len()) - ..cmp::min(offset + RESULTS_PER_PAGE, matches.len())]; - - sender.send(paginated_matches.to_vec()).log_err(); - - if matches.is_empty() { - Ok("No matches found".to_string().into()) - } else { - let mut message = format!("Found {} total matches.", matches.len()); - if matches.len() > RESULTS_PER_PAGE { - write!( - &mut message, - "\nShowing results {}-{} (provide 'offset' parameter for more results):", - offset + 1, - offset + paginated_matches.len() - ) - .unwrap(); - } - - for mat in matches.iter().skip(offset).take(RESULTS_PER_PAGE) { - write!(&mut message, "\n{}", mat.display()).unwrap(); - } - - let output = FindPathToolOutput { - glob, - paths: matches, - }; - - Ok(ToolResultOutput { - content: ToolResultContent::Text(message), - output: Some(serde_json::to_value(output)?), - }) - } - }); - - ToolResult { - output: task, - card: Some(card.into()), - } - } - - fn deserialize_card( - self: Arc, - output: serde_json::Value, - _project: Entity, - _window: &mut Window, - cx: &mut App, - ) -> Option { - let output = serde_json::from_value::(output).ok()?; - let card = cx.new(|_| FindPathToolCard::from_output(output)); - Some(card.into()) - } -} - -fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task>> { - let path_matcher = match PathMatcher::new( - [ - // Sometimes models try to search for "". In this case, return all paths in the project. - if glob.is_empty() { "*" } else { glob }, - ], - project.read(cx).path_style(cx), - ) { - Ok(matcher) => matcher, - Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))), - }; - let snapshots: Vec<_> = project - .read(cx) - .worktrees(cx) - .map(|worktree| worktree.read(cx).snapshot()) - .collect(); - - cx.background_spawn(async move { - Ok(snapshots - .iter() - .flat_map(|snapshot| { - snapshot - .entries(false, 0) - .map(move |entry| { - snapshot - .root_name() - .join(&entry.path) - .as_std_path() - .to_path_buf() - }) - .filter(|path| path_matcher.is_match(&path)) - }) - .collect()) - }) -} - -struct FindPathToolCard { - paths: Vec, - expanded: bool, - glob: String, - _receiver_task: Option>>, -} - -impl FindPathToolCard { - fn new(glob: String, receiver: Receiver>, cx: &mut Context) -> Self { - let _receiver_task = cx.spawn(async move |this, cx| { - let paths = receiver.await?; - - this.update(cx, |this, _cx| { - this.paths = paths; - }) - .log_err(); - - Ok(()) - }); - - Self { - paths: Vec::new(), - expanded: false, - glob, - _receiver_task: Some(_receiver_task), - } - } - - fn from_output(output: FindPathToolOutput) -> Self { - Self { - glob: output.glob, - paths: output.paths, - expanded: false, - _receiver_task: None, - } - } -} - -impl ToolCard for FindPathToolCard { - fn render( - &mut self, - _status: &ToolUseStatus, - _window: &mut Window, - workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let matches_label: SharedString = if self.paths.is_empty() { - "No matches".into() - } else if self.paths.len() == 1 { - "1 match".into() - } else { - format!("{} matches", self.paths.len()).into() - }; - - let content = if !self.paths.is_empty() && self.expanded { - Some( - v_flex() - .relative() - .ml_1p5() - .px_1p5() - .gap_0p5() - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .children(self.paths.iter().enumerate().map(|(index, path)| { - let path_clone = path.clone(); - let workspace_clone = workspace.clone(); - let button_label = path.to_string_lossy().into_owned(); - - Button::new(("path", index), button_label) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) - .label_size(LabelSize::Small) - .color(Color::Muted) - .tooltip(Tooltip::text("Jump to File")) - .on_click(move |_, window, cx| { - workspace_clone - .update(cx, |workspace, cx| { - let path = PathBuf::from(&path_clone); - let Some(project_path) = workspace - .project() - .read(cx) - .find_project_path(&path, cx) - else { - return; - }; - let open_task = workspace.open_path( - project_path, - None, - true, - window, - cx, - ); - window - .spawn(cx, async move |cx| { - let item = open_task.await?; - if let Some(active_editor) = - item.downcast::() - { - active_editor - .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point( - language::Point::new(0, 0), - window, - cx, - ); - }) - .log_err(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }) - .ok(); - }) - })) - .into_any(), - ) - } else { - None - }; - - v_flex() - .mb_2() - .gap_1() - .child( - ToolCallCardHeader::new(IconName::ToolSearch, matches_label) - .with_code_path(&self.glob) - .disclosure_slot( - Disclosure::new("path-search-disclosure", self.expanded) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .disabled(self.paths.is_empty()) - .on_click(cx.listener(move |this, _, _, _cx| { - this.expanded = !this.expanded; - })), - ), - ) - .children(content) - } -} - -impl Component for FindPathTool { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn sort_name() -> &'static str { - "FindPathTool" - } - - fn preview(window: &mut Window, cx: &mut App) -> Option { - let successful_card = cx.new(|_| FindPathToolCard { - paths: vec![ - PathBuf::from("src/main.rs"), - PathBuf::from("src/lib.rs"), - PathBuf::from("tests/test.rs"), - ], - expanded: true, - glob: "*.rs".to_string(), - _receiver_task: None, - }); - - let empty_card = cx.new(|_| FindPathToolCard { - paths: Vec::new(), - expanded: false, - glob: "*.nonexistent".to_string(), - _receiver_task: None, - }); - - Some( - v_flex() - .gap_6() - .children(vec![example_group(vec![ - single_example( - "With Paths", - div() - .size_full() - .child(successful_card.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Finished("".into()), - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - single_example( - "No Paths", - div() - .size_full() - .child(empty_card.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Finished("".into()), - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - ])]) - .into_any_element(), - ) - } -} - -#[cfg(test)] -mod test { - use super::*; - use gpui::TestAppContext; - use project::{FakeFs, Project}; - use settings::SettingsStore; - use util::path; - - #[gpui::test] - async fn test_find_path_tool(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - serde_json::json!({ - "apple": { - "banana": { - "carrot": "1", - }, - "bandana": { - "carbonara": "2", - }, - "endive": "3" - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - let matches = cx - .update(|cx| search_paths("root/**/car*", project.clone(), cx)) - .await - .unwrap(); - assert_eq!( - matches, - &[ - PathBuf::from(path!("root/apple/banana/carrot")), - PathBuf::from(path!("root/apple/bandana/carbonara")) - ] - ); - - let matches = cx - .update(|cx| search_paths("**/car*", project.clone(), cx)) - .await - .unwrap(); - assert_eq!( - matches, - &[ - PathBuf::from(path!("root/apple/banana/carrot")), - PathBuf::from(path!("root/apple/bandana/carbonara")) - ] - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } -} diff --git a/crates/assistant_tools/src/find_path_tool/description.md b/crates/assistant_tools/src/find_path_tool/description.md deleted file mode 100644 index f7a697c467..0000000000 --- a/crates/assistant_tools/src/find_path_tool/description.md +++ /dev/null @@ -1,7 +0,0 @@ -Fast file path pattern matching tool that works with any codebase size - -- Supports glob patterns like "**/*.js" or "src/**/*.ts" -- Returns matching file paths sorted alphabetically -- Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths. -- Use this tool when you need to find files by name patterns -- Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages. diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs deleted file mode 100644 index 609e25f338..0000000000 --- a/crates/assistant_tools/src/grep_tool.rs +++ /dev/null @@ -1,1308 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use futures::StreamExt; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language::{OffsetRangeExt, ParseStatus, Point}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::{ - Project, WorktreeSettings, - search::{SearchQuery, SearchResult}, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::{cmp, fmt::Write, sync::Arc}; -use ui::IconName; -use util::RangeExt; -use util::markdown::MarkdownInlineCode; -use util::paths::PathMatcher; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct GrepToolInput { - /// A regex pattern to search for in the entire project. Note that the regex - /// will be parsed by the Rust `regex` crate. - /// - /// Do NOT specify a path here! This will only be matched against the code **content**. - pub regex: String, - - /// A glob pattern for the paths of files to include in the search. - /// Supports standard glob patterns like "**/*.rs" or "src/**/*.ts". - /// If omitted, all files in the project will be searched. - pub include_pattern: Option, - - /// Optional starting position for paginated results (0-based). - /// When not provided, starts from the beginning. - #[serde(default)] - pub offset: u32, - - /// Whether the regex is case-sensitive. Defaults to false (case-insensitive). - #[serde(default)] - pub case_sensitive: bool, -} - -impl GrepToolInput { - /// Which page of search results this is. - pub fn page(&self) -> u32 { - 1 + (self.offset / RESULTS_PER_PAGE) - } -} - -const RESULTS_PER_PAGE: u32 = 20; - -pub struct GrepTool; - -impl Tool for GrepTool { - fn name(&self) -> String { - "grep".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./grep_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolRegex - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let page = input.page(); - let regex_str = MarkdownInlineCode(&input.regex); - let case_info = if input.case_sensitive { - " (case-sensitive)" - } else { - "" - }; - - if page > 1 { - format!("Get page {page} of search results for regex {regex_str}{case_info}") - } else { - format!("Search files for regex {regex_str}{case_info}") - } - } - Err(_) => "Search with regex".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - const CONTEXT_LINES: u32 = 2; - const MAX_ANCESTOR_LINES: u32 = 10; - - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(error) => { - return Task::ready(Err(anyhow!("Failed to parse input: {error}"))).into(); - } - }; - - let include_matcher = match PathMatcher::new( - input - .include_pattern - .as_ref() - .into_iter() - .collect::>(), - project.read(cx).path_style(cx), - ) { - Ok(matcher) => matcher, - Err(error) => { - return Task::ready(Err(anyhow!("invalid include glob pattern: {error}"))).into(); - } - }; - - // Exclude global file_scan_exclusions and private_files settings - let exclude_matcher = { - let global_settings = WorktreeSettings::get_global(cx); - let exclude_patterns = global_settings - .file_scan_exclusions - .sources() - .iter() - .chain(global_settings.private_files.sources().iter()); - - match PathMatcher::new(exclude_patterns, project.read(cx).path_style(cx)) { - Ok(matcher) => matcher, - Err(error) => { - return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))).into(); - } - } - }; - - let query = match SearchQuery::regex( - &input.regex, - false, - input.case_sensitive, - false, - false, - include_matcher, - exclude_matcher, - true, // Always match file include pattern against *full project paths* that start with a project root. - None, - ) { - Ok(query) => query, - Err(error) => return Task::ready(Err(error)).into(), - }; - - let results = project.update(cx, |project, cx| project.search(query, cx)); - - cx.spawn(async move |cx| { - futures::pin_mut!(results); - - let mut output = String::new(); - let mut skips_remaining = input.offset; - let mut matches_found = 0; - let mut has_more_matches = false; - - 'outer: while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await { - if ranges.is_empty() { - continue; - } - - let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| { - (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status()) - }) else { - continue; - }; - - // Check if this file should be excluded based on its worktree settings - if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| { - project.find_project_path(&path, cx) - }) - && cx.update(|cx| { - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - worktree_settings.is_path_excluded(&project_path.path) - || worktree_settings.is_path_private(&project_path.path) - }).unwrap_or(false) { - continue; - } - - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; - } - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - let mut ranges = ranges - .into_iter() - .map(|range| { - let matched = range.to_point(&snapshot); - let matched_end_line_len = snapshot.line_len(matched.end.row); - let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len); - let symbols = snapshot.symbols_containing(matched.start, None); - - if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) { - let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot); - let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES); - let end_col = snapshot.line_len(end_row); - let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col); - - if capped_ancestor_range.contains_inclusive(&full_lines) { - return (capped_ancestor_range, Some(full_ancestor_range), symbols) - } - } - - let mut matched = matched; - matched.start.column = 0; - matched.start.row = - matched.start.row.saturating_sub(CONTEXT_LINES); - matched.end.row = cmp::min( - snapshot.max_point().row, - matched.end.row + CONTEXT_LINES, - ); - matched.end.column = snapshot.line_len(matched.end.row); - - (matched, None, symbols) - }) - .peekable(); - - let mut file_header_written = false; - - while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){ - if skips_remaining > 0 { - skips_remaining -= 1; - continue; - } - - // We'd already found a full page of matches, and we just found one more. - if matches_found >= RESULTS_PER_PAGE { - has_more_matches = true; - break 'outer; - } - - while let Some((next_range, _, _)) = ranges.peek() { - if range.end.row >= next_range.start.row { - range.end = next_range.end; - ranges.next(); - } else { - break; - } - } - - if !file_header_written { - writeln!(output, "\n## Matches in {}", path.display())?; - file_header_written = true; - } - - let end_row = range.end.row; - output.push_str("\n### "); - - for symbol in parent_symbols { - write!(output, "{} › ", symbol.text)?; - } - - if range.start.row == end_row { - writeln!(output, "L{}", range.start.row + 1)?; - } else { - writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?; - } - - output.push_str("```\n"); - output.extend(snapshot.text_for_range(range)); - output.push_str("\n```\n"); - - if let Some(ancestor_range) = ancestor_range - && end_row < ancestor_range.end.row { - let remaining_lines = ancestor_range.end.row - end_row; - writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?; - } - - matches_found += 1; - } - } - - if matches_found == 0 { - Ok("No matches found".to_string().into()) - } else if has_more_matches { - Ok(format!( - "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}", - input.offset + 1, - input.offset + matches_found, - input.offset + RESULTS_PER_PAGE, - ).into()) - } else { - Ok(format!("Found {matches_found} matches:\n{output}").into()) - } - }).into() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use assistant_tool::Tool; - use gpui::{AppContext, TestAppContext, UpdateGlobal}; - use language::{Language, LanguageConfig, LanguageMatcher}; - use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use unindent::Unindent; - use util::path; - - #[gpui::test] - async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) { - init_test(cx); - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - serde_json::json!({ - "src": { - "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}", - "utils": { - "helper.rs": "fn helper() {\n println!(\"I'm a helper!\");\n}", - }, - }, - "tests": { - "test_main.rs": "fn test_main() {\n assert!(true);\n}", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // Test with include pattern for Rust files inside the root of the project - let input = serde_json::to_value(GrepToolInput { - regex: "println".to_string(), - include_pattern: Some("root/**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!(result.contains("main.rs"), "Should find matches in main.rs"); - assert!( - result.contains("helper.rs"), - "Should find matches in helper.rs" - ); - assert!( - !result.contains("test_main.rs"), - "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)" - ); - - // Test with include pattern for src directory only - let input = serde_json::to_value(GrepToolInput { - regex: "fn".to_string(), - include_pattern: Some("root/**/src/**".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!( - result.contains("main.rs"), - "Should find matches in src/main.rs" - ); - assert!( - result.contains("helper.rs"), - "Should find matches in src/utils/helper.rs" - ); - assert!( - !result.contains("test_main.rs"), - "Should not include test_main.rs as it's not in src directory" - ); - - // Test with empty include pattern (should default to all files) - let input = serde_json::to_value(GrepToolInput { - regex: "fn".to_string(), - include_pattern: None, - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!(result.contains("main.rs"), "Should find matches in main.rs"); - assert!( - result.contains("helper.rs"), - "Should find matches in helper.rs" - ); - assert!( - result.contains("test_main.rs"), - "Should include test_main.rs" - ); - } - - #[gpui::test] - async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) { - init_test(cx); - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - serde_json::json!({ - "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true", - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // Test case-insensitive search (default) - let input = serde_json::to_value(GrepToolInput { - regex: "uppercase".to_string(), - include_pattern: Some("**/*.txt".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!( - result.contains("UPPERCASE"), - "Case-insensitive search should match uppercase" - ); - - // Test case-sensitive search - let input = serde_json::to_value(GrepToolInput { - regex: "uppercase".to_string(), - include_pattern: Some("**/*.txt".to_string()), - offset: 0, - case_sensitive: true, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!( - !result.contains("UPPERCASE"), - "Case-sensitive search should not match uppercase" - ); - - // Test case-sensitive search - let input = serde_json::to_value(GrepToolInput { - regex: "LOWERCASE".to_string(), - include_pattern: Some("**/*.txt".to_string()), - offset: 0, - case_sensitive: true, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - - assert!( - !result.contains("lowercase"), - "Case-sensitive search should match lowercase" - ); - - // Test case-sensitive search for lowercase pattern - let input = serde_json::to_value(GrepToolInput { - regex: "lowercase".to_string(), - include_pattern: Some("**/*.txt".to_string()), - offset: 0, - case_sensitive: true, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!( - result.contains("lowercase"), - "Case-sensitive search should match lowercase text" - ); - } - - /// Helper function to set up a syntax test environment - async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity { - use unindent::Unindent; - init_test(cx); - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.executor()); - - // Create test file with syntax structures - fs.insert_tree( - path!("/root"), - serde_json::json!({ - "test_syntax.rs": r#" - fn top_level_function() { - println!("This is at the top level"); - } - - mod feature_module { - pub mod nested_module { - pub fn nested_function( - first_arg: String, - second_arg: i32, - ) { - println!("Function in nested module"); - println!("{first_arg}"); - println!("{second_arg}"); - } - } - } - - struct MyStruct { - field1: String, - field2: i32, - } - - impl MyStruct { - fn method_with_block() { - let condition = true; - if condition { - println!("Inside if block"); - } - } - - fn long_function() { - println!("Line 1"); - println!("Line 2"); - println!("Line 3"); - println!("Line 4"); - println!("Line 5"); - println!("Line 6"); - println!("Line 7"); - println!("Line 8"); - println!("Line 9"); - println!("Line 10"); - println!("Line 11"); - println!("Line 12"); - } - } - - trait Processor { - fn process(&self, input: &str) -> String; - } - - impl Processor for MyStruct { - fn process(&self, input: &str) -> String { - format!("Processed: {}", input) - } - } - "#.unindent().trim(), - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - project.update(cx, |project, _cx| { - project.languages().add(rust_lang().into()) - }); - - project - } - - #[gpui::test] - async fn test_grep_top_level_function(cx: &mut TestAppContext) { - let project = setup_syntax_test(cx).await; - - // Test: Line at the top level of the file - let input = serde_json::to_value(GrepToolInput { - regex: "This is at the top level".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### fn top_level_function › L1-3 - ``` - fn top_level_function() { - println!("This is at the top level"); - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_function_body(cx: &mut TestAppContext) { - let project = setup_syntax_test(cx).await; - - // Test: Line inside a function body - let input = serde_json::to_value(GrepToolInput { - regex: "Function in nested module".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14 - ``` - ) { - println!("Function in nested module"); - println!("{first_arg}"); - println!("{second_arg}"); - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_function_args_and_body(cx: &mut TestAppContext) { - let project = setup_syntax_test(cx).await; - - // Test: Line with a function argument - let input = serde_json::to_value(GrepToolInput { - regex: "second_arg".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14 - ``` - pub fn nested_function( - first_arg: String, - second_arg: i32, - ) { - println!("Function in nested module"); - println!("{first_arg}"); - println!("{second_arg}"); - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_if_block(cx: &mut TestAppContext) { - use unindent::Unindent; - let project = setup_syntax_test(cx).await; - - // Test: Line inside an if block - let input = serde_json::to_value(GrepToolInput { - regex: "Inside if block".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### impl MyStruct › fn method_with_block › L26-28 - ``` - if condition { - println!("Inside if block"); - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_long_function_top(cx: &mut TestAppContext) { - use unindent::Unindent; - let project = setup_syntax_test(cx).await; - - // Test: Line in the middle of a long function - should show message about remaining lines - let input = serde_json::to_value(GrepToolInput { - regex: "Line 5".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### impl MyStruct › fn long_function › L31-41 - ``` - fn long_function() { - println!("Line 1"); - println!("Line 2"); - println!("Line 3"); - println!("Line 4"); - println!("Line 5"); - println!("Line 6"); - println!("Line 7"); - println!("Line 8"); - println!("Line 9"); - println!("Line 10"); - ``` - - 3 lines remaining in ancestor node. Read the file to see all. - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_long_function_bottom(cx: &mut TestAppContext) { - use unindent::Unindent; - let project = setup_syntax_test(cx).await; - - // Test: Line in the long function - let input = serde_json::to_value(GrepToolInput { - regex: "Line 12".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### impl MyStruct › fn long_function › L41-45 - ``` - println!("Line 10"); - println!("Line 11"); - println!("Line 12"); - } - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - async fn run_grep_tool( - input: serde_json::Value, - project: Entity, - cx: &mut TestAppContext, - ) -> String { - let tool = Arc::new(GrepTool); - let action_log = cx.new(|_cx| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let task = - cx.update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx)); - - match task.output.await { - Ok(result) => { - if cfg!(windows) { - result.content.as_str().unwrap().replace("root\\", "root/") - } else { - result.content.as_str().unwrap().to_string() - } - } - Err(e) => panic!("Failed to run grep tool: {}", e), - } - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } - - #[gpui::test] - async fn test_grep_security_boundaries(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - path!("/"), - json!({ - "project_root": { - "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }", - ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }", - ".secretdir": { - "config": "fn special_configuration() { /* excluded */ }" - }, - ".mymetadata": "fn custom_metadata() { /* excluded */ }", - "subdir": { - "normal_file.rs": "fn normal_file_content() { /* Normal */ }", - "special.privatekey": "fn private_key_content() { /* private */ }", - "data.mysensitive": "fn sensitive_data() { /* private */ }" - } - }, - "outside_project": { - "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }" - } - }), - ) - .await; - - cx.update(|cx| { - use gpui::UpdateGlobal; - use settings::SettingsStore; - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.worktree.file_scan_exclusions = Some(vec![ - "**/.secretdir".to_string(), - "**/.mymetadata".to_string(), - ]); - settings.project.worktree.private_files = Some( - vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ] - .into(), - ); - }); - }); - }); - - let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // Searching for files outside the project worktree should return no results - let result = cx - .update(|cx| { - let input = json!({ - "regex": "outside_function" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not find files outside the project worktree" - ); - - // Searching within the project should succeed - let result = cx - .update(|cx| { - let input = json!({ - "regex": "main" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.iter().any(|p| p.contains("allowed_file.rs")), - "grep_tool should be able to search files inside worktrees" - ); - - // Searching files that match file_scan_exclusions should return no results - let result = cx - .update(|cx| { - let input = json!({ - "regex": "special_configuration" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search files in .secretdir (file_scan_exclusions)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "regex": "custom_metadata" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search .mymetadata files (file_scan_exclusions)" - ); - - // Searching private files should return no results - let result = cx - .update(|cx| { - let input = json!({ - "regex": "SECRET_KEY" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search .mysecrets (private_files)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "regex": "private_key_content" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search .privatekey files (private_files)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "regex": "sensitive_data" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search .mysensitive files (private_files)" - ); - - // Searching a normal file should still work, even with private_files configured - let result = cx - .update(|cx| { - let input = json!({ - "regex": "normal_file_content" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.iter().any(|p| p.contains("normal_file.rs")), - "Should be able to search normal files" - ); - - // Path traversal attempts with .. in include_pattern should not escape project - let result = cx - .update(|cx| { - let input = json!({ - "regex": "outside_function", - "include_pattern": "../outside_project/**/*.rs" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not allow escaping project boundaries with relative paths" - ); - } - - #[gpui::test] - async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - // Create first worktree with its own private files - fs.insert_tree( - path!("/worktree1"), - json!({ - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/fixture.*"], - "private_files": ["**/secret.rs"] - }"# - }, - "src": { - "main.rs": "fn main() { let secret_key = \"hidden\"; }", - "secret.rs": "const API_KEY: &str = \"secret_value\";", - "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }" - }, - "tests": { - "test.rs": "fn test_secret() { assert!(true); }", - "fixture.sql": "SELECT * FROM secret_table;" - } - }), - ) - .await; - - // Create second worktree with different private files - fs.insert_tree( - path!("/worktree2"), - json!({ - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/internal.*"], - "private_files": ["**/private.js", "**/data.json"] - }"# - }, - "lib": { - "public.js": "export function getSecret() { return 'public'; }", - "private.js": "const SECRET_KEY = \"private_value\";", - "data.json": "{\"secret_data\": \"hidden\"}" - }, - "docs": { - "README.md": "# Documentation with secret info", - "internal.md": "Internal secret documentation" - } - }), - ) - .await; - - // Set global settings - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.worktree.file_scan_exclusions = - Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.project.worktree.private_files = - Some(vec!["**/.env".to_string()].into()); - }); - }); - }); - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - - // Wait for worktrees to be fully scanned - cx.executor().run_until_parked(); - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // Search for "secret" - should exclude files based on worktree-specific settings - let result = cx - .update(|cx| { - let input = json!({ - "regex": "secret", - "case_sensitive": false - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - let paths = extract_paths_from_results(content); - - // Should find matches in non-private files - assert!( - paths.iter().any(|p| p.contains("main.rs")), - "Should find 'secret' in worktree1/src/main.rs" - ); - assert!( - paths.iter().any(|p| p.contains("test.rs")), - "Should find 'secret' in worktree1/tests/test.rs" - ); - assert!( - paths.iter().any(|p| p.contains("public.js")), - "Should find 'secret' in worktree2/lib/public.js" - ); - assert!( - paths.iter().any(|p| p.contains("README.md")), - "Should find 'secret' in worktree2/docs/README.md" - ); - - // Should NOT find matches in private/excluded files based on worktree settings - assert!( - !paths.iter().any(|p| p.contains("secret.rs")), - "Should not search in worktree1/src/secret.rs (local private_files)" - ); - assert!( - !paths.iter().any(|p| p.contains("fixture.sql")), - "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)" - ); - assert!( - !paths.iter().any(|p| p.contains("private.js")), - "Should not search in worktree2/lib/private.js (local private_files)" - ); - assert!( - !paths.iter().any(|p| p.contains("data.json")), - "Should not search in worktree2/lib/data.json (local private_files)" - ); - assert!( - !paths.iter().any(|p| p.contains("internal.md")), - "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)" - ); - - // Test with `include_pattern` specific to one worktree - let result = cx - .update(|cx| { - let input = json!({ - "regex": "secret", - "include_pattern": "worktree1/**/*.rs" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - let paths = extract_paths_from_results(content); - - // Should only find matches in worktree1 *.rs files (excluding private ones) - assert!( - paths.iter().any(|p| p.contains("main.rs")), - "Should find match in worktree1/src/main.rs" - ); - assert!( - paths.iter().any(|p| p.contains("test.rs")), - "Should find match in worktree1/tests/test.rs" - ); - assert!( - !paths.iter().any(|p| p.contains("secret.rs")), - "Should not find match in excluded worktree1/src/secret.rs" - ); - assert!( - paths.iter().all(|p| !p.contains("worktree2")), - "Should not find any matches in worktree2" - ); - } - - // Helper function to extract file paths from grep results - fn extract_paths_from_results(results: &str) -> Vec { - results - .lines() - .filter(|line| line.starts_with("## Matches in ")) - .map(|line| { - line.strip_prefix("## Matches in ") - .unwrap() - .trim() - .to_string() - }) - .collect() - } -} diff --git a/crates/assistant_tools/src/grep_tool/description.md b/crates/assistant_tools/src/grep_tool/description.md deleted file mode 100644 index e3c0b43f31..0000000000 --- a/crates/assistant_tools/src/grep_tool/description.md +++ /dev/null @@ -1,9 +0,0 @@ -Searches the contents of files in the project with a regular expression - -- Prefer this tool to path search when searching for symbols in the project, because you won't need to guess what path it's in. -- Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.) -- Pass an `include_pattern` if you know how to narrow your search on the files system -- Never use this tool to search for paths. Only search file contents with this tool. -- Use this tool when you need to find files containing specific patterns -- Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages. -- DO NOT use HTML entities solely to escape characters in the tool parameters. diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs deleted file mode 100644 index 7d70f41a8c..0000000000 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ /dev/null @@ -1,869 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::{Project, ProjectPath, WorktreeSettings}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::{fmt::Write, sync::Arc}; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ListDirectoryToolInput { - /// The fully-qualified path of the directory to list in the project. - /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. - /// - /// - /// If the project has the following root directories: - /// - /// - directory1 - /// - directory2 - /// - /// You can list the contents of `directory1` by using the path `directory1`. - /// - /// - /// - /// If the project has the following root directories: - /// - /// - foo - /// - bar - /// - /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`. - /// - pub path: String, -} - -pub struct ListDirectoryTool; - -impl Tool for ListDirectoryTool { - fn name(&self) -> String { - "list_directory".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./list_directory_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolFolder - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let path = MarkdownInlineCode(&input.path); - format!("List the {path} directory's contents") - } - Err(_) => "List directory".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let path_style = project.read(cx).path_style(cx); - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - // Sometimes models will return these even though we tell it to give a path and not a glob. - // When this happens, just list the root worktree directories. - if matches!(input.path.as_str(), "." | "" | "./" | "*") { - let output = project - .read(cx) - .worktrees(cx) - .filter_map(|worktree| { - worktree.read(cx).root_entry().and_then(|entry| { - if entry.is_dir() { - Some(entry.path.display(path_style)) - } else { - None - } - }) - }) - .collect::>() - .join("\n"); - - return Task::ready(Ok(output.into())).into(); - } - - let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else { - return Task::ready(Err(anyhow!("Path {} not found in project", input.path))).into(); - }; - let Some(worktree) = project - .read(cx) - .worktree_for_id(project_path.worktree_id, cx) - else { - return Task::ready(Err(anyhow!("Worktree not found"))).into(); - }; - - // Check if the directory whose contents we're listing is itself excluded or private - let global_settings = WorktreeSettings::get_global(cx); - if global_settings.is_path_excluded(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}", - &input.path - ))) - .into(); - } - - if global_settings.is_path_private(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot list directory because its path matches the user's global `private_files` setting: {}", - &input.path - ))) - .into(); - } - - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - if worktree_settings.is_path_excluded(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}", - &input.path - ))) - .into(); - } - - if worktree_settings.is_path_private(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}", - &input.path - ))) - .into(); - } - - let worktree_snapshot = worktree.read(cx).snapshot(); - - let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { - return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into(); - }; - - if !entry.is_dir() { - return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into(); - } - let worktree_snapshot = worktree.read(cx).snapshot(); - - let mut folders = Vec::new(); - let mut files = Vec::new(); - - for entry in worktree_snapshot.child_entries(&project_path.path) { - // Skip private and excluded files and directories - if global_settings.is_path_private(&entry.path) - || global_settings.is_path_excluded(&entry.path) - { - continue; - } - - let project_path = ProjectPath { - worktree_id: worktree_snapshot.id(), - path: entry.path.clone(), - }; - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - - if worktree_settings.is_path_excluded(&project_path.path) - || worktree_settings.is_path_private(&project_path.path) - { - continue; - } - - let full_path = worktree_snapshot - .root_name() - .join(&entry.path) - .display(worktree_snapshot.path_style()) - .to_string(); - if entry.is_dir() { - folders.push(full_path); - } else { - files.push(full_path); - } - } - - let mut output = String::new(); - - if !folders.is_empty() { - writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap(); - } - - if !files.is_empty() { - writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap(); - } - - if output.is_empty() { - writeln!(output, "{} is empty.", input.path).unwrap(); - } - - Task::ready(Ok(output.into())).into() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use assistant_tool::Tool; - use gpui::{AppContext, TestAppContext, UpdateGlobal}; - use indoc::indoc; - use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - fn platform_paths(path_str: &str) -> String { - if cfg!(target_os = "windows") { - path_str.replace("/", "\\") - } else { - path_str.to_string() - } - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } - - #[gpui::test] - async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "src": { - "main.rs": "fn main() {}", - "lib.rs": "pub fn hello() {}", - "models": { - "user.rs": "struct User {}", - "post.rs": "struct Post {}" - }, - "utils": { - "helper.rs": "pub fn help() {}" - } - }, - "tests": { - "integration_test.rs": "#[test] fn test() {}" - }, - "README.md": "# Project", - "Cargo.toml": "[package]" - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - // Test listing root directory - let input = json!({ - "path": "project" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert_eq!( - content, - platform_paths(indoc! {" - # Folders: - project/src - project/tests - - # Files: - project/Cargo.toml - project/README.md - "}) - ); - - // Test listing src directory - let input = json!({ - "path": "project/src" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert_eq!( - content, - platform_paths(indoc! {" - # Folders: - project/src/models - project/src/utils - - # Files: - project/src/lib.rs - project/src/main.rs - "}) - ); - - // Test listing directory with only files - let input = json!({ - "path": "project/tests" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(!content.contains("# Folders:")); - assert!(content.contains("# Files:")); - assert!(content.contains(&platform_paths("project/tests/integration_test.rs"))); - } - - #[gpui::test] - async fn test_list_directory_empty_directory(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "empty_dir": {} - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - let input = json!({ - "path": "project/empty_dir" - }); - - let result = cx - .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx)) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert_eq!(content, "project/empty_dir is empty.\n"); - } - - #[gpui::test] - async fn test_list_directory_error_cases(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "file.txt": "content" - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - // Test non-existent path - let input = json!({ - "path": "project/nonexistent" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Path not found")); - - // Test trying to list a file instead of directory - let input = json!({ - "path": "project/file.txt" - }); - - let result = cx - .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx)) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("is not a directory") - ); - } - - #[gpui::test] - async fn test_list_directory_security(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "normal_dir": { - "file1.txt": "content", - "file2.txt": "content" - }, - ".mysecrets": "SECRET_KEY=abc123", - ".secretdir": { - "config": "special configuration", - "secret.txt": "secret content" - }, - ".mymetadata": "custom metadata", - "visible_dir": { - "normal.txt": "normal content", - "special.privatekey": "private key content", - "data.mysensitive": "sensitive data", - ".hidden_subdir": { - "hidden_file.txt": "hidden content" - } - } - }), - ) - .await; - - // Configure settings explicitly - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.worktree.file_scan_exclusions = Some(vec![ - "**/.secretdir".to_string(), - "**/.mymetadata".to_string(), - "**/.hidden_subdir".to_string(), - ]); - settings.project.worktree.private_files = Some( - vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ] - .into(), - ); - }); - }); - }); - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - // Listing root directory should exclude private and excluded files - let input = json!({ - "path": "project" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - - // Should include normal directories - assert!(content.contains("normal_dir"), "Should list normal_dir"); - assert!(content.contains("visible_dir"), "Should list visible_dir"); - - // Should NOT include excluded or private files - assert!( - !content.contains(".secretdir"), - "Should not list .secretdir (file_scan_exclusions)" - ); - assert!( - !content.contains(".mymetadata"), - "Should not list .mymetadata (file_scan_exclusions)" - ); - assert!( - !content.contains(".mysecrets"), - "Should not list .mysecrets (private_files)" - ); - - // Trying to list an excluded directory should fail - let input = json!({ - "path": "project/.secretdir" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!( - result.is_err(), - "Should not be able to list excluded directory" - ); - assert!( - result - .unwrap_err() - .to_string() - .contains("file_scan_exclusions"), - "Error should mention file_scan_exclusions" - ); - - // Listing a directory should exclude private files within it - let input = json!({ - "path": "project/visible_dir" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - - // Should include normal files - assert!(content.contains("normal.txt"), "Should list normal.txt"); - - // Should NOT include private files - assert!( - !content.contains("privatekey"), - "Should not list .privatekey files (private_files)" - ); - assert!( - !content.contains("mysensitive"), - "Should not list .mysensitive files (private_files)" - ); - - // Should NOT include subdirectories that match exclusions - assert!( - !content.contains(".hidden_subdir"), - "Should not list .hidden_subdir (file_scan_exclusions)" - ); - } - - #[gpui::test] - async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - // Create first worktree with its own private files - fs.insert_tree( - path!("/worktree1"), - json!({ - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/fixture.*"], - "private_files": ["**/secret.rs", "**/config.toml"] - }"# - }, - "src": { - "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", - "secret.rs": "const API_KEY: &str = \"secret_key_1\";", - "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" - }, - "tests": { - "test.rs": "mod tests { fn test_it() {} }", - "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" - } - }), - ) - .await; - - // Create second worktree with different private files - fs.insert_tree( - path!("/worktree2"), - json!({ - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/internal.*"], - "private_files": ["**/private.js", "**/data.json"] - }"# - }, - "lib": { - "public.js": "export function greet() { return 'Hello from worktree2'; }", - "private.js": "const SECRET_TOKEN = \"private_token_2\";", - "data.json": "{\"api_key\": \"json_secret_key\"}" - }, - "docs": { - "README.md": "# Public Documentation", - "internal.md": "# Internal Secrets and Configuration" - } - }), - ) - .await; - - // Set global settings - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.worktree.file_scan_exclusions = - Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.project.worktree.private_files = - Some(vec!["**/.env".to_string()].into()); - }); - }); - }); - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - - // Wait for worktrees to be fully scanned - cx.executor().run_until_parked(); - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings - let input = json!({ - "path": "worktree1/src" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(content.contains("main.rs"), "Should list main.rs"); - assert!( - !content.contains("secret.rs"), - "Should not list secret.rs (local private_files)" - ); - assert!( - !content.contains("config.toml"), - "Should not list config.toml (local private_files)" - ); - - // Test listing worktree1/tests - should exclude fixture.sql based on local settings - let input = json!({ - "path": "worktree1/tests" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(content.contains("test.rs"), "Should list test.rs"); - assert!( - !content.contains("fixture.sql"), - "Should not list fixture.sql (local file_scan_exclusions)" - ); - - // Test listing worktree2/lib - should exclude private.js and data.json based on local settings - let input = json!({ - "path": "worktree2/lib" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(content.contains("public.js"), "Should list public.js"); - assert!( - !content.contains("private.js"), - "Should not list private.js (local private_files)" - ); - assert!( - !content.contains("data.json"), - "Should not list data.json (local private_files)" - ); - - // Test listing worktree2/docs - should exclude internal.md based on local settings - let input = json!({ - "path": "worktree2/docs" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(content.contains("README.md"), "Should list README.md"); - assert!( - !content.contains("internal.md"), - "Should not list internal.md (local file_scan_exclusions)" - ); - - // Test trying to list an excluded directory directly - let input = json!({ - "path": "worktree1/src/secret.rs" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - // This should fail because we're trying to list a file, not a directory - assert!(result.is_err(), "Should fail when trying to list a file"); - } -} diff --git a/crates/assistant_tools/src/list_directory_tool/description.md b/crates/assistant_tools/src/list_directory_tool/description.md deleted file mode 100644 index 30dcc012ff..0000000000 --- a/crates/assistant_tools/src/list_directory_tool/description.md +++ /dev/null @@ -1 +0,0 @@ -Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase. diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs deleted file mode 100644 index 22dbe9e625..0000000000 --- a/crates/assistant_tools/src/move_path_tool.rs +++ /dev/null @@ -1,132 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{path::Path, sync::Arc}; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct MovePathToolInput { - /// The source path of the file or directory to move/rename. - /// - /// - /// If the project has the following files: - /// - /// - directory1/a/something.txt - /// - directory2/a/things.txt - /// - directory3/a/other.txt - /// - /// You can move the first file by providing a source_path of "directory1/a/something.txt" - /// - pub source_path: String, - - /// The destination path where the file or directory should be moved/renamed to. - /// If the paths are the same except for the filename, then this will be a rename. - /// - /// - /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt", - /// provide a destination_path of "directory2/b/renamed.txt" - /// - pub destination_path: String, -} - -pub struct MovePathTool; - -impl Tool for MovePathTool { - fn name(&self) -> String { - "move_path".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("./move_path_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ArrowRightLeft - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let src = MarkdownInlineCode(&input.source_path); - let dest = MarkdownInlineCode(&input.destination_path); - let src_path = Path::new(&input.source_path); - let dest_path = Path::new(&input.destination_path); - - match dest_path - .file_name() - .and_then(|os_str| os_str.to_os_string().into_string().ok()) - { - Some(filename) if src_path.parent() == dest_path.parent() => { - let filename = MarkdownInlineCode(&filename); - format!("Rename {src} to {filename}") - } - _ => { - format!("Move {src} to {dest}") - } - } - } - Err(_) => "Move path".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let rename_task = project.update(cx, |project, cx| { - match project - .find_project_path(&input.source_path, cx) - .and_then(|project_path| project.entry_for_path(&project_path, cx)) - { - Some(entity) => match project.find_project_path(&input.destination_path, cx) { - Some(project_path) => project.rename_entry(entity.id, project_path, cx), - None => Task::ready(Err(anyhow!( - "Destination path {} was outside the project.", - input.destination_path - ))), - }, - None => Task::ready(Err(anyhow!( - "Source path {} was not found in the project.", - input.source_path - ))), - } - }); - - cx.background_spawn(async move { - let _ = rename_task.await.with_context(|| { - format!("Moving {} to {}", input.source_path, input.destination_path) - })?; - Ok(format!("Moved {} to {}", input.source_path, input.destination_path).into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/move_path_tool/description.md b/crates/assistant_tools/src/move_path_tool/description.md deleted file mode 100644 index 76bc3003d0..0000000000 --- a/crates/assistant_tools/src/move_path_tool/description.md +++ /dev/null @@ -1,5 +0,0 @@ -Moves or rename a file or directory in the project, and returns confirmation that the move succeeded. -If the source and destination directories are the same, but the filename is different, this performs -a rename. Otherwise, it performs a move. - -This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all. diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs deleted file mode 100644 index f50ad065d1..0000000000 --- a/crates/assistant_tools/src/now_tool.rs +++ /dev/null @@ -1,84 +0,0 @@ -use std::sync::Arc; - -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use chrono::{Local, Utc}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::IconName; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum Timezone { - /// Use UTC for the datetime. - Utc, - /// Use local time for the datetime. - Local, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct NowToolInput { - /// The timezone to use for the datetime. - timezone: Timezone, -} - -pub struct NowTool; - -impl Tool for NowTool { - fn name(&self) -> String { - "now".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - "Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into() - } - - fn icon(&self) -> IconName { - IconName::Info - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Get current time".to_string() - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - _cx: &mut App, - ) -> ToolResult { - let input: NowToolInput = match serde_json::from_value(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let now = match input.timezone { - Timezone::Utc => Utc::now().to_rfc3339(), - Timezone::Local => Local::now().to_rfc3339(), - }; - let text = format!("The current datetime is {now}."); - - Task::ready(Ok(text.into())).into() - } -} diff --git a/crates/assistant_tools/src/open_tool.rs b/crates/assistant_tools/src/open_tool.rs deleted file mode 100644 index a1aafad041..0000000000 --- a/crates/assistant_tools/src/open_tool.rs +++ /dev/null @@ -1,170 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{path::PathBuf, sync::Arc}; -use ui::IconName; -use util::markdown::MarkdownEscaped; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct OpenToolInput { - /// The path or URL to open with the default application. - path_or_url: String, -} - -pub struct OpenTool; - -impl Tool for OpenTool { - fn name(&self) -> String { - "open".to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - true - } - fn may_perform_edits(&self) -> bool { - false - } - fn description(&self) -> String { - include_str!("./open_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ArrowUpRight - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => format!("Open `{}`", MarkdownEscaped(&input.path_or_url)), - Err(_) => "Open file or URL".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input: OpenToolInput = match serde_json::from_value(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - // If path_or_url turns out to be a path in the project, make it absolute. - let abs_path = to_absolute_path(&input.path_or_url, project, cx); - - cx.background_spawn(async move { - match abs_path { - Some(path) => open::that(path), - None => open::that(&input.path_or_url), - } - .context("Failed to open URL or file path")?; - - Ok(format!("Successfully opened {}", input.path_or_url).into()) - }) - .into() - } -} - -fn to_absolute_path( - potential_path: &str, - project: Entity, - cx: &mut App, -) -> Option { - let project = project.read(cx); - project - .find_project_path(PathBuf::from(potential_path), cx) - .and_then(|project_path| project.absolute_path(&project_path, cx)) -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - use project::{FakeFs, Project}; - use settings::SettingsStore; - use std::path::Path; - use tempfile::TempDir; - - #[gpui::test] - async fn test_to_absolute_path(cx: &mut TestAppContext) { - init_test(cx); - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let temp_path = temp_dir.path().to_string_lossy().into_owned(); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - &temp_path, - serde_json::json!({ - "src": { - "main.rs": "fn main() {}", - "lib.rs": "pub fn lib_fn() {}" - }, - "docs": { - "readme.md": "# Project Documentation" - } - }), - ) - .await; - - // Use the temp_path as the root directory, not just its filename - let project = Project::test(fs.clone(), [temp_dir.path()], cx).await; - - // Test cases where the function should return Some - cx.update(|cx| { - // Project-relative paths should return Some - // Create paths using the last segment of the temp path to simulate a project-relative path - let root_dir_name = Path::new(&temp_path) - .file_name() - .unwrap_or_else(|| std::ffi::OsStr::new("temp")) - .to_string_lossy(); - - assert!( - to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx) - .is_some(), - "Failed to resolve main.rs path" - ); - - assert!( - to_absolute_path( - &format!("{root_dir_name}/docs/readme.md",), - project.clone(), - cx, - ) - .is_some(), - "Failed to resolve readme.md path" - ); - - // External URL should return None - let result = to_absolute_path("https://example.com", project.clone(), cx); - assert_eq!(result, None, "External URLs should return None"); - - // Path outside project - let result = to_absolute_path("../invalid/path", project.clone(), cx); - assert_eq!(result, None, "Paths outside the project should return None"); - }); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } -} diff --git a/crates/assistant_tools/src/open_tool/description.md b/crates/assistant_tools/src/open_tool/description.md deleted file mode 100644 index 99ccbb0524..0000000000 --- a/crates/assistant_tools/src/open_tool/description.md +++ /dev/null @@ -1,9 +0,0 @@ -This tool opens a file or URL with the default application associated with it on the user's operating system: -- On macOS, it's equivalent to the `open` command -- On Windows, it's equivalent to `start` -- On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate - -For example, it can open a web browser with a URL, open a PDF file with the default PDF viewer, etc. - -You MUST ONLY use this tool when the user has explicitly requested opening something. You MUST NEVER assume that -the user would like for you to use this tool. diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs deleted file mode 100644 index e30d80207d..0000000000 --- a/crates/assistant_tools/src/project_notifications_tool.rs +++ /dev/null @@ -1,360 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::Result; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{fmt::Write, sync::Arc}; -use ui::IconName; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ProjectUpdatesToolInput {} - -pub struct ProjectNotificationsTool; - -impl Tool for ProjectNotificationsTool { - fn name(&self) -> String { - "project_notifications".to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - fn may_perform_edits(&self) -> bool { - false - } - fn description(&self) -> String { - include_str!("./project_notifications_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolNotification - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Check project notifications".into() - } - - fn run( - self: Arc, - _input: serde_json::Value, - _request: Arc, - _project: Entity, - action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let Some(user_edits_diff) = - action_log.update(cx, |log, cx| log.flush_unnotified_user_edits(cx)) - else { - return result("No new notifications"); - }; - - // NOTE: Changes to this prompt require a symmetric update in the LLM Worker - const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt"); - const MAX_BYTES: usize = 8000; - let diff = fit_patch_to_size(&user_edits_diff, MAX_BYTES); - result(&format!("{HEADER}\n\n```diff\n{diff}\n```\n").replace("\r\n", "\n")) - } -} - -fn result(response: &str) -> ToolResult { - Task::ready(Ok(response.to_string().into())).into() -} - -/// Make sure that the patch fits into the size limit (in bytes). -/// Compress the patch by omitting some parts if needed. -/// Unified diff format is assumed. -fn fit_patch_to_size(patch: &str, max_size: usize) -> String { - if patch.len() <= max_size { - return patch.to_string(); - } - - // Compression level 1: remove context lines in diff bodies, but - // leave the counts and positions of inserted/deleted lines - let mut current_size = patch.len(); - let mut file_patches = split_patch(patch); - file_patches.sort_by_key(|patch| patch.len()); - let compressed_patches = file_patches - .iter() - .rev() - .map(|patch| { - if current_size > max_size { - let compressed = compress_patch(patch).unwrap_or_else(|_| patch.to_string()); - current_size -= patch.len() - compressed.len(); - compressed - } else { - patch.to_string() - } - }) - .collect::>(); - - if current_size <= max_size { - return compressed_patches.join("\n\n"); - } - - // Compression level 2: list paths of the changed files only - let filenames = file_patches - .iter() - .map(|patch| { - let patch = diffy::Patch::from_str(patch).unwrap(); - let path = patch - .modified() - .and_then(|path| path.strip_prefix("b/")) - .unwrap_or_default(); - format!("- {path}\n") - }) - .collect::>(); - - filenames.join("") -} - -/// Split a potentially multi-file patch into multiple single-file patches -fn split_patch(patch: &str) -> Vec { - let mut result = Vec::new(); - let mut current_patch = String::new(); - - for line in patch.lines() { - if line.starts_with("---") && !current_patch.is_empty() { - result.push(current_patch.trim_end_matches('\n').into()); - current_patch = String::new(); - } - current_patch.push_str(line); - current_patch.push('\n'); - } - - if !current_patch.is_empty() { - result.push(current_patch.trim_end_matches('\n').into()); - } - - result -} - -fn compress_patch(patch: &str) -> anyhow::Result { - let patch = diffy::Patch::from_str(patch)?; - let mut out = String::new(); - - writeln!(out, "--- {}", patch.original().unwrap_or("a"))?; - writeln!(out, "+++ {}", patch.modified().unwrap_or("b"))?; - - for hunk in patch.hunks() { - writeln!(out, "@@ -{} +{} @@", hunk.old_range(), hunk.new_range())?; - writeln!(out, "[...skipped...]")?; - } - - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use assistant_tool::ToolResultContent; - use gpui::{AppContext, TestAppContext}; - use indoc::indoc; - use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModelProvider}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use std::sync::Arc; - use util::path; - - #[gpui::test] - async fn test_stale_buffer_notification(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/test"), - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let project = Project::test(fs, [path!("/test").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - - let buffer_path = project - .read_with(cx, |project, cx| { - project.find_project_path("test/code.rs", cx) - }) - .unwrap(); - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(buffer_path.clone(), cx) - }) - .await - .unwrap(); - - // Start tracking the buffer - action_log.update(cx, |log, cx| { - log.buffer_read(buffer.clone(), cx); - }); - cx.run_until_parked(); - - // Run the tool before any changes - let tool = Arc::new(ProjectNotificationsTool); - let provider = Arc::new(FakeLanguageModelProvider::default()); - let model: Arc = Arc::new(provider.test_model()); - let request = Arc::new(LanguageModelRequest::default()); - let tool_input = json!({}); - - let result = cx.update(|cx| { - tool.clone().run( - tool_input.clone(), - request.clone(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }); - cx.run_until_parked(); - - let response = result.output.await.unwrap(); - let response_text = match &response.content { - ToolResultContent::Text(text) => text.clone(), - _ => panic!("Expected text response"), - }; - assert_eq!( - response_text.as_str(), - "No new notifications", - "Tool should return 'No new notifications' when no stale buffers" - ); - - // Modify the buffer (makes it stale) - buffer.update(cx, |buffer, cx| { - buffer.edit([(1..1, "\nChange!\n")], None, cx); - }); - cx.run_until_parked(); - - // Run the tool again - let result = cx.update(|cx| { - tool.clone().run( - tool_input.clone(), - request.clone(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }); - cx.run_until_parked(); - - // This time the buffer is stale, so the tool should return a notification - let response = result.output.await.unwrap(); - let response_text = match &response.content { - ToolResultContent::Text(text) => text.clone(), - _ => panic!("Expected text response"), - }; - - assert!( - response_text.contains("These files have changed"), - "Tool should return the stale buffer notification" - ); - assert!( - response_text.contains("test/code.rs"), - "Tool should return the stale buffer notification" - ); - - // Run the tool once more without any changes - should get no new notifications - let result = cx.update(|cx| { - tool.run( - tool_input.clone(), - request.clone(), - project.clone(), - action_log, - model.clone(), - None, - cx, - ) - }); - cx.run_until_parked(); - - let response = result.output.await.unwrap(); - let response_text = match &response.content { - ToolResultContent::Text(text) => text.clone(), - _ => panic!("Expected text response"), - }; - - assert_eq!( - response_text.as_str(), - "No new notifications", - "Tool should return 'No new notifications' when running again without changes" - ); - } - - #[test] - fn test_patch_compression() { - // Given a patch that doesn't fit into the size budget - let patch = indoc! {" - --- a/dir/test.txt - +++ b/dir/test.txt - @@ -1,3 +1,3 @@ - line 1 - -line 2 - +CHANGED - line 3 - @@ -10,2 +10,2 @@ - line 10 - -line 11 - +line eleven - - - --- a/dir/another.txt - +++ b/dir/another.txt - @@ -100,1 +1,1 @@ - -before - +after - "}; - - // When the size deficit can be compensated by dropping the body, - // then the body should be trimmed for larger files first - let limit = patch.len() - 10; - let compressed = fit_patch_to_size(patch, limit); - let expected = indoc! {" - --- a/dir/test.txt - +++ b/dir/test.txt - @@ -1,3 +1,3 @@ - [...skipped...] - @@ -10,2 +10,2 @@ - [...skipped...] - - - --- a/dir/another.txt - +++ b/dir/another.txt - @@ -100,1 +1,1 @@ - -before - +after"}; - assert_eq!(compressed, expected); - - // When the size deficit is too large, then only file paths - // should be returned - let limit = 10; - let compressed = fit_patch_to_size(patch, limit); - let expected = indoc! {" - - dir/another.txt - - dir/test.txt - "}; - assert_eq!(compressed, expected); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - assistant_tool::init(cx); - }); - } -} diff --git a/crates/assistant_tools/src/project_notifications_tool/description.md b/crates/assistant_tools/src/project_notifications_tool/description.md deleted file mode 100644 index 24ff678f5e..0000000000 --- a/crates/assistant_tools/src/project_notifications_tool/description.md +++ /dev/null @@ -1,3 +0,0 @@ -This tool reports which files have been modified by the user since the agent last accessed them. - -It serves as a notification mechanism to inform the agent of recent changes. No immediate action is required in response to these updates. diff --git a/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt b/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt deleted file mode 100644 index f743e239c8..0000000000 --- a/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt +++ /dev/null @@ -1,3 +0,0 @@ -[The following is an auto-generated notification; do not reply] - -These files have changed since the last read: diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs deleted file mode 100644 index f9f68491e5..0000000000 --- a/crates/assistant_tools/src/read_file_tool.rs +++ /dev/null @@ -1,1190 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use assistant_tool::{ToolResultContent, outline}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use project::{ImageItem, image_store}; - -use assistant_tool::ToolResultOutput; -use indoc::formatdoc; -use itertools::Itertools; -use language::{Anchor, Point}; -use language_model::{ - LanguageModel, LanguageModelImage, LanguageModelRequest, LanguageModelToolSchemaFormat, -}; -use project::{AgentLocation, Project, WorktreeSettings}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::sync::Arc; -use ui::IconName; - -/// If the model requests to read a file whose size exceeds this, then -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ReadFileToolInput { - /// The relative path of the file to read. - /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. - /// - /// - /// If the project has the following root directories: - /// - /// - /a/b/directory1 - /// - /c/d/directory2 - /// - /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`. - /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`. - /// - pub path: String, - - /// Optional line number to start reading on (1-based index) - #[serde(default)] - pub start_line: Option, - - /// Optional line number to end reading on (1-based index, inclusive) - #[serde(default)] - pub end_line: Option, -} - -pub struct ReadFileTool; - -impl Tool for ReadFileTool { - fn name(&self) -> String { - "read_file".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./read_file_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolSearch - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let path = &input.path; - match (input.start_line, input.end_line) { - (Some(start), Some(end)) => { - format!( - "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))", - path, start, end, path, start, end - ) - } - (Some(start), None) => { - format!( - "[Read file `{}` (from line {})](@selection:{}:({}-{}))", - path, start, path, start, start - ) - } - _ => format!("[Read file `{}`](@file:{})", path, path), - } - } - Err(_) => "Read file".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - action_log: Entity, - model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else { - return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into(); - }; - - // Error out if this path is either excluded or private in global settings - let global_settings = WorktreeSettings::get_global(cx); - if global_settings.is_path_excluded(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}", - &input.path - ))) - .into(); - } - - if global_settings.is_path_private(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot read file because its path matches the global `private_files` setting: {}", - &input.path - ))) - .into(); - } - - // Error out if this path is either excluded or private in worktree settings - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - if worktree_settings.is_path_excluded(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}", - &input.path - ))) - .into(); - } - - if worktree_settings.is_path_private(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot read file because its path matches the worktree `private_files` setting: {}", - &input.path - ))) - .into(); - } - - let file_path = input.path.clone(); - - if image_store::is_image_file(&project, &project_path, cx) { - if !model.supports_images() { - return Task::ready(Err(anyhow!( - "Attempted to read an image, but Zed doesn't currently support sending images to {}.", - model.name().0 - ))) - .into(); - } - - let task = cx.spawn(async move |cx| -> Result { - let image_entity: Entity = cx - .update(|cx| { - project.update(cx, |project, cx| { - project.open_image(project_path.clone(), cx) - }) - })? - .await?; - - let image = - image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?; - - let language_model_image = cx - .update(|cx| LanguageModelImage::from_image(image, cx))? - .await - .context("processing image")?; - - Ok(ToolResultOutput { - content: ToolResultContent::Image(language_model_image), - output: None, - }) - }); - - return task.into(); - } - - cx.spawn(async move |cx| { - let buffer = cx - .update(|cx| { - project.update(cx, |project, cx| project.open_buffer(project_path, cx)) - })? - .await?; - if buffer.read_with(cx, |buffer, _| { - buffer - .file() - .as_ref() - .is_none_or(|file| !file.disk_state().exists()) - })? { - anyhow::bail!("{file_path} not found"); - } - - project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: Anchor::MIN, - }), - cx, - ); - })?; - - // Check if specific line ranges are provided - if input.start_line.is_some() || input.end_line.is_some() { - let mut anchor = None; - let result = buffer.read_with(cx, |buffer, _cx| { - let text = buffer.text(); - // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0. - let start = input.start_line.unwrap_or(1).max(1); - let start_row = start - 1; - if start_row <= buffer.max_point().row { - let column = buffer.line_indent_for_row(start_row).raw_len(); - anchor = Some(buffer.anchor_before(Point::new(start_row, column))); - } - - let lines = text.split('\n').skip(start_row as usize); - if let Some(end) = input.end_line { - let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line - Itertools::intersperse(lines.take(count as usize), "\n") - .collect::() - .into() - } else { - Itertools::intersperse(lines, "\n") - .collect::() - .into() - } - })?; - - action_log.update(cx, |log, cx| { - log.buffer_read(buffer.clone(), cx); - })?; - - if let Some(anchor) = anchor { - project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: anchor, - }), - cx, - ); - })?; - } - - Ok(result) - } else { - // No line ranges specified, so check file size to see if it's too big. - let buffer_content = - outline::get_buffer_content_or_outline(buffer.clone(), Some(&file_path), cx) - .await?; - - action_log.update(cx, |log, cx| { - log.buffer_read(buffer, cx); - })?; - - if buffer_content.is_outline { - Ok(formatdoc! {" - This file was too big to read all at once. - - {} - - Using the line numbers in this outline, you can call this tool again - while specifying the start_line and end_line fields to see the - implementations of symbols in the outline. - - Alternatively, you can fall back to the `grep` tool (if available) - to search the file for specific content.", buffer_content.text - } - .into()) - } else { - Ok(buffer_content.text.into()) - } - } - }) - .into() - } -} - -#[cfg(test)] -mod test { - use super::*; - use gpui::{AppContext, TestAppContext, UpdateGlobal}; - use language::{Language, LanguageConfig, LanguageMatcher}; - use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - #[gpui::test] - async fn test_read_nonexistent_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/root"), json!({})).await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/nonexistent_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap_err().to_string(), - "root/nonexistent_file.txt not found" - ); - } - - #[gpui::test] - async fn test_read_small_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "small_file.txt": "This is a small file content" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/small_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap().content.as_str(), - Some("This is a small file content") - ); - } - - #[gpui::test] - async fn test_read_large_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::>().join("\n") - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(Arc::new(rust_lang())); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/large_file.rs" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let content = result.unwrap(); - let content = content.as_str().unwrap(); - assert_eq!( - content.lines().skip(4).take(6).collect::>(), - vec![ - "struct Test0 [L1-4]", - " a [L2]", - " b [L3]", - "struct Test1 [L5-8]", - " a [L6]", - " b [L7]", - ] - ); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/large_file.rs", - "offset": 1 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - let content = result.unwrap(); - let expected_content = (0..1000) - .flat_map(|i| { - vec![ - format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4), - format!(" a [L{}]", i * 4 + 2), - format!(" b [L{}]", i * 4 + 3), - ] - }) - .collect::>(); - pretty_assertions::assert_eq!( - content - .as_str() - .unwrap() - .lines() - .skip(4) - .take(expected_content.len()) - .collect::>(), - expected_content - ); - } - - #[gpui::test] - async fn test_read_file_with_line_range(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/multiline.txt", - "start_line": 2, - "end_line": 4 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap().content.as_str(), - Some("Line 2\nLine 3\nLine 4") - ); - } - - #[gpui::test] - async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // start_line of 0 should be treated as 1 - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/multiline.txt", - "start_line": 0, - "end_line": 2 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2")); - - // end_line of 0 should result in at least 1 line - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/multiline.txt", - "start_line": 1, - "end_line": 0 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert_eq!(result.unwrap().content.as_str(), Some("Line 1")); - - // when start_line > end_line, should still return at least 1 line - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/multiline.txt", - "start_line": 3, - "end_line": 2 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!(result.unwrap().content.as_str(), Some("Line 3")); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query( - r#" - (line_comment) @annotation - - (struct_item - "struct" @context - name: (_) @name) @item - (enum_item - "enum" @context - name: (_) @name) @item - (enum_variant - name: (_) @name) @item - (field_declaration - name: (_) @name) @item - (impl_item - "impl" @context - trait: (_)? @name - "for"? @context - type: (_) @name - body: (_ "{" (_)* "}")) @item - (function_item - "fn" @context - name: (_) @name) @item - (mod_item - "mod" @context - name: (_) @name) @item - "#, - ) - .unwrap() - } - - #[gpui::test] - async fn test_read_file_security(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - path!("/"), - json!({ - "project_root": { - "allowed_file.txt": "This file is in the project", - ".mysecrets": "SECRET_KEY=abc123", - ".secretdir": { - "config": "special configuration" - }, - ".mymetadata": "custom metadata", - "subdir": { - "normal_file.txt": "Normal file content", - "special.privatekey": "private key content", - "data.mysensitive": "sensitive data" - } - }, - "outside_project": { - "sensitive_file.txt": "This file is outside the project" - } - }), - ) - .await; - - cx.update(|cx| { - use gpui::UpdateGlobal; - use settings::SettingsStore; - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.worktree.file_scan_exclusions = Some(vec![ - "**/.secretdir".to_string(), - "**/.mymetadata".to_string(), - ]); - settings.project.worktree.private_files = Some( - vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ] - .into(), - ); - }); - }); - }); - - let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // Reading a file outside the project worktree should fail - let result = cx - .update(|cx| { - let input = json!({ - "path": "/outside_project/sensitive_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read an absolute path outside a worktree" - ); - - // Reading a file within the project should succeed - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/allowed_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_ok(), - "read_file_tool should be able to read files inside worktrees" - ); - - // Reading files that match file_scan_exclusions should fail - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/.secretdir/config" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/.mymetadata" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)" - ); - - // Reading private files should fail - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/.mysecrets" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read .mysecrets (private_files)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/subdir/special.privatekey" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read .privatekey files (private_files)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/subdir/data.mysensitive" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read .mysensitive files (private_files)" - ); - - // Reading a normal file should still work, even with private_files configured - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/subdir/normal_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!(result.is_ok(), "Should be able to read normal files"); - assert_eq!( - result.unwrap().content.as_str().unwrap(), - "Normal file content" - ); - - // Path traversal attempts with .. should fail - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/../outside_project/sensitive_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree" - ); - } - - #[gpui::test] - async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - // Create first worktree with its own private_files setting - fs.insert_tree( - path!("/worktree1"), - json!({ - "src": { - "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", - "secret.rs": "const API_KEY: &str = \"secret_key_1\";", - "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" - }, - "tests": { - "test.rs": "mod tests { fn test_it() {} }", - "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" - }, - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/fixture.*"], - "private_files": ["**/secret.rs", "**/config.toml"] - }"# - } - }), - ) - .await; - - // Create second worktree with different private_files setting - fs.insert_tree( - path!("/worktree2"), - json!({ - "lib": { - "public.js": "export function greet() { return 'Hello from worktree2'; }", - "private.js": "const SECRET_TOKEN = \"private_token_2\";", - "data.json": "{\"api_key\": \"json_secret_key\"}" - }, - "docs": { - "README.md": "# Public Documentation", - "internal.md": "# Internal Secrets and Configuration" - }, - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/internal.*"], - "private_files": ["**/private.js", "**/data.json"] - }"# - } - }), - ) - .await; - - // Set global settings - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.worktree.file_scan_exclusions = - Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.project.worktree.private_files = - Some(vec!["**/.env".to_string()].into()); - }); - }); - }); - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ReadFileTool); - - // Test reading allowed files in worktree1 - let input = json!({ - "path": "worktree1/src/main.rs" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - assert_eq!( - result.content.as_str().unwrap(), - "fn main() { println!(\"Hello from worktree1\"); }" - ); - - // Test reading private file in worktree1 should fail - let input = json!({ - "path": "worktree1/src/secret.rs" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `private_files` setting"), - "Error should mention worktree private_files setting" - ); - - // Test reading excluded file in worktree1 should fail - let input = json!({ - "path": "worktree1/tests/fixture.sql" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `file_scan_exclusions` setting"), - "Error should mention worktree file_scan_exclusions setting" - ); - - // Test reading allowed files in worktree2 - let input = json!({ - "path": "worktree2/lib/public.js" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - assert_eq!( - result.content.as_str().unwrap(), - "export function greet() { return 'Hello from worktree2'; }" - ); - - // Test reading private file in worktree2 should fail - let input = json!({ - "path": "worktree2/lib/private.js" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `private_files` setting"), - "Error should mention worktree private_files setting" - ); - - // Test reading excluded file in worktree2 should fail - let input = json!({ - "path": "worktree2/docs/internal.md" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `file_scan_exclusions` setting"), - "Error should mention worktree file_scan_exclusions setting" - ); - - // Test that files allowed in one worktree but not in another are handled correctly - // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2) - let input = json!({ - "path": "worktree1/src/config.toml" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `private_files` setting"), - "Config.toml should be blocked by worktree1's private_files setting" - ); - } -} diff --git a/crates/assistant_tools/src/read_file_tool/description.md b/crates/assistant_tools/src/read_file_tool/description.md deleted file mode 100644 index 7bcebc0334..0000000000 --- a/crates/assistant_tools/src/read_file_tool/description.md +++ /dev/null @@ -1,3 +0,0 @@ -Reads the content of the given file in the project. - -- Never attempt to read a path that hasn't been previously mentioned. diff --git a/crates/assistant_tools/src/schema.rs b/crates/assistant_tools/src/schema.rs deleted file mode 100644 index dab7384efd..0000000000 --- a/crates/assistant_tools/src/schema.rs +++ /dev/null @@ -1,60 +0,0 @@ -use anyhow::Result; -use language_model::LanguageModelToolSchemaFormat; -use schemars::{ - JsonSchema, Schema, - generate::SchemaSettings, - transform::{Transform, transform_subschemas}, -}; - -pub fn json_schema_for( - format: LanguageModelToolSchemaFormat, -) -> Result { - let schema = root_schema_for::(format); - schema_to_json(&schema, format) -} - -fn schema_to_json( - schema: &Schema, - format: LanguageModelToolSchemaFormat, -) -> Result { - let mut value = serde_json::to_value(schema)?; - assistant_tool::adapt_schema_to_format(&mut value, format)?; - Ok(value) -} - -fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { - let mut generator = match format { - LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), - LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() - .with(|settings| { - settings.meta_schema = None; - settings.inline_subschemas = true; - }) - .with_transform(ToJsonSchemaSubsetTransform) - .into_generator(), - }; - generator.root_schema_for::() -} - -#[derive(Debug, Clone)] -struct ToJsonSchemaSubsetTransform; - -impl Transform for ToJsonSchemaSubsetTransform { - fn transform(&mut self, schema: &mut Schema) { - // Ensure that the type field is not an array, this happens when we use - // Option, the type will be [T, "null"]. - if let Some(type_field) = schema.get_mut("type") - && let Some(types) = type_field.as_array() - && let Some(first_type) = types.first() - { - *type_field = first_type.clone(); - } - - // oneOf is not supported, use anyOf instead - if let Some(one_of) = schema.remove("oneOf") { - schema.insert("anyOf".to_string(), one_of); - } - - transform_subschemas(self, schema); - } -} diff --git a/crates/assistant_tools/src/templates.rs b/crates/assistant_tools/src/templates.rs deleted file mode 100644 index c83601199c..0000000000 --- a/crates/assistant_tools/src/templates.rs +++ /dev/null @@ -1,32 +0,0 @@ -use anyhow::Result; -use handlebars::Handlebars; -use rust_embed::RustEmbed; -use serde::Serialize; -use std::sync::Arc; - -#[derive(RustEmbed)] -#[folder = "src/templates"] -#[include = "*.hbs"] -struct Assets; - -pub struct Templates(Handlebars<'static>); - -impl Templates { - pub fn new() -> Arc { - let mut handlebars = Handlebars::new(); - handlebars.register_embed_templates::().unwrap(); - handlebars.register_escape_fn(|text| text.into()); - Arc::new(Self(handlebars)) - } -} - -pub trait Template: Sized { - const TEMPLATE_NAME: &'static str; - - fn render(&self, templates: &Templates) -> Result - where - Self: Serialize + Sized, - { - Ok(templates.0.render(Self::TEMPLATE_NAME, self)?) - } -} diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs deleted file mode 100644 index db85863f2e..0000000000 --- a/crates/assistant_tools/src/terminal_tool.rs +++ /dev/null @@ -1,883 +0,0 @@ -use crate::{ - schema::json_schema_for, - ui::{COLLAPSED_LINES, ToolOutputPreview}, -}; -use action_log::ActionLog; -use agent_settings; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolCard, ToolResult, ToolUseStatus}; -use futures::FutureExt as _; -use gpui::{ - AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement, - WeakEntity, Window, -}; -use language::LineEnding; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use portable_pty::{CommandBuilder, PtySize, native_pty_system}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsLocation}; -use std::{ - env, - path::{Path, PathBuf}, - process::ExitStatus, - sync::Arc, - time::{Duration, Instant}, -}; -use task::{Shell, ShellBuilder}; -use terminal::terminal_settings::TerminalSettings; -use terminal_view::TerminalView; -use theme::ThemeSettings; -use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*}; -use util::{ - ResultExt, get_default_system_shell_preferring_bash, markdown::MarkdownInlineCode, - size::format_file_size, time::duration_alt_display, -}; -use workspace::Workspace; - -const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024; - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct TerminalToolInput { - /// The one-liner command to execute. - command: String, - /// Working directory for the command. This must be one of the root directories of the project. - cd: String, -} - -pub struct TerminalTool; - -impl TerminalTool { - pub const NAME: &str = "terminal"; -} - -impl Tool for TerminalTool { - fn name(&self) -> String { - Self::NAME.to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - true - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./terminal_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolTerminal - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let mut lines = input.command.lines(); - let first_line = lines.next().unwrap_or_default(); - let remaining_line_count = lines.count(); - match remaining_line_count { - 0 => MarkdownInlineCode(first_line).to_string(), - 1 => MarkdownInlineCode(&format!( - "{} - {} more line", - first_line, remaining_line_count - )) - .to_string(), - n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n)) - .to_string(), - } - } - Err(_) => "Run terminal command".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - window: Option, - cx: &mut App, - ) -> ToolResult { - let input: TerminalToolInput = match serde_json::from_value(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let working_dir = match working_dir(&input, &project, cx) { - Ok(dir) => dir, - Err(err) => return Task::ready(Err(err)).into(), - }; - - let cwd = working_dir.clone(); - let env = match &cwd { - Some(dir) => project.update(cx, |project, cx| { - let worktree = project.find_worktree(dir.as_path(), cx); - let shell = TerminalSettings::get( - worktree.as_ref().map(|(worktree, path)| SettingsLocation { - worktree_id: worktree.read(cx).id(), - path: &path, - }), - cx, - ) - .shell - .clone(); - project.directory_environment(&shell, dir.as_path().into(), cx) - }), - None => Task::ready(None).shared(), - }; - let shell = project - .update(cx, |project, cx| { - project - .remote_client() - .and_then(|r| r.read(cx).default_system_shell()) - }) - .unwrap_or_else(|| get_default_system_shell_preferring_bash()); - - let env = cx.spawn(async move |_| { - let mut env = env.await.unwrap_or_default(); - if cfg!(unix) { - env.insert("PAGER".into(), "cat".into()); - } - env - }); - - let build_cmd = { - let input_command = input.command.clone(); - move || { - ShellBuilder::new(&Shell::Program(shell)) - .redirect_stdin_to_dev_null() - .build(Some(input_command), &[]) - } - }; - - let Some(window) = window else { - // Headless setup, a test or eval. Our terminal subsystem requires a workspace, - // so bypass it and provide a convincing imitation using a pty. - let task = cx.background_spawn(async move { - let env = env.await; - let pty_system = native_pty_system(); - let (command, args) = build_cmd(); - let mut cmd = CommandBuilder::new(command); - cmd.args(args); - for (k, v) in env { - cmd.env(k, v); - } - if let Some(cwd) = cwd { - cmd.cwd(cwd); - } - let pair = pty_system.openpty(PtySize { - rows: 24, - cols: 80, - ..Default::default() - })?; - let mut child = pair.slave.spawn_command(cmd)?; - let mut reader = pair.master.try_clone_reader()?; - drop(pair); - let mut content = String::new(); - reader.read_to_string(&mut content)?; - // Massage the pty output a bit to try to match what the terminal codepath gives us - LineEnding::normalize(&mut content); - content = content - .chars() - .filter(|c| c.is_ascii_whitespace() || !c.is_ascii_control()) - .collect(); - let content = content.trim_start().trim_start_matches("^D"); - let exit_status = child.wait()?; - let (processed_content, _) = - process_content(content, &input.command, Some(exit_status)); - Ok(processed_content.into()) - }); - return ToolResult { - output: task, - card: None, - }; - }; - - let terminal = cx.spawn({ - let project = project.downgrade(); - async move |cx| { - let (command, args) = build_cmd(); - let env = env.await; - project - .update(cx, |project, cx| { - project.create_terminal_task( - task::SpawnInTerminal { - command: Some(command), - args, - cwd, - env, - ..Default::default() - }, - cx, - ) - })? - .await - } - }); - - let command_markdown = cx.new(|cx| { - Markdown::new( - format!("```bash\n{}\n```", input.command).into(), - None, - None, - cx, - ) - }); - - let card = - cx.new(|cx| TerminalToolCard::new(command_markdown, working_dir, cx.entity_id(), cx)); - - let output = cx.spawn({ - let card = card.clone(); - async move |cx| { - let terminal = terminal.await?; - let workspace = window - .downcast::() - .and_then(|handle| handle.entity(cx).ok()) - .context("no workspace entity in root of window")?; - - let terminal_view = window.update(cx, |_, window, cx| { - cx.new(|cx| { - let mut view = TerminalView::new( - terminal.clone(), - workspace.downgrade(), - None, - project.downgrade(), - window, - cx, - ); - view.set_embedded_mode(None, cx); - view - }) - })?; - - card.update(cx, |card, _| { - card.terminal = Some(terminal_view.clone()); - card.start_instant = Instant::now(); - }) - .log_err(); - - let exit_status = terminal - .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? - .await; - let (content, content_line_count) = terminal.read_with(cx, |terminal, _| { - (terminal.get_content(), terminal.total_lines()) - })?; - - let previous_len = content.len(); - let (processed_content, finished_with_empty_output) = process_content( - &content, - &input.command, - exit_status.map(portable_pty::ExitStatus::from), - ); - - card.update(cx, |card, _| { - card.command_finished = true; - card.exit_status = exit_status; - card.was_content_truncated = processed_content.len() < previous_len; - card.original_content_len = previous_len; - card.content_line_count = content_line_count; - card.finished_with_empty_output = finished_with_empty_output; - card.elapsed_time = Some(card.start_instant.elapsed()); - }) - .log_err(); - - Ok(processed_content.into()) - } - }); - - ToolResult { - output, - card: Some(card.into()), - } - } -} - -fn process_content( - content: &str, - command: &str, - exit_status: Option, -) -> (String, bool) { - let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT; - - let content = if should_truncate { - let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len()); - while !content.is_char_boundary(end_ix) { - end_ix -= 1; - } - // Don't truncate mid-line, clear the remainder of the last line - end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix); - &content[..end_ix] - } else { - content - }; - let content = content.trim(); - let is_empty = content.is_empty(); - let content = format!("```\n{content}\n```"); - let content = if should_truncate { - format!( - "Command output too long. The first {} bytes:\n\n{content}", - content.len(), - ) - } else { - content - }; - - let content = match exit_status { - Some(exit_status) if exit_status.success() => { - if is_empty { - "Command executed successfully.".to_string() - } else { - content - } - } - Some(exit_status) => { - if is_empty { - format!( - "Command \"{command}\" failed with exit code {}.", - exit_status.exit_code() - ) - } else { - format!( - "Command \"{command}\" failed with exit code {}.\n\n{content}", - exit_status.exit_code() - ) - } - } - None => { - format!( - "Command failed or was interrupted.\nPartial output captured:\n\n{}", - content, - ) - } - }; - (content, is_empty) -} - -fn working_dir( - input: &TerminalToolInput, - project: &Entity, - cx: &mut App, -) -> Result> { - let project = project.read(cx); - let cd = &input.cd; - - if cd == "." || cd.is_empty() { - // Accept "." or "" as meaning "the one worktree" if we only have one worktree. - let mut worktrees = project.worktrees(cx); - - match worktrees.next() { - Some(worktree) => { - anyhow::ensure!( - worktrees.next().is_none(), - "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.", - ); - Ok(Some(worktree.read(cx).abs_path().to_path_buf())) - } - None => Ok(None), - } - } else { - let input_path = Path::new(cd); - - if input_path.is_absolute() { - // Absolute paths are allowed, but only if they're in one of the project's worktrees. - if project - .worktrees(cx) - .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path())) - { - return Ok(Some(input_path.into())); - } - } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) { - return Ok(Some(worktree.read(cx).abs_path().to_path_buf())); - } - - anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees."); - } -} - -struct TerminalToolCard { - input_command: Entity, - working_dir: Option, - entity_id: EntityId, - exit_status: Option, - terminal: Option>, - command_finished: bool, - was_content_truncated: bool, - finished_with_empty_output: bool, - content_line_count: usize, - original_content_len: usize, - preview_expanded: bool, - start_instant: Instant, - elapsed_time: Option, -} - -impl TerminalToolCard { - pub fn new( - input_command: Entity, - working_dir: Option, - entity_id: EntityId, - cx: &mut Context, - ) -> Self { - let expand_terminal_card = - agent_settings::AgentSettings::get_global(cx).expand_terminal_card; - Self { - input_command, - working_dir, - entity_id, - exit_status: None, - terminal: None, - command_finished: false, - was_content_truncated: false, - finished_with_empty_output: false, - original_content_len: 0, - content_line_count: 0, - preview_expanded: expand_terminal_card, - start_instant: Instant::now(), - elapsed_time: None, - } - } -} - -impl ToolCard for TerminalToolCard { - fn render( - &mut self, - status: &ToolUseStatus, - window: &mut Window, - _workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let Some(terminal) = self.terminal.as_ref() else { - return Empty.into_any(); - }; - - let tool_failed = matches!(status, ToolUseStatus::Error(_)); - - let command_failed = - self.command_finished && self.exit_status.is_none_or(|code| !code.success()); - - if (tool_failed || command_failed) && self.elapsed_time.is_none() { - self.elapsed_time = Some(self.start_instant.elapsed()); - } - let time_elapsed = self - .elapsed_time - .unwrap_or_else(|| self.start_instant.elapsed()); - - let header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - - let border_color = cx.theme().colors().border.opacity(0.6); - - let path = self - .working_dir - .as_ref() - .cloned() - .or_else(|| env::current_dir().ok()) - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "current directory".to_string()); - - let header = h_flex() - .flex_none() - .gap_1() - .justify_between() - .rounded_t_md() - .child( - div() - .id(("command-target-path", self.entity_id)) - .w_full() - .max_w_full() - .overflow_x_scroll() - .child( - Label::new(path) - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .when(!self.command_finished, |header| { - header.child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_rotate_animation(2), - ) - }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", self.entity_id)) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when(command_failed && self.exit_status.is_some(), |this| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - self.exit_status - .and_then(|status| status.code()) - .unwrap_or(-1), - ))) - }) - .when( - !command_failed && tool_failed && status.error().is_some(), - |this| { - this.tooltip(Tooltip::text(format!( - "Error: {}", - status.error().unwrap(), - ))) - }, - ), - ) - }) - .when(self.was_content_truncated, |header| { - let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { - "Output exceeded terminal max lines and was \ - truncated, the model received the first 16 KB." - .to_string() - } else { - format!( - "Output is {} long, to avoid unexpected token usage, \ - only 16 KB was sent back to the model.", - format_file_size(self.original_content_len as u64, true), - ) - }; - header.child( - h_flex() - .id(("terminal-tool-truncated-label", self.entity_id)) - .tooltip(Tooltip::text(tooltip)) - .gap_1() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Ignored), - ) - .child( - Label::new("Truncated") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - }) - .when(time_elapsed > Duration::from_secs(10), |header| { - header.child( - Label::new(format!("({})", duration_alt_display(time_elapsed))) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::Small), - ) - }) - .when(!self.finished_with_empty_output, |header| { - header.child( - Disclosure::new( - ("terminal-tool-disclosure", self.entity_id), - self.preview_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener( - move |this, _event, _window, _cx| { - this.preview_expanded = !this.preview_expanded; - }, - )), - ) - }); - - v_flex() - .mb_2() - .border_1() - .when(tool_failed || command_failed, |card| card.border_dashed()) - .border_color(border_color) - .rounded_lg() - .overflow_hidden() - .child( - v_flex() - .p_2() - .gap_0p5() - .bg(header_bg) - .text_xs() - .child(header) - .child( - MarkdownElement::new( - self.input_command.clone(), - markdown_style(window, cx), - ) - .code_block_renderer( - markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: true, - border: false, - }, - ), - ), - ) - .when( - self.preview_expanded && !self.finished_with_empty_output, - |this| { - this.child( - div() - .pt_2() - .border_t_1() - .when(tool_failed || command_failed, |card| card.border_dashed()) - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .rounded_b_md() - .text_ui_sm(cx) - .child({ - let content_mode = terminal.read(cx).content_mode(window, cx); - - if content_mode.is_scrollable() { - div().h_72().child(terminal.clone()).into_any_element() - } else { - ToolOutputPreview::new( - terminal.clone().into_any_element(), - terminal.entity_id(), - ) - .with_total_lines(self.content_line_count) - .toggle_state(!content_mode.is_limited()) - .on_toggle({ - let terminal = terminal.clone(); - move |is_expanded, _, cx| { - terminal.update(cx, |terminal, cx| { - terminal.set_embedded_mode( - if is_expanded { - None - } else { - Some(COLLAPSED_LINES) - }, - cx, - ); - }); - } - }) - .into_any_element() - } - }), - ) - }, - ) - .into_any() - } -} - -fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let theme_settings = ThemeSettings::get_global(cx); - let buffer_font_size = TextSize::Default.rems(cx); - let mut text_style = window.text_style(); - - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), - font_features: Some(theme_settings.buffer_font.features.clone()), - font_size: Some(buffer_font_size.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - MarkdownStyle { - base_text_style: text_style.clone(), - selection_background_color: cx.theme().colors().element_selection_background, - ..Default::default() - } -} - -#[cfg(test)] -mod tests { - use editor::EditorSettings; - use fs::RealFs; - use gpui::{BackgroundExecutor, TestAppContext}; - use language_model::fake_provider::FakeLanguageModel; - use pretty_assertions::assert_eq; - use serde_json::json; - use settings::{Settings, SettingsStore}; - use terminal::terminal_settings::TerminalSettings; - use theme::ThemeSettings; - use util::{ResultExt as _, test::TempTree}; - - use super::*; - - fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) { - zlog::init_test(); - - executor.allow_parking(); - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); - ThemeSettings::register(cx); - TerminalSettings::register(cx); - EditorSettings::register(cx); - }); - } - - #[gpui::test] - async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) { - if cfg!(windows) { - return; - } - init_test(&executor, cx); - - let fs = Arc::new(RealFs::new(None, executor)); - let tree = TempTree::new(json!({ - "project": {}, - })); - let project: Entity = - Project::test(fs, [tree.path().join("project").as_path()], cx).await; - let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone()))); - let model = Arc::new(FakeLanguageModel::default()); - - let input = TerminalToolInput { - command: "cat".to_owned(), - cd: tree - .path() - .join("project") - .as_path() - .to_string_lossy() - .to_string(), - }; - let result = cx.update(|cx| { - TerminalTool::run( - Arc::new(TerminalTool), - serde_json::to_value(input).unwrap(), - Arc::default(), - project.clone(), - action_log.clone(), - model, - None, - cx, - ) - }); - - let output = result.output.await.log_err().unwrap().content; - assert_eq!(output.as_str().unwrap(), "Command executed successfully."); - } - - #[gpui::test] - async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) { - if cfg!(windows) { - return; - } - init_test(&executor, cx); - - let fs = Arc::new(RealFs::new(None, executor)); - let tree = TempTree::new(json!({ - "project": {}, - "other-project": {}, - })); - let project: Entity = - Project::test(fs, [tree.path().join("project").as_path()], cx).await; - let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone()))); - let model = Arc::new(FakeLanguageModel::default()); - - let check = |input, expected, cx: &mut App| { - let headless_result = TerminalTool::run( - Arc::new(TerminalTool), - serde_json::to_value(input).unwrap(), - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ); - cx.spawn(async move |_| { - let output = headless_result.output.await.map(|output| output.content); - assert_eq!( - output - .ok() - .and_then(|content| content.as_str().map(ToString::to_string)), - expected - ); - }) - }; - - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: ".".into(), - }, - Some(format!( - "```\n{}\n```", - tree.path().join("project").display() - )), - cx, - ) - }) - .await; - - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: "other-project".into(), - }, - None, // other-project is a dir, but *not* a worktree (yet) - cx, - ) - }) - .await; - - // Absolute path above the worktree root - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: tree.path().to_string_lossy().into(), - }, - None, - cx, - ) - }) - .await; - - project - .update(cx, |project, cx| { - project.create_worktree(tree.path().join("other-project"), true, cx) - }) - .await - .unwrap(); - - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: "other-project".into(), - }, - Some(format!( - "```\n{}\n```", - tree.path().join("other-project").display() - )), - cx, - ) - }) - .await; - - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: ".".into(), - }, - None, - cx, - ) - }) - .await; - } -} diff --git a/crates/assistant_tools/src/terminal_tool/description.md b/crates/assistant_tools/src/terminal_tool/description.md deleted file mode 100644 index 3cb5d87d16..0000000000 --- a/crates/assistant_tools/src/terminal_tool/description.md +++ /dev/null @@ -1,11 +0,0 @@ -Executes a shell one-liner and returns the combined output. - -This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result. - -The output results will be shown to the user already, only list it again if necessary, avoid being redundant. - -Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error. - -Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. - -Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs deleted file mode 100644 index 17ce4afc2e..0000000000 --- a/crates/assistant_tools/src/thinking_tool.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::sync::Arc; - -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::IconName; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ThinkingToolInput { - /// Content to think about. This should be a description of what to think about or - /// a problem to solve. - content: String, -} - -pub struct ThinkingTool; - -impl Tool for ThinkingTool { - fn name(&self) -> String { - "thinking".to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./thinking_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolThink - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Thinking".to_string() - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - _cx: &mut App, - ) -> ToolResult { - // This tool just "thinks out loud" and doesn't perform any actions. - Task::ready(match serde_json::from_value::(input) { - Ok(_input) => Ok("Finished thinking.".to_string().into()), - Err(err) => Err(anyhow!(err)), - }) - .into() - } -} diff --git a/crates/assistant_tools/src/thinking_tool/description.md b/crates/assistant_tools/src/thinking_tool/description.md deleted file mode 100644 index b625d22f32..0000000000 --- a/crates/assistant_tools/src/thinking_tool/description.md +++ /dev/null @@ -1 +0,0 @@ -A tool for thinking through problems, brainstorming ideas, or planning without executing any actions. Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action. diff --git a/crates/assistant_tools/src/ui.rs b/crates/assistant_tools/src/ui.rs deleted file mode 100644 index 7934273854..0000000000 --- a/crates/assistant_tools/src/ui.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod tool_call_card_header; -mod tool_output_preview; - -pub use tool_call_card_header::*; -pub use tool_output_preview::*; diff --git a/crates/assistant_tools/src/ui/tool_call_card_header.rs b/crates/assistant_tools/src/ui/tool_call_card_header.rs deleted file mode 100644 index b41f19432f..0000000000 --- a/crates/assistant_tools/src/ui/tool_call_card_header.rs +++ /dev/null @@ -1,131 +0,0 @@ -use gpui::{Animation, AnimationExt, AnyElement, App, IntoElement, pulsating_between}; -use std::time::Duration; -use ui::{Tooltip, prelude::*}; - -/// A reusable header component for tool call cards. -#[derive(IntoElement)] -pub struct ToolCallCardHeader { - icon: IconName, - primary_text: SharedString, - secondary_text: Option, - code_path: Option, - disclosure_slot: Option, - is_loading: bool, - error: Option, -} - -impl ToolCallCardHeader { - pub fn new(icon: IconName, primary_text: impl Into) -> Self { - Self { - icon, - primary_text: primary_text.into(), - secondary_text: None, - code_path: None, - disclosure_slot: None, - is_loading: false, - error: None, - } - } - - pub fn with_secondary_text(mut self, text: impl Into) -> Self { - self.secondary_text = Some(text.into()); - self - } - - pub fn with_code_path(mut self, text: impl Into) -> Self { - self.code_path = Some(text.into()); - self - } - - pub fn disclosure_slot(mut self, element: impl IntoElement) -> Self { - self.disclosure_slot = Some(element.into_any_element()); - self - } - - pub fn loading(mut self) -> Self { - self.is_loading = true; - self - } - - pub fn with_error(mut self, error: impl Into) -> Self { - self.error = Some(error.into()); - self - } -} - -impl RenderOnce for ToolCallCardHeader { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let font_size = rems(0.8125); - let line_height = window.line_height(); - - let secondary_text = self.secondary_text; - let code_path = self.code_path; - - let bullet_divider = || { - div() - .size(px(3.)) - .rounded_full() - .bg(cx.theme().colors().text) - }; - - h_flex() - .id("tool-label-container") - .gap_2() - .max_w_full() - .overflow_x_scroll() - .opacity(0.8) - .child( - h_flex() - .h(line_height) - .gap_1p5() - .text_size(font_size) - .child( - h_flex().h(line_height).justify_center().child( - Icon::new(self.icon) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - .map(|this| { - if let Some(error) = &self.error { - this.child(format!("{} failed", self.primary_text)).child( - IconButton::new("error_info", IconName::Warning) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Warning) - .tooltip(Tooltip::text(error.clone())), - ) - } else { - this.child(self.primary_text.clone()) - } - }) - .when_some(secondary_text, |this, secondary_text| { - this.child(bullet_divider()) - .child(div().text_size(font_size).child(secondary_text)) - }) - .when_some(code_path, |this, code_path| { - this.child(bullet_divider()) - .child(Label::new(code_path).size(LabelSize::Small).inline_code(cx)) - }) - .with_animation( - "loading-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.)), - move |this, delta| { - if self.is_loading { - this.opacity(delta) - } else { - this - } - }, - ), - ) - .when_some(self.disclosure_slot, |container, disclosure_slot| { - container - .group("disclosure") - .justify_between() - .child(div().visible_on_hover("disclosure").child(disclosure_slot)) - }) - } -} diff --git a/crates/assistant_tools/src/ui/tool_output_preview.rs b/crates/assistant_tools/src/ui/tool_output_preview.rs deleted file mode 100644 index a672bb8b99..0000000000 --- a/crates/assistant_tools/src/ui/tool_output_preview.rs +++ /dev/null @@ -1,115 +0,0 @@ -use gpui::{AnyElement, EntityId, prelude::*}; -use ui::{Tooltip, prelude::*}; - -#[derive(IntoElement)] -pub struct ToolOutputPreview -where - F: Fn(bool, &mut Window, &mut App) + 'static, -{ - content: AnyElement, - entity_id: EntityId, - full_height: bool, - total_lines: usize, - collapsed_fade: bool, - on_toggle: Option, -} - -pub const COLLAPSED_LINES: usize = 10; - -impl ToolOutputPreview -where - F: Fn(bool, &mut Window, &mut App) + 'static, -{ - pub fn new(content: AnyElement, entity_id: EntityId) -> Self { - Self { - content, - entity_id, - full_height: true, - total_lines: 0, - collapsed_fade: false, - on_toggle: None, - } - } - - pub fn with_total_lines(mut self, total_lines: usize) -> Self { - self.total_lines = total_lines; - self - } - - pub fn toggle_state(mut self, full_height: bool) -> Self { - self.full_height = full_height; - self - } - - pub fn with_collapsed_fade(mut self) -> Self { - self.collapsed_fade = true; - self - } - - pub fn on_toggle(mut self, listener: F) -> Self { - self.on_toggle = Some(listener); - self - } -} - -impl RenderOnce for ToolOutputPreview -where - F: Fn(bool, &mut Window, &mut App) + 'static, -{ - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - if self.total_lines <= COLLAPSED_LINES { - return self.content; - } - let border_color = cx.theme().colors().border.opacity(0.6); - - let (icon, tooltip_label) = if self.full_height { - (IconName::ChevronUp, "Collapse") - } else { - (IconName::ChevronDown, "Expand") - }; - - let gradient_overlay = - if self.collapsed_fade && !self.full_height { - Some(div().absolute().bottom_5().left_0().w_full().h_2_5().bg( - gpui::linear_gradient( - 0., - gpui::linear_color_stop(cx.theme().colors().editor_background, 0.), - gpui::linear_color_stop( - cx.theme().colors().editor_background.opacity(0.), - 1., - ), - ), - )) - } else { - None - }; - - v_flex() - .relative() - .child(self.content) - .children(gradient_overlay) - .child( - h_flex() - .id(("expand-button", self.entity_id)) - .flex_none() - .cursor_pointer() - .h_5() - .justify_center() - .border_t_1() - .rounded_b_md() - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1))) - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) - .tooltip(Tooltip::text(tooltip_label)) - .when_some(self.on_toggle, |this, on_toggle| { - this.on_click({ - move |_, window, cx| { - on_toggle(!self.full_height, window, cx); - } - }) - }), - ) - .into_any() - } -} diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs deleted file mode 100644 index dbcca0a1f6..0000000000 --- a/crates/assistant_tools/src/web_search_tool.rs +++ /dev/null @@ -1,327 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use crate::schema::json_schema_for; -use crate::ui::ToolCallCardHeader; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ - Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, -}; -use cloud_llm_client::{WebSearchResponse, WebSearchResult}; -use futures::{Future, FutureExt, TryFutureExt}; -use gpui::{ - AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window, -}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::{IconName, Tooltip, prelude::*}; -use web_search::WebSearchRegistry; -use workspace::Workspace; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct WebSearchToolInput { - /// The search term or question to query on the web. - query: String, -} - -pub struct WebSearchTool; - -impl Tool for WebSearchTool { - fn name(&self) -> String { - "web_search".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - "Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into() - } - - fn icon(&self) -> IconName { - IconName::ToolWeb - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Searching the Web".to_string() - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else { - return Task::ready(Err(anyhow!("Web search is not available."))).into(); - }; - - let search_task = provider.search(input.query, cx).map_err(Arc::new).shared(); - let output = cx.background_spawn({ - let search_task = search_task.clone(); - async move { - let response = search_task.await.map_err(|err| anyhow!(err))?; - Ok(ToolResultOutput { - content: ToolResultContent::Text( - serde_json::to_string(&response) - .context("Failed to serialize search results")?, - ), - output: Some(serde_json::to_value(response)?), - }) - } - }); - - ToolResult { - output, - card: Some(cx.new(|cx| WebSearchToolCard::new(search_task, cx)).into()), - } - } - - fn deserialize_card( - self: Arc, - output: serde_json::Value, - _project: Entity, - _window: &mut Window, - cx: &mut App, - ) -> Option { - let output = serde_json::from_value::(output).ok()?; - let card = cx.new(|cx| WebSearchToolCard::new(Task::ready(Ok(output)), cx)); - Some(card.into()) - } -} - -#[derive(RegisterComponent)] -struct WebSearchToolCard { - response: Option>, - _task: Task<()>, -} - -impl WebSearchToolCard { - fn new( - search_task: impl 'static + Future>>, - cx: &mut Context, - ) -> Self { - let _task = cx.spawn(async move |this, cx| { - let response = search_task.await.map_err(|err| anyhow!(err)); - this.update(cx, |this, cx| { - this.response = Some(response); - cx.notify(); - }) - .ok(); - }); - - Self { - response: None, - _task, - } - } -} - -impl ToolCard for WebSearchToolCard { - fn render( - &mut self, - _status: &ToolUseStatus, - _window: &mut Window, - _workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let icon = IconName::ToolWeb; - - let header = match self.response.as_ref() { - Some(Ok(response)) => { - let text: SharedString = if response.results.len() == 1 { - "1 result".into() - } else { - format!("{} results", response.results.len()).into() - }; - ToolCallCardHeader::new(icon, "Searched the Web").with_secondary_text(text) - } - Some(Err(error)) => { - ToolCallCardHeader::new(icon, "Web Search").with_error(error.to_string()) - } - None => ToolCallCardHeader::new(icon, "Searching the Web").loading(), - }; - - let content = self.response.as_ref().and_then(|response| match response { - Ok(response) => Some( - v_flex() - .overflow_hidden() - .ml_1p5() - .pl(px(5.)) - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .gap_1() - .children(response.results.iter().enumerate().map(|(index, result)| { - let title = result.title.clone(); - let url = SharedString::from(result.url.clone()); - - Button::new(("result", index), title) - .label_size(LabelSize::Small) - .color(Color::Muted) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) - .truncate(true) - .tooltip({ - let url = url.clone(); - move |window, cx| { - Tooltip::with_meta( - "Web Search Result", - None, - url.clone(), - window, - cx, - ) - } - }) - .on_click(move |_, _, cx| cx.open_url(&url)) - })) - .into_any(), - ), - Err(_) => None, - }); - - v_flex().mb_3().gap_1().child(header).children(content) - } -} - -impl Component for WebSearchToolCard { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn preview(window: &mut Window, cx: &mut App) -> Option { - let in_progress_search = cx.new(|cx| WebSearchToolCard { - response: None, - _task: cx.spawn(async move |_this, cx| { - loop { - cx.background_executor() - .timer(Duration::from_secs(60)) - .await - } - }), - }); - - let successful_search = cx.new(|_cx| WebSearchToolCard { - response: Some(Ok(example_search_response())), - _task: Task::ready(()), - }); - - let error_search = cx.new(|_cx| WebSearchToolCard { - response: Some(Err(anyhow!("Failed to resolve https://google.com"))), - _task: Task::ready(()), - }); - - Some( - v_flex() - .gap_6() - .children(vec![example_group(vec![ - single_example( - "In Progress", - div() - .size_full() - .child(in_progress_search.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Pending, - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - single_example( - "Successful", - div() - .size_full() - .child(successful_search.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Finished("".into()), - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - single_example( - "Error", - div() - .size_full() - .child(error_search.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Error("".into()), - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - ])]) - .into_any_element(), - ) - } -} - -fn example_search_response() -> WebSearchResponse { - WebSearchResponse { - results: vec![ - WebSearchResult { - title: "Alo".to_string(), - url: "https://www.google.com/maps/search/Alo%2C+Toronto%2C+Canada".to_string(), - text: "Alo is a popular restaurant in Toronto.".to_string(), - }, - WebSearchResult { - title: "Alo".to_string(), - url: "https://www.google.com/maps/search/Alo%2C+Toronto%2C+Canada".to_string(), - text: "Information about Alo restaurant in Toronto.".to_string(), - }, - WebSearchResult { - title: "Edulis".to_string(), - url: "https://www.google.com/maps/search/Edulis%2C+Toronto%2C+Canada".to_string(), - text: "Details about Edulis restaurant in Toronto.".to_string(), - }, - WebSearchResult { - title: "Sushi Masaki Saito".to_string(), - url: "https://www.google.com/maps/search/Sushi+Masaki+Saito%2C+Toronto%2C+Canada" - .to_string(), - text: "Information about Sushi Masaki Saito in Toronto.".to_string(), - }, - WebSearchResult { - title: "Shoushin".to_string(), - url: "https://www.google.com/maps/search/Shoushin%2C+Toronto%2C+Canada".to_string(), - text: "Details about Shoushin restaurant in Toronto.".to_string(), - }, - WebSearchResult { - title: "Restaurant 20 Victoria".to_string(), - url: - "https://www.google.com/maps/search/Restaurant+20+Victoria%2C+Toronto%2C+Canada" - .to_string(), - text: "Information about Restaurant 20 Victoria in Toronto.".to_string(), - }, - ], - } -} diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 7f2fed80e2..2aee764007 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -21,13 +21,12 @@ gpui.workspace = true denoise = { path = "../denoise" } log.workspace = true parking_lot.workspace = true -rodio = { workspace = true, features = [ "wav", "playback", "wav_output" ] } +rodio.workspace = true serde.workspace = true settings.workspace = true smol.workspace = true thiserror.workspace = true util.workspace = true -workspace-hack.workspace = true [target.'cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))'.dependencies] libwebrtc = { rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d", git = "https://github.com/zed-industries/livekit-rust-sdks" } diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index 955713b7f0..2c1f770530 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -1,12 +1,12 @@ use anyhow::{Context as _, Result}; use collections::HashMap; use gpui::{App, BackgroundExecutor, BorrowAppContext, Global}; +use log::info; #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] mod non_windows_and_freebsd_deps { pub(super) use gpui::AsyncApp; pub(super) use libwebrtc::native::apm; - pub(super) use log::info; pub(super) use parking_lot::Mutex; pub(super) use rodio::cpal::Sample; pub(super) use rodio::source::LimitSettings; @@ -48,7 +48,6 @@ pub const LEGACY_CHANNEL_COUNT: NonZero = nz!(2); pub const REPLAY_DURATION: Duration = Duration::from_secs(30); pub fn init(cx: &mut App) { - AudioSettings::register(cx); LIVE_SETTINGS.initialize(cx); } diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs index cba7d45c31..f862462928 100644 --- a/crates/audio/src/audio_settings.rs +++ b/crates/audio/src/audio_settings.rs @@ -1,9 +1,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use gpui::App; -use settings::{Settings, SettingsStore}; +use settings::{RegisterSetting, Settings, SettingsStore}; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, RegisterSetting)] pub struct AudioSettings { /// Opt into the new audio system. /// @@ -42,7 +42,7 @@ pub struct AudioSettings { /// Configuration of audio in Zed impl Settings for AudioSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let audio = &content.audio.as_ref().unwrap(); AudioSettings { rodio_audio: audio.rodio_audio.unwrap(), diff --git a/crates/audio/src/rodio_ext.rs b/crates/audio/src/rodio_ext.rs index af4cc89252..ab74c59fe6 100644 --- a/crates/audio/src/rodio_ext.rs +++ b/crates/audio/src/rodio_ext.rs @@ -433,7 +433,7 @@ where /// Stores already emitted samples, once its full we call the callback. buffer: [Sample; N], /// Next free element in buffer. If this is equal to the buffer length - /// we have no more free lements. + /// we have no more free elements. free: usize, } diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 21df028a88..6f352fbd7b 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -21,16 +21,22 @@ http_client.workspace = true log.workspace = true paths.workspace = true release_channel.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true tempfile.workspace = true +util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [target.'cfg(not(target_os = "windows"))'.dependencies] which.workspace = true [dev-dependencies] +ctor.workspace = true +clock= { workspace = true, "features" = ["test-support"] } +futures.workspace = true gpui = { workspace = true, "features" = ["test-support"] } +parking_lot.workspace = true +zlog.workspace = true diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 0d66ddf52f..0c122717d7 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -1,18 +1,18 @@ use anyhow::{Context as _, Result}; -use client::{Client, TelemetrySettings}; -use db::RELEASE_CHANNEL; +use client::Client; use db::kvp::KEY_VALUE_STORE; use gpui::{ - App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, SemanticVersion, - Task, Window, actions, + App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, Window, + actions, }; -use http_client::{AsyncBody, HttpClient, HttpClientWithUrl}; +use http_client::{HttpClient, HttpClientWithUrl}; use paths::remote_servers_dir; use release_channel::{AppCommitSha, ReleaseChannel}; +use semver::Version; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::{RegisterSetting, Settings, SettingsStore}; +use smol::fs::File; use smol::{fs, io::AsyncReadExt}; -use smol::{fs::File, process::Command}; use std::mem; use std::{ env::{ @@ -24,6 +24,7 @@ use std::{ sync::Arc, time::Duration, }; +use util::command::new_smol_command; use workspace::Workspace; const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification"; @@ -41,22 +42,23 @@ actions!( ] ); -#[derive(Serialize)] -struct UpdateRequestBody { - installation_id: Option>, - release_channel: Option<&'static str>, - telemetry: bool, - is_staff: Option, - destination: &'static str, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum VersionCheckType { Sha(AppCommitSha), - Semantic(SemanticVersion), + Semantic(Version), } -#[derive(Clone)] +#[derive(Serialize, Debug)] +pub struct AssetQuery<'a> { + asset: &'a str, + os: &'a str, + arch: &'a str, + metrics_id: Option<&'a str>, + system_id: Option<&'a str>, + is_staff: Option, +} + +#[derive(Clone, Debug)] pub enum AutoUpdateStatus { Idle, Checking, @@ -66,6 +68,31 @@ pub enum AutoUpdateStatus { Errored { error: Arc }, } +impl PartialEq for AutoUpdateStatus { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (AutoUpdateStatus::Idle, AutoUpdateStatus::Idle) => true, + (AutoUpdateStatus::Checking, AutoUpdateStatus::Checking) => true, + ( + AutoUpdateStatus::Downloading { version: v1 }, + AutoUpdateStatus::Downloading { version: v2 }, + ) => v1 == v2, + ( + AutoUpdateStatus::Installing { version: v1 }, + AutoUpdateStatus::Installing { version: v2 }, + ) => v1 == v2, + ( + AutoUpdateStatus::Updated { version: v1 }, + AutoUpdateStatus::Updated { version: v2 }, + ) => v1 == v2, + (AutoUpdateStatus::Errored { error: e1 }, AutoUpdateStatus::Errored { error: e2 }) => { + e1.to_string() == e2.to_string() + } + _ => false, + } + } +} + impl AutoUpdateStatus { pub fn is_updated(&self) -> bool { matches!(self, Self::Updated { .. }) @@ -74,14 +101,14 @@ impl AutoUpdateStatus { pub struct AutoUpdater { status: AutoUpdateStatus, - current_version: SemanticVersion, - http_client: Arc, + current_version: Version, + client: Arc, pending_poll: Option>>, quit_subscription: Option, } -#[derive(Deserialize, Clone, Debug)] -pub struct JsonRelease { +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct ReleaseAsset { pub version: String, pub url: String, } @@ -96,7 +123,7 @@ impl Drop for MacOsUnmounter<'_> { let mount_path = mem::take(&mut self.mount_path); self.background_executor .spawn(async move { - let unmount_output = Command::new("hdiutil") + let unmount_output = new_smol_command("hdiutil") .args(["detach", "-force"]) .arg(&mount_path) .output() @@ -120,14 +147,14 @@ impl Drop for MacOsUnmounter<'_> { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, RegisterSetting)] struct AutoUpdateSetting(bool); /// Whether or not to automatically check for updates. /// /// Default: true impl Settings for AutoUpdateSetting { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { Self(content.auto_update.unwrap()) } } @@ -137,9 +164,7 @@ struct GlobalAutoUpdate(Option>); impl Global for GlobalAutoUpdate {} -pub fn init(http_client: Arc, cx: &mut App) { - AutoUpdateSetting::register(cx); - +pub fn init(client: Arc, cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _window, _cx| { workspace.register_action(|_, action, window, cx| check(action, window, cx)); @@ -151,7 +176,7 @@ pub fn init(http_client: Arc, cx: &mut App) { let version = release_channel::AppVersion::global(cx); let auto_updater = cx.new(|cx| { - let updater = AutoUpdater::new(version, http_client, cx); + let updater = AutoUpdater::new(version, client, cx); let poll_for_updates = ReleaseChannel::try_global(cx) .map(|channel| channel.poll_for_updates()) @@ -232,10 +257,10 @@ pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut App) -> Option<()> { match release_channel { ReleaseChannel::Stable | ReleaseChannel::Preview => { let auto_updater = auto_updater.read(cx); - let current_version = auto_updater.current_version; + let current_version = auto_updater.current_version.clone(); let release_channel = release_channel.dev_name(); let path = format!("/releases/{release_channel}/{current_version}"); - let url = &auto_updater.http_client.build_url(&path); + let url = &auto_updater.client.http_client().build_url(&path); cx.open_url(url); } ReleaseChannel::Nightly => { @@ -298,11 +323,7 @@ impl AutoUpdater { cx.default_global::().0.clone() } - fn new( - current_version: SemanticVersion, - http_client: Arc, - cx: &mut Context, - ) -> Self { + fn new(current_version: Version, client: Arc, cx: &mut Context) -> Self { // On windows, executable files cannot be overwritten while they are // running, so we must wait to overwrite the application until quitting // or restarting. When quitting the app, we spawn the auto update helper @@ -323,7 +344,7 @@ impl AutoUpdater { Self { status: AutoUpdateStatus::Idle, current_version, - http_client, + client, pending_poll: None, quit_subscription, } @@ -331,6 +352,15 @@ impl AutoUpdater { pub fn start_polling(&self, cx: &mut Context) -> Task> { cx.spawn(async move |this, cx| { + if cfg!(target_os = "windows") { + use util::ResultExt; + + cleanup_windows() + .await + .context("failed to cleanup old directories") + .log_err(); + } + loop { this.update(cx, |this, cx| this.poll(UpdateCheckType::Automatic, cx))?; cx.background_executor().timer(POLL_INTERVAL).await; @@ -346,7 +376,7 @@ impl AutoUpdater { cx.notify(); self.pending_poll = Some(cx.spawn(async move |this, cx| { - let result = Self::update(this.upgrade()?, cx.clone()).await; + let result = Self::update(this.upgrade()?, cx).await; this.update(cx, |this, cx| { this.pending_poll = None; if let Err(error) = result { @@ -371,8 +401,8 @@ impl AutoUpdater { })); } - pub fn current_version(&self) -> SemanticVersion { - self.current_version + pub fn current_version(&self) -> Version { + self.current_version.clone() } pub fn status(&self) -> AutoUpdateStatus { @@ -392,10 +422,11 @@ impl AutoUpdater { // you can override this function. You should also update get_remote_server_release_url to return // Ok(None). pub async fn download_remote_server_release( + release_channel: ReleaseChannel, + version: Option, os: &str, arch: &str, - release_channel: ReleaseChannel, - version: Option, + set_status: impl Fn(&str, &mut AsyncApp) + Send + 'static, cx: &mut AsyncApp, ) -> Result { let this = cx.update(|cx| { @@ -405,13 +436,14 @@ impl AutoUpdater { .context("auto-update not initialized") })??; - let release = Self::get_release( + set_status("Fetching remote server release", cx); + let release = Self::get_release_asset( &this, + release_channel, + version, "zed-remote-server", os, arch, - version, - Some(release_channel), cx, ) .await?; @@ -422,26 +454,27 @@ impl AutoUpdater { let version_path = platform_dir.join(format!("{}.gz", release.version)); smol::fs::create_dir_all(&platform_dir).await.ok(); - let client = this.read_with(cx, |this, _| this.http_client.clone())?; + let client = this.read_with(cx, |this, _| this.client.http_client())?; if smol::fs::metadata(&version_path).await.is_err() { log::info!( "downloading zed-remote-server {os} {arch} version {}", release.version ); - download_remote_server_binary(&version_path, release, client, cx).await?; + set_status("Downloading remote server", cx); + download_remote_server_binary(&version_path, release, client).await?; } Ok(version_path) } pub async fn get_remote_server_release_url( + channel: ReleaseChannel, + version: Option, os: &str, arch: &str, - release_channel: ReleaseChannel, - version: Option, cx: &mut AsyncApp, - ) -> Result> { + ) -> Result> { let this = cx.update(|cx| { cx.default_global::() .0 @@ -449,108 +482,101 @@ impl AutoUpdater { .context("auto-update not initialized") })??; - let release = Self::get_release( - &this, - "zed-remote-server", - os, - arch, - version, - Some(release_channel), - cx, - ) - .await?; + let release = + Self::get_release_asset(&this, channel, version, "zed-remote-server", os, arch, cx) + .await?; - let update_request_body = build_remote_server_update_request_body(cx)?; - let body = serde_json::to_string(&update_request_body)?; - - Ok(Some((release.url, body))) + Ok(Some(release.url)) } - async fn get_release( + async fn get_release_asset( this: &Entity, + release_channel: ReleaseChannel, + version: Option, asset: &str, os: &str, arch: &str, - version: Option, - release_channel: Option, cx: &mut AsyncApp, - ) -> Result { - let client = this.read_with(cx, |this, _| this.http_client.clone())?; + ) -> Result { + let client = this.read_with(cx, |this, _| this.client.clone())?; - if let Some(version) = version { - let channel = release_channel.map(|c| c.dev_name()).unwrap_or("stable"); - - let url = format!("/api/releases/{channel}/{version}/{asset}-{os}-{arch}.gz?update=1",); - - Ok(JsonRelease { - version: version.to_string(), - url: client.build_url(&url), - }) + let (system_id, metrics_id, is_staff) = if client.telemetry().metrics_enabled() { + ( + client.telemetry().system_id(), + client.telemetry().metrics_id(), + client.telemetry().is_staff(), + ) } else { - let mut url_string = client.build_url(&format!( - "/api/releases/latest?asset={}&os={}&arch={}", - asset, os, arch - )); - if let Some(param) = release_channel.and_then(|c| c.release_query_param()) { - url_string += "&"; - url_string += param; - } + (None, None, None) + }; - let mut response = client.get(&url_string, Default::default(), true).await?; - let mut body = Vec::new(); - response.body_mut().read_to_end(&mut body).await?; + let version = if let Some(mut version) = version { + version.pre = semver::Prerelease::EMPTY; + version.build = semver::BuildMetadata::EMPTY; + version.to_string() + } else { + "latest".to_string() + }; + let http_client = client.http_client(); - anyhow::ensure!( - response.status().is_success(), - "failed to fetch release: {:?}", + let path = format!("/releases/{}/{}/asset", release_channel.dev_name(), version,); + let url = http_client.build_zed_cloud_url_with_query( + &path, + AssetQuery { + os, + arch, + asset, + metrics_id: metrics_id.as_deref(), + system_id: system_id.as_deref(), + is_staff: is_staff, + }, + )?; + + let mut response = http_client + .get(url.as_str(), Default::default(), true) + .await?; + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + anyhow::ensure!( + response.status().is_success(), + "failed to fetch release: {:?}", + String::from_utf8_lossy(&body), + ); + + serde_json::from_slice(body.as_slice()).with_context(|| { + format!( + "error deserializing release {:?}", String::from_utf8_lossy(&body), - ); - - serde_json::from_slice(body.as_slice()).with_context(|| { - format!( - "error deserializing release {:?}", - String::from_utf8_lossy(&body), - ) - }) - } + ) + }) } - async fn get_latest_release( - this: &Entity, - asset: &str, - os: &str, - arch: &str, - release_channel: Option, - cx: &mut AsyncApp, - ) -> Result { - Self::get_release(this, asset, os, arch, None, release_channel, cx).await - } - - async fn update(this: Entity, mut cx: AsyncApp) -> Result<()> { + async fn update(this: Entity, cx: &mut AsyncApp) -> Result<()> { let (client, installed_version, previous_status, release_channel) = - this.read_with(&cx, |this, cx| { + this.read_with(cx, |this, cx| { ( - this.http_client.clone(), - this.current_version, + this.client.http_client(), + this.current_version.clone(), this.status.clone(), - ReleaseChannel::try_global(cx), + ReleaseChannel::try_global(cx).unwrap_or(ReleaseChannel::Stable), ) })?; Self::check_dependencies()?; - this.update(&mut cx, |this, cx| { + this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Checking; log::info!("Auto Update: checking for updates"); cx.notify(); })?; let fetched_release_data = - Self::get_latest_release(&this, "zed", OS, ARCH, release_channel, &mut cx).await?; + Self::get_release_asset(&this, release_channel, None, "zed", OS, ARCH, cx).await?; let fetched_version = fetched_release_data.clone().version; let app_commit_sha = cx.update(|cx| AppCommitSha::try_global(cx).map(|sha| sha.full())); let newer_version = Self::check_if_fetched_version_is_newer( - *RELEASE_CHANNEL, + release_channel, app_commit_sha, installed_version, fetched_version, @@ -558,7 +584,7 @@ impl AutoUpdater { )?; let Some(newer_version) = newer_version else { - return this.update(&mut cx, |this, cx| { + return this.update(cx, |this, cx| { let status = match previous_status { AutoUpdateStatus::Updated { .. } => previous_status, _ => AutoUpdateStatus::Idle, @@ -568,7 +594,7 @@ impl AutoUpdater { }); }; - this.update(&mut cx, |this, cx| { + this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Downloading { version: newer_version.clone(), }; @@ -577,21 +603,21 @@ impl AutoUpdater { let installer_dir = InstallerDir::new().await?; let target_path = Self::target_path(&installer_dir).await?; - download_release(&target_path, fetched_release_data, client, &cx).await?; + download_release(&target_path, fetched_release_data, client).await?; - this.update(&mut cx, |this, cx| { + this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Installing { version: newer_version.clone(), }; cx.notify(); })?; - let new_binary_path = Self::install_release(installer_dir, target_path, &cx).await?; + let new_binary_path = Self::install_release(installer_dir, target_path, cx).await?; if let Some(new_binary_path) = new_binary_path { cx.update(|cx| cx.set_restart_path(new_binary_path))?; } - this.update(&mut cx, |this, cx| { + this.update(cx, |this, cx| { this.set_should_show_update_notification(true, cx) .detach_and_log_err(cx); this.status = AutoUpdateStatus::Updated { @@ -604,16 +630,20 @@ impl AutoUpdater { fn check_if_fetched_version_is_newer( release_channel: ReleaseChannel, app_commit_sha: Result>, - installed_version: SemanticVersion, + installed_version: Version, fetched_version: String, status: AutoUpdateStatus, ) -> Result> { - let parsed_fetched_version = fetched_version.parse::(); + let parsed_fetched_version = fetched_version.parse::(); if let AutoUpdateStatus::Updated { version, .. } = status { match version { VersionCheckType::Sha(cached_version) => { - let should_download = fetched_version != cached_version.full(); + let should_download = + parsed_fetched_version.as_ref().ok().is_none_or(|version| { + version.build.as_str().rsplit('.').next() + != Some(&cached_version.full()) + }); let newer_version = should_download .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); return Ok(newer_version); @@ -632,7 +662,11 @@ impl AutoUpdater { let should_download = app_commit_sha .ok() .flatten() - .map(|sha| fetched_version != sha) + .map(|sha| { + parsed_fetched_version.as_ref().ok().is_none_or(|version| { + version.build.as_str().rsplit('.').next() != Some(&sha) + }) + }) .unwrap_or(true); let newer_version = should_download .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); @@ -649,7 +683,7 @@ impl AutoUpdater { #[cfg(not(target_os = "windows"))] anyhow::ensure!( which::which("rsync").is_ok(), - "Aborting. Could not find rsync which is required for auto-updates." + "Could not auto-update because the required rsync utility was not found." ); Ok(()) } @@ -658,7 +692,7 @@ impl AutoUpdater { let filename = match OS { "macos" => anyhow::Ok("Zed.dmg"), "linux" => Ok("zed.tar.gz"), - "windows" => Ok("zed_editor_installer.exe"), + "windows" => Ok("Zed.exe"), unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), }?; @@ -670,6 +704,12 @@ impl AutoUpdater { target_path: PathBuf, cx: &AsyncApp, ) -> Result> { + #[cfg(test)] + if let Some(test_install) = + cx.try_read_global::(|g, _| g.0.clone()) + { + return test_install(target_path, cx); + } match OS { "macos" => install_release_macos(&installer_dir, target_path, cx).await, "linux" => install_release_linux(&installer_dir, target_path, cx).await, @@ -679,9 +719,12 @@ impl AutoUpdater { } fn check_if_fetched_version_is_newer_non_nightly( - installed_version: SemanticVersion, - fetched_version: SemanticVersion, + mut installed_version: Version, + fetched_version: Version, ) -> Result> { + // For non-nightly releases, ignore build and pre-release fields as they're not provided by our endpoints right now. + installed_version.build = semver::BuildMetadata::EMPTY; + installed_version.pre = semver::Prerelease::EMPTY; let should_download = fetched_version > installed_version; let newer_version = should_download.then(|| VersionCheckType::Semantic(fetched_version)); Ok(newer_version) @@ -720,16 +763,13 @@ impl AutoUpdater { async fn download_remote_server_binary( target_path: &PathBuf, - release: JsonRelease, + release: ReleaseAsset, client: Arc, - cx: &AsyncApp, ) -> Result<()> { let temp = tempfile::Builder::new().tempfile_in(remote_servers_dir())?; let mut temp_file = File::create(&temp).await?; - let update_request_body = build_remote_server_update_request_body(cx)?; - let request_body = AsyncBody::from(serde_json::to_string(&update_request_body)?); - let mut response = client.get(&release.url, request_body, true).await?; + let mut response = client.get(&release.url, Default::default(), true).await?; anyhow::ensure!( response.status().is_success(), "failed to download remote server release: {:?}", @@ -741,65 +781,19 @@ async fn download_remote_server_binary( Ok(()) } -fn build_remote_server_update_request_body(cx: &AsyncApp) -> Result { - let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| { - let telemetry = Client::global(cx).telemetry().clone(); - let is_staff = telemetry.is_staff(); - let installation_id = telemetry.installation_id(); - let release_channel = - ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name()); - let telemetry_enabled = TelemetrySettings::get_global(cx).metrics; - - ( - installation_id, - release_channel, - telemetry_enabled, - is_staff, - ) - })?; - - Ok(UpdateRequestBody { - installation_id, - release_channel, - telemetry: telemetry_enabled, - is_staff, - destination: "remote", - }) -} - async fn download_release( target_path: &Path, - release: JsonRelease, + release: ReleaseAsset, client: Arc, - cx: &AsyncApp, ) -> Result<()> { let mut target_file = File::create(&target_path).await?; - let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| { - let telemetry = Client::global(cx).telemetry().clone(); - let is_staff = telemetry.is_staff(); - let installation_id = telemetry.installation_id(); - let release_channel = - ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name()); - let telemetry_enabled = TelemetrySettings::get_global(cx).metrics; - - ( - installation_id, - release_channel, - telemetry_enabled, - is_staff, - ) - })?; - - let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody { - installation_id, - release_channel, - telemetry: telemetry_enabled, - is_staff, - destination: "local", - })?); - - let mut response = client.get(&release.url, request_body, true).await?; + let mut response = client.get(&release.url, Default::default(), true).await?; + anyhow::ensure!( + response.status().is_success(), + "failed to download update: {:?}", + response.status() + ); smol::io::copy(response.body_mut(), &mut target_file).await?; log::info!("downloaded update. path:{:?}", target_path); @@ -820,7 +814,7 @@ async fn install_release_linux( .await .context("failed to create directory into which to extract update")?; - let output = Command::new("tar") + let output = new_smol_command("tar") .arg("-xzf") .arg(&downloaded_tar_gz) .arg("-C") @@ -855,7 +849,7 @@ async fn install_release_linux( to = PathBuf::from(prefix); } - let output = Command::new("rsync") + let output = new_smol_command("rsync") .args(["-av", "--delete"]) .arg(&from) .arg(&to) @@ -887,7 +881,7 @@ async fn install_release_macos( let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into(); mounted_app_path.push("/"); - let output = Command::new("hdiutil") + let output = new_smol_command("hdiutil") .args(["attach", "-nobrowse"]) .arg(&downloaded_dmg) .arg("-mountroot") @@ -907,7 +901,7 @@ async fn install_release_macos( background_executor: cx.background_executor(), }; - let output = Command::new("rsync") + let output = new_smol_command("rsync") .args(["-av", "--delete"]) .arg(&mounted_app_path) .arg(&running_app_path) @@ -923,8 +917,22 @@ async fn install_release_macos( Ok(None) } +async fn cleanup_windows() -> Result<()> { + let parent = std::env::current_exe()? + .parent() + .context("No parent dir for Zed.exe")? + .to_owned(); + + // keep in sync with crates/auto_update_helper/src/updater.rs + _ = smol::fs::remove_dir(parent.join("updates")).await; + _ = smol::fs::remove_dir(parent.join("install")).await; + _ = smol::fs::remove_dir(parent.join("old")).await; + + Ok(()) +} + async fn install_release_windows(downloaded_installer: PathBuf) -> Result> { - let output = Command::new(downloaded_installer) + let output = new_smol_command(downloaded_installer) .arg("/verysilent") .arg("/update=true") .arg("!desktopicon") @@ -962,7 +970,7 @@ pub async fn finalize_auto_update_on_quit() { .parent() .map(|p| p.join("tools").join("auto_update_helper.exe")) { - let mut command = smol::process::Command::new(helper); + let mut command = util::command::new_smol_command(helper); command.arg("--launch"); command.arg("false"); if let Ok(mut cmd) = command.spawn() { @@ -973,11 +981,33 @@ pub async fn finalize_auto_update_on_quit() { #[cfg(test)] mod tests { + use client::Client; + use clock::FakeSystemClock; + use futures::channel::oneshot; use gpui::TestAppContext; + use http_client::{FakeHttpClient, Response}; use settings::default_settings; + use std::{ + rc::Rc, + sync::{ + Arc, + atomic::{self, AtomicBool}, + }, + }; + use tempfile::tempdir; + + #[ctor::ctor] + fn init_logger() { + zlog::init_test(); + } use super::*; + pub(super) struct InstallOverride( + pub Rc Result>>, + ); + impl Global for InstallOverride {} + #[gpui::test] fn test_auto_update_defaults_to_true(cx: &mut TestAppContext) { cx.update(|cx| { @@ -989,18 +1019,126 @@ mod tests { .set_user_settings("{}", cx) .expect("Unable to set user settings"); cx.set_global(store); - AutoUpdateSetting::register(cx); assert!(AutoUpdateSetting::get_global(cx).0); }); } + #[gpui::test] + async fn test_auto_update_downloads(cx: &mut TestAppContext) { + cx.background_executor.allow_parking(); + zlog::init_test(); + let release_available = Arc::new(AtomicBool::new(false)); + + let (dmg_tx, dmg_rx) = oneshot::channel::(); + + cx.update(|cx| { + settings::init(cx); + + let current_version = semver::Version::new(0, 100, 0); + release_channel::init_test(current_version, ReleaseChannel::Stable, cx); + + let clock = Arc::new(FakeSystemClock::new()); + let release_available = Arc::clone(&release_available); + let dmg_rx = Arc::new(parking_lot::Mutex::new(Some(dmg_rx))); + let fake_client_http = FakeHttpClient::create(move |req| { + let release_available = release_available.load(atomic::Ordering::Relaxed); + let dmg_rx = dmg_rx.clone(); + async move { + if req.uri().path() == "/releases/stable/latest/asset" { + if release_available { + return Ok(Response::builder().status(200).body( + r#"{"version":"0.100.1","url":"https://test.example/new-download"}"#.into() + ).unwrap()); + } else { + return Ok(Response::builder().status(200).body( + r#"{"version":"0.100.0","url":"https://test.example/old-download"}"#.into() + ).unwrap()); + } + } else if req.uri().path() == "/new-download" { + return Ok(Response::builder().status(200).body({ + let dmg_rx = dmg_rx.lock().take().unwrap(); + dmg_rx.await.unwrap().into() + }).unwrap()); + } + Ok(Response::builder().status(404).body("".into()).unwrap()) + } + }); + let client = Client::new(clock, fake_client_http, cx); + crate::init(client, cx); + }); + + let auto_updater = cx.update(|cx| AutoUpdater::get(cx).expect("auto updater should exist")); + + cx.background_executor.run_until_parked(); + + auto_updater.read_with(cx, |updater, _| { + assert_eq!(updater.status(), AutoUpdateStatus::Idle); + assert_eq!(updater.current_version(), semver::Version::new(0, 100, 0)); + }); + + release_available.store(true, atomic::Ordering::SeqCst); + cx.background_executor.advance_clock(POLL_INTERVAL); + cx.background_executor.run_until_parked(); + + loop { + cx.background_executor.timer(Duration::from_millis(0)).await; + cx.run_until_parked(); + let status = auto_updater.read_with(cx, |updater, _| updater.status()); + if !matches!(status, AutoUpdateStatus::Idle) { + break; + } + } + let status = auto_updater.read_with(cx, |updater, _| updater.status()); + assert_eq!( + status, + AutoUpdateStatus::Downloading { + version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)) + } + ); + + dmg_tx.send("".to_owned()).unwrap(); + + let tmp_dir = Arc::new(tempdir().unwrap()); + + cx.update(|cx| { + let tmp_dir = tmp_dir.clone(); + cx.set_global(InstallOverride(Rc::new(move |target_path, _cx| { + let tmp_dir = tmp_dir.clone(); + let dest_path = tmp_dir.path().join("zed"); + std::fs::copy(&target_path, &dest_path)?; + Ok(Some(dest_path)) + }))); + }); + + loop { + cx.background_executor.timer(Duration::from_millis(0)).await; + cx.run_until_parked(); + let status = auto_updater.read_with(cx, |updater, _| updater.status()); + if !matches!(status, AutoUpdateStatus::Downloading { .. }) { + break; + } + } + let status = auto_updater.read_with(cx, |updater, _| updater.status()); + assert_eq!( + status, + AutoUpdateStatus::Updated { + version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)) + } + ); + let will_restart = cx.expect_restart(); + cx.update(|cx| cx.restart()); + let path = will_restart.await.unwrap().unwrap(); + assert_eq!(path, tmp_dir.path().join("zed")); + assert_eq!(std::fs::read_to_string(path).unwrap(), ""); + } + #[test] fn test_stable_does_not_update_when_fetched_version_is_not_higher() { let release_channel = ReleaseChannel::Stable; let app_commit_sha = Ok(Some("a".to_string())); - let installed_version = SemanticVersion::new(1, 0, 0); + let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; - let fetched_version = SemanticVersion::new(1, 0, 0); + let fetched_version = semver::Version::new(1, 0, 0); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, @@ -1017,9 +1155,9 @@ mod tests { fn test_stable_does_update_when_fetched_version_is_higher() { let release_channel = ReleaseChannel::Stable; let app_commit_sha = Ok(Some("a".to_string())); - let installed_version = SemanticVersion::new(1, 0, 0); + let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; - let fetched_version = SemanticVersion::new(1, 0, 1); + let fetched_version = semver::Version::new(1, 0, 1); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, @@ -1039,11 +1177,11 @@ mod tests { fn test_stable_does_not_update_when_fetched_version_is_not_higher_than_cached() { let release_channel = ReleaseChannel::Stable; let app_commit_sha = Ok(Some("a".to_string())); - let installed_version = SemanticVersion::new(1, 0, 0); + let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(SemanticVersion::new(1, 0, 1)), + version: VersionCheckType::Semantic(semver::Version::new(1, 0, 1)), }; - let fetched_version = SemanticVersion::new(1, 0, 1); + let fetched_version = semver::Version::new(1, 0, 1); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, @@ -1060,11 +1198,11 @@ mod tests { fn test_stable_does_update_when_fetched_version_is_higher_than_cached() { let release_channel = ReleaseChannel::Stable; let app_commit_sha = Ok(Some("a".to_string())); - let installed_version = SemanticVersion::new(1, 0, 0); + let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(SemanticVersion::new(1, 0, 1)), + version: VersionCheckType::Semantic(semver::Version::new(1, 0, 1)), }; - let fetched_version = SemanticVersion::new(1, 0, 2); + let fetched_version = semver::Version::new(1, 0, 2); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, @@ -1084,9 +1222,10 @@ mod tests { fn test_nightly_does_not_update_when_fetched_sha_is_same() { let release_channel = ReleaseChannel::Nightly; let app_commit_sha = Ok(Some("a".to_string())); - let installed_version = SemanticVersion::new(1, 0, 0); + let mut installed_version = semver::Version::new(1, 0, 0); + installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Idle; - let fetched_sha = "a".to_string(); + let fetched_sha = "1.0.0+a".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, @@ -1103,7 +1242,7 @@ mod tests { fn test_nightly_does_update_when_fetched_sha_is_not_same() { let release_channel = ReleaseChannel::Nightly; let app_commit_sha = Ok(Some("a".to_string())); - let installed_version = SemanticVersion::new(1, 0, 0); + let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; let fetched_sha = "b".to_string(); @@ -1122,14 +1261,15 @@ mod tests { } #[test] - fn test_nightly_does_not_update_when_fetched_sha_is_same_as_cached() { + fn test_nightly_does_not_update_when_fetched_version_is_same_as_cached() { let release_channel = ReleaseChannel::Nightly; let app_commit_sha = Ok(Some("a".to_string())); - let installed_version = SemanticVersion::new(1, 0, 0); + let mut installed_version = semver::Version::new(1, 0, 0); + installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Updated { version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), }; - let fetched_sha = "b".to_string(); + let fetched_sha = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, @@ -1146,11 +1286,12 @@ mod tests { fn test_nightly_does_update_when_fetched_sha_is_not_same_as_cached() { let release_channel = ReleaseChannel::Nightly; let app_commit_sha = Ok(Some("a".to_string())); - let installed_version = SemanticVersion::new(1, 0, 0); + let mut installed_version = semver::Version::new(1, 0, 0); + installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Updated { version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), }; - let fetched_sha = "c".to_string(); + let fetched_sha = "1.0.0+c".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, @@ -1170,7 +1311,7 @@ mod tests { fn test_nightly_does_update_when_installed_versions_sha_cannot_be_retrieved() { let release_channel = ReleaseChannel::Nightly; let app_commit_sha = Ok(None); - let installed_version = SemanticVersion::new(1, 0, 0); + let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; let fetched_sha = "a".to_string(); @@ -1193,11 +1334,11 @@ mod tests { { let release_channel = ReleaseChannel::Nightly; let app_commit_sha = Ok(None); - let installed_version = SemanticVersion::new(1, 0, 0); + let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), }; - let fetched_sha = "b".to_string(); + let fetched_sha = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, @@ -1215,7 +1356,7 @@ mod tests { { let release_channel = ReleaseChannel::Nightly; let app_commit_sha = Ok(None); - let installed_version = SemanticVersion::new(1, 0, 0); + let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), }; diff --git a/crates/auto_update_helper/Cargo.toml b/crates/auto_update_helper/Cargo.toml index 6581de48d2..73c38d80dd 100644 --- a/crates/auto_update_helper/Cargo.toml +++ b/crates/auto_update_helper/Cargo.toml @@ -17,11 +17,13 @@ doctest = false anyhow.workspace = true log.workspace = true simplelog.workspace = true -workspace-hack.workspace = true [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true +[target.'cfg(target_os = "windows")'.dev-dependencies] +tempfile.workspace = true + [target.'cfg(target_os = "windows")'.build-dependencies] winresource = "0.1" diff --git a/crates/auto_update_helper/manifest.xml b/crates/auto_update_helper/manifest.xml index 5a69b43486..c3a99d23ff 100644 --- a/crates/auto_update_helper/manifest.xml +++ b/crates/auto_update_helper/manifest.xml @@ -1,16 +1,32 @@ - - - - true + + + + + + + + + + + + + + + + + true/pm PerMonitorV2 - - + + - + version='6.0.0.0' + processorArchitecture='*' + publicKeyToken='6595b64144ccf1df' + /> diff --git a/crates/auto_update_helper/src/updater.rs b/crates/auto_update_helper/src/updater.rs index f741f0eaff..076e11fb4e 100644 --- a/crates/auto_update_helper/src/updater.rs +++ b/crates/auto_update_helper/src/updater.rs @@ -1,5 +1,6 @@ use std::{ path::Path, + sync::LazyLock, time::{Duration, Instant}, }; @@ -11,180 +12,270 @@ use windows::Win32::{ use crate::windows_impl::WM_JOB_UPDATED; -type Job = fn(&Path) -> Result<()>; +pub(crate) struct Job { + pub apply: Box Result<()> + Send + Sync>, + pub rollback: Box Result<()> + Send + Sync>, +} + +impl Job { + pub fn mkdir(name: &'static Path) -> Self { + Job { + apply: Box::new(move |app_dir| { + let dir = app_dir.join(name); + std::fs::create_dir_all(&dir) + .context(format!("Failed to create directory {}", dir.display())) + }), + rollback: Box::new(move |app_dir| { + let dir = app_dir.join(name); + std::fs::remove_dir_all(&dir) + .context(format!("Failed to remove directory {}", dir.display())) + }), + } + } + + pub fn mkdir_if_exists(name: &'static Path, check: &'static Path) -> Self { + Job { + apply: Box::new(move |app_dir| { + let dir = app_dir.join(name); + let check = app_dir.join(check); + + if check.exists() { + std::fs::create_dir_all(&dir) + .context(format!("Failed to create directory {}", dir.display()))? + } + Ok(()) + }), + rollback: Box::new(move |app_dir| { + let dir = app_dir.join(name); + + if dir.exists() { + std::fs::remove_dir_all(&dir) + .context(format!("Failed to remove directory {}", dir.display()))? + } + + Ok(()) + }), + } + } + + pub fn move_file(filename: &'static Path, new_filename: &'static Path) -> Self { + Job { + apply: Box::new(move |app_dir| { + let old_file = app_dir.join(filename); + let new_file = app_dir.join(new_filename); + log::info!( + "Moving file: {}->{}", + old_file.display(), + new_file.display() + ); + + std::fs::rename(&old_file, new_file) + .context(format!("Failed to move file {}", old_file.display())) + }), + rollback: Box::new(move |app_dir| { + let old_file = app_dir.join(filename); + let new_file = app_dir.join(new_filename); + log::info!( + "Rolling back file move: {}->{}", + old_file.display(), + new_file.display() + ); + + std::fs::rename(&new_file, &old_file).context(format!( + "Failed to rollback file move {}->{}", + new_file.display(), + old_file.display() + )) + }), + } + } + + pub fn move_if_exists(filename: &'static Path, new_filename: &'static Path) -> Self { + Job { + apply: Box::new(move |app_dir| { + let old_file = app_dir.join(filename); + let new_file = app_dir.join(new_filename); + + if old_file.exists() { + log::info!( + "Moving file: {}->{}", + old_file.display(), + new_file.display() + ); + + std::fs::rename(&old_file, new_file) + .context(format!("Failed to move file {}", old_file.display()))?; + } + + Ok(()) + }), + rollback: Box::new(move |app_dir| { + let old_file = app_dir.join(filename); + let new_file = app_dir.join(new_filename); + + if new_file.exists() { + log::info!( + "Rolling back file move: {}->{}", + old_file.display(), + new_file.display() + ); + + std::fs::rename(&new_file, &old_file).context(format!( + "Failed to rollback file move {}->{}", + new_file.display(), + old_file.display() + ))? + } + + Ok(()) + }), + } + } + + pub fn rmdir_nofail(filename: &'static Path) -> Self { + Job { + apply: Box::new(move |app_dir| { + let filename = app_dir.join(filename); + log::info!("Removing file: {}", filename.display()); + if let Err(e) = std::fs::remove_dir_all(&filename) { + log::warn!("Failed to remove directory: {}", e); + } + + Ok(()) + }), + rollback: Box::new(move |app_dir| { + let filename = app_dir.join(filename); + anyhow::bail!( + "Delete operations cannot be rolled back, file: {}", + filename.display() + ) + }), + } + } +} #[cfg(not(test))] -pub(crate) const JOBS: &[Job] = &[ - // Delete old files - |app_dir| { - let zed_executable = app_dir.join("Zed.exe"); - log::info!("Removing old file: {}", zed_executable.display()); - std::fs::remove_file(&zed_executable).context(format!( - "Failed to remove old file {}", - zed_executable.display() - )) - }, - |app_dir| { - let zed_cli = app_dir.join("bin\\zed.exe"); - log::info!("Removing old file: {}", zed_cli.display()); - std::fs::remove_file(&zed_cli) - .context(format!("Failed to remove old file {}", zed_cli.display())) - }, - |app_dir| { - let zed_wsl = app_dir.join("bin\\zed"); - log::info!("Removing old file: {}", zed_wsl.display()); - std::fs::remove_file(&zed_wsl) - .context(format!("Failed to remove old file {}", zed_wsl.display())) - }, - |app_dir| { - let open_console = app_dir.join("OpenConsole.exe"); - log::info!("Removing old file: {}", open_console.display()); - std::fs::remove_file(&open_console).context(format!( - "Failed to remove old file {}", - open_console.display() - )) - }, - |app_dir| { - let conpty = app_dir.join("conpty.dll"); - log::info!("Removing old file: {}", conpty.display()); - std::fs::remove_file(&conpty) - .context(format!("Failed to remove old file {}", conpty.display())) - }, - // Copy new files - |app_dir| { - let zed_executable_source = app_dir.join("install\\Zed.exe"); - let zed_executable_dest = app_dir.join("Zed.exe"); - log::info!( - "Copying new file {} to {}", - zed_executable_source.display(), - zed_executable_dest.display() - ); - std::fs::copy(&zed_executable_source, &zed_executable_dest) - .map(|_| ()) - .context(format!( - "Failed to copy new file {} to {}", - zed_executable_source.display(), - zed_executable_dest.display() - )) - }, - |app_dir| { - let zed_cli_source = app_dir.join("install\\bin\\zed.exe"); - let zed_cli_dest = app_dir.join("bin\\zed.exe"); - log::info!( - "Copying new file {} to {}", - zed_cli_source.display(), - zed_cli_dest.display() - ); - std::fs::copy(&zed_cli_source, &zed_cli_dest) - .map(|_| ()) - .context(format!( - "Failed to copy new file {} to {}", - zed_cli_source.display(), - zed_cli_dest.display() - )) - }, - |app_dir| { - let zed_wsl_source = app_dir.join("install\\bin\\zed"); - let zed_wsl_dest = app_dir.join("bin\\zed"); - log::info!( - "Copying new file {} to {}", - zed_wsl_source.display(), - zed_wsl_dest.display() - ); - std::fs::copy(&zed_wsl_source, &zed_wsl_dest) - .map(|_| ()) - .context(format!( - "Failed to copy new file {} to {}", - zed_wsl_source.display(), - zed_wsl_dest.display() - )) - }, - |app_dir| { - let open_console_source = app_dir.join("install\\OpenConsole.exe"); - let open_console_dest = app_dir.join("OpenConsole.exe"); - log::info!( - "Copying new file {} to {}", - open_console_source.display(), - open_console_dest.display() - ); - std::fs::copy(&open_console_source, &open_console_dest) - .map(|_| ()) - .context(format!( - "Failed to copy new file {} to {}", - open_console_source.display(), - open_console_dest.display() - )) - }, - |app_dir| { - let conpty_source = app_dir.join("install\\conpty.dll"); - let conpty_dest = app_dir.join("conpty.dll"); - log::info!( - "Copying new file {} to {}", - conpty_source.display(), - conpty_dest.display() - ); - std::fs::copy(&conpty_source, &conpty_dest) - .map(|_| ()) - .context(format!( - "Failed to copy new file {} to {}", - conpty_source.display(), - conpty_dest.display() - )) - }, - // Clean up installer folder and updates folder - |app_dir| { - let updates_folder = app_dir.join("updates"); - log::info!("Cleaning up: {}", updates_folder.display()); - std::fs::remove_dir_all(&updates_folder).context(format!( - "Failed to remove updates folder {}", - updates_folder.display() - )) - }, - |app_dir| { - let installer_folder = app_dir.join("install"); - log::info!("Cleaning up: {}", installer_folder.display()); - std::fs::remove_dir_all(&installer_folder).context(format!( - "Failed to remove installer folder {}", - installer_folder.display() - )) - }, -]; +pub(crate) static JOBS: LazyLock<[Job; 22]> = LazyLock::new(|| { + fn p(value: &str) -> &Path { + Path::new(value) + } + [ + // Move old files + // Not deleting because installing new files can fail + Job::mkdir(p("old")), + Job::move_file(p("Zed.exe"), p("old\\Zed.exe")), + Job::mkdir(p("old\\bin")), + Job::move_file(p("bin\\Zed.exe"), p("old\\bin\\Zed.exe")), + Job::move_file(p("bin\\zed"), p("old\\bin\\zed")), + // + // TODO: remove after a few weeks once everyone is on the new version and this file never exists + Job::move_if_exists(p("OpenConsole.exe"), p("old\\OpenConsole.exe")), + Job::mkdir(p("old\\x64")), + Job::mkdir(p("old\\arm64")), + Job::move_if_exists(p("x64\\OpenConsole.exe"), p("old\\x64\\OpenConsole.exe")), + Job::move_if_exists( + p("arm64\\OpenConsole.exe"), + p("old\\arm64\\OpenConsole.exe"), + ), + // + Job::move_file(p("conpty.dll"), p("old\\conpty.dll")), + // Copy new files + Job::move_file(p("install\\Zed.exe"), p("Zed.exe")), + Job::move_file(p("install\\bin\\Zed.exe"), p("bin\\Zed.exe")), + Job::move_file(p("install\\bin\\zed"), p("bin\\zed")), + // + Job::mkdir_if_exists(p("x64"), p("install\\x64")), + Job::mkdir_if_exists(p("arm64"), p("install\\arm64")), + Job::move_if_exists( + p("install\\x64\\OpenConsole.exe"), + p("x64\\OpenConsole.exe"), + ), + Job::move_if_exists( + p("install\\arm64\\OpenConsole.exe"), + p("arm64\\OpenConsole.exe"), + ), + // + Job::move_file(p("install\\conpty.dll"), p("conpty.dll")), + // Cleanup installer and updates folder + Job::rmdir_nofail(p("updates")), + Job::rmdir_nofail(p("install")), + // Cleanup old installation + Job::rmdir_nofail(p("old")), + ] +}); #[cfg(test)] -pub(crate) const JOBS: &[Job] = &[ - |_| { - std::thread::sleep(Duration::from_millis(1000)); - if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") { - match config.as_str() { - "err" => Err(std::io::Error::other("Simulated error")).context("Anyhow!"), - _ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config), - } - } else { - Ok(()) - } - }, - |_| { - std::thread::sleep(Duration::from_millis(1000)); - if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") { - match config.as_str() { - "err" => Err(std::io::Error::other("Simulated error")).context("Anyhow!"), - _ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config), - } - } else { - Ok(()) - } - }, -]; +pub(crate) static JOBS: LazyLock<[Job; 9]> = LazyLock::new(|| { + fn p(value: &str) -> &Path { + Path::new(value) + } + [ + Job { + apply: Box::new(|_| { + std::thread::sleep(Duration::from_millis(1000)); + if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") { + match config.as_str() { + "err1" => Err(std::io::Error::other("Simulated error")).context("Anyhow!"), + "err2" => Ok(()), + _ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config), + } + } else { + Ok(()) + } + }), + rollback: Box::new(|_| { + unsafe { std::env::set_var("ZED_AUTO_UPDATE_RB", "rollback1") }; + Ok(()) + }), + }, + Job::mkdir(p("test1")), + Job::mkdir_if_exists(p("test_exists"), p("test1")), + Job::mkdir_if_exists(p("test_missing"), p("dont")), + Job { + apply: Box::new(|folder| { + std::fs::write(folder.join("test1/test"), "test")?; + Ok(()) + }), + rollback: Box::new(|folder| { + std::fs::remove_file(folder.join("test1/test"))?; + Ok(()) + }), + }, + Job::move_file(p("test1/test"), p("test1/moved")), + Job::move_if_exists(p("test1/test"), p("test1/noop")), + Job { + apply: Box::new(|_| { + std::thread::sleep(Duration::from_millis(1000)); + if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") { + match config.as_str() { + "err1" => Ok(()), + "err2" => Err(std::io::Error::other("Simulated error")).context("Anyhow!"), + _ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config), + } + } else { + Ok(()) + } + }), + rollback: Box::new(|_| Ok(())), + }, + Job::rmdir_nofail(p("test1/nofolder")), + ] +}); pub(crate) fn perform_update(app_dir: &Path, hwnd: Option, launch: bool) -> Result<()> { let hwnd = hwnd.map(|ptr| HWND(ptr as _)); - for job in JOBS.iter() { + let mut last_successful_job = None; + 'outer: for (i, job) in JOBS.iter().enumerate() { let start = Instant::now(); loop { - anyhow::ensure!(start.elapsed().as_secs() <= 2, "Timed out"); - match (*job)(app_dir) { + if start.elapsed().as_secs() > 2 { + log::error!("Timed out, rolling back"); + break 'outer; + } + match (job.apply)(app_dir) { Ok(_) => { + last_successful_job = Some(i); unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? }; break; } @@ -193,16 +284,39 @@ pub(crate) fn perform_update(app_dir: &Path, hwnd: Option, launch: bool) let io_err = err.downcast_ref::().unwrap(); if io_err.kind() == std::io::ErrorKind::NotFound { log::warn!("File or folder not found."); + last_successful_job = Some(i); unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? }; break; } - log::error!("Operation failed: {}", err); + log::error!("Operation failed: {} ({:?})", err, io_err.kind()); std::thread::sleep(Duration::from_millis(50)); } } } } + + if last_successful_job + .map(|job| job != JOBS.len() - 1) + .unwrap_or(true) + { + let Some(last_successful_job) = last_successful_job else { + anyhow::bail!("Autoupdate failed, nothing to rollback"); + }; + + for job in (0..=last_successful_job).rev() { + let job = &JOBS[job]; + if let Err(e) = (job.rollback)(app_dir) { + anyhow::bail!( + "Job rollback failed, the app might be left in an inconsistent state: ({:?})", + e + ); + } + } + + anyhow::bail!("Autoupdate failed, rollback successful"); + } + if launch { #[allow(clippy::disallowed_methods, reason = "doesn't run in the main binary")] let _ = std::process::Command::new(app_dir.join("Zed.exe")).spawn(); @@ -217,12 +331,27 @@ mod test { #[test] fn test_perform_update() { - let app_dir = std::path::Path::new("C:/"); + let app_dir = tempfile::tempdir().unwrap(); + let app_dir = app_dir.path(); assert!(perform_update(app_dir, None, false).is_ok()); + let app_dir = tempfile::tempdir().unwrap(); + let app_dir = app_dir.path(); // Simulate a timeout - unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err") }; + unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err1") }; let ret = perform_update(app_dir, None, false); - assert!(ret.is_err_and(|e| e.to_string().as_str() == "Timed out")); + assert!( + ret.is_err_and(|e| e.to_string().as_str() == "Autoupdate failed, nothing to rollback") + ); + + let app_dir = tempfile::tempdir().unwrap(); + let app_dir = app_dir.path(); + // Simulate a timeout + unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err2") }; + let ret = perform_update(app_dir, None, false); + assert!( + ret.is_err_and(|e| e.to_string().as_str() == "Autoupdate failed, rollback successful") + ); + assert!(std::env::var("ZED_AUTO_UPDATE_RB").is_ok_and(|e| e == "rollback1")); } } diff --git a/crates/auto_update_ui/Cargo.toml b/crates/auto_update_ui/Cargo.toml index 6a8ba02b82..2b1421e35d 100644 --- a/crates/auto_update_ui/Cargo.toml +++ b/crates/auto_update_ui/Cargo.toml @@ -20,9 +20,9 @@ gpui.workspace = true http_client.workspace = true markdown_preview.workspace = true release_channel.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true smol.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index aeaa6ae93e..6c32ee3b6c 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -148,7 +148,9 @@ pub fn notify_if_app_was_updated(cx: &mut App) { let should_show_notification = should_show_notification.await?; if should_show_notification { cx.update(|cx| { - let version = updater.read(cx).current_version(); + let mut version = updater.read(cx).current_version(); + version.build = semver::BuildMetadata::EMPTY; + version.pre = semver::Prerelease::EMPTY; let app_name = ReleaseChannel::global(cx).display_name(); show_app_notification( NotificationId::unique::(), diff --git a/crates/aws_http_client/Cargo.toml b/crates/aws_http_client/Cargo.toml index 2749286d4c..24569a764d 100644 --- a/crates/aws_http_client/Cargo.toml +++ b/crates/aws_http_client/Cargo.toml @@ -18,4 +18,3 @@ default = [] aws-smithy-runtime-api.workspace = true aws-smithy-types.workspace = true http_client.workspace = true -workspace-hack.workspace = true diff --git a/crates/bedrock/Cargo.toml b/crates/bedrock/Cargo.toml index 3000af50bb..f8f6fa4601 100644 --- a/crates/bedrock/Cargo.toml +++ b/crates/bedrock/Cargo.toml @@ -25,4 +25,3 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true -workspace-hack.workspace = true diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index ab0426bb7d..51e1b29f9a 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -51,6 +51,13 @@ pub enum Model { alias = "claude-opus-4-1-thinking-latest" )] ClaudeOpus4_1Thinking, + #[serde(rename = "claude-opus-4-5", alias = "claude-opus-4-5-latest")] + ClaudeOpus4_5, + #[serde( + rename = "claude-opus-4-5-thinking", + alias = "claude-opus-4-5-thinking-latest" + )] + ClaudeOpus4_5Thinking, #[serde(rename = "claude-3-5-sonnet-v2", alias = "claude-3-5-sonnet-latest")] Claude3_5SonnetV2, #[serde(rename = "claude-3-7-sonnet", alias = "claude-3-7-sonnet-latest")] @@ -66,6 +73,8 @@ pub enum Model { Claude3Sonnet, #[serde(rename = "claude-3-5-haiku", alias = "claude-3-5-haiku-latest")] Claude3_5Haiku, + #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] + ClaudeHaiku4_5, Claude3_5Sonnet, Claude3Haiku, // Amazon Nova Models @@ -139,7 +148,19 @@ impl Model { } pub fn from_id(id: &str) -> anyhow::Result { - if id.starts_with("claude-3-5-sonnet-v2") { + if id.starts_with("claude-opus-4-5-thinking") { + Ok(Self::ClaudeOpus4_5Thinking) + } else if id.starts_with("claude-opus-4-5") { + Ok(Self::ClaudeOpus4_5) + } else if id.starts_with("claude-opus-4-1-thinking") { + Ok(Self::ClaudeOpus4_1Thinking) + } else if id.starts_with("claude-opus-4-1") { + Ok(Self::ClaudeOpus4_1) + } else if id.starts_with("claude-opus-4-thinking") { + Ok(Self::ClaudeOpus4Thinking) + } else if id.starts_with("claude-opus-4") { + Ok(Self::ClaudeOpus4) + } else if id.starts_with("claude-3-5-sonnet-v2") { Ok(Self::Claude3_5SonnetV2) } else if id.starts_with("claude-3-opus") { Ok(Self::Claude3Opus) @@ -147,6 +168,8 @@ impl Model { Ok(Self::Claude3Sonnet) } else if id.starts_with("claude-3-5-haiku") { Ok(Self::Claude3_5Haiku) + } else if id.starts_with("claude-haiku-4-5") { + Ok(Self::ClaudeHaiku4_5) } else if id.starts_with("claude-3-7-sonnet") { Ok(Self::Claude3_7Sonnet) } else if id.starts_with("claude-3-7-sonnet-thinking") { @@ -174,12 +197,15 @@ impl Model { Model::ClaudeOpus4_1 => "claude-opus-4-1", Model::ClaudeOpus4Thinking => "claude-opus-4-thinking", Model::ClaudeOpus4_1Thinking => "claude-opus-4-1-thinking", + Model::ClaudeOpus4_5 => "claude-opus-4-5", + Model::ClaudeOpus4_5Thinking => "claude-opus-4-5-thinking", Model::Claude3_5SonnetV2 => "claude-3-5-sonnet-v2", Model::Claude3_5Sonnet => "claude-3-5-sonnet", Model::Claude3Opus => "claude-3-opus", Model::Claude3Sonnet => "claude-3-sonnet", Model::Claude3Haiku => "claude-3-haiku", Model::Claude3_5Haiku => "claude-3-5-haiku", + Model::ClaudeHaiku4_5 => "claude-haiku-4-5", Model::Claude3_7Sonnet => "claude-3-7-sonnet", Model::Claude3_7SonnetThinking => "claude-3-7-sonnet-thinking", Model::AmazonNovaLite => "amazon-nova-lite", @@ -240,12 +266,16 @@ impl Model { Model::ClaudeOpus4_1 | Model::ClaudeOpus4_1Thinking => { "anthropic.claude-opus-4-1-20250805-v1:0" } + Model::ClaudeOpus4_5 | Model::ClaudeOpus4_5Thinking => { + "anthropic.claude-opus-4-5-20251101-v1:0" + } Model::Claude3_5SonnetV2 => "anthropic.claude-3-5-sonnet-20241022-v2:0", Model::Claude3_5Sonnet => "anthropic.claude-3-5-sonnet-20240620-v1:0", Model::Claude3Opus => "anthropic.claude-3-opus-20240229-v1:0", Model::Claude3Sonnet => "anthropic.claude-3-sonnet-20240229-v1:0", Model::Claude3Haiku => "anthropic.claude-3-haiku-20240307-v1:0", Model::Claude3_5Haiku => "anthropic.claude-3-5-haiku-20241022-v1:0", + Model::ClaudeHaiku4_5 => "anthropic.claude-haiku-4-5-20251001-v1:0", Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking => { "anthropic.claude-3-7-sonnet-20250219-v1:0" } @@ -303,12 +333,15 @@ impl Model { Self::ClaudeOpus4_1 => "Claude Opus 4.1", Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking", Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking", + Self::ClaudeOpus4_5 => "Claude Opus 4.5", + Self::ClaudeOpus4_5Thinking => "Claude Opus 4.5 Thinking", Self::Claude3_5SonnetV2 => "Claude 3.5 Sonnet v2", Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", Self::Claude3Opus => "Claude 3 Opus", Self::Claude3Sonnet => "Claude 3 Sonnet", Self::Claude3Haiku => "Claude 3 Haiku", Self::Claude3_5Haiku => "Claude 3.5 Haiku", + Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", Self::Claude3_7Sonnet => "Claude 3.7 Sonnet", Self::Claude3_7SonnetThinking => "Claude 3.7 Sonnet Thinking", Self::AmazonNovaLite => "Amazon Nova Lite", @@ -363,6 +396,7 @@ impl Model { | Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3_5Haiku + | Self::ClaudeHaiku4_5 | Self::Claude3_7Sonnet | Self::ClaudeSonnet4 | Self::ClaudeOpus4 @@ -371,7 +405,9 @@ impl Model { | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking | Self::ClaudeOpus4Thinking - | Self::ClaudeOpus4_1Thinking => 200_000, + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking => 200_000, Self::AmazonNovaPremier => 1_000_000, Self::PalmyraWriterX5 => 1_000_000, Self::PalmyraWriterX4 => 128_000, @@ -385,7 +421,11 @@ impl Model { Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3_5Haiku => 4_096, Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => 128_000, Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => 64_000, - Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking => 64_000, + Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4_5Thinking + | Self::ClaudeHaiku4_5 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking => 64_000, Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 @@ -404,11 +444,14 @@ impl Model { | Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3_5Haiku + | Self::ClaudeHaiku4_5 | Self::Claude3_7Sonnet | Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -434,11 +477,14 @@ impl Model { | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking - | Self::Claude3_5Haiku => true, + | Self::Claude3_5Haiku + | Self::ClaudeHaiku4_5 => true, // Amazon Nova models (all support tool use) Self::AmazonNovaPremier @@ -464,6 +510,7 @@ impl Model { // Nova models support only text caching // https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html#prompt-caching-models Self::Claude3_5Haiku + | Self::ClaudeHaiku4_5 | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking | Self::ClaudeSonnet4 @@ -473,7 +520,9 @@ impl Model { | Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_1Thinking => true, + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking => true, // Custom models - check if they have cache configuration Self::Custom { @@ -495,12 +544,14 @@ impl Model { | Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_1Thinking => Some(BedrockModelCacheConfiguration { + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking => Some(BedrockModelCacheConfiguration { max_cache_anchors: 4, min_total_token: 1024, }), - Self::Claude3_5Haiku => Some(BedrockModelCacheConfiguration { + Self::Claude3_5Haiku | Self::ClaudeHaiku4_5 => Some(BedrockModelCacheConfiguration { max_cache_anchors: 4, min_total_token: 2048, }), @@ -524,51 +575,111 @@ impl Model { budget_tokens: Some(4096), } } - Model::ClaudeOpus4Thinking | Model::ClaudeOpus4_1Thinking => { - BedrockModelMode::Thinking { - budget_tokens: Some(4096), - } - } + Model::ClaudeOpus4Thinking + | Model::ClaudeOpus4_1Thinking + | Model::ClaudeOpus4_5Thinking => BedrockModelMode::Thinking { + budget_tokens: Some(4096), + }, _ => BedrockModelMode::Default, } } - pub fn cross_region_inference_id(&self, region: &str) -> anyhow::Result { + pub fn cross_region_inference_id( + &self, + region: &str, + allow_global: bool, + ) -> anyhow::Result { + // List derived from here: + // https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system + let model_id = self.request_id(); + + let supports_global = matches!( + self, + Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeHaiku4_5 + | Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking + ); + let region_group = if region.starts_with("us-gov-") { "us-gov" - } else if region.starts_with("us-") { - "us" + } else if region.starts_with("us-") + || region.starts_with("ca-") + || region.starts_with("sa-") + { + if allow_global && supports_global { + "global" + } else { + "us" + } } else if region.starts_with("eu-") { - "eu" + if allow_global && supports_global { + "global" + } else { + "eu" + } } else if region.starts_with("ap-") || region == "me-central-1" || region == "me-south-1" { - "apac" - } else if region.starts_with("ca-") || region.starts_with("sa-") { - // Canada and South America regions - default to US profiles - "us" + if allow_global && supports_global { + "global" + } else { + "apac" + } } else { anyhow::bail!("Unsupported Region {region}"); }; - let model_id = self.request_id(); + match (self, region_group, region) { + (Model::Custom { .. }, _, _) => Ok(self.request_id().into()), - match (self, region_group) { - // Custom models can't have CRI IDs - (Model::Custom { .. }, _) => Ok(self.request_id().into()), + ( + Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeHaiku4_5 + | Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking, + "global", + _, + ) => Ok(format!("{}.{}", region_group, model_id)), - // Models with US Gov only - (Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "us-gov") => { - Ok(format!("{}.{}", region_group, model_id)) + ( + Model::Claude3Haiku + | Model::Claude3_5Sonnet + | Model::Claude3_7Sonnet + | Model::Claude3_7SonnetThinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking, + "us-gov", + _, + ) => Ok(format!("{}.{}", region_group, model_id)), + + ( + Model::ClaudeHaiku4_5 | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking, + "apac", + "ap-southeast-2" | "ap-southeast-4", + ) => Ok(format!("au.{}", model_id)), + + ( + Model::ClaudeHaiku4_5 | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking, + "apac", + "ap-northeast-1" | "ap-northeast-3", + ) => Ok(format!("jp.{}", model_id)), + + (Model::AmazonNovaLite, "us", r) if r.starts_with("ca-") => { + Ok(format!("ca.{}", model_id)) } - // Available everywhere - (Model::AmazonNovaLite | Model::AmazonNovaMicro | Model::AmazonNovaPro, _) => { - Ok(format!("{}.{}", region_group, model_id)) - } - - // Models in US ( Model::AmazonNovaPremier + | Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro | Model::Claude3_5Haiku + | Model::ClaudeHaiku4_5 | Model::Claude3_5Sonnet | Model::Claude3_5SonnetV2 | Model::Claude3_7Sonnet @@ -581,6 +692,8 @@ impl Model { | Model::ClaudeOpus4Thinking | Model::ClaudeOpus4_1 | Model::ClaudeOpus4_1Thinking + | Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking | Model::Claude3Haiku | Model::Claude3Opus | Model::Claude3Sonnet @@ -601,15 +714,18 @@ impl Model { | Model::PalmyraWriterX4 | Model::PalmyraWriterX5, "us", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Models available in EU ( - Model::Claude3_5Sonnet + Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro + | Model::Claude3_5Sonnet + | Model::ClaudeHaiku4_5 | Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking | Model::ClaudeSonnet4 - | Model::ClaudeSonnet4Thinking | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking | Model::Claude3Haiku @@ -618,25 +734,26 @@ impl Model { | Model::MetaLlama323BInstructV1 | Model::MistralPixtralLarge2502V1, "eu", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Models available in APAC ( - Model::Claude3_5Sonnet + Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro + | Model::Claude3_5Sonnet | Model::Claude3_5SonnetV2 - | Model::Claude3Haiku - | Model::Claude3Sonnet + | Model::ClaudeHaiku4_5 | Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking | Model::ClaudeSonnet4 - | Model::ClaudeSonnet4Thinking - | Model::ClaudeSonnet4_5 - | Model::ClaudeSonnet4_5Thinking, + | Model::Claude3Haiku + | Model::Claude3Sonnet, "apac", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Any other combination is not supported - _ => Ok(self.request_id().into()), + _ => Ok(model_id.into()), } } } @@ -649,15 +766,15 @@ mod tests { fn test_us_region_inference_ids() -> anyhow::Result<()> { // Test US regions assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("us-east-1")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("us-west-2")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("us-west-2", false)?, "us.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::AmazonNovaPro.cross_region_inference_id("us-east-2")?, + Model::AmazonNovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" ); Ok(()) @@ -667,19 +784,19 @@ mod tests { fn test_eu_region_inference_ids() -> anyhow::Result<()> { // Test European regions assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1")?, + Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1")?, + Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::Claude3Sonnet.cross_region_inference_id("eu-west-1")?, + Model::Claude3Sonnet.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-3-sonnet-20240229-v1:0" ); assert_eq!( - Model::AmazonNovaMicro.cross_region_inference_id("eu-north-1")?, + Model::AmazonNovaMicro.cross_region_inference_id("eu-north-1", false)?, "eu.amazon.nova-micro-v1:0" ); Ok(()) @@ -689,15 +806,15 @@ mod tests { fn test_apac_region_inference_ids() -> anyhow::Result<()> { // Test Asia-Pacific regions assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1", false)?, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2", false)?, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::AmazonNovaLite.cross_region_inference_id("ap-south-1")?, + Model::AmazonNovaLite.cross_region_inference_id("ap-south-1", false)?, "apac.amazon.nova-lite-v1:0" ); Ok(()) @@ -707,11 +824,11 @@ mod tests { fn test_gov_region_inference_ids() -> anyhow::Result<()> { // Test Government regions assert_eq!( - Model::Claude3_5Sonnet.cross_region_inference_id("us-gov-east-1")?, + Model::Claude3_5Sonnet.cross_region_inference_id("us-gov-east-1", false)?, "us-gov.anthropic.claude-3-5-sonnet-20240620-v1:0" ); assert_eq!( - Model::Claude3Haiku.cross_region_inference_id("us-gov-west-1")?, + Model::Claude3Haiku.cross_region_inference_id("us-gov-west-1", false)?, "us-gov.anthropic.claude-3-haiku-20240307-v1:0" ); Ok(()) @@ -721,15 +838,15 @@ mod tests { fn test_meta_models_inference_ids() -> anyhow::Result<()> { // Test Meta models assert_eq!( - Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?, + Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1", false)?, "meta.llama3-70b-instruct-v1:0" ); assert_eq!( - Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1")?, + Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1", false)?, "us.meta.llama3-1-70b-instruct-v1:0" ); assert_eq!( - Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?, + Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1", false)?, "eu.meta.llama3-2-1b-instruct-v1:0" ); Ok(()) @@ -740,11 +857,11 @@ mod tests { // Mistral models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::MistralMistralLarge2402V1.cross_region_inference_id("us-east-1")?, + Model::MistralMistralLarge2402V1.cross_region_inference_id("us-east-1", false)?, "mistral.mistral-large-2402-v1:0" ); assert_eq!( - Model::MistralMixtral8x7BInstructV0.cross_region_inference_id("eu-west-1")?, + Model::MistralMixtral8x7BInstructV0.cross_region_inference_id("eu-west-1", false)?, "mistral.mixtral-8x7b-instruct-v0:1" ); Ok(()) @@ -755,11 +872,11 @@ mod tests { // AI21 models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::AI21J2UltraV1.cross_region_inference_id("us-east-1")?, + Model::AI21J2UltraV1.cross_region_inference_id("us-east-1", false)?, "ai21.j2-ultra-v1" ); assert_eq!( - Model::AI21JambaInstructV1.cross_region_inference_id("eu-west-1")?, + Model::AI21JambaInstructV1.cross_region_inference_id("eu-west-1", false)?, "ai21.jamba-instruct-v1:0" ); Ok(()) @@ -770,11 +887,11 @@ mod tests { // Cohere models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::CohereCommandRV1.cross_region_inference_id("us-east-1")?, + Model::CohereCommandRV1.cross_region_inference_id("us-east-1", false)?, "cohere.command-r-v1:0" ); assert_eq!( - Model::CohereCommandTextV14_4k.cross_region_inference_id("ap-southeast-1")?, + Model::CohereCommandTextV14_4k.cross_region_inference_id("ap-southeast-1", false)?, "cohere.command-text-v14:7:4k" ); Ok(()) @@ -794,10 +911,17 @@ mod tests { // Custom model should return its name unchanged assert_eq!( - custom_model.cross_region_inference_id("us-east-1")?, + custom_model.cross_region_inference_id("us-east-1", false)?, "custom.my-model-v1:0" ); + // Test that models without global support fall back to regional when allow_global is true + assert_eq!( + Model::AmazonNovaPro.cross_region_inference_id("us-east-1", true)?, + "us.amazon.nova-pro-v1:0", + "Nova Pro should fall back to regional profile even when allow_global is true" + ); + Ok(()) } @@ -836,3 +960,28 @@ mod tests { ); } } + +#[test] +fn test_global_inference_ids() -> anyhow::Result<()> { + // Test global inference for models that support it when allow_global is true + assert_eq!( + Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-sonnet-4-20250514-v1:0" + ); + assert_eq!( + Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?, + "global.anthropic.claude-sonnet-4-5-20250929-v1:0" + ); + assert_eq!( + Model::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, + "global.anthropic.claude-haiku-4-5-20251001-v1:0" + ); + + // Test that regional prefix is used when allow_global is false + assert_eq!( + Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-sonnet-4-20250514-v1:0" + ); + + Ok(()) +} diff --git a/crates/breadcrumbs/Cargo.toml b/crates/breadcrumbs/Cargo.toml index c25cfc3c86..16d0ff10e1 100644 --- a/crates/breadcrumbs/Cargo.toml +++ b/crates/breadcrumbs/Cargo.toml @@ -21,7 +21,6 @@ theme.workspace = true ui.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index a6b27476fe..00c1c0939b 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -100,13 +100,21 @@ impl Render for Breadcrumbs { let breadcrumbs_stack = h_flex().gap_1().children(breadcrumbs); + let prefix_element = active_item.breadcrumb_prefix(window, cx); + + let breadcrumbs = if let Some(prefix) = prefix_element { + h_flex().gap_1p5().child(prefix).child(breadcrumbs_stack) + } else { + breadcrumbs_stack + }; + match active_item .downcast::() .map(|editor| editor.downgrade()) { Some(editor) => element.child( ButtonLike::new("toggle outline view") - .child(breadcrumbs_stack) + .child(breadcrumbs) .style(ButtonStyle::Transparent) .on_click({ let editor = editor.clone(); @@ -115,25 +123,23 @@ impl Render for Breadcrumbs { .upgrade() .zip(zed_actions::outline::TOGGLE_OUTLINE.get()) { - callback(editor.to_any(), window, cx); + callback(editor.to_any_view(), window, cx); } } }) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if let Some(editor) = editor.upgrade() { let focus_handle = editor.read(cx).focus_handle(cx); Tooltip::for_action_in( "Show Symbol Outline", &zed_actions::outline::ToggleOutline, &focus_handle, - window, cx, ) } else { Tooltip::for_action( "Show Symbol Outline", &zed_actions::outline::ToggleOutline, - window, cx, ) } @@ -143,7 +149,7 @@ impl Render for Breadcrumbs { // Match the height and padding of the `ButtonLike` in the other arm. .h(rems_from_px(22.)) .pl_1() - .child(breadcrumbs_stack), + .child(breadcrumbs), } } } diff --git a/crates/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index 3d6c2a24e9..6249ae418c 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -12,7 +12,7 @@ workspace = true path = "src/buffer_diff.rs" [features] -test-support = [] +test-support = ["settings"] [dependencies] anyhow.workspace = true @@ -24,16 +24,17 @@ language.workspace = true log.workspace = true pretty_assertions.workspace = true rope.workspace = true +settings = { workspace = true, optional = true } sum_tree.workspace = true text.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } rand.workspace = true serde_json.workspace = true +settings.workspace = true text = { workspace = true, features = ["test-support"] } unindent.workspace = true zlog.workspace = true diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 1787f616ad..55de3f968b 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1,7 +1,10 @@ use futures::channel::oneshot; use git2::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch}; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, TaskLabel}; -use language::{Language, LanguageRegistry}; +use language::{ + BufferRow, DiffOptions, File, Language, LanguageName, LanguageRegistry, + language_settings::language_settings, word_diff_ranges, +}; use rope::Rope; use std::{ cmp::Ordering, @@ -11,14 +14,16 @@ use std::{ sync::{Arc, LazyLock}, }; use sum_tree::SumTree; -use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point, ToOffset as _}; +use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point, ToOffset as _, ToPoint as _}; use util::ResultExt; pub static CALCULATE_DIFF_TASK: LazyLock = LazyLock::new(TaskLabel::new); +pub const MAX_WORD_DIFF_LINE_COUNT: usize = 5; pub struct BufferDiff { pub buffer_id: BufferId, inner: BufferDiffInner, + // diff of the index vs head secondary_diff: Option>, } @@ -31,6 +36,7 @@ pub struct BufferDiffSnapshot { #[derive(Clone)] struct BufferDiffInner { hunks: SumTree, + // Used for making staging mo pending_hunks: SumTree, base_text: language::BufferSnapshot, base_text_exists: bool, @@ -50,11 +56,18 @@ pub enum DiffHunkStatusKind { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Diff of Working Copy vs Index +/// aka 'is this hunk staged or not' pub enum DiffHunkSecondaryStatus { + /// Unstaged HasSecondaryHunk, + /// Partially staged OverlapsWithSecondaryHunk, + /// Staged NoSecondaryHunk, + /// We are unstaging SecondaryHunkAdditionPending, + /// We are stagind SecondaryHunkRemovalPending, } @@ -68,6 +81,10 @@ pub struct DiffHunk { /// The range in the buffer's diff base text to which this hunk corresponds. pub diff_base_byte_range: Range, pub secondary_status: DiffHunkSecondaryStatus, + // Anchors representing the word diff locations in the active buffer + pub buffer_word_diffs: Vec>, + // Offsets relative to the start of the deleted diff that represent word diff locations + pub base_word_diffs: Vec>, } /// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range. @@ -75,6 +92,8 @@ pub struct DiffHunk { struct InternalDiffHunk { buffer_range: Range, diff_base_byte_range: Range, + base_word_diffs: Vec>, + buffer_word_diffs: Vec>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -85,9 +104,10 @@ struct PendingHunk { new_status: DiffHunkSecondaryStatus, } -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] pub struct DiffHunkSummary { buffer_range: Range, + diff_base_byte_range: Range, } impl sum_tree::Item for InternalDiffHunk { @@ -96,6 +116,7 @@ impl sum_tree::Item for InternalDiffHunk { fn summary(&self, _cx: &text::BufferSnapshot) -> Self::Summary { DiffHunkSummary { buffer_range: self.buffer_range.clone(), + diff_base_byte_range: self.diff_base_byte_range.clone(), } } } @@ -106,6 +127,7 @@ impl sum_tree::Item for PendingHunk { fn summary(&self, _cx: &text::BufferSnapshot) -> Self::Summary { DiffHunkSummary { buffer_range: self.buffer_range.clone(), + diff_base_byte_range: self.diff_base_byte_range.clone(), } } } @@ -114,15 +136,27 @@ impl sum_tree::Summary for DiffHunkSummary { type Context<'a> = &'a text::BufferSnapshot; fn zero(_cx: Self::Context<'_>) -> Self { - Default::default() + DiffHunkSummary { + buffer_range: Anchor::MIN..Anchor::MIN, + diff_base_byte_range: 0..0, + } } fn add_summary(&mut self, other: &Self, buffer: Self::Context<'_>) { - self.buffer_range.start = self + self.buffer_range.start = *self .buffer_range .start .min(&other.buffer_range.start, buffer); - self.buffer_range.end = self.buffer_range.end.max(&other.buffer_range.end, buffer); + self.buffer_range.end = *self.buffer_range.end.max(&other.buffer_range.end, buffer); + + self.diff_base_byte_range.start = self + .diff_base_byte_range + .start + .min(other.diff_base_byte_range.start); + self.diff_base_byte_range.end = self + .diff_base_byte_range + .end + .max(other.diff_base_byte_range.end); } } @@ -145,11 +179,16 @@ impl std::fmt::Debug for BufferDiffInner { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BufferDiffSnapshot") .field("hunks", &self.hunks) + .field("remote_id", &self.base_text.remote_id()) .finish() } } impl BufferDiffSnapshot { + pub fn buffer_diff_id(&self) -> BufferId { + self.inner.base_text.remote_id() + } + fn empty(buffer: &text::BufferSnapshot, cx: &mut App) -> BufferDiffSnapshot { BufferDiffSnapshot { inner: BufferDiffInner { @@ -188,6 +227,13 @@ impl BufferDiffSnapshot { let base_text_pair; let base_text_exists; let base_text_snapshot; + let diff_options = build_diff_options( + None, + language.as_ref().map(|l| l.name()), + language.as_ref().map(|l| l.default_scope()), + cx, + ); + if let Some(text) = &base_text { let base_text_rope = Rope::from(text.as_str()); base_text_pair = Some((text.clone(), base_text_rope.clone())); @@ -205,7 +251,7 @@ impl BufferDiffSnapshot { .background_executor() .spawn_labeled(*CALCULATE_DIFF_TASK, { let buffer = buffer.clone(); - async move { compute_hunks(base_text_pair, buffer) } + async move { compute_hunks(base_text_pair, buffer, diff_options) } }); async move { @@ -228,6 +274,12 @@ impl BufferDiffSnapshot { base_text_snapshot: language::BufferSnapshot, cx: &App, ) -> impl Future + use<> { + let diff_options = build_diff_options( + base_text_snapshot.file(), + base_text_snapshot.language().map(|l| l.name()), + base_text_snapshot.language().map(|l| l.default_scope()), + cx, + ); let base_text_exists = base_text.is_some(); let base_text_pair = base_text.map(|text| { debug_assert_eq!(&*text, &base_text_snapshot.text()); @@ -239,7 +291,7 @@ impl BufferDiffSnapshot { inner: BufferDiffInner { base_text: base_text_snapshot, pending_hunks: SumTree::new(&buffer), - hunks: compute_hunks(base_text_pair, buffer), + hunks: compute_hunks(base_text_pair, buffer, diff_options), base_text_exists, }, secondary_diff: None, @@ -298,6 +350,54 @@ impl BufferDiffSnapshot { let (new_id, new_empty) = (right.remote_id(), right.is_empty()); new_id == old_id || (new_empty && old_empty) } + + pub fn row_to_base_text_row(&self, row: BufferRow, buffer: &text::BufferSnapshot) -> u32 { + // TODO(split-diff) expose a parameter to reuse a cursor to avoid repeatedly seeking from the start + + // Find the last hunk that starts before this position. + let mut cursor = self.inner.hunks.cursor::(buffer); + let position = buffer.anchor_before(Point::new(row, 0)); + cursor.seek(&position, Bias::Left); + if cursor + .item() + .is_none_or(|hunk| hunk.buffer_range.start.cmp(&position, buffer).is_gt()) + { + cursor.prev(); + } + + let unclipped_point = if let Some(hunk) = cursor.item() + && hunk.buffer_range.start.cmp(&position, buffer).is_le() + { + let mut unclipped_point = cursor + .end() + .diff_base_byte_range + .end + .to_point(self.base_text()); + if position.cmp(&cursor.end().buffer_range.end, buffer).is_ge() { + unclipped_point += + Point::new(row, 0) - cursor.end().buffer_range.end.to_point(buffer); + } + // Move the cursor so that at the next step we can clip with the start of the next hunk. + cursor.next(); + unclipped_point + } else { + // Position is before the added region for the first hunk. + debug_assert!(self.inner.hunks.first().is_none_or(|first_hunk| { + position.cmp(&first_hunk.buffer_range.start, buffer).is_le() + })); + Point::new(row, 0) + }; + + let max_point = if let Some(next_hunk) = cursor.item() { + next_hunk + .diff_base_byte_range + .start + .to_point(self.base_text()) + } else { + self.base_text().max_point() + }; + unclipped_point.min(max_point).row + } } impl BufferDiffInner { @@ -337,7 +437,7 @@ impl BufferDiffInner { }; let hunk = PendingHunk { - buffer_range: Anchor::MIN..Anchor::MAX, + buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), diff_base_byte_range: 0..index_text.map_or(0, |rope| rope.len()), buffer_version: buffer.version().clone(), new_status, @@ -534,11 +634,15 @@ impl BufferDiffInner { [ ( &hunk.buffer_range.start, - (hunk.buffer_range.start, hunk.diff_base_byte_range.start), + ( + hunk.buffer_range.start, + hunk.diff_base_byte_range.start, + hunk, + ), ), ( &hunk.buffer_range.end, - (hunk.buffer_range.end, hunk.diff_base_byte_range.end), + (hunk.buffer_range.end, hunk.diff_base_byte_range.end, hunk), ), ] }); @@ -557,8 +661,11 @@ impl BufferDiffInner { let mut summaries = buffer.summaries_for_anchors_with_payload::(anchor_iter); iter::from_fn(move || { loop { - let (start_point, (start_anchor, start_base)) = summaries.next()?; - let (mut end_point, (mut end_anchor, end_base)) = summaries.next()?; + let (start_point, (start_anchor, start_base, hunk)) = summaries.next()?; + let (mut end_point, (mut end_anchor, end_base, _)) = summaries.next()?; + + let base_word_diffs = hunk.base_word_diffs.clone(); + let buffer_word_diffs = hunk.buffer_word_diffs.clone(); if !start_anchor.is_valid(buffer) { continue; @@ -628,6 +735,8 @@ impl BufferDiffInner { range: start_point..end_point, diff_base_byte_range: start_base..end_base, buffer_range: start_anchor..end_anchor, + base_word_diffs, + buffer_word_diffs, secondary_status, }); } @@ -659,6 +768,8 @@ impl BufferDiffInner { buffer_range: hunk.buffer_range.clone(), // The secondary status is not used by callers of this method. secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, + base_word_diffs: hunk.base_word_diffs.clone(), + buffer_word_diffs: hunk.buffer_word_diffs.clone(), }) }) } @@ -727,9 +838,36 @@ impl BufferDiffInner { } } +fn build_diff_options( + file: Option<&Arc>, + language: Option, + language_scope: Option, + cx: &App, +) -> Option { + #[cfg(any(test, feature = "test-support"))] + { + if !cx.has_global::() { + return Some(DiffOptions { + language_scope, + max_word_diff_line_count: MAX_WORD_DIFF_LINE_COUNT, + ..Default::default() + }); + } + } + + language_settings(language, file, cx) + .word_diff_enabled + .then_some(DiffOptions { + language_scope, + max_word_diff_line_count: MAX_WORD_DIFF_LINE_COUNT, + ..Default::default() + }) +} + fn compute_hunks( diff_base: Option<(Arc, Rope)>, buffer: text::BufferSnapshot, + diff_options: Option, ) -> SumTree { let mut tree = SumTree::new(&buffer); @@ -755,6 +893,8 @@ fn compute_hunks( InternalDiffHunk { buffer_range: buffer.anchor_before(0)..buffer.anchor_before(0), diff_base_byte_range: 0..diff_base.len() - 1, + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), }, &buffer, ); @@ -770,6 +910,7 @@ fn compute_hunks( &diff_base_rope, &buffer, &mut divergence, + diff_options.as_ref(), ); tree.push(hunk, &buffer); } @@ -777,8 +918,10 @@ fn compute_hunks( } else { tree.push( InternalDiffHunk { - buffer_range: Anchor::MIN..Anchor::MAX, + buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), diff_base_byte_range: 0..0, + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), }, &buffer, ); @@ -793,6 +936,7 @@ fn process_patch_hunk( diff_base: &Rope, buffer: &text::BufferSnapshot, buffer_row_divergence: &mut i64, + diff_options: Option<&DiffOptions>, ) -> InternalDiffHunk { let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap(); assert!(line_item_count > 0); @@ -857,9 +1001,49 @@ fn process_patch_hunk( let start = Point::new(buffer_row_range.start, 0); let end = Point::new(buffer_row_range.end, 0); let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end); + + let base_line_count = line_item_count.saturating_sub(buffer_row_range.len()); + + let (base_word_diffs, buffer_word_diffs) = if let Some(diff_options) = diff_options + && !buffer_row_range.is_empty() + && base_line_count == buffer_row_range.len() + && diff_options.max_word_diff_line_count >= base_line_count + { + let base_text: String = diff_base + .chunks_in_range(diff_base_byte_range.clone()) + .collect(); + + let buffer_text: String = buffer.text_for_range(buffer_range.clone()).collect(); + + let (base_word_diffs, buffer_word_diffs_relative) = word_diff_ranges( + &base_text, + &buffer_text, + DiffOptions { + language_scope: diff_options.language_scope.clone(), + ..*diff_options + }, + ); + + let buffer_start_offset = buffer_range.start.to_offset(buffer); + let buffer_word_diffs = buffer_word_diffs_relative + .into_iter() + .map(|range| { + let start = buffer.anchor_after(buffer_start_offset + range.start); + let end = buffer.anchor_after(buffer_start_offset + range.end); + start..end + }) + .collect(); + + (base_word_diffs, buffer_word_diffs) + } else { + (Vec::default(), Vec::default()) + }; + InternalDiffHunk { buffer_range, diff_base_byte_range, + base_word_diffs, + buffer_word_diffs, } } @@ -937,9 +1121,12 @@ impl BufferDiff { pub fn clear_pending_hunks(&mut self, cx: &mut Context) { if self.secondary_diff.is_some() { - self.inner.pending_hunks = SumTree::from_summary(DiffHunkSummary::default()); + self.inner.pending_hunks = SumTree::from_summary(DiffHunkSummary { + buffer_range: Anchor::min_min_range_for_buffer(self.buffer_id), + diff_base_byte_range: 0..0, + }); cx.emit(BufferDiffEvent::DiffChanged { - changed_range: Some(Anchor::MIN..Anchor::MAX), + changed_range: Some(Anchor::min_max_range_for_buffer(self.buffer_id)), }); } } @@ -1060,7 +1247,10 @@ impl BufferDiff { { (false, new_state.compare(state, buffer)) } - _ => (true, Some(text::Anchor::MIN..text::Anchor::MAX)), + _ => ( + true, + Some(text::Anchor::min_max_range_for_buffer(self.buffer_id)), + ), }; if let Some(secondary_changed_range) = secondary_diff_change @@ -1068,8 +1258,8 @@ impl BufferDiff { self.range_to_hunk_range(secondary_changed_range, buffer, cx) { if let Some(range) = &mut changed_range { - range.start = secondary_hunk_range.start.min(&range.start, buffer); - range.end = secondary_hunk_range.end.max(&range.end, buffer); + range.start = *secondary_hunk_range.start.min(&range.start, buffer); + range.end = *secondary_hunk_range.end.max(&range.end, buffer); } else { changed_range = Some(secondary_hunk_range); } @@ -1083,8 +1273,8 @@ impl BufferDiff { if let Some((first, last)) = state.pending_hunks.first().zip(state.pending_hunks.last()) { if let Some(range) = &mut changed_range { - range.start = range.start.min(&first.buffer_range.start, buffer); - range.end = range.end.max(&last.buffer_range.end, buffer); + range.start = *range.start.min(&first.buffer_range.start, buffer); + range.end = *range.end.max(&last.buffer_range.end, buffer); } else { changed_range = Some(first.buffer_range.start..last.buffer_range.end); } @@ -1121,7 +1311,11 @@ impl BufferDiff { buffer_snapshot: &'a text::BufferSnapshot, cx: &'a App, ) -> impl 'a + Iterator { - self.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer_snapshot, cx) + self.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer_snapshot.remote_id()), + buffer_snapshot, + cx, + ) } pub fn hunks_intersecting_range<'a>( @@ -1158,34 +1352,22 @@ impl BufferDiff { self.hunks_intersecting_range(start..end, buffer, cx) } - pub fn set_base_text_buffer( - &mut self, - base_buffer: Entity, - buffer: text::BufferSnapshot, - cx: &mut Context, - ) -> oneshot::Receiver<()> { - let base_buffer = base_buffer.read(cx); - let language_registry = base_buffer.language_registry(); - let base_buffer = base_buffer.snapshot(); - self.set_base_text(base_buffer, language_registry, buffer, cx) - } - /// Used in cases where the change set isn't derived from git. pub fn set_base_text( &mut self, - base_buffer: language::BufferSnapshot, + base_text: Option>, + language: Option>, language_registry: Option>, buffer: text::BufferSnapshot, cx: &mut Context, ) -> oneshot::Receiver<()> { let (tx, rx) = oneshot::channel(); let this = cx.weak_entity(); - let base_text = Arc::new(base_buffer.text()); let snapshot = BufferDiffSnapshot::new_with_base_text( buffer.clone(), - Some(base_text), - base_buffer.language().cloned(), + base_text, + language, language_registry, cx, ); @@ -1229,7 +1411,9 @@ impl BufferDiff { impl DiffHunk { pub fn is_created_file(&self) -> bool { - self.diff_base_byte_range == (0..0) && self.buffer_range == (Anchor::MIN..Anchor::MAX) + self.diff_base_byte_range == (0..0) + && self.buffer_range.start.is_min() + && self.buffer_range.end.is_max() } pub fn status(&self) -> DiffHunkStatus { @@ -1368,7 +1552,7 @@ mod tests { use gpui::TestAppContext; use pretty_assertions::{assert_eq, assert_ne}; use rand::{Rng as _, rngs::StdRng}; - use text::{Buffer, BufferId, Rope}; + use text::{Buffer, BufferId, ReplicaId, Rope}; use unindent::Unindent as _; use util::test::marked_text_ranges; @@ -1393,10 +1577,13 @@ mod tests { " .unindent(); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); let mut diff = BufferDiffSnapshot::new_sync(buffer.clone(), diff_base.clone(), cx); assert_hunks( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer, + ), &buffer, &diff_base, &[(1..2, "two\n", "HELLO\n", DiffHunkStatus::modified_none())], @@ -1405,7 +1592,10 @@ mod tests { buffer.edit([(0..0, "point five\n")]); diff = BufferDiffSnapshot::new_sync(buffer.clone(), diff_base.clone(), cx); assert_hunks( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer, + ), &buffer, &diff_base, &[ @@ -1416,7 +1606,10 @@ mod tests { diff = cx.update(|cx| BufferDiffSnapshot::empty(&buffer, cx)); assert_hunks::<&str, _>( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer, + ), &buffer, &diff_base, &[], @@ -1467,7 +1660,7 @@ mod tests { " .unindent(); - let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); let unstaged_diff = BufferDiffSnapshot::new_sync(buffer.clone(), index_text, cx); let mut uncommitted_diff = BufferDiffSnapshot::new_sync(buffer.clone(), head_text.clone(), cx); @@ -1490,7 +1683,10 @@ mod tests { ]; assert_hunks( - uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), + uncommitted_diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer, + ), &buffer, &head_text, &expected_hunks, @@ -1536,7 +1732,7 @@ mod tests { " .unindent(); - let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); let diff = cx .update(|cx| { BufferDiffSnapshot::new_with_base_text( @@ -1549,8 +1745,11 @@ mod tests { }) .await; assert_eq!( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer) - .count(), + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer + ) + .count(), 8 ); @@ -1799,7 +1998,7 @@ mod tests { for example in table { let (buffer_text, ranges) = marked_text_ranges(&example.buffer_marked_text, false); - let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); let hunk_range = buffer.anchor_before(ranges[0].start)..buffer.anchor_before(ranges[0].end); @@ -1872,7 +2071,11 @@ mod tests { " .unindent(); - let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text.clone()); + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + buffer_text.clone(), + ); let unstaged = BufferDiffSnapshot::new_sync(buffer.clone(), index_text, cx); let uncommitted = BufferDiffSnapshot::new_sync(buffer.clone(), head_text.clone(), cx); let unstaged_diff = cx.new(|cx| { @@ -1945,7 +2148,7 @@ mod tests { " .unindent(); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text_1); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text_1); let empty_diff = cx.update(|cx| BufferDiffSnapshot::empty(&buffer, cx)); let diff_1 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx); @@ -2158,8 +2361,12 @@ mod tests { let mut diff = uncommitted_diff(&working_copy, &index_text, head_text.clone(), cx); let mut hunks = diff.update(cx, |diff, cx| { - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &working_copy, cx) - .collect::>() + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(diff.buffer_id), + &working_copy, + cx, + ) + .collect::>() }); if hunks.is_empty() { return; @@ -2188,8 +2395,12 @@ mod tests { diff = uncommitted_diff(&working_copy, &index_text, head_text.clone(), cx); let found_hunks = diff.update(cx, |diff, cx| { - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &working_copy, cx) - .collect::>() + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(diff.buffer_id), + &working_copy, + cx, + ) + .collect::>() }); assert_eq!(hunks.len(), found_hunks.len()); @@ -2207,4 +2418,62 @@ mod tests { hunks = found_hunks; } } + + #[gpui::test] + async fn test_row_to_base_text_row(cx: &mut TestAppContext) { + let base_text = " + zero + one + two + three + four + five + six + seven + eight + " + .unindent(); + let buffer_text = " + zero + ONE + two + NINE + five + seven + " + .unindent(); + + // zero + // - one + // + ONE + // two + // - three + // - four + // + NINE + // five + // - six + // seven + // + eight + + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); + let buffer_snapshot = buffer.snapshot(); + let diff = BufferDiffSnapshot::new_sync(buffer_snapshot.clone(), base_text, cx); + let expected_results = [ + // don't format me + (0, 0), + (1, 2), + (2, 2), + (3, 5), + (4, 5), + (5, 7), + (6, 9), + ]; + for (buffer_row, expected) in expected_results { + assert_eq!( + diff.row_to_base_text_row(buffer_row, &buffer_snapshot), + expected, + "{buffer_row}" + ); + } + } } diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 1d5fbccb46..ff034f914b 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -41,7 +41,6 @@ telemetry.workspace = true util.workspace = true gpui_tokio.workspace = true livekit_client.workspace = true -workspace-hack.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index 156a80faba..b4fcdb2552 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -1,7 +1,6 @@ pub mod participant; pub mod room; -use crate::call_settings::CallSettings; use anyhow::{Context as _, Result, anyhow}; use audio::Audio; use client::{ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE, proto}; @@ -14,7 +13,6 @@ use gpui::{ use postage::watch; use project::Project; use room::Event; -use settings::Settings; use std::sync::Arc; pub use livekit_client::{RemoteVideoTrack, RemoteVideoTrackView, RemoteVideoTrackViewEvent}; @@ -26,8 +24,6 @@ struct GlobalActiveCall(Entity); impl Global for GlobalActiveCall {} pub fn init(client: Arc, user_store: Entity, cx: &mut App) { - CallSettings::register(cx); - let active_call = cx.new(|cx| ActiveCall::new(client, user_store, cx)); cx.set_global(GlobalActiveCall(active_call)); } diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index e659d1cf05..ccc8c067c2 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -305,6 +305,7 @@ impl Room { pub(crate) fn leave(&mut self, cx: &mut Context) -> Task> { cx.notify(); + self.emit_video_track_unsubscribed_events(cx); self.leave_internal(cx) } @@ -352,6 +353,14 @@ impl Room { self.maintain_connection.take(); } + fn emit_video_track_unsubscribed_events(&self, cx: &mut Context) { + for participant in self.remote_participants.values() { + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } + } + } + async fn maintain_connection( this: WeakEntity, client: Arc, @@ -524,6 +533,16 @@ impl Room { self.id } + pub fn room_id(&self) -> impl Future> + 'static { + let room = self.live_kit.as_ref().map(|lk| lk.room.clone()); + async move { + let room = room?; + let sid = room.sid().await; + let name = room.name(); + Some(format!("{} (sid: {sid})", name)) + } + } + pub fn status(&self) -> RoomStatus { self.status } @@ -872,6 +891,9 @@ impl Room { project_id: project.id, }); } + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } false } }); @@ -1683,7 +1705,9 @@ impl LiveKitRoom { } } +#[derive(Default)] enum LocalTrack { + #[default] None, Pending { publish_id: usize, @@ -1694,12 +1718,6 @@ enum LocalTrack { }, } -impl Default for LocalTrack { - fn default() -> Self { - Self::None - } -} - #[derive(Copy, Clone, PartialEq, Eq)] pub enum RoomStatus { Online, diff --git a/crates/call/src/call_settings.rs b/crates/call/src/call_settings.rs index a97ac68202..1b10a8bc34 100644 --- a/crates/call/src/call_settings.rs +++ b/crates/call/src/call_settings.rs @@ -1,24 +1,17 @@ -use gpui::App; -use settings::Settings; +use settings::{RegisterSetting, Settings}; -#[derive(Debug)] +#[derive(Debug, RegisterSetting)] pub struct CallSettings { pub mute_on_join: bool, pub share_on_join: bool, } impl Settings for CallSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let call = content.calls.clone().unwrap(); CallSettings { mute_on_join: call.mute_on_join.unwrap(), share_on_join: call.share_on_join.unwrap(), } } - - fn import_from_vscode( - _vscode: &settings::VsCodeSettings, - _current: &mut settings::SettingsContent, - ) { - } } diff --git a/crates/channel/Cargo.toml b/crates/channel/Cargo.toml index ab6e1dfc2b..a8664da8e9 100644 --- a/crates/channel/Cargo.toml +++ b/crates/channel/Cargo.toml @@ -31,13 +31,13 @@ settings.workspace = true text.workspace = true time.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } client = { workspace = true, features = ["test-support"] } +semver.workspace = true settings = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } diff --git a/crates/channel/src/channel_buffer.rs b/crates/channel/src/channel_buffer.rs index 828248b330..efa0850753 100644 --- a/crates/channel/src/channel_buffer.rs +++ b/crates/channel/src/channel_buffer.rs @@ -9,7 +9,7 @@ use rpc::{ proto::{self, PeerId}, }; use std::{sync::Arc, time::Duration}; -use text::BufferId; +use text::{BufferId, ReplicaId}; use util::ResultExt; pub const ACKNOWLEDGE_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(250); @@ -65,7 +65,12 @@ impl ChannelBuffer { let buffer = cx.new(|cx| { let capability = channel_store.read(cx).channel_capability(channel.id); - language::Buffer::remote(buffer_id, response.replica_id as u16, capability, base_text) + language::Buffer::remote( + buffer_id, + ReplicaId::new(response.replica_id as u16), + capability, + base_text, + ) })?; buffer.update(cx, |buffer, cx| buffer.apply_ops(operations, cx))?; @@ -272,7 +277,7 @@ impl ChannelBuffer { self.connected } - pub fn replica_id(&self, cx: &App) -> u16 { + pub fn replica_id(&self, cx: &App) -> ReplicaId { self.buffer.read(cx).replica_id() } } diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index fbdfe9f8b5..f1f9d23a99 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -1,7 +1,7 @@ use super::*; use client::{Client, UserStore}; use clock::FakeSystemClock; -use gpui::{App, AppContext as _, Entity, SemanticVersion}; +use gpui::{App, AppContext as _, Entity}; use http_client::FakeHttpClient; use rpc::proto::{self}; use settings::SettingsStore; @@ -236,8 +236,7 @@ fn test_dangling_channel_paths(cx: &mut App) { fn init_test(cx: &mut App) -> Entity { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - release_channel::init(SemanticVersion::default(), cx); - client::init_settings(cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); let clock = Arc::new(FakeSystemClock::new()); let http = FakeHttpClient::with_404_response(); diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 812e56d173..63e99a3ed2 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -32,7 +32,11 @@ release_channel.workspace = true serde.workspace = true util.workspace = true tempfile.workspace = true -workspace-hack.workspace = true +rayon.workspace = true + +[dev-dependencies] +serde_json.workspace = true +util = { workspace = true, features = ["test-support"] } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] exec.workspace = true diff --git a/crates/cli/README.md b/crates/cli/README.md new file mode 100644 index 0000000000..ac4384e256 --- /dev/null +++ b/crates/cli/README.md @@ -0,0 +1,15 @@ +# Cli + +## Testing + +You can test your changes to the `cli` crate by first building the main zed binary: + +``` +cargo build -p zed +``` + +And then building and running the `cli` crate with the following parameters: + +``` + cargo run -p cli -- --zed ./target/debug/zed.exe +``` diff --git a/crates/cli/build.rs b/crates/cli/build.rs index 50ef631ebf..a3c4bc6437 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -23,4 +23,7 @@ fn main() { println!("cargo:rustc-env=ZED_COMMIT_SHA={git_sha}"); } + if let Some(build_identifier) = option_env!("GITHUB_RUN_NUMBER") { + println!("cargo:rustc-env=ZED_BUILD_ID={build_identifier}"); + } } diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 79a10fa2b0..fbd7e2693a 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -17,6 +17,7 @@ pub enum CliRequest { wsl: Option, wait: bool, open_new_workspace: Option, + reuse: bool, env: Option>, user_data_dir: Option, }, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3044172d89..e1a7a1481b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -12,7 +12,9 @@ use clap::Parser; use cli::{CliRequest, CliResponse, IpcHandshake, ipc::IpcOneShotServer}; use parking_lot::Mutex; use std::{ - env, fs, io, + env, + ffi::OsStr, + fs, io, path::{Path, PathBuf}, process::ExitStatus, sync::Arc, @@ -30,7 +32,7 @@ struct Detect; trait InstalledApp { fn zed_version_string(&self) -> String; - fn launch(&self, ipc_url: String) -> anyhow::Result<()>; + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()>; fn run_foreground( &self, ipc_url: String, @@ -59,19 +61,27 @@ Examples: )] struct Args { /// Wait for all of the given paths to be opened/closed before exiting. + /// + /// When opening a directory, waits until the created window is closed. #[arg(short, long)] wait: bool, /// Add files to the currently open workspace - #[arg(short, long, overrides_with = "new")] + #[arg(short, long, overrides_with_all = ["new", "reuse"])] add: bool, /// Create a new workspace - #[arg(short, long, overrides_with = "add")] + #[arg(short, long, overrides_with_all = ["add", "reuse"])] new: bool, + /// Reuse an existing window, replacing its workspace + #[arg(short, long, overrides_with_all = ["add", "new"])] + reuse: bool, /// Sets a custom directory for all user data (e.g., database, extensions, logs). - /// This overrides the default platform-specific data directory location. - /// On macOS, the default is `~/Library/Application Support/Zed`. - /// On Linux/FreeBSD, the default is `$XDG_DATA_HOME/zed`. - /// On Windows, the default is `%LOCALAPPDATA%\Zed`. + /// This overrides the default platform-specific data directory location: + #[cfg_attr(target_os = "macos", doc = "`~/Library/Application Support/Zed`.")] + #[cfg_attr(target_os = "windows", doc = "`%LOCALAPPDATA%\\Zed`.")] + #[cfg_attr( + not(any(target_os = "windows", target_os = "macos")), + doc = "`$XDG_DATA_HOME/zed`." + )] #[arg(long, value_name = "DIR")] user_data_dir: Option, /// The paths to open in Zed (space-separated). @@ -123,36 +133,177 @@ struct Args { askpass: Option, } +/// Parses a path containing a position (e.g. `path:line:column`) +/// and returns its canonicalized string representation. +/// +/// If a part of path doesn't exist, it will canonicalize the +/// existing part and append the non-existing part. +/// +/// This method must return an absolute path, as many zed +/// crates assume absolute paths. fn parse_path_with_position(argument_str: &str) -> anyhow::Result { - let canonicalized = match Path::new(argument_str).canonicalize() { - Ok(existing_path) => PathWithPosition::from_path(existing_path), - Err(_) => { - let path = PathWithPosition::parse_str(argument_str); + match Path::new(argument_str).canonicalize() { + Ok(existing_path) => Ok(PathWithPosition::from_path(existing_path)), + Err(_) => PathWithPosition::parse_str(argument_str).map_path(|mut path| { let curdir = env::current_dir().context("retrieving current directory")?; - path.map_path(|path| match fs::canonicalize(&path) { - Ok(path) => Ok(path), - Err(e) => { - if let Some(mut parent) = path.parent() { - if parent == Path::new("") { - parent = &curdir - } - match fs::canonicalize(parent) { - Ok(parent) => Ok(parent.join(path.file_name().unwrap())), - Err(_) => Err(e), - } - } else { - Err(e) - } + let mut children = Vec::new(); + let root; + loop { + // canonicalize handles './', and '/'. + if let Ok(canonicalized) = fs::canonicalize(&path) { + root = canonicalized; + break; } - }) - } - .with_context(|| format!("parsing as path with position {argument_str}"))?, - }; - Ok(canonicalized.to_string(|path| path.to_string_lossy().into_owned())) + // The comparison to `curdir` is just a shortcut + // since we know it is canonical. The other one + // is if `argument_str` is a string that starts + // with a name (e.g. "foo/bar"). + if path == curdir || path == Path::new("") { + root = curdir; + break; + } + children.push( + path.file_name() + .with_context(|| format!("parsing as path with position {argument_str}"))? + .to_owned(), + ); + if !path.pop() { + unreachable!("parsing as path with position {argument_str}"); + } + } + Ok(children.iter().rev().fold(root, |mut path, child| { + path.push(child); + path + })) + }), + } + .map(|path_with_pos| path_with_pos.to_string(|path| path.to_string_lossy().into_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use util::path; + use util::paths::SanitizedPath; + use util::test::TempTree; + + macro_rules! assert_path_eq { + ($left:expr, $right:expr) => { + assert_eq!( + SanitizedPath::new(Path::new(&$left)), + SanitizedPath::new(Path::new(&$right)) + ) + }; + } + + fn cwd() -> PathBuf { + env::current_dir().unwrap() + } + + static CWD_LOCK: Mutex<()> = Mutex::new(()); + + fn with_cwd(path: &Path, f: impl FnOnce() -> anyhow::Result) -> anyhow::Result { + let _lock = CWD_LOCK.lock(); + let old_cwd = cwd(); + env::set_current_dir(path)?; + let result = f(); + env::set_current_dir(old_cwd)?; + result + } + + #[test] + fn test_parse_non_existing_path() { + // Absolute path + let result = parse_path_with_position(path!("/non/existing/path.txt")).unwrap(); + assert_path_eq!(result, path!("/non/existing/path.txt")); + + // Absolute path in cwd + let path = cwd().join(path!("non/existing/path.txt")); + let expected = path.to_string_lossy().to_string(); + let result = parse_path_with_position(&expected).unwrap(); + assert_path_eq!(result, expected); + + // Relative path + let result = parse_path_with_position(path!("non/existing/path.txt")).unwrap(); + assert_path_eq!(result, expected) + } + + #[test] + fn test_parse_existing_path() { + let temp_tree = TempTree::new(json!({ + "file.txt": "", + })); + let file_path = temp_tree.path().join("file.txt"); + let expected = file_path.to_string_lossy().to_string(); + + // Absolute path + let result = parse_path_with_position(file_path.to_str().unwrap()).unwrap(); + assert_path_eq!(result, expected); + + // Relative path + let result = with_cwd(temp_tree.path(), || parse_path_with_position("file.txt")).unwrap(); + assert_path_eq!(result, expected); + } + + // NOTE: + // While POSIX symbolic links are somewhat supported on Windows, they are an opt in by the user, and thus + // we assume that they are not supported out of the box. + #[cfg(not(windows))] + #[test] + fn test_parse_symlink_file() { + let temp_tree = TempTree::new(json!({ + "target.txt": "", + })); + let target_path = temp_tree.path().join("target.txt"); + let symlink_path = temp_tree.path().join("symlink.txt"); + std::os::unix::fs::symlink(&target_path, &symlink_path).unwrap(); + + // Absolute path + let result = parse_path_with_position(symlink_path.to_str().unwrap()).unwrap(); + assert_eq!(result, target_path.to_string_lossy()); + + // Relative path + let result = + with_cwd(temp_tree.path(), || parse_path_with_position("symlink.txt")).unwrap(); + assert_eq!(result, target_path.to_string_lossy()); + } + + #[cfg(not(windows))] + #[test] + fn test_parse_symlink_dir() { + let temp_tree = TempTree::new(json!({ + "some": { + "dir": { // symlink target + "ec": { + "tory": { + "file.txt": "", + }}}}})); + + let target_file_path = temp_tree.path().join("some/dir/ec/tory/file.txt"); + let expected = target_file_path.to_string_lossy(); + + let dir_path = temp_tree.path().join("some/dir"); + let symlink_path = temp_tree.path().join("symlink"); + std::os::unix::fs::symlink(&dir_path, &symlink_path).unwrap(); + + // Absolute path + let result = + parse_path_with_position(symlink_path.join("ec/tory/file.txt").to_str().unwrap()) + .unwrap(); + assert_eq!(result, expected); + + // Relative path + let result = with_cwd(temp_tree.path(), || { + parse_path_with_position("symlink/ec/tory/file.txt") + }) + .unwrap(); + assert_eq!(result, expected); + } } fn parse_path_in_wsl(source: &str, wsl: &str) -> Result { - let mut command = util::command::new_std_command("wsl.exe"); + let mut source = PathWithPosition::parse_str(source); let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') { if user.is_empty() { @@ -163,26 +314,37 @@ fn parse_path_in_wsl(source: &str, wsl: &str) -> Result { (None, wsl) }; + let mut args = vec!["--distribution", distro_name]; if let Some(user) = user { - command.arg("--user").arg(user); + args.push("--user"); + args.push(user); } - let output = command - .arg("--distribution") - .arg(distro_name) - .arg("wslpath") - .arg("-m") - .arg(source) + let command = [ + OsStr::new("realpath"), + OsStr::new("-s"), + source.path.as_ref(), + ]; + + let output = util::command::new_std_command("wsl.exe") + .args(&args) + .arg("--exec") + .args(&command) .output()?; + let result = if output.status.success() { + String::from_utf8_lossy(&output.stdout).to_string() + } else { + let fallback = util::command::new_std_command("wsl.exe") + .args(&args) + .arg("--") + .args(&command) + .output()?; + String::from_utf8_lossy(&fallback.stdout).to_string() + }; - let result = String::from_utf8_lossy(&output.stdout); - let prefix = format!("//wsl.localhost/{}", distro_name); + source.path = Path::new(result.trim()).to_owned(); - Ok(result - .trim() - .strip_prefix(&prefix) - .unwrap_or(&result) - .to_string()) + Ok(source.to_string(|path| path.to_string_lossy().into_owned())) } fn main() -> Result<()> { @@ -351,6 +513,13 @@ fn main() -> Result<()> { "Dev servers were removed in v0.157.x please upgrade to SSH remoting: https://zed.dev/docs/remote-development" ); + rayon::ThreadPoolBuilder::new() + .num_threads(4) + .stack_size(10 * 1024 * 1024) + .thread_name(|ix| format!("RayonWorker{}", ix)) + .build_global() + .unwrap(); + let sender: JoinHandle> = thread::Builder::new() .name("CliReceiver".to_string()) .spawn({ @@ -372,6 +541,7 @@ fn main() -> Result<()> { wsl, wait: args.wait, open_new_workspace, + reuse: args.reuse, env, user_data_dir: user_data_dir_for_thread, })?; @@ -420,7 +590,7 @@ fn main() -> Result<()> { if args.foreground { app.run_foreground(url, user_data_dir.as_deref())?; } else { - app.launch(url)?; + app.launch(url, user_data_dir.as_deref())?; sender.join().unwrap()?; if let Some(handle) = stdin_pipe_handle { handle.join().unwrap()?; @@ -541,14 +711,18 @@ mod linux { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { - let sock_path = paths::data_dir().join(format!( + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { + let data_dir = user_data_dir + .map(PathBuf::from) + .unwrap_or_else(|| paths::data_dir().clone()); + + let sock_path = data_dir.join(format!( "zed-{}.sock", *release_channel::RELEASE_CHANNEL_NAME )); let sock = UnixDatagram::unbound()?; if sock.connect(&sock_path).is_err() { - self.boot_background(ipc_url)?; + self.boot_background(ipc_url, user_data_dir)?; } else { sock.send(ipc_url.as_bytes())?; } @@ -574,7 +748,11 @@ mod linux { } impl App { - fn boot_background(&self, ipc_url: String) -> anyhow::Result<()> { + fn boot_background( + &self, + ipc_url: String, + user_data_dir: Option<&str>, + ) -> anyhow::Result<()> { let path = &self.0; match fork::fork() { @@ -588,8 +766,13 @@ mod linux { if fork::close_fd().is_err() { eprintln!("failed to close_fd: {}", std::io::Error::last_os_error()); } - let error = - exec::execvp(path.clone(), &[path.as_os_str(), &OsString::from(ipc_url)]); + let mut args: Vec = + vec![path.as_os_str().to_owned(), OsString::from(ipc_url)]; + if let Some(dir) = user_data_dir { + args.push(OsString::from("--user-data-dir")); + args.push(OsString::from(dir)); + } + let error = exec::execvp(path.clone(), &args); // if exec succeeded, we never get here. eprintln!("failed to exec {:?}: {}", path, error); process::exit(1) @@ -775,11 +958,14 @@ mod windows { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { if check_single_instance() { - std::process::Command::new(self.0.clone()) - .arg(ipc_url) - .spawn()?; + let mut cmd = std::process::Command::new(self.0.clone()); + cmd.arg(ipc_url); + if let Some(dir) = user_data_dir { + cmd.arg("--user-data-dir").arg(dir); + } + cmd.spawn()?; } else { unsafe { let pipe = CreateFileW( @@ -928,7 +1114,7 @@ mod mac_os { format!("Zed {} – {}", self.version(), self.path().display(),) } - fn launch(&self, url: String) -> anyhow::Result<()> { + fn launch(&self, url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { match self { Self::App { app_bundle, .. } => { let app_path = app_bundle; @@ -978,8 +1164,11 @@ mod mac_os { format!("Cloning descriptor for file {subprocess_stdout_file:?}") })?; let mut command = std::process::Command::new(executable); - let command = command - .env(FORCE_CLI_MODE_ENV_VAR_NAME, "") + command.env(FORCE_CLI_MODE_ENV_VAR_NAME, ""); + if let Some(dir) = user_data_dir { + command.arg("--user-data-dir").arg(dir); + } + command .stderr(subprocess_stdout_file) .stdout(subprocess_stdin_file) .arg(url); diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 86ecb1b34e..50cf12b977 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -53,11 +53,10 @@ text.workspace = true thiserror.workspace = true time.workspace = true tiny_http.workspace = true -tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io"] } +tokio-socks.workspace = true tokio.workspace = true url.workspace = true util.workspace = true -workspace-hack.workspace = true worktree.workspace = true [dev-dependencies] @@ -71,6 +70,7 @@ settings = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } [target.'cfg(target_os = "windows")'.dependencies] +semver.workspace = true windows.workspace = true [target.'cfg(target_os = "macos")'.dependencies] diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index e098e7aed5..801c8c3de8 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -30,7 +30,7 @@ use rand::prelude::*; use release_channel::{AppVersion, ReleaseChannel}; use rpc::proto::{AnyTypedEnvelope, EnvelopedMessage, PeerId, RequestMessage}; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsContent}; +use settings::{RegisterSetting, Settings, SettingsContent}; use std::{ any::TypeId, convert::TryFrom, @@ -95,13 +95,13 @@ actions!( ] ); -#[derive(Deserialize)] +#[derive(Deserialize, RegisterSetting)] pub struct ClientSettings { pub server_url: String, } impl Settings for ClientSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { if let Some(server_url) = &*ZED_SERVER_URL { return Self { server_url: server_url.clone(), @@ -113,7 +113,7 @@ impl Settings for ClientSettings { } } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, RegisterSetting)] pub struct ProxySettings { pub proxy: Option, } @@ -133,21 +133,11 @@ impl ProxySettings { } impl Settings for ProxySettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { Self { proxy: content.proxy.clone(), } } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut SettingsContent) { - vscode.string_setting("http.proxy", &mut current.proxy); - } -} - -pub fn init_settings(cx: &mut App) { - TelemetrySettings::register(cx); - ClientSettings::register(cx); - ProxySettings::register(cx); } pub fn init(client: &Arc, cx: &mut App) { @@ -160,9 +150,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach_and_log_err(cx); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client.clone(); move |_: &SignOut, cx| { if let Some(client) = client.upgrade() { @@ -172,9 +161,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach(); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client; move |_: &Reconnect, cx| { if let Some(client) = client.upgrade() { @@ -512,40 +500,19 @@ impl Drop for PendingEntitySubscription { } } -#[derive(Copy, Clone, Deserialize, Debug)] +#[derive(Copy, Clone, Deserialize, Debug, RegisterSetting)] pub struct TelemetrySettings { pub diagnostics: bool, pub metrics: bool, } impl settings::Settings for TelemetrySettings { - fn from_settings(content: &SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &SettingsContent) -> Self { Self { diagnostics: content.telemetry.as_ref().unwrap().diagnostics.unwrap(), metrics: content.telemetry.as_ref().unwrap().metrics.unwrap(), } } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut SettingsContent) { - let mut telemetry = settings::TelemetrySettingsContent::default(); - vscode.enum_setting("telemetry.telemetryLevel", &mut telemetry.metrics, |s| { - Some(s == "all") - }); - vscode.enum_setting( - "telemetry.telemetryLevel", - &mut telemetry.diagnostics, - |s| Some(matches!(s, "all" | "error" | "crash")), - ); - // we could translate telemetry.telemetryLevel, but just because users didn't want - // to send microsoft telemetry doesn't mean they don't want to send it to zed. their - // all/error/crash/off correspond to combinations of our "diagnostics" and "metrics". - if let Some(diagnostics) = telemetry.diagnostics { - current.telemetry.get_or_insert_default().diagnostics = Some(diagnostics) - } - if let Some(metrics) = telemetry.metrics { - current.telemetry.get_or_insert_default().metrics = Some(metrics) - } - } } impl Client { @@ -1518,7 +1485,7 @@ impl Client { let url = self .http - .build_zed_cloud_url("/internal/users/impersonate", &[])?; + .build_zed_cloud_url("/internal/users/impersonate")?; let request = Request::post(url.as_str()) .header("Content-Type", "application/json") .header("Authorization", format!("Bearer {api_token}")) @@ -1754,28 +1721,68 @@ impl ProtoClient for Client { fn is_via_collab(&self) -> bool { true } + + fn has_wsl_interop(&self) -> bool { + false + } } /// prefix for the zed:// url scheme pub const ZED_URL_SCHEME: &str = "zed"; +/// A parsed Zed link that can be handled internally by the application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ZedLink { + /// Join a channel: `zed.dev/channel/channel-name-123` or `zed://channel/channel-name-123` + Channel { channel_id: u64 }, + /// Open channel notes: `zed.dev/channel/channel-name-123/notes` or with heading `notes#heading` + ChannelNotes { + channel_id: u64, + heading: Option, + }, +} + /// Parses the given link into a Zed link. /// -/// Returns a [`Some`] containing the unprefixed link if the link is a Zed link. -/// Returns [`None`] otherwise. -pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> { +/// Returns a [`Some`] containing the parsed link if the link is a recognized Zed link +/// that should be handled internally by the application. +/// Returns [`None`] for links that should be opened in the browser. +pub fn parse_zed_link(link: &str, cx: &App) -> Option { let server_url = &ClientSettings::get_global(cx).server_url; - if let Some(stripped) = link + let path = link .strip_prefix(server_url) .and_then(|result| result.strip_prefix('/')) - { - return Some(stripped); + .or_else(|| { + link.strip_prefix(ZED_URL_SCHEME) + .and_then(|result| result.strip_prefix("://")) + })?; + + let mut parts = path.split('/'); + + if parts.next() != Some("channel") { + return None; } - if let Some(stripped) = link - .strip_prefix(ZED_URL_SCHEME) - .and_then(|result| result.strip_prefix("://")) - { - return Some(stripped); + + let slug = parts.next()?; + let id_str = slug.split('-').next_back()?; + let channel_id = id_str.parse::().ok()?; + + let Some(next) = parts.next() else { + return Some(ZedLink::Channel { channel_id }); + }; + + if let Some(heading) = next.strip_prefix("notes#") { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: Some(heading.to_string()), + }); + } + + if next == "notes" { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: None, + }); } None @@ -2202,7 +2209,6 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - init_settings(cx); }); } } diff --git a/crates/client/src/proxy/socks_proxy.rs b/crates/client/src/proxy/socks_proxy.rs index 9ccf4906d8..bf2a5eab62 100644 --- a/crates/client/src/proxy/socks_proxy.rs +++ b/crates/client/src/proxy/socks_proxy.rs @@ -23,7 +23,7 @@ pub(super) struct Socks5Authorization<'a> { /// Socks Proxy Protocol Version /// -/// V4 allows idenfication using a user_id +/// V4 allows identification using a user_id /// V5 allows authorization using a username and password pub(super) enum SocksVersion<'a> { V4 { diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 35c8d0d7d0..68b6c302fb 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -158,7 +158,7 @@ pub fn os_version() -> String { let mut info = unsafe { std::mem::zeroed() }; let status = unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut info) }; if status.is_ok() { - gpui::SemanticVersion::new( + semver::Version::new( info.dwMajorVersion as _, info.dwMinorVersion as _, info.dwBuildNumber as _, @@ -179,8 +179,6 @@ impl Telemetry { let release_channel = ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name()); - TelemetrySettings::register(cx); - let state = Arc::new(Mutex::new(TelemetryState { settings: *TelemetrySettings::get_global(cx), architecture: env::consts::ARCH, @@ -295,10 +293,11 @@ impl Telemetry { } pub fn metrics_enabled(self: &Arc) -> bool { - let state = self.state.lock(); - let enabled = state.settings.metrics; - drop(state); - enabled + self.state.lock().settings.metrics + } + + pub fn diagnostics_enabled(self: &Arc) -> bool { + self.state.lock().settings.diagnostics } pub fn set_authenticated_user_info( @@ -437,7 +436,7 @@ impl Telemetry { Some(project_types) } - fn report_event(self: &Arc, event: Event) { + fn report_event(self: &Arc, mut event: Event) { let mut state = self.state.lock(); // RUST_LOG=telemetry=trace to debug telemetry events log::trace!(target: "telemetry", "{:?}", event); @@ -446,6 +445,12 @@ impl Telemetry { return; } + match &mut event { + Event::Flexible(event) => event + .event_properties + .insert("event_source".into(), "zed".into()), + }; + if state.flush_events_task.is_none() { let this = self.clone(); state.flush_events_task = Some(self.executor.spawn(async move { diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index de0668b406..37f0f3ec27 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -267,6 +267,7 @@ impl UserStore { Status::SignedOut => { current_user_tx.send(None).await.ok(); this.update(cx, |this, cx| { + this.clear_plan_and_usage(); cx.emit(Event::PrivateUserInfoUpdated); cx.notify(); this.clear_contacts() @@ -779,6 +780,12 @@ impl UserStore { cx.notify(); } + pub fn clear_plan_and_usage(&mut self) { + self.plan_info = None; + self.model_request_usage = None; + self.edit_prediction_usage = None; + } + fn update_authenticated_user( &mut self, response: GetAuthenticatedUserResponse, @@ -943,7 +950,7 @@ impl Collaborator { pub fn from_proto(message: proto::Collaborator) -> Result { Ok(Self { peer_id: message.peer_id.context("invalid peer id")?, - replica_id: message.replica_id as ReplicaId, + replica_id: ReplicaId::new(message.replica_id as u16), user_id: message.user_id as UserId, is_host: message.is_host, committer_name: message.committer_name, diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 7193c09947..2fe4725169 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -51,3 +51,19 @@ pub fn external_agents_docs(cx: &App) -> String { server_url = server_url(cx) ) } + +/// Returns the URL to Zed agent servers documentation. +pub fn agent_server_docs(cx: &App) -> String { + format!( + "{server_url}/docs/extensions/agent-servers", + server_url = server_url(cx) + ) +} + +/// Returns the URL to Zed's edit prediction documentation. +pub fn edit_prediction_docs(cx: &App) -> String { + format!( + "{server_url}/docs/ai/edit-prediction", + server_url = server_url(cx) + ) +} diff --git a/crates/clock/Cargo.toml b/crates/clock/Cargo.toml index c2fa1e003a..486cf0ba8b 100644 --- a/crates/clock/Cargo.toml +++ b/crates/clock/Cargo.toml @@ -19,4 +19,3 @@ test-support = ["dep:parking_lot"] parking_lot = { workspace = true, optional = true } serde.workspace = true smallvec.workspace = true -workspace-hack.workspace = true diff --git a/crates/clock/src/clock.rs b/crates/clock/src/clock.rs index b4f57116d2..6526ae1d55 100644 --- a/crates/clock/src/clock.rs +++ b/crates/clock/src/clock.rs @@ -4,33 +4,73 @@ use serde::{Deserialize, Serialize}; use smallvec::SmallVec; use std::{ cmp::{self, Ordering}, - fmt, iter, + fmt, }; pub use system_clock::*; -pub const LOCAL_BRANCH_REPLICA_ID: u16 = u16::MAX; -pub const AGENT_REPLICA_ID: u16 = u16::MAX - 1; - /// A unique identifier for each distributed node. -pub type ReplicaId = u16; +#[derive(Clone, Copy, Default, Eq, Hash, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +pub struct ReplicaId(u16); + +impl ReplicaId { + /// The local replica + pub const LOCAL: ReplicaId = ReplicaId(0); + /// The remote replica of the connected remote server. + pub const REMOTE_SERVER: ReplicaId = ReplicaId(1); + /// The agent's unique identifier. + pub const AGENT: ReplicaId = ReplicaId(2); + /// A local branch. + pub const LOCAL_BRANCH: ReplicaId = ReplicaId(3); + /// The first collaborative replica ID, any replica equal or greater than this is a collaborative replica. + pub const FIRST_COLLAB_ID: ReplicaId = ReplicaId(8); + + pub fn new(id: u16) -> Self { + ReplicaId(id) + } + + pub fn as_u16(&self) -> u16 { + self.0 + } + + pub fn is_remote(self) -> bool { + self == ReplicaId::REMOTE_SERVER || self >= ReplicaId::FIRST_COLLAB_ID + } +} + +impl fmt::Debug for ReplicaId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if *self == ReplicaId::LOCAL { + write!(f, "") + } else if *self == ReplicaId::REMOTE_SERVER { + write!(f, "") + } else if *self == ReplicaId::AGENT { + write!(f, "") + } else if *self == ReplicaId::LOCAL_BRANCH { + write!(f, "") + } else { + write!(f, "{}", self.0) + } + } +} /// A [Lamport sequence number](https://en.wikipedia.org/wiki/Lamport_timestamp). pub type Seq = u32; /// A [Lamport timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp), /// used to determine the ordering of events in the editor. -#[derive(Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Copy, Eq, Hash, PartialEq, Serialize, Deserialize)] pub struct Lamport { pub replica_id: ReplicaId, pub value: Seq, } -/// A [vector clock](https://en.wikipedia.org/wiki/Vector_clock). +/// A [version vector](https://en.wikipedia.org/wiki/Version_vector). #[derive(Clone, Default, Hash, Eq, PartialEq)] pub struct Global { - values: SmallVec<[u32; 8]>, - local_branch_value: u32, + // 4 is chosen as it is the biggest count that does not increase the size of the field itself. + // Coincidentally, it also covers all the important non-collab replica ids. + values: SmallVec<[u32; 4]>, } impl Global { @@ -38,30 +78,31 @@ impl Global { Self::default() } + /// Fetches the sequence number for the given replica ID. pub fn get(&self, replica_id: ReplicaId) -> Seq { - if replica_id == LOCAL_BRANCH_REPLICA_ID { - self.local_branch_value - } else { - self.values.get(replica_id as usize).copied().unwrap_or(0) as Seq - } + self.values.get(replica_id.0 as usize).copied().unwrap_or(0) as Seq } + /// Observe the lamport timestamp. + /// + /// This sets the current sequence number of the observed replica ID to the maximum of this global's observed sequence and the observed timestamp. pub fn observe(&mut self, timestamp: Lamport) { + debug_assert_ne!(timestamp.replica_id, Lamport::MAX.replica_id); if timestamp.value > 0 { - if timestamp.replica_id == LOCAL_BRANCH_REPLICA_ID { - self.local_branch_value = cmp::max(self.local_branch_value, timestamp.value); - } else { - let new_len = timestamp.replica_id as usize + 1; - if new_len > self.values.len() { - self.values.resize(new_len, 0); - } - - let entry = &mut self.values[timestamp.replica_id as usize]; - *entry = cmp::max(*entry, timestamp.value); + let new_len = timestamp.replica_id.0 as usize + 1; + if new_len > self.values.len() { + self.values.resize(new_len, 0); } + + let entry = &mut self.values[timestamp.replica_id.0 as usize]; + *entry = cmp::max(*entry, timestamp.value); } } + /// Join another global. + /// + /// This observes all timestamps from the other global. + #[doc(alias = "synchronize")] pub fn join(&mut self, other: &Self) { if other.values.len() > self.values.len() { self.values.resize(other.values.len(), 0); @@ -70,34 +111,36 @@ impl Global { for (left, right) in self.values.iter_mut().zip(&other.values) { *left = cmp::max(*left, *right); } - - self.local_branch_value = cmp::max(self.local_branch_value, other.local_branch_value); } + /// Meet another global. + /// + /// Sets all unobserved timestamps of this global to the sequences of other and sets all observed timestamps of this global to the minimum observed of both globals. pub fn meet(&mut self, other: &Self) { if other.values.len() > self.values.len() { self.values.resize(other.values.len(), 0); } let mut new_len = 0; - for (ix, (left, right)) in self - .values - .iter_mut() - .zip(other.values.iter().chain(iter::repeat(&0))) - .enumerate() - { - if *left == 0 { - *left = *right; - } else if *right > 0 { - *left = cmp::min(*left, *right); + for (ix, (left, &right)) in self.values.iter_mut().zip(&other.values).enumerate() { + match (*left, right) { + // left has not observed the replica + (0, _) => *left = right, + // right has not observed the replica + (_, 0) => (), + (_, _) => *left = cmp::min(*left, right), } - if *left != 0 { new_len = ix + 1; } } - self.values.resize(new_len, 0); - self.local_branch_value = cmp::min(self.local_branch_value, other.local_branch_value); + if other.values.len() == self.values.len() { + // only truncate if other was equal or shorter (which at this point + // cant be due to the resize above) to `self` as otherwise we would + // truncate the unprocessed tail that is guaranteed to contain + // non-null timestamps + self.values.truncate(new_len); + } } pub fn observed(&self, timestamp: Lamport) -> bool { @@ -105,20 +148,18 @@ impl Global { } pub fn observed_any(&self, other: &Self) -> bool { - self.values - .iter() - .zip(other.values.iter()) - .any(|(left, right)| *right > 0 && left >= right) - || (other.local_branch_value > 0 && self.local_branch_value >= other.local_branch_value) + self.iter() + .zip(other.iter()) + .any(|(left, right)| right.value > 0 && left.value >= right.value) } pub fn observed_all(&self, other: &Self) -> bool { - let mut rhs = other.values.iter(); - self.values.iter().all(|left| match rhs.next() { - Some(right) => left >= right, - None => true, - }) && rhs.next().is_none() - && self.local_branch_value >= other.local_branch_value + if self.values.len() < other.values.len() { + return false; + } + self.iter() + .zip(other.iter()) + .all(|(left, right)| left.value >= right.value) } pub fn changed_since(&self, other: &Self) -> bool { @@ -128,21 +169,21 @@ impl Global { .iter() .zip(other.values.iter()) .any(|(left, right)| left > right) - || self.local_branch_value > other.local_branch_value } + pub fn most_recent(&self) -> Option { + self.iter().max_by_key(|timestamp| timestamp.value) + } + + /// Iterates all replicas observed by this global as well as any unobserved replicas whose ID is lower than the highest observed replica. pub fn iter(&self) -> impl Iterator + '_ { self.values .iter() .enumerate() .map(|(replica_id, seq)| Lamport { - replica_id: replica_id as ReplicaId, + replica_id: ReplicaId(replica_id as u16), value: *seq, }) - .chain((self.local_branch_value > 0).then_some(Lamport { - replica_id: LOCAL_BRANCH_REPLICA_ID, - value: self.local_branch_value, - })) } } @@ -173,12 +214,12 @@ impl PartialOrd for Lamport { impl Lamport { pub const MIN: Self = Self { - replica_id: ReplicaId::MIN, + replica_id: ReplicaId(u16::MIN), value: Seq::MIN, }; pub const MAX: Self = Self { - replica_id: ReplicaId::MAX, + replica_id: ReplicaId(u16::MAX), value: Seq::MAX, }; @@ -190,7 +231,7 @@ impl Lamport { } pub fn as_u64(self) -> u64 { - ((self.value as u64) << 32) | (self.replica_id as u64) + ((self.value as u64) << 32) | (self.replica_id.0 as u64) } pub fn tick(&mut self) -> Self { @@ -206,22 +247,24 @@ impl Lamport { impl fmt::Debug for Lamport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Lamport {{{}: {}}}", self.replica_id, self.value) + if *self == Self::MAX { + write!(f, "Lamport {{MAX}}") + } else if *self == Self::MIN { + write!(f, "Lamport {{MIN}}") + } else { + write!(f, "Lamport {{{:?}: {}}}", self.replica_id, self.value) + } } } impl fmt::Debug for Global { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Global {{")?; - for timestamp in self.iter() { - if timestamp.replica_id > 0 { + for timestamp in self.iter().filter(|t| t.value > 0) { + if timestamp.replica_id.0 > 0 { write!(f, ", ")?; } - if timestamp.replica_id == LOCAL_BRANCH_REPLICA_ID { - write!(f, ": {}", timestamp.value)?; - } else { - write!(f, "{}: {}", timestamp.replica_id, timestamp.value)?; - } + write!(f, "{:?}: {}", timestamp.replica_id, timestamp.value)?; } write!(f, "}}") } diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml index 8e50ccb191..9dc009bf2e 100644 --- a/crates/cloud_api_client/Cargo.toml +++ b/crates/cloud_api_client/Cargo.toml @@ -20,5 +20,4 @@ gpui_tokio.workspace = true http_client.workspace = true parking_lot.workspace = true serde_json.workspace = true -workspace-hack.workspace = true yawc.workspace = true diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index 53b2b16a6a..9206e5e7ef 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -62,7 +62,7 @@ impl CloudApiClient { let request = self.build_request( Request::builder().method(Method::GET).uri( self.http_client - .build_zed_cloud_url("/client/users/me", &[])? + .build_zed_cloud_url("/client/users/me")? .as_ref(), ), AsyncBody::default(), @@ -89,7 +89,7 @@ impl CloudApiClient { pub fn connect(&self, cx: &App) -> Result>> { let mut connect_url = self .http_client - .build_zed_cloud_url("/client/users/connect", &[])?; + .build_zed_cloud_url("/client/users/connect")?; connect_url .set_scheme(match connect_url.scheme() { "https" => "wss", @@ -123,7 +123,7 @@ impl CloudApiClient { .method(Method::POST) .uri( self.http_client - .build_zed_cloud_url("/client/llm_tokens", &[])? + .build_zed_cloud_url("/client/llm_tokens")? .as_ref(), ) .when_some(system_id, |builder, system_id| { @@ -154,7 +154,7 @@ impl CloudApiClient { let request = build_request( Request::builder().method(Method::GET).uri( self.http_client - .build_zed_cloud_url("/client/users/me", &[])? + .build_zed_cloud_url("/client/users/me")? .as_ref(), ), AsyncBody::default(), diff --git a/crates/cloud_api_types/Cargo.toml b/crates/cloud_api_types/Cargo.toml index 28e0a36a44..46d5d109b1 100644 --- a/crates/cloud_api_types/Cargo.toml +++ b/crates/cloud_api_types/Cargo.toml @@ -17,7 +17,6 @@ chrono.workspace = true ciborium.workspace = true cloud_llm_client.workspace = true serde.workspace = true -workspace-hack.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/cloud_llm_client/Cargo.toml b/crates/cloud_llm_client/Cargo.toml index 1ef978f0a7..c6a551a1fb 100644 --- a/crates/cloud_llm_client/Cargo.toml +++ b/crates/cloud_llm_client/Cargo.toml @@ -21,7 +21,7 @@ serde = { workspace = true, features = ["derive", "rc"] } serde_json.workspace = true strum = { workspace = true, features = ["derive"] } uuid = { workspace = true, features = ["serde"] } -workspace-hack.workspace = true [dev-dependencies] pretty_assertions.workspace = true +indoc.workspace = true diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 4ae72ce0a4..2c5b264900 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -58,6 +58,9 @@ pub const SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME: &str = /// The name of the header used by the client to indicate that it supports receiving xAI models. pub const CLIENT_SUPPORTS_X_AI_HEADER_NAME: &str = "x-zed-client-supports-x-ai"; +/// The maximum number of edit predictions that can be rejected per request. +pub const MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST: usize = 100; + #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UsageLimit { @@ -166,6 +169,17 @@ pub struct PredictEditsBody { /// Info about the git repository state, only present when can_collect_data is true. #[serde(skip_serializing_if = "Option::is_none", default)] pub git_info: Option, + /// The trigger for this request. + #[serde(default)] + pub trigger: PredictEditsRequestTrigger, +} + +#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)] +pub enum PredictEditsRequestTrigger { + Diagnostics, + Cli, + #[default] + Other, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -183,13 +197,48 @@ pub struct PredictEditsGitInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PredictEditsResponse { - pub request_id: Uuid, + pub request_id: String, pub output_excerpt: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AcceptEditPredictionBody { - pub request_id: Uuid, + pub request_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RejectEditPredictionsBody { + pub rejections: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RejectEditPredictionsBodyRef<'a> { + pub rejections: &'a [EditPredictionRejection], +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EditPredictionRejection { + pub request_id: String, + #[serde(default)] + pub reason: EditPredictionRejectReason, + pub was_shown: bool, +} + +#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub enum EditPredictionRejectReason { + /// New requests were triggered before this one completed + Canceled, + /// No edits returned + Empty, + /// Edits returned, but none remained after interpolation + InterpolatedEmpty, + /// The new prediction was preferred over the current one + Replaced, + /// The current prediction was preferred over the new one + CurrentPreferred, + /// The current prediction was discarded + #[default] + Discarded, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] @@ -322,6 +371,11 @@ pub struct LanguageModel { pub supports_images: bool, pub supports_thinking: bool, pub supports_max_mode: bool, + #[serde(default)] + pub supports_streaming_tools: bool, + // only used by OpenAI and xAI + #[serde(default)] + pub supports_parallel_tool_calls: bool, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/cloud_llm_client/src/predict_edits_v3.rs b/crates/cloud_llm_client/src/predict_edits_v3.rs index 90df92f542..9e590dc4cf 100644 --- a/crates/cloud_llm_client/src/predict_edits_v3.rs +++ b/crates/cloud_llm_client/src/predict_edits_v3.rs @@ -1,16 +1,24 @@ use chrono::Duration; use serde::{Deserialize, Serialize}; use std::{ - ops::Range, - path::{Path, PathBuf}, + fmt::{Display, Write as _}, + ops::{Add, Range, Sub}, + path::Path, sync::Arc, }; use strum::EnumIter; use uuid::Uuid; -use crate::PredictEditsGitInfo; +use crate::{PredictEditsGitInfo, PredictEditsRequestTrigger}; -// TODO: snippet ordering within file / relative to excerpt +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanContextRetrievalRequest { + pub excerpt: String, + pub excerpt_path: Arc, + pub excerpt_line_range: Range, + pub cursor_file_max_row: Line, + pub events: Vec>, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PredictEditsRequest { @@ -18,19 +26,15 @@ pub struct PredictEditsRequest { pub excerpt_path: Arc, /// Within file pub excerpt_range: Range, - /// Within `excerpt` - pub cursor_offset: usize, + pub excerpt_line_range: Range, + pub cursor_point: Point, /// Within `signatures` pub excerpt_parent: Option, - pub signatures: Vec, - pub referenced_declarations: Vec, - pub events: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub related_files: Vec, + pub events: Vec>, #[serde(default)] pub can_collect_data: bool, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub diagnostic_groups: Vec, - #[serde(skip_serializing_if = "is_default", default)] - pub diagnostic_groups_truncated: bool, /// Info about the git repository state, only present when can_collect_data is true. #[serde(skip_serializing_if = "Option::is_none", default)] pub git_info: Option, @@ -41,18 +45,39 @@ pub struct PredictEditsRequest { pub prompt_max_bytes: Option, #[serde(default)] pub prompt_format: PromptFormat, + #[serde(default)] + pub trigger: PredictEditsRequestTrigger, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelatedFile { + pub path: Arc, + pub max_row: Line, + pub excerpts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Excerpt { + pub start_line: Line, + pub text: Arc, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumIter)] pub enum PromptFormat { - MarkedExcerpt, - LabeledSections, - /// Prompt format intended for use via zeta_cli + /// XML old_tex/new_text + OldTextNewText, + /// Prompt format intended for use via edit_prediction_cli OnlySnippets, + /// One-sentence instructions used in fine-tuned models + Minimal, + /// One-sentence instructions + FIM-like template + MinimalQwen, + /// No instructions, Qwen chat + Seed-Coder 1120 FIM-like template + SeedCoder1120, } impl PromptFormat { - pub const DEFAULT: PromptFormat = PromptFormat::LabeledSections; + pub const DEFAULT: PromptFormat = PromptFormat::Minimal; } impl Default for PromptFormat { @@ -70,9 +95,11 @@ impl PromptFormat { impl std::fmt::Display for PromptFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PromptFormat::MarkedExcerpt => write!(f, "Marked Excerpt"), - PromptFormat::LabeledSections => write!(f, "Labeled Sections"), PromptFormat::OnlySnippets => write!(f, "Only Snippets"), + PromptFormat::OldTextNewText => write!(f, "Old Text / New Text"), + PromptFormat::Minimal => write!(f, "Minimal"), + PromptFormat::MinimalQwen => write!(f, "Minimal + Qwen FIM"), + PromptFormat::SeedCoder1120 => write!(f, "Seed-Coder 1120"), } } } @@ -82,66 +109,62 @@ impl std::fmt::Display for PromptFormat { #[serde(tag = "event")] pub enum Event { BufferChange { - path: Option, - old_path: Option, + path: Arc, + old_path: Arc, diff: String, predicted: bool, + in_open_source_repo: bool, }, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Signature { - pub text: String, - pub text_is_truncated: bool, - #[serde(skip_serializing_if = "Option::is_none", default)] - pub parent_index: Option, - /// Range of `text` within the file, possibly truncated according to `text_is_truncated`. The - /// file is implicitly the file that contains the descendant declaration or excerpt. - pub range: Range, +impl Display for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Event::BufferChange { + path, + old_path, + diff, + predicted, + .. + } => { + if *predicted { + write!( + f, + "// User accepted prediction:\n--- a/{}\n+++ b/{}\n{diff}", + DiffPathFmt(old_path), + DiffPathFmt(path) + ) + } else { + write!( + f, + "--- a/{}\n+++ b/{}\n{diff}", + DiffPathFmt(old_path), + DiffPathFmt(path) + ) + } + } + } + } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferencedDeclaration { - pub path: Arc, - pub text: String, - pub text_is_truncated: bool, - /// Range of `text` within file, possibly truncated according to `text_is_truncated` - pub range: Range, - /// Range within `text` - pub signature_range: Range, - /// Index within `signatures`. - #[serde(skip_serializing_if = "Option::is_none", default)] - pub parent_index: Option, - pub score_components: DeclarationScoreComponents, - pub signature_score: f32, - pub declaration_score: f32, -} +/// always format the Path as a unix path with `/` as the path sep in Diffs +pub struct DiffPathFmt<'a>(pub &'a Path); -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeclarationScoreComponents { - pub is_same_file: bool, - pub is_referenced_nearby: bool, - pub is_referenced_in_breadcrumb: bool, - pub reference_count: usize, - pub same_file_declaration_count: usize, - pub declaration_count: usize, - pub reference_line_distance: u32, - pub declaration_line_distance: u32, - pub declaration_line_distance_rank: usize, - pub excerpt_vs_item_jaccard: f32, - pub excerpt_vs_signature_jaccard: f32, - pub adjacent_vs_item_jaccard: f32, - pub adjacent_vs_signature_jaccard: f32, - pub excerpt_vs_item_weighted_overlap: f32, - pub excerpt_vs_signature_weighted_overlap: f32, - pub adjacent_vs_item_weighted_overlap: f32, - pub adjacent_vs_signature_weighted_overlap: f32, +impl<'a> std::fmt::Display for DiffPathFmt<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut is_first = true; + for component in self.0.components() { + if !is_first { + f.write_char('/')?; + } else { + is_first = false; + } + write!(f, "{}", component.as_os_str().display())?; + } + Ok(()) + } } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct DiagnosticGroup(pub Box); - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PredictEditsResponse { pub request_id: Uuid, @@ -161,10 +184,115 @@ pub struct DebugInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Edit { pub path: Arc, - pub range: Range, + pub range: Range, pub content: String, } -fn is_default(value: &T) -> bool { - *value == T::default() +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)] +pub struct Point { + pub line: Line, + pub column: u32, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)] +#[serde(transparent)] +pub struct Line(pub u32); + +impl Add for Line { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl Sub for Line { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use pretty_assertions::assert_eq; + + #[test] + fn test_event_display() { + let ev = Event::BufferChange { + path: Path::new("untitled").into(), + old_path: Path::new("untitled").into(), + diff: "@@ -1,2 +1,2 @@\n-a\n-b\n".into(), + predicted: false, + in_open_source_repo: true, + }; + assert_eq!( + ev.to_string(), + indoc! {" + --- a/untitled + +++ b/untitled + @@ -1,2 +1,2 @@ + -a + -b + "} + ); + + let ev = Event::BufferChange { + path: Path::new("foo/bar.txt").into(), + old_path: Path::new("foo/bar.txt").into(), + diff: "@@ -1,2 +1,2 @@\n-a\n-b\n".into(), + predicted: false, + in_open_source_repo: true, + }; + assert_eq!( + ev.to_string(), + indoc! {" + --- a/foo/bar.txt + +++ b/foo/bar.txt + @@ -1,2 +1,2 @@ + -a + -b + "} + ); + + let ev = Event::BufferChange { + path: Path::new("abc.txt").into(), + old_path: Path::new("123.txt").into(), + diff: "@@ -1,2 +1,2 @@\n-a\n-b\n".into(), + predicted: false, + in_open_source_repo: true, + }; + assert_eq!( + ev.to_string(), + indoc! {" + --- a/123.txt + +++ b/abc.txt + @@ -1,2 +1,2 @@ + -a + -b + "} + ); + + let ev = Event::BufferChange { + path: Path::new("abc.txt").into(), + old_path: Path::new("123.txt").into(), + diff: "@@ -1,2 +1,2 @@\n-a\n-b\n".into(), + predicted: true, + in_open_source_repo: true, + }; + assert_eq!( + ev.to_string(), + indoc! {" + // User accepted prediction: + --- a/123.txt + +++ b/abc.txt + @@ -1,2 +1,2 @@ + -a + -b + "} + ); + } } diff --git a/crates/cloud_zeta2_prompt/Cargo.toml b/crates/cloud_zeta2_prompt/Cargo.toml deleted file mode 100644 index f5b23d653b..0000000000 --- a/crates/cloud_zeta2_prompt/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "cloud_zeta2_prompt" -version = "0.1.0" -publish.workspace = true -edition.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/cloud_zeta2_prompt.rs" - -[dependencies] -anyhow.workspace = true -cloud_llm_client.workspace = true -indoc.workspace = true -ordered-float.workspace = true -rustc-hash.workspace = true -serde.workspace = true -strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs b/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs deleted file mode 100644 index df70119b7f..0000000000 --- a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs +++ /dev/null @@ -1,532 +0,0 @@ -//! Zeta2 prompt planning and generation code shared with cloud. - -use anyhow::{Context as _, Result, anyhow}; -use cloud_llm_client::predict_edits_v3::{self, Event, PromptFormat, ReferencedDeclaration}; -use indoc::indoc; -use ordered_float::OrderedFloat; -use rustc_hash::{FxHashMap, FxHashSet}; -use serde::Serialize; -use std::fmt::Write; -use std::sync::Arc; -use std::{cmp::Reverse, collections::BinaryHeap, ops::Range, path::Path}; -use strum::{EnumIter, IntoEnumIterator}; - -pub const DEFAULT_MAX_PROMPT_BYTES: usize = 10 * 1024; - -pub const CURSOR_MARKER: &str = "<|cursor_position|>"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_START_MARKER_WITH_NEWLINE: &str = "<|editable_region_start|>\n"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_END_MARKER_WITH_NEWLINE: &str = "<|editable_region_end|>\n"; - -// TODO: use constants for markers? -const MARKED_EXCERPT_SYSTEM_PROMPT: &str = indoc! {" - You are a code completion assistant and your task is to analyze user edits and then rewrite an excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking into account the cursor location. - - The excerpt to edit will be wrapped in markers <|editable_region_start|> and <|editable_region_end|>. The cursor position is marked with <|cursor_position|>. Please respond with edited code for that region. - - Other code is provided for context, and `…` indicates when code has been skipped. -"}; - -const LABELED_SECTIONS_SYSTEM_PROMPT: &str = indoc! {r#" - You are a code completion assistant and your task is to analyze user edits, and suggest an edit to one of the provided sections of code. - - Sections of code are grouped by file and then labeled by `<|section_N|>` (e.g `<|section_8|>`). - - The cursor position is marked with `<|cursor_position|>` and it will appear within a special section labeled `<|current_section|>`. Prefer editing the current section until no more changes are needed within it. - - Respond ONLY with the name of the section to edit on a single line, followed by all of the code that should replace that section. For example: - - <|current_section|> - for i in 0..16 { - println!("{i}"); - } -"#}; - -pub struct PlannedPrompt<'a> { - request: &'a predict_edits_v3::PredictEditsRequest, - /// Snippets to include in the prompt. These may overlap - they are merged / deduplicated in - /// `to_prompt_string`. - snippets: Vec>, - budget_used: usize, -} - -pub fn system_prompt(format: PromptFormat) -> &'static str { - match format { - PromptFormat::MarkedExcerpt => MARKED_EXCERPT_SYSTEM_PROMPT, - PromptFormat::LabeledSections => LABELED_SECTIONS_SYSTEM_PROMPT, - // only intended for use via zeta_cli - PromptFormat::OnlySnippets => "", - } -} - -#[derive(Clone, Debug)] -pub struct PlannedSnippet<'a> { - path: Arc, - range: Range, - text: &'a str, - // TODO: Indicate this in the output - #[allow(dead_code)] - text_is_truncated: bool, -} - -#[derive(EnumIter, Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] -pub enum DeclarationStyle { - Signature, - Declaration, -} - -#[derive(Clone, Debug, Serialize)] -pub struct SectionLabels { - pub excerpt_index: usize, - pub section_ranges: Vec<(Arc, Range)>, -} - -impl<'a> PlannedPrompt<'a> { - /// Greedy one-pass knapsack algorithm to populate the prompt plan. Does the following: - /// - /// Initializes a priority queue by populating it with each snippet, finding the - /// DeclarationStyle that minimizes `score_density = score / snippet.range(style).len()`. When a - /// "signature" snippet is popped, insert an entry for the "declaration" variant that reflects - /// the cost of upgrade. - /// - /// TODO: Implement an early halting condition. One option might be to have another priority - /// queue where the score is the size, and update it accordingly. Another option might be to - /// have some simpler heuristic like bailing after N failed insertions, or based on how much - /// budget is left. - /// - /// TODO: Has the current known sources of imprecision: - /// - /// * Does not consider snippet overlap when ranking. For example, it might add a field to the - /// plan even though the containing struct is already included. - /// - /// * Does not consider cost of signatures when ranking snippets - this is tricky since - /// signatures may be shared by multiple snippets. - /// - /// * Does not include file paths / other text when considering max_bytes. - pub fn populate(request: &'a predict_edits_v3::PredictEditsRequest) -> Result { - let mut this = PlannedPrompt { - request, - snippets: Vec::new(), - budget_used: request.excerpt.len(), - }; - let mut included_parents = FxHashSet::default(); - let additional_parents = this.additional_parent_signatures( - &request.excerpt_path, - request.excerpt_parent, - &included_parents, - )?; - this.add_parents(&mut included_parents, additional_parents); - - let max_bytes = request.prompt_max_bytes.unwrap_or(DEFAULT_MAX_PROMPT_BYTES); - - if this.budget_used > max_bytes { - return Err(anyhow!( - "Excerpt + signatures size of {} already exceeds budget of {}", - this.budget_used, - max_bytes - )); - } - - #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] - struct QueueEntry { - score_density: OrderedFloat, - declaration_index: usize, - style: DeclarationStyle, - } - - // Initialize priority queue with the best score for each snippet. - let mut queue: BinaryHeap = BinaryHeap::new(); - for (declaration_index, declaration) in request.referenced_declarations.iter().enumerate() { - let (style, score_density) = DeclarationStyle::iter() - .map(|style| { - ( - style, - OrderedFloat(declaration_score_density(&declaration, style)), - ) - }) - .max_by_key(|(_, score_density)| *score_density) - .unwrap(); - queue.push(QueueEntry { - score_density, - declaration_index, - style, - }); - } - - // Knapsack selection loop - while let Some(queue_entry) = queue.pop() { - let Some(declaration) = request - .referenced_declarations - .get(queue_entry.declaration_index) - else { - return Err(anyhow!( - "Invalid declaration index {}", - queue_entry.declaration_index - )); - }; - - let mut additional_bytes = declaration_size(declaration, queue_entry.style); - if this.budget_used + additional_bytes > max_bytes { - continue; - } - - let additional_parents = this.additional_parent_signatures( - &declaration.path, - declaration.parent_index, - &mut included_parents, - )?; - additional_bytes += additional_parents - .iter() - .map(|(_, snippet)| snippet.text.len()) - .sum::(); - if this.budget_used + additional_bytes > max_bytes { - continue; - } - - this.budget_used += additional_bytes; - this.add_parents(&mut included_parents, additional_parents); - let planned_snippet = match queue_entry.style { - DeclarationStyle::Signature => { - let Some(text) = declaration.text.get(declaration.signature_range.clone()) - else { - return Err(anyhow!( - "Invalid declaration signature_range {:?} with text.len() = {}", - declaration.signature_range, - declaration.text.len() - )); - }; - PlannedSnippet { - path: declaration.path.clone(), - range: (declaration.signature_range.start + declaration.range.start) - ..(declaration.signature_range.end + declaration.range.start), - text, - text_is_truncated: declaration.text_is_truncated, - } - } - DeclarationStyle::Declaration => PlannedSnippet { - path: declaration.path.clone(), - range: declaration.range.clone(), - text: &declaration.text, - text_is_truncated: declaration.text_is_truncated, - }, - }; - this.snippets.push(planned_snippet); - - // When a Signature is consumed, insert an entry for Definition style. - if queue_entry.style == DeclarationStyle::Signature { - let signature_size = declaration_size(&declaration, DeclarationStyle::Signature); - let declaration_size = - declaration_size(&declaration, DeclarationStyle::Declaration); - let signature_score = declaration_score(&declaration, DeclarationStyle::Signature); - let declaration_score = - declaration_score(&declaration, DeclarationStyle::Declaration); - - let score_diff = declaration_score - signature_score; - let size_diff = declaration_size.saturating_sub(signature_size); - if score_diff > 0.0001 && size_diff > 0 { - queue.push(QueueEntry { - declaration_index: queue_entry.declaration_index, - score_density: OrderedFloat(score_diff / (size_diff as f32)), - style: DeclarationStyle::Declaration, - }); - } - } - } - - anyhow::Ok(this) - } - - fn add_parents( - &mut self, - included_parents: &mut FxHashSet, - snippets: Vec<(usize, PlannedSnippet<'a>)>, - ) { - for (parent_index, snippet) in snippets { - included_parents.insert(parent_index); - self.budget_used += snippet.text.len(); - self.snippets.push(snippet); - } - } - - fn additional_parent_signatures( - &self, - path: &Arc, - parent_index: Option, - included_parents: &FxHashSet, - ) -> Result)>> { - let mut results = Vec::new(); - self.additional_parent_signatures_impl(path, parent_index, included_parents, &mut results)?; - Ok(results) - } - - fn additional_parent_signatures_impl( - &self, - path: &Arc, - parent_index: Option, - included_parents: &FxHashSet, - results: &mut Vec<(usize, PlannedSnippet<'a>)>, - ) -> Result<()> { - let Some(parent_index) = parent_index else { - return Ok(()); - }; - if included_parents.contains(&parent_index) { - return Ok(()); - } - let Some(parent_signature) = self.request.signatures.get(parent_index) else { - return Err(anyhow!("Invalid parent index {}", parent_index)); - }; - results.push(( - parent_index, - PlannedSnippet { - path: path.clone(), - range: parent_signature.range.clone(), - text: &parent_signature.text, - text_is_truncated: parent_signature.text_is_truncated, - }, - )); - self.additional_parent_signatures_impl( - path, - parent_signature.parent_index, - included_parents, - results, - ) - } - - /// Renders the planned context. Each file starts with "```FILE_PATH\n` and ends with triple - /// backticks, with a newline after each file. Outputs a line with "..." between nonconsecutive - /// chunks. - pub fn to_prompt_string(&'a self) -> Result<(String, SectionLabels)> { - let mut file_to_snippets: FxHashMap<&'a std::path::Path, Vec<&PlannedSnippet<'a>>> = - FxHashMap::default(); - for snippet in &self.snippets { - file_to_snippets - .entry(&snippet.path) - .or_default() - .push(snippet); - } - - // Reorder so that file with cursor comes last - let mut file_snippets = Vec::new(); - let mut excerpt_file_snippets = Vec::new(); - for (file_path, snippets) in file_to_snippets { - if file_path == self.request.excerpt_path.as_ref() { - excerpt_file_snippets = snippets; - } else { - file_snippets.push((file_path, snippets, false)); - } - } - let excerpt_snippet = PlannedSnippet { - path: self.request.excerpt_path.clone(), - range: self.request.excerpt_range.clone(), - text: &self.request.excerpt, - text_is_truncated: false, - }; - excerpt_file_snippets.push(&excerpt_snippet); - file_snippets.push((&self.request.excerpt_path, excerpt_file_snippets, true)); - - let mut excerpt_file_insertions = match self.request.prompt_format { - PromptFormat::MarkedExcerpt => vec![ - ( - self.request.excerpt_range.start, - EDITABLE_REGION_START_MARKER_WITH_NEWLINE, - ), - ( - self.request.excerpt_range.start + self.request.cursor_offset, - CURSOR_MARKER, - ), - ( - self.request - .excerpt_range - .end - .saturating_sub(0) - .max(self.request.excerpt_range.start), - EDITABLE_REGION_END_MARKER_WITH_NEWLINE, - ), - ], - PromptFormat::LabeledSections => vec![( - self.request.excerpt_range.start + self.request.cursor_offset, - CURSOR_MARKER, - )], - PromptFormat::OnlySnippets => vec![], - }; - - let mut prompt = String::new(); - prompt.push_str("## User Edits\n\n"); - Self::push_events(&mut prompt, &self.request.events); - - prompt.push_str("\n## Code\n\n"); - let section_labels = - self.push_file_snippets(&mut prompt, &mut excerpt_file_insertions, file_snippets)?; - Ok((prompt, section_labels)) - } - - fn push_events(output: &mut String, events: &[predict_edits_v3::Event]) { - for event in events { - match event { - Event::BufferChange { - path, - old_path, - diff, - predicted, - } => { - if let Some(old_path) = &old_path - && let Some(new_path) = &path - { - if old_path != new_path { - writeln!( - output, - "User renamed {} to {}\n\n", - old_path.display(), - new_path.display() - ) - .unwrap(); - } - } - - let path = path - .as_ref() - .map_or_else(|| "untitled".to_string(), |path| path.display().to_string()); - - if *predicted { - writeln!( - output, - "User accepted prediction {:?}:\n```diff\n{}\n```\n", - path, diff - ) - .unwrap(); - } else { - writeln!(output, "User edited {:?}:\n```diff\n{}\n```\n", path, diff) - .unwrap(); - } - } - } - } - } - - fn push_file_snippets( - &self, - output: &mut String, - excerpt_file_insertions: &mut Vec<(usize, &'static str)>, - file_snippets: Vec<(&'a Path, Vec<&'a PlannedSnippet>, bool)>, - ) -> Result { - let mut section_ranges = Vec::new(); - let mut excerpt_index = None; - - for (file_path, mut snippets, is_excerpt_file) in file_snippets { - snippets.sort_by_key(|s| (s.range.start, Reverse(s.range.end))); - - // TODO: What if the snippets get expanded too large to be editable? - let mut current_snippet: Option<(&PlannedSnippet, Range)> = None; - let mut disjoint_snippets: Vec<(&PlannedSnippet, Range)> = Vec::new(); - for snippet in snippets { - if let Some((_, current_snippet_range)) = current_snippet.as_mut() - && snippet.range.start < current_snippet_range.end - { - if snippet.range.end > current_snippet_range.end { - current_snippet_range.end = snippet.range.end; - } - continue; - } - if let Some(current_snippet) = current_snippet.take() { - disjoint_snippets.push(current_snippet); - } - current_snippet = Some((snippet, snippet.range.clone())); - } - if let Some(current_snippet) = current_snippet.take() { - disjoint_snippets.push(current_snippet); - } - - writeln!(output, "```{}", file_path.display()).ok(); - let mut skipped_last_snippet = false; - for (snippet, range) in disjoint_snippets { - let section_index = section_ranges.len(); - - match self.request.prompt_format { - PromptFormat::MarkedExcerpt | PromptFormat::OnlySnippets => { - if range.start > 0 && !skipped_last_snippet { - output.push_str("…\n"); - } - } - PromptFormat::LabeledSections => { - if is_excerpt_file - && range.start <= self.request.excerpt_range.start - && range.end >= self.request.excerpt_range.end - { - writeln!(output, "<|current_section|>").ok(); - } else { - writeln!(output, "<|section_{}|>", section_index).ok(); - } - } - } - - if is_excerpt_file { - if self.request.prompt_format == PromptFormat::OnlySnippets { - if range.start >= self.request.excerpt_range.start - && range.end <= self.request.excerpt_range.end - { - skipped_last_snippet = true; - } else { - skipped_last_snippet = false; - output.push_str(snippet.text); - } - } else { - let mut last_offset = range.start; - let mut i = 0; - while i < excerpt_file_insertions.len() { - let (offset, insertion) = &excerpt_file_insertions[i]; - let found = *offset >= range.start && *offset <= range.end; - if found { - excerpt_index = Some(section_index); - output.push_str( - &snippet.text[last_offset - range.start..offset - range.start], - ); - output.push_str(insertion); - last_offset = *offset; - excerpt_file_insertions.remove(i); - continue; - } - i += 1; - } - skipped_last_snippet = false; - output.push_str(&snippet.text[last_offset - range.start..]); - } - } else { - skipped_last_snippet = false; - output.push_str(snippet.text); - } - - section_ranges.push((snippet.path.clone(), range)); - } - - output.push_str("```\n\n"); - } - - Ok(SectionLabels { - // TODO: Clean this up - excerpt_index: match self.request.prompt_format { - PromptFormat::OnlySnippets => 0, - _ => excerpt_index.context("bug: no snippet found for excerpt")?, - }, - section_ranges, - }) - } -} - -fn declaration_score_density(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> f32 { - declaration_score(declaration, style) / declaration_size(declaration, style) as f32 -} - -fn declaration_score(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> f32 { - match style { - DeclarationStyle::Signature => declaration.signature_score, - DeclarationStyle::Declaration => declaration.declaration_score, - } -} - -fn declaration_size(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> usize { - match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.text.len(), - } -} diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml new file mode 100644 index 0000000000..7f3bf3b22d --- /dev/null +++ b/crates/codestral/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codestral" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lib] +path = "src/codestral.rs" + +[dependencies] +anyhow.workspace = true +edit_prediction_types.workspace = true +edit_prediction_context.workspace = true +futures.workspace = true +gpui.workspace = true +http_client.workspace = true +language.workspace = true +language_models.workspace = true +log.workspace = true +mistral.workspace = true +serde.workspace = true +serde_json.workspace = true +smol.workspace = true +text.workspace = true + +[dev-dependencies] diff --git a/crates/assistant_context/LICENSE-GPL b/crates/codestral/LICENSE-GPL similarity index 100% rename from crates/assistant_context/LICENSE-GPL rename to crates/codestral/LICENSE-GPL diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs new file mode 100644 index 0000000000..9bf0296ac3 --- /dev/null +++ b/crates/codestral/src/codestral.rs @@ -0,0 +1,396 @@ +use anyhow::{Context as _, Result}; +use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions}; +use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate}; +use futures::AsyncReadExt; +use gpui::{App, Context, Entity, Task}; +use http_client::HttpClient; +use language::{ + language_settings::all_language_settings, Anchor, Buffer, BufferSnapshot, EditPreview, ToPoint, +}; +use language_models::MistralLanguageModelProvider; +use mistral::CODESTRAL_API_URL; +use serde::{Deserialize, Serialize}; +use std::{ + ops::Range, + sync::Arc, + time::{Duration, Instant}, +}; +use text::ToOffset; + +pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150); + +const EXCERPT_OPTIONS: EditPredictionExcerptOptions = EditPredictionExcerptOptions { + max_bytes: 1050, + min_bytes: 525, + target_before_cursor_over_total_bytes: 0.66, +}; + +/// Represents a completion that has been received and processed from Codestral. +/// This struct maintains the state needed to interpolate the completion as the user types. +#[derive(Clone)] +struct CurrentCompletion { + /// The buffer snapshot at the time the completion was generated. + /// Used to detect changes and interpolate edits. + snapshot: BufferSnapshot, + /// The edits that should be applied to transform the original text into the predicted text. + /// Each edit is a range in the buffer and the text to replace it with. + edits: Arc<[(Range, Arc)]>, + /// Preview of how the buffer will look after applying the edits. + edit_preview: EditPreview, +} + +impl CurrentCompletion { + /// Attempts to adjust the edits based on changes made to the buffer since the completion was generated. + /// Returns None if the user's edits conflict with the predicted edits. + fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option, Arc)>> { + edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits) + } +} + +pub struct CodestralEditPredictionDelegate { + http_client: Arc, + pending_request: Option>>, + current_completion: Option, +} + +impl CodestralEditPredictionDelegate { + pub fn new(http_client: Arc) -> Self { + Self { + http_client, + pending_request: None, + current_completion: None, + } + } + + pub fn has_api_key(cx: &App) -> bool { + Self::api_key(cx).is_some() + } + + /// This is so we can immediately show Codestral as a provider users can + /// switch to in the edit prediction menu, if the API has been added + pub fn ensure_api_key_loaded(http_client: Arc, cx: &mut App) { + MistralLanguageModelProvider::global(http_client, cx) + .load_codestral_api_key(cx) + .detach(); + } + + fn api_key(cx: &App) -> Option> { + MistralLanguageModelProvider::try_global(cx) + .and_then(|provider| provider.codestral_api_key(CODESTRAL_API_URL, cx)) + } + + /// Uses Codestral's Fill-in-the-Middle API for code completion. + async fn fetch_completion( + http_client: Arc, + api_key: &str, + prompt: String, + suffix: String, + model: String, + max_tokens: Option, + api_url: String, + ) -> Result { + let start_time = Instant::now(); + + log::debug!( + "Codestral: Requesting completion (model: {}, max_tokens: {:?})", + model, + max_tokens + ); + + let request = CodestralRequest { + model, + prompt, + suffix: if suffix.is_empty() { + None + } else { + Some(suffix) + }, + max_tokens: max_tokens.or(Some(350)), + temperature: Some(0.2), + top_p: Some(1.0), + stream: Some(false), + stop: None, + random_seed: None, + min_tokens: None, + }; + + let request_body = serde_json::to_string(&request)?; + + log::debug!("Codestral: Sending FIM request"); + + let http_request = http_client::Request::builder() + .method(http_client::Method::POST) + .uri(format!("{}/v1/fim/completions", api_url)) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .body(http_client::AsyncBody::from(request_body))?; + + let mut response = http_client.send(http_request).await?; + let status = response.status(); + + log::debug!("Codestral: Response status: {}", status); + + if !status.is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + return Err(anyhow::anyhow!( + "Codestral API error: {} - {}", + status, + body + )); + } + + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + let codestral_response: CodestralResponse = serde_json::from_str(&body)?; + + let elapsed = start_time.elapsed(); + + if let Some(choice) = codestral_response.choices.first() { + let completion = &choice.message.content; + + log::debug!( + "Codestral: Completion received ({} tokens, {:.2}s)", + codestral_response.usage.completion_tokens, + elapsed.as_secs_f64() + ); + + // Return just the completion text for insertion at cursor + Ok(completion.clone()) + } else { + log::error!("Codestral: No completion returned in response"); + Err(anyhow::anyhow!("No completion returned from Codestral")) + } + } +} + +impl EditPredictionDelegate for CodestralEditPredictionDelegate { + fn name() -> &'static str { + "codestral" + } + + fn display_name() -> &'static str { + "Codestral" + } + + fn show_predictions_in_menu() -> bool { + true + } + + fn is_enabled(&self, _buffer: &Entity, _cursor_position: Anchor, cx: &App) -> bool { + Self::api_key(cx).is_some() + } + + fn is_refreshing(&self, _cx: &App) -> bool { + self.pending_request.is_some() + } + + fn refresh( + &mut self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut Context, + ) { + log::debug!("Codestral: Refresh called (debounce: {})", debounce); + + let Some(api_key) = Self::api_key(cx) else { + log::warn!("Codestral: No API key configured, skipping refresh"); + return; + }; + + let snapshot = buffer.read(cx).snapshot(); + + // Check if current completion is still valid + if let Some(current_completion) = self.current_completion.as_ref() { + if current_completion.interpolate(&snapshot).is_some() { + return; + } + } + + let http_client = self.http_client.clone(); + + // Get settings + let settings = all_language_settings(None, cx); + let model = settings + .edit_predictions + .codestral + .model + .clone() + .unwrap_or_else(|| "codestral-latest".to_string()); + let max_tokens = settings.edit_predictions.codestral.max_tokens; + let api_url = settings + .edit_predictions + .codestral + .api_url + .clone() + .unwrap_or_else(|| CODESTRAL_API_URL.to_string()); + + self.pending_request = Some(cx.spawn(async move |this, cx| { + if debounce { + log::debug!("Codestral: Debouncing for {:?}", DEBOUNCE_TIMEOUT); + smol::Timer::after(DEBOUNCE_TIMEOUT).await; + } + + let cursor_offset = cursor_position.to_offset(&snapshot); + let cursor_point = cursor_offset.to_point(&snapshot); + let excerpt = EditPredictionExcerpt::select_from_buffer( + cursor_point, + &snapshot, + &EXCERPT_OPTIONS, + ) + .context("Line containing cursor doesn't fit in excerpt max bytes")?; + + let excerpt_text = excerpt.text(&snapshot); + let cursor_within_excerpt = cursor_offset + .saturating_sub(excerpt.range.start) + .min(excerpt_text.body.len()); + let prompt = excerpt_text.body[..cursor_within_excerpt].to_string(); + let suffix = excerpt_text.body[cursor_within_excerpt..].to_string(); + + let completion_text = match Self::fetch_completion( + http_client, + &api_key, + prompt, + suffix, + model, + max_tokens, + api_url, + ) + .await + { + Ok(completion) => completion, + Err(e) => { + log::error!("Codestral: Failed to fetch completion: {}", e); + this.update(cx, |this, cx| { + this.pending_request = None; + cx.notify(); + })?; + return Err(e); + } + }; + + if completion_text.trim().is_empty() { + log::debug!("Codestral: Completion was empty after trimming; ignoring"); + this.update(cx, |this, cx| { + this.pending_request = None; + cx.notify(); + })?; + return Ok(()); + } + + let edits: Arc<[(Range, Arc)]> = + vec![(cursor_position..cursor_position, completion_text.into())].into(); + let edit_preview = buffer + .read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx))? + .await; + + this.update(cx, |this, cx| { + this.current_completion = Some(CurrentCompletion { + snapshot, + edits, + edit_preview, + }); + this.pending_request = None; + cx.notify(); + })?; + + Ok(()) + })); + } + + fn cycle( + &mut self, + _buffer: Entity, + _cursor_position: Anchor, + _direction: Direction, + _cx: &mut Context, + ) { + // Codestral doesn't support multiple completions, so cycling does nothing + } + + fn accept(&mut self, _cx: &mut Context) { + log::debug!("Codestral: Completion accepted"); + self.pending_request = None; + self.current_completion = None; + } + + fn discard(&mut self, _cx: &mut Context) { + log::debug!("Codestral: Completion discarded"); + self.pending_request = None; + self.current_completion = None; + } + + /// Returns the completion suggestion, adjusted or invalidated based on user edits + fn suggest( + &mut self, + buffer: &Entity, + _cursor_position: Anchor, + cx: &mut Context, + ) -> Option { + let current_completion = self.current_completion.as_ref()?; + let buffer = buffer.read(cx); + let edits = current_completion.interpolate(&buffer.snapshot())?; + if edits.is_empty() { + return None; + } + Some(EditPrediction::Local { + id: None, + edits, + edit_preview: Some(current_completion.edit_preview.clone()), + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CodestralRequest { + pub model: String, + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suffix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub random_seed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_tokens: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CodestralResponse { + pub id: String, + pub object: String, + pub model: String, + pub usage: Usage, + pub created: u64, + pub choices: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Usage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} + +#[derive(Debug, Deserialize)] +pub struct Choice { + pub index: u32, + pub message: Message, + pub finish_reason: String, +} + +#[derive(Debug, Deserialize)] +pub struct Message { + pub content: String, + pub role: String, +} diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index d95b318b0e..79fc21fe33 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -20,7 +20,7 @@ test-support = ["sqlite"] [dependencies] anyhow.workspace = true async-trait.workspace = true -async-tungstenite.workspace = true +async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots" ] } aws-config = { version = "1.1.5" } aws-sdk-kinesis = "1.51.0" aws-sdk-s3 = { version = "1.15.0" } @@ -47,8 +47,9 @@ reqwest = { version = "0.11", features = ["json"] } reqwest_client.workspace = true rpc.workspace = true scrypt = "0.11" -sea-orm = { version = "1.1.0-rc.1", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls", "with-uuid"] } -semantic_version.workspace = true +# sea-orm and sea-orm-macros versions must match exactly. +sea-orm = { version = "=1.1.10", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls", "with-uuid"] } +sea-orm-macros = "=1.1.10" semver.workspace = true serde.workspace = true serde_json.workspace = true @@ -64,15 +65,14 @@ tokio = { workspace = true, features = ["full"] } toml.workspace = true tower = "0.4" tower-http = { workspace = true, features = ["trace"] } -tracing = "0.1.40" +tracing.workspace = true tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "registry", "tracing-log"] } # workaround for https://github.com/tokio-rs/tracing/issues/2927 util.workspace = true uuid.workspace = true -workspace-hack.workspace = true [dev-dependencies] agent_settings.workspace = true -assistant_context.workspace = true +assistant_text_thread.workspace = true assistant_slash_command.workspace = true async-trait.workspace = true audio.workspace = true @@ -116,7 +116,7 @@ release_channel.workspace = true remote = { workspace = true, features = ["test-support"] } remote_server.workspace = true rpc = { workspace = true, features = ["test-support"] } -sea-orm = { version = "1.1.0-rc.1", features = ["sqlx-sqlite"] } +sea-orm = { version = "=1.1.10", features = ["sqlx-sqlite"] } serde_json.workspace = true session = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } diff --git a/crates/collab/README.md b/crates/collab/README.md index 0ec6d8008b..902c9841e2 100644 --- a/crates/collab/README.md +++ b/crates/collab/README.md @@ -63,15 +63,3 @@ Deployment is triggered by pushing to the `collab-staging` (or `collab-productio - `./script/deploy-collab production` You can tell what is currently deployed with `./script/what-is-deployed`. - -# Database Migrations - -To create a new migration: - -```sh -./script/create-migration -``` - -Migrations are run automatically on service start, so run `foreman start` again. The service will crash if the migrations fail. - -When you create a new migration, you also need to update the [SQLite schema](./migrations.sqlite/20221109000000_test_schema.sql) that is used for testing. diff --git a/crates/collab/k8s/migrate.template.yml b/crates/collab/k8s/migrate.template.yml deleted file mode 100644 index c890d7b330..0000000000 --- a/crates/collab/k8s/migrate.template.yml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: ${ZED_MIGRATE_JOB_NAME} -spec: - template: - spec: - restartPolicy: Never - containers: - - name: migrator - imagePullPolicy: Always - image: ${ZED_IMAGE_ID} - args: - - migrate - env: - - name: DATABASE_URL - valueFrom: - secretKeyRef: - name: database - key: url diff --git a/crates/collab/k8s/postgrest.template.yml b/crates/collab/k8s/postgrest.template.yml deleted file mode 100644 index 4819408bff..0000000000 --- a/crates/collab/k8s/postgrest.template.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -kind: Service -apiVersion: v1 -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: postgrest - annotations: - service.beta.kubernetes.io/do-loadbalancer-name: "postgrest-${ZED_KUBE_NAMESPACE}" - service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443" - service.beta.kubernetes.io/do-loadbalancer-certificate-id: ${ZED_DO_CERTIFICATE_ID} - service.beta.kubernetes.io/do-loadbalancer-disable-lets-encrypt-dns-records: "true" -spec: - type: LoadBalancer - selector: - app: nginx - ports: - - name: web - protocol: TCP - port: 443 - targetPort: 8080 - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: nginx -spec: - replicas: 1 - selector: - matchLabels: - app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:latest - ports: - - containerPort: 8080 - protocol: TCP - volumeMounts: - - name: nginx-config - mountPath: /etc/nginx/nginx.conf - subPath: nginx.conf - volumes: - - name: nginx-config - configMap: - name: nginx-config - ---- -apiVersion: v1 -kind: ConfigMap -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: nginx-config -data: - nginx.conf: | - events {} - - http { - server { - listen 8080; - - location /app/ { - proxy_pass http://postgrest-app:8080/; - } - - location /llm/ { - proxy_pass http://postgrest-llm:8080/; - } - } - } - ---- -apiVersion: v1 -kind: Service -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: postgrest-app -spec: - selector: - app: postgrest-app - ports: - - protocol: TCP - port: 8080 - targetPort: 8080 - ---- -apiVersion: v1 -kind: Service -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: postgrest-llm -spec: - selector: - app: postgrest-llm - ports: - - protocol: TCP - port: 8080 - targetPort: 8080 - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: postgrest-app -spec: - replicas: 1 - selector: - matchLabels: - app: postgrest-app - template: - metadata: - labels: - app: postgrest-app - spec: - containers: - - name: postgrest - image: "postgrest/postgrest" - ports: - - containerPort: 8080 - protocol: TCP - env: - - name: PGRST_SERVER_PORT - value: "8080" - - name: PGRST_DB_URI - valueFrom: - secretKeyRef: - name: database - key: url - - name: PGRST_JWT_SECRET - valueFrom: - secretKeyRef: - name: postgrest - key: jwt_secret - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: postgrest-llm -spec: - replicas: 1 - selector: - matchLabels: - app: postgrest-llm - template: - metadata: - labels: - app: postgrest-llm - spec: - containers: - - name: postgrest - image: "postgrest/postgrest" - ports: - - containerPort: 8080 - protocol: TCP - env: - - name: PGRST_SERVER_PORT - value: "8080" - - name: PGRST_DB_URI - valueFrom: - secretKeyRef: - name: llm-database - key: url - - name: PGRST_JWT_SECRET - valueFrom: - secretKeyRef: - name: postgrest - key: jwt_secret diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index d498ecd50a..32a2ed2e13 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -97,6 +97,7 @@ CREATE TABLE "worktree_entries" ( "is_external" BOOL NOT NULL, "is_ignored" BOOL NOT NULL, "is_deleted" BOOL NOT NULL, + "is_hidden" BOOL NOT NULL, "git_status" INTEGER, "is_fifo" BOOL NOT NULL, PRIMARY KEY (project_id, worktree_id, id), @@ -120,6 +121,8 @@ CREATE TABLE "project_repositories" ( "merge_message" VARCHAR, "branch_summary" VARCHAR, "head_commit_details" VARCHAR, + "remote_upstream_url" VARCHAR, + "remote_origin_url" VARCHAR, PRIMARY KEY (project_id, id) ); @@ -290,29 +293,6 @@ CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id"); -CREATE TABLE IF NOT EXISTS "channel_messages" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "sender_id" INTEGER NOT NULL REFERENCES users (id), - "body" TEXT NOT NULL, - "sent_at" TIMESTAMP, - "edited_at" TIMESTAMP, - "nonce" BLOB NOT NULL, - "reply_to_message_id" INTEGER DEFAULT NULL -); - -CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id"); - -CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce"); - -CREATE TABLE "channel_message_mentions" ( - "message_id" INTEGER NOT NULL REFERENCES channel_messages (id) ON DELETE CASCADE, - "start_offset" INTEGER NOT NULL, - "end_offset" INTEGER NOT NULL, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - PRIMARY KEY (message_id, start_offset) -); - CREATE TABLE "channel_members" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, @@ -407,15 +387,6 @@ CREATE TABLE "observed_buffer_edits" ( CREATE UNIQUE INDEX "index_observed_buffers_user_and_buffer_id" ON "observed_buffer_edits" ("user_id", "buffer_id"); -CREATE TABLE IF NOT EXISTS "observed_channel_messages" ( - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "channel_message_id" INTEGER NOT NULL, - PRIMARY KEY (user_id, channel_id) -); - -CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id"); - CREATE TABLE "notification_kinds" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR NOT NULL @@ -466,6 +437,7 @@ CREATE TABLE extension_versions ( provides_grammars BOOLEAN NOT NULL DEFAULT FALSE, provides_language_servers BOOLEAN NOT NULL DEFAULT FALSE, provides_context_servers BOOLEAN NOT NULL DEFAULT FALSE, + provides_agent_servers BOOLEAN NOT NULL DEFAULT FALSE, provides_slash_commands BOOLEAN NOT NULL DEFAULT FALSE, provides_indexed_docs_providers BOOLEAN NOT NULL DEFAULT FALSE, provides_snippets BOOLEAN NOT NULL DEFAULT FALSE, diff --git a/crates/collab/migrations/20210527024318_initial_schema.sql b/crates/collab/migrations/20210527024318_initial_schema.sql deleted file mode 100644 index 4b06531848..0000000000 --- a/crates/collab/migrations/20210527024318_initial_schema.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE IF NOT EXISTS "sessions" ( - "id" VARCHAR NOT NULL PRIMARY KEY, - "expires" TIMESTAMP WITH TIME ZONE NULL, - "session" TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS "users" ( - "id" SERIAL PRIMARY KEY, - "github_login" VARCHAR, - "admin" BOOLEAN -); - -CREATE UNIQUE INDEX "index_users_github_login" ON "users" ("github_login"); - -CREATE TABLE IF NOT EXISTS "signups" ( - "id" SERIAL PRIMARY KEY, - "github_login" VARCHAR, - "email_address" VARCHAR, - "about" TEXT -); diff --git a/crates/collab/migrations/20210607190313_create_access_tokens.sql b/crates/collab/migrations/20210607190313_create_access_tokens.sql deleted file mode 100644 index 60745a98ba..0000000000 --- a/crates/collab/migrations/20210607190313_create_access_tokens.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS "access_tokens" ( - "id" SERIAL PRIMARY KEY, - "user_id" INTEGER REFERENCES users (id), - "hash" VARCHAR(128) -); - -CREATE INDEX "index_access_tokens_user_id" ON "access_tokens" ("user_id"); diff --git a/crates/collab/migrations/20210805175147_create_chat_tables.sql b/crates/collab/migrations/20210805175147_create_chat_tables.sql deleted file mode 100644 index 5bba4689d9..0000000000 --- a/crates/collab/migrations/20210805175147_create_chat_tables.sql +++ /dev/null @@ -1,46 +0,0 @@ -CREATE TABLE IF NOT EXISTS "orgs" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL, - "slug" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_orgs_slug" ON "orgs" ("slug"); - -CREATE TABLE IF NOT EXISTS "org_memberships" ( - "id" SERIAL PRIMARY KEY, - "org_id" INTEGER REFERENCES orgs (id) NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "admin" BOOLEAN NOT NULL -); - -CREATE INDEX "index_org_memberships_user_id" ON "org_memberships" ("user_id"); -CREATE UNIQUE INDEX "index_org_memberships_org_id_and_user_id" ON "org_memberships" ("org_id", "user_id"); - -CREATE TABLE IF NOT EXISTS "channels" ( - "id" SERIAL PRIMARY KEY, - "owner_id" INTEGER NOT NULL, - "owner_is_user" BOOLEAN NOT NULL, - "name" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_channels_owner_and_name" ON "channels" ("owner_is_user", "owner_id", "name"); - -CREATE TABLE IF NOT EXISTS "channel_memberships" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER REFERENCES channels (id) NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "admin" BOOLEAN NOT NULL -); - -CREATE INDEX "index_channel_memberships_user_id" ON "channel_memberships" ("user_id"); -CREATE UNIQUE INDEX "index_channel_memberships_channel_id_and_user_id" ON "channel_memberships" ("channel_id", "user_id"); - -CREATE TABLE IF NOT EXISTS "channel_messages" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER REFERENCES channels (id) NOT NULL, - "sender_id" INTEGER REFERENCES users (id) NOT NULL, - "body" TEXT NOT NULL, - "sent_at" TIMESTAMP -); - -CREATE INDEX "index_channel_messages_channel_id" ON "channel_messages" ("channel_id"); diff --git a/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql b/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql deleted file mode 100644 index ee4d4aa319..0000000000 --- a/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "channel_messages" -ADD "nonce" UUID NOT NULL DEFAULT gen_random_uuid(); - -CREATE UNIQUE INDEX "index_channel_messages_nonce" ON "channel_messages" ("nonce"); diff --git a/crates/collab/migrations/20210920192001_add_interests_to_signups.sql b/crates/collab/migrations/20210920192001_add_interests_to_signups.sql deleted file mode 100644 index 2457abfc75..0000000000 --- a/crates/collab/migrations/20210920192001_add_interests_to_signups.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "signups" - ADD "wants_releases" BOOLEAN, - ADD "wants_updates" BOOLEAN, - ADD "wants_community" BOOLEAN; \ No newline at end of file diff --git a/crates/collab/migrations/20220421165757_drop_signups.sql b/crates/collab/migrations/20220421165757_drop_signups.sql deleted file mode 100644 index d7cd6e204c..0000000000 --- a/crates/collab/migrations/20220421165757_drop_signups.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS "signups"; diff --git a/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql b/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql deleted file mode 100644 index 3d6fd3179a..0000000000 --- a/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE INDEX trigram_index_users_on_github_login ON users USING GIN(github_login gin_trgm_ops); diff --git a/crates/collab/migrations/20220506130724_create_contacts.sql b/crates/collab/migrations/20220506130724_create_contacts.sql deleted file mode 100644 index 56beb70fd0..0000000000 --- a/crates/collab/migrations/20220506130724_create_contacts.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS "contacts" ( - "id" SERIAL PRIMARY KEY, - "user_id_a" INTEGER REFERENCES users (id) NOT NULL, - "user_id_b" INTEGER REFERENCES users (id) NOT NULL, - "a_to_b" BOOLEAN NOT NULL, - "should_notify" BOOLEAN NOT NULL, - "accepted" BOOLEAN NOT NULL -); - -CREATE UNIQUE INDEX "index_contacts_user_ids" ON "contacts" ("user_id_a", "user_id_b"); -CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b"); diff --git a/crates/collab/migrations/20220518151305_add_invites_to_users.sql b/crates/collab/migrations/20220518151305_add_invites_to_users.sql deleted file mode 100644 index 2ac89b649e..0000000000 --- a/crates/collab/migrations/20220518151305_add_invites_to_users.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE users -ADD email_address VARCHAR(255) DEFAULT NULL, -ADD invite_code VARCHAR(64), -ADD invite_count INTEGER NOT NULL DEFAULT 0, -ADD inviter_id INTEGER REFERENCES users (id), -ADD connected_once BOOLEAN NOT NULL DEFAULT false, -ADD created_at TIMESTAMP NOT NULL DEFAULT NOW(); - -CREATE UNIQUE INDEX "index_invite_code_users" ON "users" ("invite_code"); diff --git a/crates/collab/migrations/20220523232954_allow_user_deletes.sql b/crates/collab/migrations/20220523232954_allow_user_deletes.sql deleted file mode 100644 index ddf3f6f9bd..0000000000 --- a/crates/collab/migrations/20220523232954_allow_user_deletes.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE contacts DROP CONSTRAINT contacts_user_id_a_fkey; -ALTER TABLE contacts DROP CONSTRAINT contacts_user_id_b_fkey; -ALTER TABLE contacts ADD CONSTRAINT contacts_user_id_a_fkey FOREIGN KEY (user_id_a) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE contacts ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE users DROP CONSTRAINT users_inviter_id_fkey; -ALTER TABLE users ADD CONSTRAINT users_inviter_id_fkey FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE SET NULL; diff --git a/crates/collab/migrations/20220620211403_create_projects.sql b/crates/collab/migrations/20220620211403_create_projects.sql deleted file mode 100644 index d813c9f7a1..0000000000 --- a/crates/collab/migrations/20220620211403_create_projects.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE IF NOT EXISTS "projects" ( - "id" SERIAL PRIMARY KEY, - "host_user_id" INTEGER REFERENCES users (id) NOT NULL, - "unregistered" BOOLEAN NOT NULL DEFAULT false -); - -CREATE TABLE IF NOT EXISTS "worktree_extensions" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER REFERENCES projects (id) NOT NULL, - "worktree_id" INTEGER NOT NULL, - "extension" VARCHAR(255), - "count" INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS "project_activity_periods" ( - "id" SERIAL PRIMARY KEY, - "duration_millis" INTEGER NOT NULL, - "ended_at" TIMESTAMP NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "project_id" INTEGER REFERENCES projects (id) NOT NULL -); - -CREATE INDEX "index_project_activity_periods_on_ended_at" ON "project_activity_periods" ("ended_at"); -CREATE UNIQUE INDEX "index_worktree_extensions_on_project_id_and_worktree_id_and_extension" ON "worktree_extensions" ("project_id", "worktree_id", "extension"); \ No newline at end of file diff --git a/crates/collab/migrations/20220913211150_create_signups.sql b/crates/collab/migrations/20220913211150_create_signups.sql deleted file mode 100644 index 19559b747c..0000000000 --- a/crates/collab/migrations/20220913211150_create_signups.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE IF NOT EXISTS "signups" ( - "id" SERIAL PRIMARY KEY, - "email_address" VARCHAR NOT NULL, - "email_confirmation_code" VARCHAR(64) NOT NULL, - "email_confirmation_sent" BOOLEAN NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "device_id" VARCHAR, - "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, - "inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL, - - "platform_mac" BOOLEAN NOT NULL, - "platform_linux" BOOLEAN NOT NULL, - "platform_windows" BOOLEAN NOT NULL, - "platform_unknown" BOOLEAN NOT NULL, - - "editor_features" VARCHAR[], - "programming_languages" VARCHAR[] -); - -CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address"); -CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent"); - -ALTER TABLE "users" - ADD "github_user_id" INTEGER; - -CREATE INDEX "index_users_on_email_address" ON "users" ("email_address"); -CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"); diff --git a/crates/collab/migrations/20220929182110_add_metrics_id.sql b/crates/collab/migrations/20220929182110_add_metrics_id.sql deleted file mode 100644 index 665d6323bf..0000000000 --- a/crates/collab/migrations/20220929182110_add_metrics_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "users" - ADD "metrics_id" uuid NOT NULL DEFAULT gen_random_uuid(); diff --git a/crates/collab/migrations/20221111092550_reconnection_support.sql b/crates/collab/migrations/20221111092550_reconnection_support.sql deleted file mode 100644 index 3289f6bbdd..0000000000 --- a/crates/collab/migrations/20221111092550_reconnection_support.sql +++ /dev/null @@ -1,90 +0,0 @@ -CREATE TABLE IF NOT EXISTS "rooms" ( - "id" SERIAL PRIMARY KEY, - "live_kit_room" VARCHAR NOT NULL -); - -ALTER TABLE "projects" - ADD "room_id" INTEGER REFERENCES rooms (id), - ADD "host_connection_id" INTEGER, - ADD "host_connection_epoch" UUID; -CREATE INDEX "index_projects_on_host_connection_epoch" ON "projects" ("host_connection_epoch"); - -CREATE TABLE "worktrees" ( - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "id" INT8 NOT NULL, - "root_name" VARCHAR NOT NULL, - "abs_path" VARCHAR NOT NULL, - "visible" BOOL NOT NULL, - "scan_id" INT8 NOT NULL, - "is_complete" BOOL NOT NULL, - PRIMARY KEY(project_id, id) -); -CREATE INDEX "index_worktrees_on_project_id" ON "worktrees" ("project_id"); - -CREATE TABLE "worktree_entries" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "id" INT8 NOT NULL, - "is_dir" BOOL NOT NULL, - "path" VARCHAR NOT NULL, - "inode" INT8 NOT NULL, - "mtime_seconds" INT8 NOT NULL, - "mtime_nanos" INTEGER NOT NULL, - "is_symlink" BOOL NOT NULL, - "is_ignored" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, id), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_entries_on_project_id" ON "worktree_entries" ("project_id"); -CREATE INDEX "index_worktree_entries_on_project_id_and_worktree_id" ON "worktree_entries" ("project_id", "worktree_id"); - -CREATE TABLE "worktree_diagnostic_summaries" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "path" VARCHAR NOT NULL, - "language_server_id" INT8 NOT NULL, - "error_count" INTEGER NOT NULL, - "warning_count" INTEGER NOT NULL, - PRIMARY KEY(project_id, worktree_id, path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_diagnostic_summaries_on_project_id" ON "worktree_diagnostic_summaries" ("project_id"); -CREATE INDEX "index_worktree_diagnostic_summaries_on_project_id_and_worktree_id" ON "worktree_diagnostic_summaries" ("project_id", "worktree_id"); - -CREATE TABLE "language_servers" ( - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "id" INT8 NOT NULL, - "name" VARCHAR NOT NULL, - PRIMARY KEY(project_id, id) -); -CREATE INDEX "index_language_servers_on_project_id" ON "language_servers" ("project_id"); - -CREATE TABLE "project_collaborators" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_epoch" UUID NOT NULL, - "user_id" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - "is_host" BOOLEAN NOT NULL -); -CREATE INDEX "index_project_collaborators_on_project_id" ON "project_collaborators" ("project_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_and_replica_id" ON "project_collaborators" ("project_id", "replica_id"); -CREATE INDEX "index_project_collaborators_on_connection_epoch" ON "project_collaborators" ("connection_epoch"); - -CREATE TABLE "room_participants" ( - "id" SERIAL PRIMARY KEY, - "room_id" INTEGER NOT NULL REFERENCES rooms (id), - "user_id" INTEGER NOT NULL REFERENCES users (id), - "answering_connection_id" INTEGER, - "answering_connection_epoch" UUID, - "location_kind" INTEGER, - "location_project_id" INTEGER, - "initial_project_id" INTEGER, - "calling_user_id" INTEGER NOT NULL REFERENCES users (id), - "calling_connection_id" INTEGER NOT NULL, - "calling_connection_epoch" UUID NOT NULL -); -CREATE UNIQUE INDEX "index_room_participants_on_user_id" ON "room_participants" ("user_id"); -CREATE INDEX "index_room_participants_on_answering_connection_epoch" ON "room_participants" ("answering_connection_epoch"); -CREATE INDEX "index_room_participants_on_calling_connection_epoch" ON "room_participants" ("calling_connection_epoch"); diff --git a/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql b/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql deleted file mode 100644 index b154396df1..0000000000 --- a/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "signups" - ADD "added_to_mailing_list" BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql b/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql deleted file mode 100644 index ed0cf972bc..0000000000 --- a/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE "room_participants" - ADD "answering_connection_lost" BOOLEAN NOT NULL DEFAULT FALSE; - -CREATE INDEX "index_project_collaborators_on_connection_id" ON "project_collaborators" ("connection_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_epoch" ON "project_collaborators" ("project_id", "connection_id", "connection_epoch"); -CREATE INDEX "index_room_participants_on_answering_connection_id" ON "room_participants" ("answering_connection_id"); -CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_epoch" ON "room_participants" ("answering_connection_id", "answering_connection_epoch"); diff --git a/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql b/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql deleted file mode 100644 index f40ca81906..0000000000 --- a/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id"); diff --git a/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql b/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql deleted file mode 100644 index 5e02f76ce2..0000000000 --- a/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE servers ( - id SERIAL PRIMARY KEY, - environment VARCHAR NOT NULL -); - -DROP TABLE worktree_extensions; -DROP TABLE project_activity_periods; -DELETE from projects; -ALTER TABLE projects - DROP COLUMN host_connection_epoch, - ADD COLUMN host_connection_server_id INTEGER REFERENCES servers (id) ON DELETE CASCADE; -CREATE INDEX "index_projects_on_host_connection_server_id" ON "projects" ("host_connection_server_id"); -CREATE INDEX "index_projects_on_host_connection_id_and_host_connection_server_id" ON "projects" ("host_connection_id", "host_connection_server_id"); - -DELETE FROM project_collaborators; -ALTER TABLE project_collaborators - DROP COLUMN connection_epoch, - ADD COLUMN connection_server_id INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE; -CREATE INDEX "index_project_collaborators_on_connection_server_id" ON "project_collaborators" ("connection_server_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_server_id" ON "project_collaborators" ("project_id", "connection_id", "connection_server_id"); - -DELETE FROM room_participants; -ALTER TABLE room_participants - DROP COLUMN answering_connection_epoch, - DROP COLUMN calling_connection_epoch, - ADD COLUMN answering_connection_server_id INTEGER REFERENCES servers (id) ON DELETE CASCADE, - ADD COLUMN calling_connection_server_id INTEGER REFERENCES servers (id) ON DELETE SET NULL; -CREATE INDEX "index_room_participants_on_answering_connection_server_id" ON "room_participants" ("answering_connection_server_id"); -CREATE INDEX "index_room_participants_on_calling_connection_server_id" ON "room_participants" ("calling_connection_server_id"); -CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_server_id" ON "room_participants" ("answering_connection_id", "answering_connection_server_id"); diff --git a/crates/collab/migrations/20221219181850_project_reconnection_support.sql b/crates/collab/migrations/20221219181850_project_reconnection_support.sql deleted file mode 100644 index 6efef5571c..0000000000 --- a/crates/collab/migrations/20221219181850_project_reconnection_support.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "worktree_entries" - ADD COLUMN "scan_id" INT8, - ADD COLUMN "is_deleted" BOOL; diff --git a/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql b/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql deleted file mode 100644 index 1894d888b9..0000000000 --- a/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE worktrees - ALTER COLUMN is_complete SET DEFAULT FALSE, - ADD COLUMN completed_scan_id INT8; diff --git a/crates/collab/migrations/20230202155735_followers.sql b/crates/collab/migrations/20230202155735_followers.sql deleted file mode 100644 index c82d6ba3bd..0000000000 --- a/crates/collab/migrations/20230202155735_followers.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE IF NOT EXISTS "followers" ( - "id" SERIAL PRIMARY KEY, - "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "leader_connection_id" INTEGER NOT NULL, - "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "follower_connection_id" INTEGER NOT NULL -); - -CREATE UNIQUE INDEX - "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" -ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id"); - -CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); diff --git a/crates/collab/migrations/20230508211523_add-repository-entries.sql b/crates/collab/migrations/20230508211523_add-repository-entries.sql deleted file mode 100644 index 1e59347939..0000000000 --- a/crates/collab/migrations/20230508211523_add-repository-entries.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE "worktree_repositories" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "work_directory_id" INT8 NOT NULL, - "scan_id" INT8 NOT NULL, - "branch" VARCHAR, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, work_directory_id), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE, - FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_repositories_on_project_id" ON "worktree_repositories" ("project_id"); -CREATE INDEX "index_worktree_repositories_on_project_id_and_worktree_id" ON "worktree_repositories" ("project_id", "worktree_id"); diff --git a/crates/collab/migrations/20230511004019_add_repository_statuses.sql b/crates/collab/migrations/20230511004019_add_repository_statuses.sql deleted file mode 100644 index 862561c686..0000000000 --- a/crates/collab/migrations/20230511004019_add_repository_statuses.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE "worktree_repository_statuses" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "work_directory_id" INT8 NOT NULL, - "repo_path" VARCHAR NOT NULL, - "status" INT8 NOT NULL, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, work_directory_id, repo_path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE, - FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_wt_repos_statuses_on_project_id" ON "worktree_repository_statuses" ("project_id"); -CREATE INDEX "index_wt_repos_statuses_on_project_id_and_wt_id" ON "worktree_repository_statuses" ("project_id", "worktree_id"); -CREATE INDEX "index_wt_repos_statuses_on_project_id_and_wt_id_and_wd_id" ON "worktree_repository_statuses" ("project_id", "worktree_id", "work_directory_id"); diff --git a/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql b/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql deleted file mode 100644 index 973a40af0f..0000000000 --- a/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE "worktree_settings_files" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "path" VARCHAR NOT NULL, - "content" TEXT NOT NULL, - PRIMARY KEY(project_id, worktree_id, path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_settings_files_on_project_id" ON "worktree_settings_files" ("project_id"); -CREATE INDEX "index_settings_files_on_project_id_and_wt_id" ON "worktree_settings_files" ("project_id", "worktree_id"); diff --git a/crates/collab/migrations/20230605191135_remove_repository_statuses.sql b/crates/collab/migrations/20230605191135_remove_repository_statuses.sql deleted file mode 100644 index 3e5f907c44..0000000000 --- a/crates/collab/migrations/20230605191135_remove_repository_statuses.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "git_status" INT8; diff --git a/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql b/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql deleted file mode 100644 index e4348af0cc..0000000000 --- a/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "is_external" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql deleted file mode 100644 index df981838bf..0000000000 --- a/crates/collab/migrations/20230727150500_add_channels.sql +++ /dev/null @@ -1,30 +0,0 @@ -DROP TABLE "channel_messages"; -DROP TABLE "channel_memberships"; -DROP TABLE "org_memberships"; -DROP TABLE "orgs"; -DROP TABLE "channels"; - -CREATE TABLE "channels" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT now() -); - -CREATE TABLE "channel_paths" ( - "id_path" VARCHAR NOT NULL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE -); -CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id"); - -CREATE TABLE "channel_members" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "admin" BOOLEAN NOT NULL DEFAULT false, - "accepted" BOOLEAN NOT NULL DEFAULT false, - "updated_at" TIMESTAMP NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id"); - -ALTER TABLE rooms ADD COLUMN "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE; diff --git a/crates/collab/migrations/20230819154600_add_channel_buffers.sql b/crates/collab/migrations/20230819154600_add_channel_buffers.sql deleted file mode 100644 index 5e6e7ce339..0000000000 --- a/crates/collab/migrations/20230819154600_add_channel_buffers.sql +++ /dev/null @@ -1,40 +0,0 @@ -CREATE TABLE "buffers" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL DEFAULT 0 -); - -CREATE INDEX "index_buffers_on_channel_id" ON "buffers" ("channel_id"); - -CREATE TABLE "buffer_operations" ( - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - "lamport_timestamp" INTEGER NOT NULL, - "value" BYTEA NOT NULL, - PRIMARY KEY(buffer_id, epoch, lamport_timestamp, replica_id) -); - -CREATE TABLE "buffer_snapshots" ( - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "text" TEXT NOT NULL, - "operation_serialization_version" INTEGER NOT NULL, - PRIMARY KEY(buffer_id, epoch) -); - -CREATE TABLE "channel_buffer_collaborators" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "connection_lost" BOOLEAN NOT NULL DEFAULT FALSE, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "replica_id" INTEGER NOT NULL -); - -CREATE INDEX "index_channel_buffer_collaborators_on_channel_id" ON "channel_buffer_collaborators" ("channel_id"); -CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_and_replica_id" ON "channel_buffer_collaborators" ("channel_id", "replica_id"); -CREATE INDEX "index_channel_buffer_collaborators_on_connection_server_id" ON "channel_buffer_collaborators" ("connection_server_id"); -CREATE INDEX "index_channel_buffer_collaborators_on_connection_id" ON "channel_buffer_collaborators" ("connection_id"); -CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_connection_id_and_server_id" ON "channel_buffer_collaborators" ("channel_id", "connection_id", "connection_server_id"); diff --git a/crates/collab/migrations/20230825190322_add_server_feature_flags.sql b/crates/collab/migrations/20230825190322_add_server_feature_flags.sql deleted file mode 100644 index fffde54a20..0000000000 --- a/crates/collab/migrations/20230825190322_add_server_feature_flags.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE "feature_flags" ( - "id" SERIAL PRIMARY KEY, - "flag" VARCHAR(255) NOT NULL UNIQUE -); - -CREATE UNIQUE INDEX "index_feature_flags" ON "feature_flags" ("id"); - -CREATE TABLE "user_features" ( - "user_id" INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - "feature_id" INTEGER NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, feature_id) -); - -CREATE UNIQUE INDEX "index_user_features_user_id_and_feature_id" ON "user_features" ("user_id", "feature_id"); -CREATE INDEX "index_user_features_on_user_id" ON "user_features" ("user_id"); -CREATE INDEX "index_user_features_on_feature_id" ON "user_features" ("feature_id"); diff --git a/crates/collab/migrations/20230907114200_add_channel_messages.sql b/crates/collab/migrations/20230907114200_add_channel_messages.sql deleted file mode 100644 index abe7753ca6..0000000000 --- a/crates/collab/migrations/20230907114200_add_channel_messages.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE IF NOT EXISTS "channel_messages" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "sender_id" INTEGER NOT NULL REFERENCES users (id), - "body" TEXT NOT NULL, - "sent_at" TIMESTAMP, - "nonce" UUID NOT NULL -); -CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id"); -CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce"); - -CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( - "id" SERIAL PRIMARY KEY, - "user_id" INTEGER NOT NULL REFERENCES users (id), - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE -); -CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id"); diff --git a/crates/collab/migrations/20230925210437_add_channel_changes.sql b/crates/collab/migrations/20230925210437_add_channel_changes.sql deleted file mode 100644 index 250a9ac731..0000000000 --- a/crates/collab/migrations/20230925210437_add_channel_changes.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE IF NOT EXISTS "observed_buffer_edits" ( - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "lamport_timestamp" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - PRIMARY KEY (user_id, buffer_id) -); - -CREATE UNIQUE INDEX "index_observed_buffer_user_and_buffer_id" ON "observed_buffer_edits" ("user_id", "buffer_id"); - -CREATE TABLE IF NOT EXISTS "observed_channel_messages" ( - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "channel_message_id" INTEGER NOT NULL, - PRIMARY KEY (user_id, channel_id) -); - -CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id"); diff --git a/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql b/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql deleted file mode 100644 index 1493119e2a..0000000000 --- a/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE room_participants ADD COLUMN participant_index INTEGER; diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql deleted file mode 100644 index 93c282c631..0000000000 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE "notification_kinds" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name"); - -CREATE TABLE notifications ( - "id" SERIAL PRIMARY KEY, - "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "entity_id" INTEGER, - "content" TEXT, - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, - "response" BOOLEAN -); - -CREATE INDEX - "index_notifications_on_recipient_id_is_read_kind_entity_id" - ON "notifications" - ("recipient_id", "is_read", "kind", "entity_id"); diff --git a/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql b/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql deleted file mode 100644 index 8f3a704add..0000000000 --- a/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE rooms ADD COLUMN enviroment TEXT; diff --git a/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql b/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql deleted file mode 100644 index 21ec4cfbb7..0000000000 --- a/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id"); diff --git a/crates/collab/migrations/20231011214412_add_guest_role.sql b/crates/collab/migrations/20231011214412_add_guest_role.sql deleted file mode 100644 index 1713547158..0000000000 --- a/crates/collab/migrations/20231011214412_add_guest_role.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE channel_members ADD COLUMN role TEXT; -UPDATE channel_members SET role = CASE WHEN admin THEN 'admin' ELSE 'member' END; - -ALTER TABLE channels ADD COLUMN visibility TEXT NOT NULL DEFAULT 'members'; diff --git a/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql b/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql deleted file mode 100644 index be535ff7fa..0000000000 --- a/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Add migration script here - -ALTER TABLE projects - DROP CONSTRAINT projects_room_id_fkey, - ADD CONSTRAINT projects_room_id_fkey - FOREIGN KEY (room_id) - REFERENCES rooms (id) - ON DELETE CASCADE; diff --git a/crates/collab/migrations/20231018102700_create_mentions.sql b/crates/collab/migrations/20231018102700_create_mentions.sql deleted file mode 100644 index 221a1748cf..0000000000 --- a/crates/collab/migrations/20231018102700_create_mentions.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE "channel_message_mentions" ( - "message_id" INTEGER NOT NULL REFERENCES channel_messages (id) ON DELETE CASCADE, - "start_offset" INTEGER NOT NULL, - "end_offset" INTEGER NOT NULL, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - PRIMARY KEY(message_id, start_offset) -); - --- We use 'on conflict update' with this index, so it should be per-user. -CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce"); -DROP INDEX "index_channel_messages_on_nonce"; diff --git a/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql b/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql deleted file mode 100644 index d9fc6c8722..0000000000 --- a/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql +++ /dev/null @@ -1,12 +0,0 @@ -ALTER TABLE channels ADD COLUMN parent_path TEXT; - -UPDATE channels -SET parent_path = substr( - channel_paths.id_path, - 2, - length(channel_paths.id_path) - length('/' || channel_paths.channel_id::text || '/') -) -FROM channel_paths -WHERE channel_paths.channel_id = channels.id; - -CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path"); diff --git a/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql b/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql deleted file mode 100644 index 2748e00eba..0000000000 --- a/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE room_participants ADD COLUMN role TEXT; diff --git a/crates/collab/migrations/20240111085546_fix_column_name.sql b/crates/collab/migrations/20240111085546_fix_column_name.sql deleted file mode 100644 index 3f32ee35c5..0000000000 --- a/crates/collab/migrations/20240111085546_fix_column_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE rooms ADD COLUMN environment TEXT; diff --git a/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql b/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql deleted file mode 100644 index 8c79640cd8..0000000000 --- a/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE access_tokens ADD COLUMN impersonated_user_id integer; diff --git a/crates/collab/migrations/20240122174606_add_contributors.sql b/crates/collab/migrations/20240122174606_add_contributors.sql deleted file mode 100644 index 16bec82d4f..0000000000 --- a/crates/collab/migrations/20240122174606_add_contributors.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE contributors ( - user_id INTEGER REFERENCES users(id), - signed_at TIMESTAMP NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id) -); diff --git a/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql b/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql deleted file mode 100644 index a9248d294a..0000000000 --- a/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "channels" ADD COLUMN "requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20240129193601_fix_parent_path_index.sql b/crates/collab/migrations/20240129193601_fix_parent_path_index.sql deleted file mode 100644 index 73dd6e37cd..0000000000 --- a/crates/collab/migrations/20240129193601_fix_parent_path_index.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here - -DROP INDEX index_channels_on_parent_path; -CREATE INDEX index_channels_on_parent_path ON channels (parent_path text_pattern_ops); diff --git a/crates/collab/migrations/20240203113741_add_reply_to_message.sql b/crates/collab/migrations/20240203113741_add_reply_to_message.sql deleted file mode 100644 index 6f40b62822..0000000000 --- a/crates/collab/migrations/20240203113741_add_reply_to_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE channel_messages ADD reply_to_message_id INTEGER DEFAULT NULL diff --git a/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql b/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql deleted file mode 100644 index 09463c6e78..0000000000 --- a/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here - -ALTER TABLE room_participants ADD COLUMN in_call BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql b/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql deleted file mode 100644 index dc4897af48..0000000000 --- a/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here -ALTER TABLE rooms DROP COLUMN enviroment; -ALTER TABLE rooms DROP COLUMN environment; -ALTER TABLE room_participants DROP COLUMN in_call; diff --git a/crates/collab/migrations/20240214102900_add_extensions.sql b/crates/collab/migrations/20240214102900_add_extensions.sql deleted file mode 100644 index b32094036d..0000000000 --- a/crates/collab/migrations/20240214102900_add_extensions.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE IF NOT EXISTS extensions ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - external_id TEXT NOT NULL, - latest_version TEXT NOT NULL, - total_download_count BIGINT NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS extension_versions ( - extension_id INTEGER REFERENCES extensions(id), - version TEXT NOT NULL, - published_at TIMESTAMP NOT NULL DEFAULT now(), - authors TEXT NOT NULL, - repository TEXT NOT NULL, - description TEXT NOT NULL, - download_count BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY(extension_id, version) -); - -CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id"); -CREATE INDEX "trigram_index_extensions_name" ON "extensions" USING GIN(name gin_trgm_ops); -CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count"); diff --git a/crates/collab/migrations/20240220234826_add_rate_buckets.sql b/crates/collab/migrations/20240220234826_add_rate_buckets.sql deleted file mode 100644 index 864a437303..0000000000 --- a/crates/collab/migrations/20240220234826_add_rate_buckets.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS rate_buckets ( - user_id INT NOT NULL, - rate_limit_name VARCHAR(255) NOT NULL, - token_count INT NOT NULL, - last_refill TIMESTAMP WITHOUT TIME ZONE NOT NULL, - PRIMARY KEY (user_id, rate_limit_name), - CONSTRAINT fk_user - FOREIGN KEY (user_id) REFERENCES users(id) -); - -CREATE INDEX idx_user_id_rate_limit ON rate_buckets (user_id, rate_limit_name); diff --git a/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql b/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql deleted file mode 100644 index 1d07b07de7..0000000000 --- a/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE channel_messages ADD edited_at TIMESTAMP DEFAULT NULL; diff --git a/crates/collab/migrations/20240226163408_hosted_projects.sql b/crates/collab/migrations/20240226163408_hosted_projects.sql deleted file mode 100644 index c6ade7161c..0000000000 --- a/crates/collab/migrations/20240226163408_hosted_projects.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Add migration script here - -CREATE TABLE hosted_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - name TEXT NOT NULL, - visibility TEXT NOT NULL, - deleted_at TIMESTAMP NULL -); -CREATE INDEX idx_hosted_projects_on_channel_id ON hosted_projects (channel_id); -CREATE UNIQUE INDEX uix_hosted_projects_on_channel_id_and_name ON hosted_projects (channel_id, name) WHERE (deleted_at IS NULL); diff --git a/crates/collab/migrations/20240226164505_unique_channel_names.sql b/crates/collab/migrations/20240226164505_unique_channel_names.sql deleted file mode 100644 index c9d9f0a1cb..0000000000 --- a/crates/collab/migrations/20240226164505_unique_channel_names.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here - -CREATE UNIQUE INDEX uix_channels_parent_path_name ON channels(parent_path, name) WHERE (parent_path IS NOT NULL AND parent_path != ''); diff --git a/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql b/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql deleted file mode 100644 index 69905d12f6..0000000000 --- a/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here -ALTER TABLE projects ALTER COLUMN host_user_id DROP NOT NULL; -ALTER TABLE projects ADD COLUMN hosted_project_id INTEGER REFERENCES hosted_projects(id) UNIQUE NULL; diff --git a/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql b/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql deleted file mode 100644 index a332a20d52..0000000000 --- a/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Add migration script here - -ALTER TABLE buffers ADD COLUMN latest_operation_epoch INTEGER; -ALTER TABLE buffers ADD COLUMN latest_operation_lamport_timestamp INTEGER; -ALTER TABLE buffers ADD COLUMN latest_operation_replica_id INTEGER; - -WITH ops AS ( - SELECT DISTINCT ON (buffer_id) buffer_id, epoch, lamport_timestamp, replica_id - FROM buffer_operations - ORDER BY buffer_id, epoch DESC, lamport_timestamp DESC, replica_id DESC -) -UPDATE buffers -SET latest_operation_epoch = ops.epoch, - latest_operation_lamport_timestamp = ops.lamport_timestamp, - latest_operation_replica_id = ops.replica_id -FROM ops -WHERE buffers.id = ops.buffer_id; diff --git a/crates/collab/migrations/20240315182903_non_null_channel_role.sql b/crates/collab/migrations/20240315182903_non_null_channel_role.sql deleted file mode 100644 index 2d359f8058..0000000000 --- a/crates/collab/migrations/20240315182903_non_null_channel_role.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here - -ALTER TABLE channel_members ALTER role SET NOT NULL; -ALTER TABLE channel_members DROP COLUMN admin; diff --git a/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql b/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql deleted file mode 100644 index 5703578b00..0000000000 --- a/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add migration script here -ALTER TABLE channels ALTER parent_path SET NOT NULL; diff --git a/crates/collab/migrations/20240320124800_add_extension_schema_version.sql b/crates/collab/migrations/20240320124800_add_extension_schema_version.sql deleted file mode 100644 index 75fd0f40e4..0000000000 --- a/crates/collab/migrations/20240320124800_add_extension_schema_version.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add migration script here -ALTER TABLE extension_versions ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 0; diff --git a/crates/collab/migrations/20240321162658_add_devservers.sql b/crates/collab/migrations/20240321162658_add_devservers.sql deleted file mode 100644 index cb1ff4df40..0000000000 --- a/crates/collab/migrations/20240321162658_add_devservers.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE dev_servers ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - name TEXT NOT NULL, - hashed_token TEXT NOT NULL -); -CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id); diff --git a/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql b/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql deleted file mode 100644 index 3b95323d26..0000000000 --- a/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE extension_versions ADD COLUMN wasm_api_version TEXT; diff --git a/crates/collab/migrations/20240402155003_add_dev_server_projects.sql b/crates/collab/migrations/20240402155003_add_dev_server_projects.sql deleted file mode 100644 index 003c43f4e2..0000000000 --- a/crates/collab/migrations/20240402155003_add_dev_server_projects.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE remote_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - dev_server_id INT NOT NULL REFERENCES dev_servers(id), - name TEXT NOT NULL, - path TEXT NOT NULL -); - -ALTER TABLE projects ADD COLUMN remote_project_id INTEGER REFERENCES remote_projects(id); diff --git a/crates/collab/migrations/20240409082755_create_embeddings.sql b/crates/collab/migrations/20240409082755_create_embeddings.sql deleted file mode 100644 index ae4b4bcb61..0000000000 --- a/crates/collab/migrations/20240409082755_create_embeddings.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS "embeddings" ( - "model" TEXT, - "digest" BYTEA, - "dimensions" FLOAT4[1536], - "retrieved_at" TIMESTAMP NOT NULL DEFAULT now(), - PRIMARY KEY ("model", "digest") -); - -CREATE INDEX IF NOT EXISTS "idx_retrieved_at_on_embeddings" ON "embeddings" ("retrieved_at"); diff --git a/crates/collab/migrations/20240412165156_dev_servers_per_user.sql b/crates/collab/migrations/20240412165156_dev_servers_per_user.sql deleted file mode 100644 index 7ef9e2fde0..0000000000 --- a/crates/collab/migrations/20240412165156_dev_servers_per_user.sql +++ /dev/null @@ -1,7 +0,0 @@ -DELETE FROM remote_projects; -DELETE FROM dev_servers; - -ALTER TABLE dev_servers DROP COLUMN channel_id; -ALTER TABLE dev_servers ADD COLUMN user_id INT NOT NULL REFERENCES users(id); - -ALTER TABLE remote_projects DROP COLUMN channel_id; diff --git a/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql b/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql deleted file mode 100644 index 923b948cee..0000000000 --- a/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE remote_projects DROP COLUMN name; -ALTER TABLE remote_projects -ADD CONSTRAINT unique_path_constraint UNIQUE(dev_server_id, path); diff --git a/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql b/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql deleted file mode 100644 index 0d8e9de5e6..0000000000 --- a/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE dev_server_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY (START WITH 100), - dev_server_id INT NOT NULL REFERENCES dev_servers(id) ON DELETE CASCADE, - path TEXT NOT NULL -); -INSERT INTO dev_server_projects OVERRIDING SYSTEM VALUE SELECT * FROM remote_projects; - -ALTER TABLE dev_server_projects ADD CONSTRAINT uix_dev_server_projects_dev_server_id_path UNIQUE(dev_server_id, path); - -ALTER TABLE projects ADD COLUMN dev_server_project_id INTEGER REFERENCES dev_server_projects(id); -UPDATE projects SET dev_server_project_id = remote_project_id; diff --git a/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql b/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql deleted file mode 100644 index 01ace43fab..0000000000 --- a/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE projects DROP COLUMN remote_project_id; -DROP TABLE remote_projects; diff --git a/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql b/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql deleted file mode 100644 index 5085ca271b..0000000000 --- a/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE dev_servers ADD COLUMN ssh_connection_string TEXT; diff --git a/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql b/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql deleted file mode 100644 index 675df4885b..0000000000 --- a/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE dev_server_projects ADD COLUMN paths JSONB NULL; -UPDATE dev_server_projects SET paths = to_json(ARRAY[path]); -ALTER TABLE dev_server_projects ALTER COLUMN paths SET NOT NULL; -ALTER TABLE dev_server_projects ALTER COLUMN path DROP NOT NULL; diff --git a/crates/collab/migrations/20240729170526_add_billing_subscription.sql b/crates/collab/migrations/20240729170526_add_billing_subscription.sql deleted file mode 100644 index acec4b3ddb..0000000000 --- a/crates/collab/migrations/20240729170526_add_billing_subscription.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS billing_subscriptions ( - id SERIAL PRIMARY KEY, - created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stripe_customer_id TEXT NOT NULL, - stripe_subscription_id TEXT NOT NULL, - stripe_subscription_status TEXT NOT NULL -); - -CREATE INDEX "ix_billing_subscriptions_on_user_id" ON billing_subscriptions (user_id); -CREATE INDEX "ix_billing_subscriptions_on_stripe_customer_id" ON billing_subscriptions (stripe_customer_id); -CREATE UNIQUE INDEX "uix_billing_subscriptions_on_stripe_subscription_id" ON billing_subscriptions (stripe_subscription_id); diff --git a/crates/collab/migrations/20240730014107_add_billing_customer.sql b/crates/collab/migrations/20240730014107_add_billing_customer.sql deleted file mode 100644 index 7f7d4a0f85..0000000000 --- a/crates/collab/migrations/20240730014107_add_billing_customer.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE IF NOT EXISTS billing_customers ( - id SERIAL PRIMARY KEY, - created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stripe_customer_id TEXT NOT NULL -); - -CREATE UNIQUE INDEX "uix_billing_customers_on_user_id" ON billing_customers (user_id); -CREATE UNIQUE INDEX "uix_billing_customers_on_stripe_customer_id" ON billing_customers (stripe_customer_id); - --- Make `billing_subscriptions` reference `billing_customers` instead of having its --- own `user_id` and `stripe_customer_id`. -DROP INDEX IF EXISTS "ix_billing_subscriptions_on_user_id"; -DROP INDEX IF EXISTS "ix_billing_subscriptions_on_stripe_customer_id"; -ALTER TABLE billing_subscriptions DROP COLUMN user_id; -ALTER TABLE billing_subscriptions DROP COLUMN stripe_customer_id; -ALTER TABLE billing_subscriptions ADD COLUMN billing_customer_id INTEGER NOT NULL REFERENCES billing_customers (id) ON DELETE CASCADE; -CREATE INDEX "ix_billing_subscriptions_on_billing_customer_id" ON billing_subscriptions (billing_customer_id); diff --git a/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql b/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql deleted file mode 100644 index 477eadd742..0000000000 --- a/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE billing_customers ADD COLUMN last_stripe_event_id TEXT; -ALTER TABLE billing_subscriptions ADD COLUMN last_stripe_event_id TEXT; diff --git a/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql b/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql deleted file mode 100644 index baf1aa3122..0000000000 --- a/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE billing_customers DROP COLUMN last_stripe_event_id; -ALTER TABLE billing_subscriptions DROP COLUMN last_stripe_event_id; - -CREATE TABLE IF NOT EXISTS processed_stripe_events ( - stripe_event_id TEXT PRIMARY KEY, - stripe_event_type TEXT NOT NULL, - stripe_event_created_timestamp BIGINT NOT NULL, - processed_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now() -); - -CREATE INDEX "ix_processed_stripe_events_on_stripe_event_created_timestamp" ON processed_stripe_events (stripe_event_created_timestamp); diff --git a/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql b/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql deleted file mode 100644 index b09640bb1e..0000000000 --- a/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE billing_subscriptions ADD COLUMN stripe_cancel_at TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql b/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql deleted file mode 100644 index 43fa0e7bbd..0000000000 --- a/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD accepted_tos_at TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql b/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql deleted file mode 100644 index a5f713ef7c..0000000000 --- a/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "users" ADD COLUMN "github_user_created_at" TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql b/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql deleted file mode 100644 index a56c87b97a..0000000000 --- a/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql +++ /dev/null @@ -1 +0,0 @@ -alter table feature_flags add column enabled_for_all boolean not null default false; diff --git a/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql b/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql deleted file mode 100644 index 3b418f7e26..0000000000 --- a/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table users alter column github_user_id set not null; - -drop index index_users_on_github_user_id; -create unique index uix_users_on_github_user_id on users (github_user_id); diff --git a/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql b/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql deleted file mode 100644 index af6fdac19d..0000000000 --- a/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "is_fifo" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20241002120231_add_local_settings_kind.sql b/crates/collab/migrations/20241002120231_add_local_settings_kind.sql deleted file mode 100644 index aec4ffb8f8..0000000000 --- a/crates/collab/migrations/20241002120231_add_local_settings_kind.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "worktree_settings_files" ADD COLUMN "kind" VARCHAR; diff --git a/crates/collab/migrations/20241009190639_add_billing_preferences.sql b/crates/collab/migrations/20241009190639_add_billing_preferences.sql deleted file mode 100644 index 9aa5a1a303..0000000000 --- a/crates/collab/migrations/20241009190639_add_billing_preferences.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table if not exists billing_preferences ( - id serial primary key, - created_at timestamp without time zone not null default now(), - user_id integer not null references users(id) on delete cascade, - max_monthly_llm_usage_spending_in_cents integer not null -); - -create unique index "uix_billing_preferences_on_user_id" on billing_preferences (user_id); diff --git a/crates/collab/migrations/20241019184824_adjust_symlink_data.sql b/crates/collab/migrations/20241019184824_adjust_symlink_data.sql deleted file mode 100644 index a38dd21cde..0000000000 --- a/crates/collab/migrations/20241019184824_adjust_symlink_data.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_entries ADD COLUMN canonical_path text; -ALTER TABLE worktree_entries ALTER COLUMN is_symlink SET DEFAULT false; diff --git a/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql b/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql deleted file mode 100644 index 60a9bfa910..0000000000 --- a/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql +++ /dev/null @@ -1 +0,0 @@ -alter table users add column custom_llm_monthly_allowance_in_cents integer; diff --git a/crates/collab/migrations/20241023201725_remove_dev_servers.sql b/crates/collab/migrations/20241023201725_remove_dev_servers.sql deleted file mode 100644 index c5da673a29..0000000000 --- a/crates/collab/migrations/20241023201725_remove_dev_servers.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE projects DROP COLUMN dev_server_project_id; -ALTER TABLE projects DROP COLUMN hosted_project_id; - -DROP TABLE hosted_projects; -DROP TABLE dev_server_projects; -DROP TABLE dev_servers; diff --git a/crates/collab/migrations/20241121185750_add_breakpoints.sql b/crates/collab/migrations/20241121185750_add_breakpoints.sql deleted file mode 100644 index 4b30714573..0000000000 --- a/crates/collab/migrations/20241121185750_add_breakpoints.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS "breakpoints" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "position" INTEGER NOT NULL, - "log_message" TEXT NULL, - "worktree_id" BIGINT NOT NULL, - "path" TEXT NOT NULL, - "kind" VARCHAR NOT NULL -); - -CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); diff --git a/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql b/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql deleted file mode 100644 index 31686f56bb..0000000000 --- a/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions -add column stripe_cancellation_reason text; diff --git a/crates/collab/migrations/20250113230049_expand_git_status_information.sql b/crates/collab/migrations/20250113230049_expand_git_status_information.sql deleted file mode 100644 index eada39fe30..0000000000 --- a/crates/collab/migrations/20250113230049_expand_git_status_information.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE worktree_repository_statuses -ADD COLUMN status_kind INTEGER, -ADD COLUMN first_status INTEGER, -ADD COLUMN second_status INTEGER; - -UPDATE worktree_repository_statuses -SET - status_kind = 0; - -ALTER TABLE worktree_repository_statuses -ALTER COLUMN status_kind -SET - NOT NULL; diff --git a/crates/collab/migrations/20250117100620_add_user_name.sql b/crates/collab/migrations/20250117100620_add_user_name.sql deleted file mode 100644 index fff7f95b60..0000000000 --- a/crates/collab/migrations/20250117100620_add_user_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN name TEXT; diff --git a/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql b/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql deleted file mode 100644 index 07c4030399..0000000000 --- a/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers -add column has_overdue_invoices bool not null default false; diff --git a/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql b/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql deleted file mode 100644 index 50dcb0508f..0000000000 --- a/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql +++ /dev/null @@ -1,10 +0,0 @@ -alter table extension_versions -add column provides_themes bool not null default false, -add column provides_icon_themes bool not null default false, -add column provides_languages bool not null default false, -add column provides_grammars bool not null default false, -add column provides_language_servers bool not null default false, -add column provides_context_servers bool not null default false, -add column provides_slash_commands bool not null default false, -add column provides_indexed_docs_providers bool not null default false, -add column provides_snippets bool not null default false; diff --git a/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql b/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql deleted file mode 100644 index e6e0770bba..0000000000 --- a/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_repositories -ADD COLUMN current_merge_conflicts VARCHAR NULL; diff --git a/crates/collab/migrations/20250210223746_add_branch_summary.sql b/crates/collab/migrations/20250210223746_add_branch_summary.sql deleted file mode 100644 index 3294f38b94..0000000000 --- a/crates/collab/migrations/20250210223746_add_branch_summary.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_repositories -ADD COLUMN worktree_repositories VARCHAR NULL; diff --git a/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql b/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql deleted file mode 100644 index d7e3c04e2f..0000000000 --- a/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE worktree_repositories ADD COLUMN branch_summary TEXT NULL; diff --git a/crates/collab/migrations/20250319182812_create_project_repositories.sql b/crates/collab/migrations/20250319182812_create_project_repositories.sql deleted file mode 100644 index 8ca8c3444e..0000000000 --- a/crates/collab/migrations/20250319182812_create_project_repositories.sql +++ /dev/null @@ -1,32 +0,0 @@ -CREATE TABLE "project_repositories" ( - "project_id" INTEGER NOT NULL, - "abs_path" VARCHAR, - "id" INT8 NOT NULL, - "legacy_worktree_id" INT8, - "entry_ids" VARCHAR, - "branch" VARCHAR, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - "current_merge_conflicts" VARCHAR, - "branch_summary" VARCHAR, - PRIMARY KEY (project_id, id) -); - -CREATE INDEX "index_project_repositories_on_project_id" ON "project_repositories" ("project_id"); - -CREATE TABLE "project_repository_statuses" ( - "project_id" INTEGER NOT NULL, - "repository_id" INT8 NOT NULL, - "repo_path" VARCHAR NOT NULL, - "status" INT8 NOT NULL, - "status_kind" INT4 NOT NULL, - "first_status" INT4 NULL, - "second_status" INT4 NULL, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY (project_id, repository_id, repo_path) -); - -CREATE INDEX "index_project_repos_statuses_on_project_id" ON "project_repository_statuses" ("project_id"); - -CREATE INDEX "index_project_repos_statuses_on_project_id_and_repo_id" ON "project_repository_statuses" ("project_id", "repository_id"); diff --git a/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql b/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql deleted file mode 100644 index b91431b28b..0000000000 --- a/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table billing_subscriptions - add column kind text, - add column stripe_current_period_start bigint, - add column stripe_current_period_end bigint; diff --git a/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql b/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql deleted file mode 100644 index 34a159cf65..0000000000 --- a/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column trial_started_at timestamp without time zone; diff --git a/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql b/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql deleted file mode 100644 index c37fed2242..0000000000 --- a/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table project_repositories - add column head_commit_details varchar; diff --git a/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql b/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql deleted file mode 100644 index 86e35c9202..0000000000 --- a/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_preferences - add column model_request_overages_enabled bool not null default false, - add column model_request_overages_spend_limit_in_cents integer not null default 0; diff --git a/crates/collab/migrations/20250530175450_add_channel_order.sql b/crates/collab/migrations/20250530175450_add_channel_order.sql deleted file mode 100644 index 977a4611cd..0000000000 --- a/crates/collab/migrations/20250530175450_add_channel_order.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Add channel_order column to channels table with default value -ALTER TABLE channels ADD COLUMN channel_order INTEGER NOT NULL DEFAULT 1; - --- Update channel_order for existing channels using ROW_NUMBER for deterministic ordering -UPDATE channels -SET channel_order = ( - SELECT ROW_NUMBER() OVER ( - PARTITION BY parent_path - ORDER BY name, id - ) - FROM channels c2 - WHERE c2.id = channels.id -); - --- Create index for efficient ordering queries -CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order"); diff --git a/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql b/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql deleted file mode 100644 index 73876e8965..0000000000 --- a/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table project_collaborators - add column committer_name varchar; -alter table project_collaborators - add column committer_email varchar; diff --git a/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql b/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql deleted file mode 100644 index 8455a82f9e..0000000000 --- a/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table extension_versions -add column provides_debug_adapters bool not null default false 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 deleted file mode 100644 index 6d898c4811..0000000000 --- a/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index ae0ffe24f6..0000000000 --- a/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql +++ /dev/null @@ -1,3 +0,0 @@ -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/migrations/20250804080620_language_server_capabilities.sql b/crates/collab/migrations/20250804080620_language_server_capabilities.sql deleted file mode 100644 index f74f094ed2..0000000000 --- a/crates/collab/migrations/20250804080620_language_server_capabilities.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE language_servers - ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}'; - -ALTER TABLE language_servers - ALTER COLUMN capabilities DROP DEFAULT; diff --git a/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql b/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql deleted file mode 100644 index e372723d6d..0000000000 --- a/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table users -alter column admin set not null; diff --git a/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql b/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql deleted file mode 100644 index ea5e4de52a..0000000000 --- a/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column orb_customer_id text; diff --git a/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql b/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql deleted file mode 100644 index f51a33ed30..0000000000 --- a/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql +++ /dev/null @@ -1 +0,0 @@ -drop table rate_buckets; diff --git a/crates/collab/migrations/20250818192156_add_git_merge_message.sql b/crates/collab/migrations/20250818192156_add_git_merge_message.sql deleted file mode 100644 index 335ea2f824..0000000000 --- a/crates/collab/migrations/20250818192156_add_git_merge_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "project_repositories" ADD COLUMN "merge_message" VARCHAR; diff --git a/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql b/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql deleted file mode 100644 index 317f6a7653..0000000000 --- a/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions - add column orb_subscription_id text; diff --git a/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql b/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql deleted file mode 100644 index cf3b79da60..0000000000 --- a/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_subscriptions - alter column stripe_subscription_id drop not null, - alter column stripe_subscription_status drop not null; diff --git a/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql b/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql deleted file mode 100644 index 89a42ab82b..0000000000 --- a/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table billing_subscriptions - add column orb_subscription_status text, - add column orb_current_billing_period_start_date timestamp without time zone, - add column orb_current_billing_period_end_date timestamp without time zone; diff --git a/crates/collab/migrations/20250827084812_worktree_in_servers.sql b/crates/collab/migrations/20250827084812_worktree_in_servers.sql deleted file mode 100644 index d4c6ffbbcc..0000000000 --- a/crates/collab/migrations/20250827084812_worktree_in_servers.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE language_servers - ADD COLUMN worktree_id BIGINT; diff --git a/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql b/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql deleted file mode 100644 index 5614423742..0000000000 --- a/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions - add column orb_cancellation_date timestamp without time zone; diff --git a/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql b/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql deleted file mode 100644 index 2de0574041..0000000000 --- a/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column orb_portal_url text; diff --git a/crates/collab/migrations/20250916173002_add_path_style_to_project.sql b/crates/collab/migrations/20250916173002_add_path_style_to_project.sql deleted file mode 100644 index b1244818f1..0000000000 --- a/crates/collab/migrations/20250916173002_add_path_style_to_project.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE projects ADD COLUMN windows_paths BOOLEAN DEFAULT FALSE; diff --git a/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql b/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql deleted file mode 100644 index ccae01e283..0000000000 --- a/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_subscriptions - add column token_spend_in_cents integer, - add column token_spend_in_cents_updated_at timestamp without time zone; diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql new file mode 100644 index 0000000000..ed9c9d16db --- /dev/null +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -0,0 +1,899 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; + +CREATE TABLE public.access_tokens ( + id integer NOT NULL, + user_id integer, + hash character varying(128), + impersonated_user_id integer +); + +CREATE SEQUENCE public.access_tokens_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.access_tokens_id_seq OWNED BY public.access_tokens.id; + +CREATE TABLE public.breakpoints ( + id integer NOT NULL, + project_id integer NOT NULL, + "position" integer NOT NULL, + log_message text, + worktree_id bigint NOT NULL, + path text NOT NULL, + kind character varying NOT NULL +); + +CREATE SEQUENCE public.breakpoints_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.breakpoints_id_seq OWNED BY public.breakpoints.id; + +CREATE TABLE public.buffer_operations ( + buffer_id integer NOT NULL, + epoch integer NOT NULL, + replica_id integer NOT NULL, + lamport_timestamp integer NOT NULL, + value bytea NOT NULL +); + +CREATE TABLE public.buffer_snapshots ( + buffer_id integer NOT NULL, + epoch integer NOT NULL, + text text NOT NULL, + operation_serialization_version integer NOT NULL +); + +CREATE TABLE public.buffers ( + id integer NOT NULL, + channel_id integer NOT NULL, + epoch integer DEFAULT 0 NOT NULL, + latest_operation_epoch integer, + latest_operation_lamport_timestamp integer, + latest_operation_replica_id integer +); + +CREATE SEQUENCE public.buffers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.buffers_id_seq OWNED BY public.buffers.id; + +CREATE TABLE public.channel_buffer_collaborators ( + id integer NOT NULL, + channel_id integer NOT NULL, + connection_id integer NOT NULL, + connection_server_id integer NOT NULL, + connection_lost boolean DEFAULT false NOT NULL, + user_id integer NOT NULL, + replica_id integer NOT NULL +); + +CREATE SEQUENCE public.channel_buffer_collaborators_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_buffer_collaborators_id_seq OWNED BY public.channel_buffer_collaborators.id; + +CREATE TABLE public.channel_chat_participants ( + id integer NOT NULL, + user_id integer NOT NULL, + channel_id integer NOT NULL, + connection_id integer NOT NULL, + connection_server_id integer NOT NULL +); + +CREATE SEQUENCE public.channel_chat_participants_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_chat_participants_id_seq OWNED BY public.channel_chat_participants.id; + +CREATE TABLE public.channel_members ( + id integer NOT NULL, + channel_id integer NOT NULL, + user_id integer NOT NULL, + accepted boolean DEFAULT false NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + role text NOT NULL +); + +CREATE SEQUENCE public.channel_members_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_members_id_seq OWNED BY public.channel_members.id; + +CREATE TABLE public.channels ( + id integer NOT NULL, + name character varying NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + visibility text DEFAULT 'members'::text NOT NULL, + parent_path text NOT NULL, + requires_zed_cla boolean DEFAULT false NOT NULL, + channel_order integer DEFAULT 1 NOT NULL +); + +CREATE SEQUENCE public.channels_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channels_id_seq OWNED BY public.channels.id; + +CREATE TABLE public.contacts ( + id integer NOT NULL, + user_id_a integer NOT NULL, + user_id_b integer NOT NULL, + a_to_b boolean NOT NULL, + should_notify boolean NOT NULL, + accepted boolean NOT NULL +); + +CREATE SEQUENCE public.contacts_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.contacts_id_seq OWNED BY public.contacts.id; + +CREATE TABLE public.contributors ( + user_id integer NOT NULL, + signed_at timestamp without time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.extension_versions ( + extension_id integer NOT NULL, + version text NOT NULL, + published_at timestamp without time zone DEFAULT now() NOT NULL, + authors text NOT NULL, + repository text NOT NULL, + description text NOT NULL, + download_count bigint DEFAULT 0 NOT NULL, + schema_version integer DEFAULT 0 NOT NULL, + wasm_api_version text, + provides_themes boolean DEFAULT false NOT NULL, + provides_icon_themes boolean DEFAULT false NOT NULL, + provides_languages boolean DEFAULT false NOT NULL, + provides_grammars boolean DEFAULT false NOT NULL, + provides_language_servers boolean DEFAULT false NOT NULL, + provides_context_servers boolean DEFAULT false NOT NULL, + provides_slash_commands boolean DEFAULT false NOT NULL, + provides_indexed_docs_providers boolean DEFAULT false NOT NULL, + provides_snippets boolean DEFAULT false NOT NULL, + provides_debug_adapters boolean DEFAULT false NOT NULL, + provides_agent_servers boolean DEFAULT false NOT NULL +); + +CREATE TABLE public.extensions ( + id integer NOT NULL, + name text NOT NULL, + external_id text NOT NULL, + latest_version text NOT NULL, + total_download_count bigint DEFAULT 0 NOT NULL +); + +CREATE SEQUENCE public.extensions_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.extensions_id_seq OWNED BY public.extensions.id; + +CREATE TABLE public.feature_flags ( + id integer NOT NULL, + flag character varying(255) NOT NULL, + enabled_for_all boolean DEFAULT false NOT NULL +); + +CREATE SEQUENCE public.feature_flags_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.feature_flags_id_seq OWNED BY public.feature_flags.id; + +CREATE TABLE public.followers ( + id integer NOT NULL, + room_id integer NOT NULL, + project_id integer NOT NULL, + leader_connection_server_id integer NOT NULL, + leader_connection_id integer NOT NULL, + follower_connection_server_id integer NOT NULL, + follower_connection_id integer NOT NULL +); + +CREATE SEQUENCE public.followers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.followers_id_seq OWNED BY public.followers.id; + +CREATE TABLE public.language_servers ( + project_id integer NOT NULL, + id bigint NOT NULL, + name character varying NOT NULL, + capabilities text NOT NULL, + worktree_id bigint +); + +CREATE TABLE public.notification_kinds ( + id integer NOT NULL, + name character varying NOT NULL +); + +CREATE SEQUENCE public.notification_kinds_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.notification_kinds_id_seq OWNED BY public.notification_kinds.id; + +CREATE TABLE public.notifications ( + id integer NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + recipient_id integer NOT NULL, + kind integer NOT NULL, + entity_id integer, + content text, + is_read boolean DEFAULT false NOT NULL, + response boolean +); + +CREATE SEQUENCE public.notifications_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.notifications_id_seq OWNED BY public.notifications.id; + +CREATE TABLE public.observed_buffer_edits ( + user_id integer NOT NULL, + buffer_id integer NOT NULL, + epoch integer NOT NULL, + lamport_timestamp integer NOT NULL, + replica_id integer NOT NULL +); + +CREATE TABLE public.project_collaborators ( + id integer NOT NULL, + project_id integer NOT NULL, + connection_id integer NOT NULL, + user_id integer NOT NULL, + replica_id integer NOT NULL, + is_host boolean NOT NULL, + connection_server_id integer NOT NULL, + committer_name character varying, + committer_email character varying +); + +CREATE SEQUENCE public.project_collaborators_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.project_collaborators_id_seq OWNED BY public.project_collaborators.id; + +CREATE TABLE public.project_repositories ( + project_id integer NOT NULL, + abs_path character varying, + id bigint NOT NULL, + legacy_worktree_id bigint, + entry_ids character varying, + branch character varying, + scan_id bigint NOT NULL, + is_deleted boolean NOT NULL, + current_merge_conflicts character varying, + branch_summary character varying, + head_commit_details character varying, + merge_message character varying +); + +CREATE TABLE public.project_repository_statuses ( + project_id integer NOT NULL, + repository_id bigint NOT NULL, + repo_path character varying NOT NULL, + status bigint NOT NULL, + status_kind integer NOT NULL, + first_status integer, + second_status integer, + scan_id bigint NOT NULL, + is_deleted boolean NOT NULL +); + +CREATE TABLE public.projects ( + id integer NOT NULL, + host_user_id integer, + unregistered boolean DEFAULT false NOT NULL, + room_id integer, + host_connection_id integer, + host_connection_server_id integer, + windows_paths boolean DEFAULT false +); + +CREATE SEQUENCE public.projects_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.projects_id_seq OWNED BY public.projects.id; + +CREATE TABLE public.room_participants ( + id integer NOT NULL, + room_id integer NOT NULL, + user_id integer NOT NULL, + answering_connection_id integer, + location_kind integer, + location_project_id integer, + initial_project_id integer, + calling_user_id integer NOT NULL, + calling_connection_id integer NOT NULL, + answering_connection_lost boolean DEFAULT false NOT NULL, + answering_connection_server_id integer, + calling_connection_server_id integer, + participant_index integer, + role text +); + +CREATE SEQUENCE public.room_participants_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.room_participants_id_seq OWNED BY public.room_participants.id; + +CREATE TABLE public.rooms ( + id integer NOT NULL, + live_kit_room character varying NOT NULL, + channel_id integer +); + +CREATE SEQUENCE public.rooms_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.rooms_id_seq OWNED BY public.rooms.id; + +CREATE TABLE public.servers ( + id integer NOT NULL, + environment character varying NOT NULL +); + +CREATE SEQUENCE public.servers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.servers_id_seq OWNED BY public.servers.id; + +CREATE TABLE public.user_features ( + user_id integer NOT NULL, + feature_id integer NOT NULL +); + +CREATE TABLE public.users ( + id integer NOT NULL, + github_login character varying, + admin boolean NOT NULL, + email_address character varying(255) DEFAULT NULL::character varying, + invite_code character varying(64), + invite_count integer DEFAULT 0 NOT NULL, + inviter_id integer, + connected_once boolean DEFAULT false NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + github_user_id integer NOT NULL, + metrics_id uuid DEFAULT gen_random_uuid() NOT NULL, + accepted_tos_at timestamp without time zone, + github_user_created_at timestamp without time zone, + custom_llm_monthly_allowance_in_cents integer, + name text +); + +CREATE SEQUENCE public.users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + +CREATE TABLE public.worktree_diagnostic_summaries ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + path character varying NOT NULL, + language_server_id bigint NOT NULL, + error_count integer NOT NULL, + warning_count integer NOT NULL +); + +CREATE TABLE public.worktree_entries ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + id bigint NOT NULL, + is_dir boolean NOT NULL, + path character varying NOT NULL, + inode bigint NOT NULL, + mtime_seconds bigint NOT NULL, + mtime_nanos integer NOT NULL, + is_symlink boolean DEFAULT false NOT NULL, + is_ignored boolean NOT NULL, + scan_id bigint, + is_deleted boolean, + git_status bigint, + is_external boolean DEFAULT false NOT NULL, + is_fifo boolean DEFAULT false NOT NULL, + canonical_path text, + is_hidden boolean DEFAULT false NOT NULL +); + +CREATE TABLE public.worktree_settings_files ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + path character varying NOT NULL, + content text NOT NULL, + kind character varying +); + +CREATE TABLE public.worktrees ( + project_id integer NOT NULL, + id bigint NOT NULL, + root_name character varying NOT NULL, + abs_path character varying NOT NULL, + visible boolean NOT NULL, + scan_id bigint NOT NULL, + is_complete boolean DEFAULT false NOT NULL, + completed_scan_id bigint +); + +ALTER TABLE ONLY public.access_tokens ALTER COLUMN id SET DEFAULT nextval('public.access_tokens_id_seq'::regclass); + +ALTER TABLE ONLY public.breakpoints ALTER COLUMN id SET DEFAULT nextval('public.breakpoints_id_seq'::regclass); + +ALTER TABLE ONLY public.buffers ALTER COLUMN id SET DEFAULT nextval('public.buffers_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_buffer_collaborators ALTER COLUMN id SET DEFAULT nextval('public.channel_buffer_collaborators_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_chat_participants ALTER COLUMN id SET DEFAULT nextval('public.channel_chat_participants_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_members ALTER COLUMN id SET DEFAULT nextval('public.channel_members_id_seq'::regclass); + +ALTER TABLE ONLY public.channels ALTER COLUMN id SET DEFAULT nextval('public.channels_id_seq'::regclass); + +ALTER TABLE ONLY public.contacts ALTER COLUMN id SET DEFAULT nextval('public.contacts_id_seq'::regclass); + +ALTER TABLE ONLY public.extensions ALTER COLUMN id SET DEFAULT nextval('public.extensions_id_seq'::regclass); + +ALTER TABLE ONLY public.feature_flags ALTER COLUMN id SET DEFAULT nextval('public.feature_flags_id_seq'::regclass); + +ALTER TABLE ONLY public.followers ALTER COLUMN id SET DEFAULT nextval('public.followers_id_seq'::regclass); + +ALTER TABLE ONLY public.notification_kinds ALTER COLUMN id SET DEFAULT nextval('public.notification_kinds_id_seq'::regclass); + +ALTER TABLE ONLY public.notifications ALTER COLUMN id SET DEFAULT nextval('public.notifications_id_seq'::regclass); + +ALTER TABLE ONLY public.project_collaborators ALTER COLUMN id SET DEFAULT nextval('public.project_collaborators_id_seq'::regclass); + +ALTER TABLE ONLY public.projects ALTER COLUMN id SET DEFAULT nextval('public.projects_id_seq'::regclass); + +ALTER TABLE ONLY public.room_participants ALTER COLUMN id SET DEFAULT nextval('public.room_participants_id_seq'::regclass); + +ALTER TABLE ONLY public.rooms ALTER COLUMN id SET DEFAULT nextval('public.rooms_id_seq'::regclass); + +ALTER TABLE ONLY public.servers ALTER COLUMN id SET DEFAULT nextval('public.servers_id_seq'::regclass); + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + +ALTER TABLE ONLY public.access_tokens + ADD CONSTRAINT access_tokens_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.breakpoints + ADD CONSTRAINT breakpoints_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.buffer_operations + ADD CONSTRAINT buffer_operations_pkey PRIMARY KEY (buffer_id, epoch, lamport_timestamp, replica_id); + +ALTER TABLE ONLY public.buffer_snapshots + ADD CONSTRAINT buffer_snapshots_pkey PRIMARY KEY (buffer_id, epoch); + +ALTER TABLE ONLY public.buffers + ADD CONSTRAINT buffers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channels + ADD CONSTRAINT channels_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.contributors + ADD CONSTRAINT contributors_pkey PRIMARY KEY (user_id); + +ALTER TABLE ONLY public.extension_versions + ADD CONSTRAINT extension_versions_pkey PRIMARY KEY (extension_id, version); + +ALTER TABLE ONLY public.extensions + ADD CONSTRAINT extensions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.feature_flags + ADD CONSTRAINT feature_flags_flag_key UNIQUE (flag); + +ALTER TABLE ONLY public.feature_flags + ADD CONSTRAINT feature_flags_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.language_servers + ADD CONSTRAINT language_servers_pkey PRIMARY KEY (project_id, id); + +ALTER TABLE ONLY public.notification_kinds + ADD CONSTRAINT notification_kinds_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_pkey PRIMARY KEY (user_id, buffer_id); + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.project_repositories + ADD CONSTRAINT project_repositories_pkey PRIMARY KEY (project_id, id); + +ALTER TABLE ONLY public.project_repository_statuses + ADD CONSTRAINT project_repository_statuses_pkey PRIMARY KEY (project_id, repository_id, repo_path); + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.rooms + ADD CONSTRAINT rooms_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.servers + ADD CONSTRAINT servers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_pkey PRIMARY KEY (user_id, feature_id); + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.worktree_diagnostic_summaries + ADD CONSTRAINT worktree_diagnostic_summaries_pkey PRIMARY KEY (project_id, worktree_id, path); + +ALTER TABLE ONLY public.worktree_entries + ADD CONSTRAINT worktree_entries_pkey PRIMARY KEY (project_id, worktree_id, id); + +ALTER TABLE ONLY public.worktree_settings_files + ADD CONSTRAINT worktree_settings_files_pkey PRIMARY KEY (project_id, worktree_id, path); + +ALTER TABLE ONLY public.worktrees + ADD CONSTRAINT worktrees_pkey PRIMARY KEY (project_id, id); + +CREATE INDEX index_access_tokens_user_id ON public.access_tokens USING btree (user_id); + +CREATE INDEX index_breakpoints_on_project_id ON public.breakpoints USING btree (project_id); + +CREATE INDEX index_buffers_on_channel_id ON public.buffers USING btree (channel_id); + +CREATE INDEX index_channel_buffer_collaborators_on_channel_id ON public.channel_buffer_collaborators USING btree (channel_id); + +CREATE UNIQUE INDEX index_channel_buffer_collaborators_on_channel_id_and_replica_id ON public.channel_buffer_collaborators USING btree (channel_id, replica_id); + +CREATE UNIQUE INDEX index_channel_buffer_collaborators_on_channel_id_connection_id_ ON public.channel_buffer_collaborators USING btree (channel_id, connection_id, connection_server_id); + +CREATE INDEX index_channel_buffer_collaborators_on_connection_id ON public.channel_buffer_collaborators USING btree (connection_id); + +CREATE INDEX index_channel_buffer_collaborators_on_connection_server_id ON public.channel_buffer_collaborators USING btree (connection_server_id); + +CREATE INDEX index_channel_chat_participants_on_channel_id ON public.channel_chat_participants USING btree (channel_id); + +CREATE UNIQUE INDEX index_channel_members_on_channel_id_and_user_id ON public.channel_members USING btree (channel_id, user_id); + +CREATE INDEX index_channels_on_parent_path ON public.channels USING btree (parent_path text_pattern_ops); + +CREATE INDEX index_channels_on_parent_path_and_order ON public.channels USING btree (parent_path, channel_order); + +CREATE INDEX index_contacts_user_id_b ON public.contacts USING btree (user_id_b); + +CREATE UNIQUE INDEX index_contacts_user_ids ON public.contacts USING btree (user_id_a, user_id_b); + +CREATE UNIQUE INDEX index_extensions_external_id ON public.extensions USING btree (external_id); + +CREATE INDEX index_extensions_total_download_count ON public.extensions USING btree (total_download_count); + +CREATE UNIQUE INDEX index_feature_flags ON public.feature_flags USING btree (id); + +CREATE UNIQUE INDEX index_followers_on_project_id_and_leader_connection_server_id_a ON public.followers USING btree (project_id, leader_connection_server_id, leader_connection_id, follower_connection_server_id, follower_connection_id); + +CREATE INDEX index_followers_on_room_id ON public.followers USING btree (room_id); + +CREATE UNIQUE INDEX index_invite_code_users ON public.users USING btree (invite_code); + +CREATE INDEX index_language_servers_on_project_id ON public.language_servers USING btree (project_id); + +CREATE UNIQUE INDEX index_notification_kinds_on_name ON public.notification_kinds USING btree (name); + +CREATE INDEX index_notifications_on_recipient_id_is_read_kind_entity_id ON public.notifications USING btree (recipient_id, is_read, kind, entity_id); + +CREATE UNIQUE INDEX index_observed_buffer_user_and_buffer_id ON public.observed_buffer_edits USING btree (user_id, buffer_id); + +CREATE INDEX index_project_collaborators_on_connection_id ON public.project_collaborators USING btree (connection_id); + +CREATE INDEX index_project_collaborators_on_connection_server_id ON public.project_collaborators USING btree (connection_server_id); + +CREATE INDEX index_project_collaborators_on_project_id ON public.project_collaborators USING btree (project_id); + +CREATE UNIQUE INDEX index_project_collaborators_on_project_id_and_replica_id ON public.project_collaborators USING btree (project_id, replica_id); + +CREATE UNIQUE INDEX index_project_collaborators_on_project_id_connection_id_and_ser ON public.project_collaborators USING btree (project_id, connection_id, connection_server_id); + +CREATE INDEX index_project_repos_statuses_on_project_id ON public.project_repository_statuses USING btree (project_id); + +CREATE INDEX index_project_repos_statuses_on_project_id_and_repo_id ON public.project_repository_statuses USING btree (project_id, repository_id); + +CREATE INDEX index_project_repositories_on_project_id ON public.project_repositories USING btree (project_id); + +CREATE INDEX index_projects_on_host_connection_id_and_host_connection_server ON public.projects USING btree (host_connection_id, host_connection_server_id); + +CREATE INDEX index_projects_on_host_connection_server_id ON public.projects USING btree (host_connection_server_id); + +CREATE INDEX index_room_participants_on_answering_connection_id ON public.room_participants USING btree (answering_connection_id); + +CREATE UNIQUE INDEX index_room_participants_on_answering_connection_id_and_answerin ON public.room_participants USING btree (answering_connection_id, answering_connection_server_id); + +CREATE INDEX index_room_participants_on_answering_connection_server_id ON public.room_participants USING btree (answering_connection_server_id); + +CREATE INDEX index_room_participants_on_calling_connection_server_id ON public.room_participants USING btree (calling_connection_server_id); + +CREATE INDEX index_room_participants_on_room_id ON public.room_participants USING btree (room_id); + +CREATE UNIQUE INDEX index_room_participants_on_user_id ON public.room_participants USING btree (user_id); + +CREATE UNIQUE INDEX index_rooms_on_channel_id ON public.rooms USING btree (channel_id); + +CREATE INDEX index_settings_files_on_project_id ON public.worktree_settings_files USING btree (project_id); + +CREATE INDEX index_settings_files_on_project_id_and_wt_id ON public.worktree_settings_files USING btree (project_id, worktree_id); + +CREATE INDEX index_user_features_on_feature_id ON public.user_features USING btree (feature_id); + +CREATE INDEX index_user_features_on_user_id ON public.user_features USING btree (user_id); + +CREATE UNIQUE INDEX index_user_features_user_id_and_feature_id ON public.user_features USING btree (user_id, feature_id); + +CREATE UNIQUE INDEX index_users_github_login ON public.users USING btree (github_login); + +CREATE INDEX index_users_on_email_address ON public.users USING btree (email_address); + +CREATE INDEX index_worktree_diagnostic_summaries_on_project_id ON public.worktree_diagnostic_summaries USING btree (project_id); + +CREATE INDEX index_worktree_diagnostic_summaries_on_project_id_and_worktree_ ON public.worktree_diagnostic_summaries USING btree (project_id, worktree_id); + +CREATE INDEX index_worktree_entries_on_project_id ON public.worktree_entries USING btree (project_id); + +CREATE INDEX index_worktree_entries_on_project_id_and_worktree_id ON public.worktree_entries USING btree (project_id, worktree_id); + +CREATE INDEX index_worktrees_on_project_id ON public.worktrees USING btree (project_id); + +CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name public.gin_trgm_ops); + +CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops); + +CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text)); + +CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id); + +ALTER TABLE ONLY public.access_tokens + ADD CONSTRAINT access_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.breakpoints + ADD CONSTRAINT breakpoints_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffer_operations + ADD CONSTRAINT buffer_operations_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffer_snapshots + ADD CONSTRAINT buffer_snapshots_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffers + ADD CONSTRAINT buffers_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_user_id_a_fkey FOREIGN KEY (user_id_a) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contributors + ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.extension_versions + ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id); + +ALTER TABLE ONLY public.project_repositories + ADD CONSTRAINT fk_project_repositories_project_id FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_repository_statuses + ADD CONSTRAINT fk_project_repository_statuses_project_id FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_follower_connection_server_id_fkey FOREIGN KEY (follower_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_leader_connection_server_id_fkey FOREIGN KEY (leader_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.language_servers + ADD CONSTRAINT language_servers_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_kind_fkey FOREIGN KEY (kind) REFERENCES public.notification_kinds(id); + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_recipient_id_fkey FOREIGN KEY (recipient_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_host_connection_server_id_fkey FOREIGN KEY (host_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_host_user_id_fkey FOREIGN KEY (host_user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_answering_connection_server_id_fkey FOREIGN KEY (answering_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_calling_connection_server_id_fkey FOREIGN KEY (calling_connection_server_id) REFERENCES public.servers(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_calling_user_id_fkey FOREIGN KEY (calling_user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.rooms + ADD CONSTRAINT rooms_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_feature_id_fkey FOREIGN KEY (feature_id) REFERENCES public.feature_flags(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_inviter_id_fkey FOREIGN KEY (inviter_id) REFERENCES public.users(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.worktree_diagnostic_summaries + ADD CONSTRAINT worktree_diagnostic_summaries_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktree_entries + ADD CONSTRAINT worktree_entries_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktree_settings_files + ADD CONSTRAINT worktree_settings_files_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktrees + ADD CONSTRAINT worktrees_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; diff --git a/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql b/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql deleted file mode 100644 index b81ab7567f..0000000000 --- a/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql +++ /dev/null @@ -1,19 +0,0 @@ -create table if not exists providers ( - id serial primary key, - name text not null -); - -create unique index uix_providers_on_name on providers (name); - -create table if not exists models ( - id serial primary key, - provider_id integer not null references providers (id) on delete cascade, - name text not null, - max_requests_per_minute integer not null, - max_tokens_per_minute integer not null, - max_tokens_per_day integer not null -); - -create unique index uix_models_on_provider_id_name on models (provider_id, name); -create index ix_models_on_provider_id on models (provider_id); -create index ix_models_on_name on models (name); diff --git a/crates/collab/migrations_llm/20240806213401_create_usages.sql b/crates/collab/migrations_llm/20240806213401_create_usages.sql deleted file mode 100644 index da2245d4b9..0000000000 --- a/crates/collab/migrations_llm/20240806213401_create_usages.sql +++ /dev/null @@ -1,19 +0,0 @@ -create table usage_measures ( - id serial primary key, - name text not null -); - -create unique index uix_usage_measures_on_name on usage_measures (name); - -create table if not exists usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - measure_id integer not null references usage_measures (id) on delete cascade, - timestamp timestamp without time zone not null, - buckets bigint[] not null -); - -create index ix_usages_on_user_id on usages (user_id); -create index ix_usages_on_model_id on usages (model_id); -create unique index uix_usages_on_user_id_model_id_measure_id on usages (user_id, model_id, measure_id); diff --git a/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql b/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql deleted file mode 100644 index f1def8209a..0000000000 --- a/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE models - ALTER COLUMN max_requests_per_minute TYPE bigint, - ALTER COLUMN max_tokens_per_minute TYPE bigint, - ALTER COLUMN max_tokens_per_day TYPE bigint; diff --git a/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql b/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql deleted file mode 100644 index d9ffe2f9f2..0000000000 --- a/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE models - ADD COLUMN price_per_million_input_tokens integer NOT NULL DEFAULT 0, - ADD COLUMN price_per_million_output_tokens integer NOT NULL DEFAULT 0; diff --git a/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql b/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql deleted file mode 100644 index a50feb2e3f..0000000000 --- a/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql +++ /dev/null @@ -1 +0,0 @@ -alter table usages add column is_staff boolean not null default false; diff --git a/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql b/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql deleted file mode 100644 index 42047433e5..0000000000 --- a/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql +++ /dev/null @@ -1,9 +0,0 @@ -create table lifetime_usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - input_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create unique index uix_lifetime_usages_on_user_id_model_id on lifetime_usages (user_id, model_id); diff --git a/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql b/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql deleted file mode 100644 index c30e58a6dd..0000000000 --- a/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql +++ /dev/null @@ -1,7 +0,0 @@ -create table revoked_access_tokens ( - id serial primary key, - jti text not null, - revoked_at timestamp without time zone not null default now() -); - -create unique index uix_revoked_access_tokens_on_jti on revoked_access_tokens (jti); diff --git a/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql b/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql deleted file mode 100644 index 855e46ab02..0000000000 --- a/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql +++ /dev/null @@ -1,11 +0,0 @@ -alter table models - add column price_per_million_cache_creation_input_tokens integer not null default 0, - add column price_per_million_cache_read_input_tokens integer not null default 0; - -alter table usages - add column cache_creation_input_tokens_this_month bigint not null default 0, - add column cache_read_input_tokens_this_month bigint not null default 0; - -alter table lifetime_usages - add column cache_creation_input_tokens bigint not null default 0, - add column cache_read_input_tokens bigint not null default 0; diff --git a/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql b/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql deleted file mode 100644 index c204451b75..0000000000 --- a/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table usages - drop column cache_creation_input_tokens_this_month, - drop column cache_read_input_tokens_this_month; diff --git a/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql b/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql deleted file mode 100644 index 2733552a3a..0000000000 --- a/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql +++ /dev/null @@ -1,13 +0,0 @@ -create table monthly_usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - month integer not null, - year integer not null, - input_tokens bigint not null default 0, - cache_creation_input_tokens bigint not null default 0, - cache_read_input_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create unique index uix_monthly_usages_on_user_id_model_id_month_year on monthly_usages (user_id, model_id, month, year); diff --git a/crates/collab/migrations_llm/20241010151249_create_billing_events.sql b/crates/collab/migrations_llm/20241010151249_create_billing_events.sql deleted file mode 100644 index 74a270872e..0000000000 --- a/crates/collab/migrations_llm/20241010151249_create_billing_events.sql +++ /dev/null @@ -1,12 +0,0 @@ -create table billing_events ( - id serial primary key, - idempotency_key uuid not null default gen_random_uuid(), - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - input_tokens bigint not null default 0, - input_cache_creation_tokens bigint not null default 0, - input_cache_read_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create index uix_billing_events_on_user_id_model_id on billing_events (user_id, model_id); diff --git a/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql b/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql deleted file mode 100644 index e5c50d8385..0000000000 --- a/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table models - add column max_input_tokens_per_minute bigint not null default 0, - add column max_output_tokens_per_minute bigint not null default 0; diff --git a/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql b/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql deleted file mode 100644 index b387371058..0000000000 --- a/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql +++ /dev/null @@ -1,10 +0,0 @@ -create table subscription_usages ( - id serial primary key, - user_id integer not null, - period_start_at timestamp without time zone not null, - period_end_at timestamp without time zone not null, - model_requests int not null default 0, - edit_predictions int not null default 0 -); - -create unique index uix_subscription_usages_on_user_id_start_at_end_at on subscription_usages (user_id, period_start_at, period_end_at); diff --git a/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql b/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql deleted file mode 100644 index 8d54c8b87c..0000000000 --- a/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table subscription_usages - add column plan text not null; - -create index ix_subscription_usages_on_plan on subscription_usages (plan); diff --git a/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql b/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql deleted file mode 100644 index ded918e183..0000000000 --- a/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table subscription_usage_meters ( - id serial primary key, - subscription_usage_id integer not null references subscription_usages (id) on delete cascade, - model_id integer not null references models (id) on delete cascade, - requests integer not null default 0 -); - -create unique index uix_subscription_usage_meters_on_subscription_usage_model on subscription_usage_meters (subscription_usage_id, model_id); diff --git a/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql b/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql deleted file mode 100644 index 9d63e299f5..0000000000 --- a/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql +++ /dev/null @@ -1,6 +0,0 @@ -alter table subscription_usage_meters - add column mode text not null default 'normal'; - -drop index uix_subscription_usage_meters_on_subscription_usage_model; - -create unique index uix_subscription_usage_meters_on_subscription_usage_model_mode on subscription_usage_meters (subscription_usage_id, model_id, mode); diff --git a/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql b/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql deleted file mode 100644 index 59169d3c3e..0000000000 --- a/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql +++ /dev/null @@ -1,23 +0,0 @@ -create table subscription_usages_v2 ( - id uuid primary key, - user_id integer not null, - period_start_at timestamp without time zone not null, - period_end_at timestamp without time zone not null, - plan text not null, - model_requests int not null default 0, - edit_predictions int not null default 0 -); - -create unique index uix_subscription_usages_v2_on_user_id_start_at_end_at on subscription_usages_v2 (user_id, period_start_at, period_end_at); - -create index ix_subscription_usages_v2_on_plan on subscription_usages_v2 (plan); - -create table subscription_usage_meters_v2 ( - id uuid primary key, - subscription_usage_id uuid not null references subscription_usages_v2 (id) on delete cascade, - model_id integer not null references models (id) on delete cascade, - mode text not null, - requests integer not null default 0 -); - -create unique index uix_subscription_usage_meters_v2_on_usage_model_mode on subscription_usage_meters_v2 (subscription_usage_id, model_id, mode); diff --git a/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql b/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql deleted file mode 100644 index f06b152d7b..0000000000 --- a/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql +++ /dev/null @@ -1,2 +0,0 @@ -drop table subscription_usage_meters; -drop table subscription_usages; diff --git a/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql b/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql deleted file mode 100644 index 5f03f50d0b..0000000000 --- a/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql +++ /dev/null @@ -1,2 +0,0 @@ -drop table monthly_usages; -drop table lifetime_usages; diff --git a/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql b/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql deleted file mode 100644 index 36b79266b6..0000000000 --- a/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql +++ /dev/null @@ -1 +0,0 @@ -drop table billing_events; diff --git a/crates/collab/postgrest_app.conf b/crates/collab/postgrest_app.conf deleted file mode 100644 index 5d3b0e65b7..0000000000 --- a/crates/collab/postgrest_app.conf +++ /dev/null @@ -1,4 +0,0 @@ -db-uri = "postgres://postgres@localhost/zed" -server-port = 8081 -jwt-secret = "the-postgrest-jwt-secret-for-authorization" -log-level = "info" diff --git a/crates/collab/postgrest_llm.conf b/crates/collab/postgrest_llm.conf deleted file mode 100644 index 3a0cdfa493..0000000000 --- a/crates/collab/postgrest_llm.conf +++ /dev/null @@ -1,4 +0,0 @@ -db-uri = "postgres://postgres@localhost/zed_llm" -server-port = 8082 -jwt-secret = "the-postgrest-jwt-secret-for-authorization" -log-level = "info" diff --git a/crates/collab/src/api/contributors.rs b/crates/collab/src/api/contributors.rs index 8cfef0ad7e..e09ac4f8b7 100644 --- a/crates/collab/src/api/contributors.rs +++ b/crates/collab/src/api/contributors.rs @@ -54,6 +54,26 @@ async fn check_is_contributor( ) -> Result> { let params = params.into_contributor_selector()?; + if CopilotSweAgentBot::is_copilot_bot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + CopilotSweAgentBot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + + if Dependabot::is_dependabot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + Dependabot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + if RenovateBot::is_renovate_bot(¶ms) { return Ok(Json(CheckIsContributorResponse { signed_at: Some( @@ -64,6 +84,16 @@ async fn check_is_contributor( })); } + if ZedZippyBot::is_zed_zippy_bot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + ZedZippyBot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + Ok(Json(CheckIsContributorResponse { signed_at: app .db @@ -73,6 +103,66 @@ async fn check_is_contributor( })) } +/// The Copilot bot GitHub user (`copilot-swe-agent[bot]`). +/// +/// https://api.github.com/users/copilot-swe-agent[bot] +struct CopilotSweAgentBot; + +impl CopilotSweAgentBot { + const LOGIN: &'static str = "copilot-swe-agent[bot]"; + const USER_ID: i32 = 198982749; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2025-02-12T20:26:08Z") + .expect("failed to parse 'created_at' for 'copilot-swe-agent[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Copilot bot user. + fn is_copilot_bot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + +/// The Dependabot bot GitHub user (`dependabot[bot]`). +/// +/// https://api.github.com/users/dependabot[bot] +struct Dependabot; + +impl Dependabot { + const LOGIN: &'static str = "dependabot[bot]"; + const USER_ID: i32 = 49699333; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2019-04-16T22:34:25Z") + .expect("failed to parse 'created_at' for 'dependabot[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Dependabot bot user. + fn is_dependabot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + /// The Renovate bot GitHub user (`renovate[bot]`). /// /// https://api.github.com/users/renovate[bot] @@ -103,6 +193,36 @@ impl RenovateBot { } } +/// The Zed Zippy bot GitHub user (`zed-zippy[bot]`). +/// +/// https://api.github.com/users/zed-zippy[bot] +struct ZedZippyBot; + +impl ZedZippyBot { + const LOGIN: &'static str = "zed-zippy[bot]"; + const USER_ID: i32 = 234243425; + + /// Returns the `created_at` timestamp for the Zed Zippy bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2025-09-24T17:00:11Z") + .expect("failed to parse 'created_at' for 'zed-zippy[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Zed Zippy bot user. + fn is_zed_zippy_bot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + #[derive(Debug, Deserialize)] struct AddContributorBody { github_user_id: i32, diff --git a/crates/collab/src/api/extensions.rs b/crates/collab/src/api/extensions.rs index 1ace433db2..187b2ab279 100644 --- a/crates/collab/src/api/extensions.rs +++ b/crates/collab/src/api/extensions.rs @@ -11,7 +11,7 @@ use axum::{ }; use collections::{BTreeSet, HashMap}; use rpc::{ExtensionApiManifest, ExtensionProvides, GetExtensionsResponse}; -use semantic_version::SemanticVersion; +use semver::Version as SemanticVersion; use serde::Deserialize; use std::str::FromStr; use std::{sync::Arc, time::Duration}; @@ -108,8 +108,8 @@ struct GetExtensionUpdatesParams { ids: String, min_schema_version: i32, max_schema_version: i32, - min_wasm_api_version: SemanticVersion, - max_wasm_api_version: SemanticVersion, + min_wasm_api_version: semver::Version, + max_wasm_api_version: semver::Version, } async fn get_extension_updates( diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 1152cb97d7..a3eceb472c 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -22,7 +22,7 @@ use sea_orm::{ entity::prelude::*, sea_query::{Alias, Expr, OnConflict}, }; -use semantic_version::SemanticVersion; +use semver::Version; use serde::{Deserialize, Serialize}; use std::ops::RangeInclusive; use std::{ @@ -671,7 +671,7 @@ pub struct NewExtensionVersion { pub struct ExtensionVersionConstraints { pub schema_versions: RangeInclusive, - pub wasm_api_versions: RangeInclusive, + pub wasm_api_versions: RangeInclusive, } impl LocalSettingsKind { diff --git a/crates/collab/src/db/queries.rs b/crates/collab/src/db/queries.rs index 7b457a5da4..db91021c22 100644 --- a/crates/collab/src/db/queries.rs +++ b/crates/collab/src/db/queries.rs @@ -5,7 +5,6 @@ pub mod buffers; pub mod channels; pub mod contacts; pub mod contributors; -pub mod embeddings; pub mod extensions; pub mod notifications; pub mod projects; diff --git a/crates/collab/src/db/queries/buffers.rs b/crates/collab/src/db/queries/buffers.rs index 2e6b4719d1..6c4cd58d13 100644 --- a/crates/collab/src/db/queries/buffers.rs +++ b/crates/collab/src/db/queries/buffers.rs @@ -62,9 +62,9 @@ impl Database { .iter() .map(|c| c.replica_id) .collect::>(); - let mut replica_id = ReplicaId(0); + let mut replica_id = ReplicaId(clock::ReplicaId::FIRST_COLLAB_ID.as_u16() as i32); while replica_ids.contains(&replica_id) { - replica_id.0 += 1; + replica_id = ReplicaId(replica_id.0 + 1); } let collaborator = channel_buffer_collaborator::ActiveModel { channel_id: ActiveValue::Set(channel_id), @@ -203,7 +203,7 @@ impl Database { while let Some(row) = rows.next().await { let row = row?; let timestamp = clock::Lamport { - replica_id: row.replica_id as u16, + replica_id: clock::ReplicaId::new(row.replica_id as u16), value: row.lamport_timestamp as u32, }; server_version.observe(timestamp); @@ -701,7 +701,11 @@ impl Database { return Ok(()); } - let mut text_buffer = text::Buffer::new(0, text::BufferId::new(1).unwrap(), base_text); + let mut text_buffer = text::Buffer::new( + clock::ReplicaId::LOCAL, + text::BufferId::new(1).unwrap(), + base_text, + ); text_buffer.apply_ops(operations.into_iter().filter_map(operation_from_wire)); let base_text = text_buffer.text(); @@ -934,7 +938,7 @@ pub fn operation_from_wire(operation: proto::Operation) -> Option Some(text::Operation::Edit(EditOperation { timestamp: clock::Lamport { - replica_id: edit.replica_id as text::ReplicaId, + replica_id: clock::ReplicaId::new(edit.replica_id as u16), value: edit.lamport_timestamp, }, version: version_from_wire(&edit.version), @@ -949,7 +953,7 @@ pub fn operation_from_wire(operation: proto::Operation) -> Option Some(text::Operation::Undo(UndoOperation { timestamp: clock::Lamport { - replica_id: undo.replica_id as text::ReplicaId, + replica_id: clock::ReplicaId::new(undo.replica_id as u16), value: undo.lamport_timestamp, }, version: version_from_wire(&undo.version), @@ -959,7 +963,7 @@ pub fn operation_from_wire(operation: proto::Operation) -> Option clock::Global { let mut version = clock::Global::new(); for entry in message { version.observe(clock::Lamport { - replica_id: entry.replica_id as text::ReplicaId, + replica_id: clock::ReplicaId::new(entry.replica_id as u16), value: entry.timestamp, }); } @@ -986,7 +990,7 @@ fn version_to_wire(version: &clock::Global) -> Vec { let mut message = Vec::new(); for entry in version.iter() { message.push(proto::VectorClockEntry { - replica_id: entry.replica_id as u32, + replica_id: entry.replica_id.as_u16() as u32, timestamp: entry.value, }); } diff --git a/crates/collab/src/db/queries/embeddings.rs b/crates/collab/src/db/queries/embeddings.rs deleted file mode 100644 index 6ae8013284..0000000000 --- a/crates/collab/src/db/queries/embeddings.rs +++ /dev/null @@ -1,94 +0,0 @@ -use super::*; -use time::Duration; -use time::OffsetDateTime; - -impl Database { - pub async fn get_embeddings( - &self, - model: &str, - digests: &[Vec], - ) -> Result, Vec>> { - self.transaction(|tx| async move { - let embeddings = { - let mut db_embeddings = embedding::Entity::find() - .filter( - embedding::Column::Model.eq(model).and( - embedding::Column::Digest - .is_in(digests.iter().map(|digest| digest.as_slice())), - ), - ) - .stream(&*tx) - .await?; - - let mut embeddings = HashMap::default(); - while let Some(db_embedding) = db_embeddings.next().await { - let db_embedding = db_embedding?; - embeddings.insert(db_embedding.digest, db_embedding.dimensions); - } - embeddings - }; - - if !embeddings.is_empty() { - let now = OffsetDateTime::now_utc(); - let retrieved_at = PrimitiveDateTime::new(now.date(), now.time()); - - embedding::Entity::update_many() - .filter( - embedding::Column::Digest - .is_in(embeddings.keys().map(|digest| digest.as_slice())), - ) - .col_expr(embedding::Column::RetrievedAt, Expr::value(retrieved_at)) - .exec(&*tx) - .await?; - } - - Ok(embeddings) - }) - .await - } - - pub async fn save_embeddings( - &self, - model: &str, - embeddings: &HashMap, Vec>, - ) -> Result<()> { - self.transaction(|tx| async move { - embedding::Entity::insert_many(embeddings.iter().map(|(digest, dimensions)| { - let now_offset_datetime = OffsetDateTime::now_utc(); - let retrieved_at = - PrimitiveDateTime::new(now_offset_datetime.date(), now_offset_datetime.time()); - - embedding::ActiveModel { - model: ActiveValue::set(model.to_string()), - digest: ActiveValue::set(digest.clone()), - dimensions: ActiveValue::set(dimensions.clone()), - retrieved_at: ActiveValue::set(retrieved_at), - } - })) - .on_conflict( - OnConflict::columns([embedding::Column::Model, embedding::Column::Digest]) - .do_nothing() - .to_owned(), - ) - .exec_without_returning(&*tx) - .await?; - Ok(()) - }) - .await - } - - pub async fn purge_old_embeddings(&self) -> Result<()> { - self.transaction(|tx| async move { - embedding::Entity::delete_many() - .filter( - embedding::Column::RetrievedAt - .lte(OffsetDateTime::now_utc() - Duration::days(60)), - ) - .exec(&*tx) - .await?; - - Ok(()) - }) - .await - } -} diff --git a/crates/collab/src/db/queries/extensions.rs b/crates/collab/src/db/queries/extensions.rs index f218ff2850..729e3de99f 100644 --- a/crates/collab/src/db/queries/extensions.rs +++ b/crates/collab/src/db/queries/extensions.rs @@ -69,7 +69,7 @@ impl Database { extensions: &[extension::Model], constraints: Option<&ExtensionVersionConstraints>, tx: &DatabaseTransaction, - ) -> Result> { + ) -> Result> { let mut versions = extension_version::Entity::find() .filter( extension_version::Column::ExtensionId @@ -79,11 +79,10 @@ impl Database { .await?; let mut max_versions = - HashMap::::default(); + HashMap::::default(); while let Some(version) = versions.next().await { let version = version?; - let Some(extension_version) = SemanticVersion::from_str(&version.version).log_err() - else { + let Some(extension_version) = Version::from_str(&version.version).log_err() else { continue; }; @@ -102,7 +101,7 @@ impl Database { } if let Some(wasm_api_version) = version.wasm_api_version.as_ref() { - if let Some(version) = SemanticVersion::from_str(wasm_api_version).log_err() { + if let Some(version) = Version::from_str(wasm_api_version).log_err() { if !constraints.wasm_api_versions.contains(&version) { continue; } @@ -255,7 +254,7 @@ impl Database { let insert = extension::Entity::insert(extension::ActiveModel { name: ActiveValue::Set(latest_version.name.clone()), - external_id: ActiveValue::Set(external_id.to_string()), + external_id: ActiveValue::Set((*external_id).to_owned()), id: ActiveValue::NotSet, latest_version: ActiveValue::Set(latest_version.version.to_string()), total_download_count: ActiveValue::NotSet, @@ -310,6 +309,9 @@ impl Database { .provides .contains(&ExtensionProvides::ContextServers), ), + provides_agent_servers: ActiveValue::Set( + version.provides.contains(&ExtensionProvides::AgentServers), + ), provides_slash_commands: ActiveValue::Set( version.provides.contains(&ExtensionProvides::SlashCommands), ), @@ -422,6 +424,10 @@ fn apply_provides_filter( condition = condition.add(extension_version::Column::ProvidesContextServers.eq(true)); } + if provides_filter.contains(&ExtensionProvides::AgentServers) { + condition = condition.add(extension_version::Column::ProvidesAgentServers.eq(true)); + } + if provides_filter.contains(&ExtensionProvides::SlashCommands) { condition = condition.add(extension_version::Column::ProvidesSlashCommands.eq(true)); } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index cc22ee99b5..e92c269b7e 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -17,7 +17,7 @@ impl Database { .any(|existing| existing.name == **kind) }) .map(|kind| notification_kind::ActiveModel { - name: ActiveValue::Set(kind.to_string()), + name: ActiveValue::Set((*kind).to_owned()), ..Default::default() }) .collect(); @@ -260,7 +260,7 @@ pub fn model_to_proto(this: &Database, row: notification::Model) -> Result>(); - let mut replica_id = ReplicaId(1); + let mut replica_id = ReplicaId(clock::ReplicaId::FIRST_COLLAB_ID.as_u16() as i32); while replica_ids.contains(&replica_id) { replica_id.0 += 1; } @@ -905,6 +915,7 @@ impl Database { canonical_path: db_entry.canonical_path, is_ignored: db_entry.is_ignored, is_external: db_entry.is_external, + is_hidden: db_entry.is_hidden, // This is only used in the summarization backlog, so if it's None, // that just means we won't be able to detect when to resummarize // based on total number of backlogged bytes - instead, we'd go @@ -998,6 +1009,8 @@ impl Database { is_last_update: true, merge_message: db_repository_entry.merge_message, stash_entries: Vec::new(), + remote_upstream_url: db_repository_entry.remote_upstream_url.clone(), + remote_origin_url: db_repository_entry.remote_origin_url.clone(), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 175361af35..eafb5cac44 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -671,6 +671,7 @@ impl Database { canonical_path: db_entry.canonical_path, is_ignored: db_entry.is_ignored, is_external: db_entry.is_external, + is_hidden: db_entry.is_hidden, // This is only used in the summarization backlog, so if it's None, // that just means we won't be able to detect when to resummarize // based on total number of backlogged bytes - instead, we'd go @@ -795,6 +796,8 @@ impl Database { is_last_update: true, merge_message: db_repository.merge_message, stash_entries: Vec::new(), + remote_upstream_url: db_repository.remote_upstream_url.clone(), + remote_origin_url: db_repository.remote_origin_url.clone(), }); } } diff --git a/crates/collab/src/db/queries/users.rs b/crates/collab/src/db/queries/users.rs index 89211130b8..79b5d227c0 100644 --- a/crates/collab/src/db/queries/users.rs +++ b/crates/collab/src/db/queries/users.rs @@ -66,40 +66,6 @@ impl Database { .await } - /// Returns all users flagged as staff. - pub async fn get_staff_users(&self) -> Result> { - self.transaction(|tx| async { - let tx = tx; - Ok(user::Entity::find() - .filter(user::Column::Admin.eq(true)) - .all(&*tx) - .await?) - }) - .await - } - - /// Returns a user by email address. There are no access checks here, so this should only be used internally. - pub async fn get_user_by_email(&self, email: &str) -> Result> { - self.transaction(|tx| async move { - Ok(user::Entity::find() - .filter(user::Column::EmailAddress.eq(email)) - .one(&*tx) - .await?) - }) - .await - } - - /// Returns a user by GitHub user ID. There are no access checks here, so this should only be used internally. - pub async fn get_user_by_github_user_id(&self, github_user_id: i32) -> Result> { - self.transaction(|tx| async move { - Ok(user::Entity::find() - .filter(user::Column::GithubUserId.eq(github_user_id)) - .one(&*tx) - .await?) - }) - .await - } - /// Returns a user by GitHub login. There are no access checks here, so this should only be used internally. pub async fn get_user_by_github_login(&self, github_login: &str) -> Result> { self.transaction(|tx| async move { @@ -270,39 +236,6 @@ impl Database { .await } - /// Sets "accepted_tos_at" on the user to the given timestamp. - pub async fn set_user_accepted_tos_at( - &self, - id: UserId, - accepted_tos_at: Option, - ) -> Result<()> { - self.transaction(|tx| async move { - user::Entity::update_many() - .filter(user::Column::Id.eq(id)) - .set(user::ActiveModel { - accepted_tos_at: ActiveValue::set(accepted_tos_at), - ..Default::default() - }) - .exec(&*tx) - .await?; - Ok(()) - }) - .await - } - - /// hard delete the user. - pub async fn destroy_user(&self, id: UserId) -> Result<()> { - self.transaction(|tx| async move { - access_token::Entity::delete_many() - .filter(access_token::Column::UserId.eq(id)) - .exec(&*tx) - .await?; - user::Entity::delete_by_id(id).exec(&*tx).await?; - Ok(()) - }) - .await - } - /// Find users where github_login ILIKE name_query. pub async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result> { self.transaction(|tx| async { @@ -341,14 +274,4 @@ impl Database { result.push('%'); result } - - pub async fn get_users_missing_github_user_created_at(&self) -> Result> { - self.transaction(|tx| async move { - Ok(user::Entity::find() - .filter(user::Column::GithubUserCreatedAt.is_null()) - .all(&*tx) - .await?) - }) - .await - } } diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index 32c4570af5..c179539e4b 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -6,11 +6,8 @@ pub mod channel; pub mod channel_buffer_collaborator; pub mod channel_chat_participant; pub mod channel_member; -pub mod channel_message; -pub mod channel_message_mention; pub mod contact; pub mod contributor; -pub mod embedding; pub mod extension; pub mod extension_version; pub mod follower; @@ -18,7 +15,6 @@ pub mod language_server; pub mod notification; pub mod notification_kind; pub mod observed_buffer_edits; -pub mod observed_channel_messages; pub mod project; pub mod project_collaborator; pub mod project_repository; @@ -26,7 +22,6 @@ pub mod project_repository_statuses; pub mod room; pub mod room_participant; pub mod server; -pub mod signup; pub mod user; pub mod worktree; pub mod worktree_diagnostic_summary; diff --git a/crates/collab/src/db/tables/channel_message.rs b/crates/collab/src/db/tables/channel_message.rs deleted file mode 100644 index 2ec776f189..0000000000 --- a/crates/collab/src/db/tables/channel_message.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::db::{ChannelId, MessageId, UserId}; -use sea_orm::entity::prelude::*; -use time::PrimitiveDateTime; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "channel_messages")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: MessageId, - pub channel_id: ChannelId, - pub sender_id: UserId, - pub body: String, - pub sent_at: PrimitiveDateTime, - pub edited_at: Option, - pub nonce: Uuid, - pub reply_to_message_id: Option, -} - -impl ActiveModelBehavior for ActiveModel {} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::channel::Entity", - from = "Column::ChannelId", - to = "super::channel::Column::Id" - )] - Channel, - #[sea_orm( - belongs_to = "super::user::Entity", - from = "Column::SenderId", - to = "super::user::Column::Id" - )] - Sender, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Channel.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Sender.def() - } -} diff --git a/crates/collab/src/db/tables/channel_message_mention.rs b/crates/collab/src/db/tables/channel_message_mention.rs deleted file mode 100644 index 6155b057f0..0000000000 --- a/crates/collab/src/db/tables/channel_message_mention.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::db::{MessageId, UserId}; -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "channel_message_mentions")] -pub struct Model { - #[sea_orm(primary_key)] - pub message_id: MessageId, - #[sea_orm(primary_key)] - pub start_offset: i32, - pub end_offset: i32, - pub user_id: UserId, -} - -impl ActiveModelBehavior for ActiveModel {} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::channel_message::Entity", - from = "Column::MessageId", - to = "super::channel_message::Column::Id" - )] - Message, - #[sea_orm( - belongs_to = "super::user::Entity", - from = "Column::UserId", - to = "super::user::Column::Id" - )] - MentionedUser, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Message.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::MentionedUser.def() - } -} diff --git a/crates/collab/src/db/tables/embedding.rs b/crates/collab/src/db/tables/embedding.rs deleted file mode 100644 index 8743b4b9e6..0000000000 --- a/crates/collab/src/db/tables/embedding.rs +++ /dev/null @@ -1,18 +0,0 @@ -use sea_orm::entity::prelude::*; -use time::PrimitiveDateTime; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "embeddings")] -pub struct Model { - #[sea_orm(primary_key)] - pub model: String, - #[sea_orm(primary_key)] - pub digest: Vec, - pub dimensions: Vec, - pub retrieved_at: PrimitiveDateTime, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tables/extension_version.rs b/crates/collab/src/db/tables/extension_version.rs index 8072624871..5e71914ddb 100644 --- a/crates/collab/src/db/tables/extension_version.rs +++ b/crates/collab/src/db/tables/extension_version.rs @@ -24,6 +24,7 @@ pub struct Model { pub provides_grammars: bool, pub provides_language_servers: bool, pub provides_context_servers: bool, + pub provides_agent_servers: bool, pub provides_slash_commands: bool, pub provides_indexed_docs_providers: bool, pub provides_snippets: bool, @@ -57,6 +58,10 @@ impl Model { provides.insert(ExtensionProvides::ContextServers); } + if self.provides_agent_servers { + provides.insert(ExtensionProvides::AgentServers); + } + if self.provides_slash_commands { provides.insert(ExtensionProvides::SlashCommands); } diff --git a/crates/collab/src/db/tables/observed_channel_messages.rs b/crates/collab/src/db/tables/observed_channel_messages.rs deleted file mode 100644 index 18259f8442..0000000000 --- a/crates/collab/src/db/tables/observed_channel_messages.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::db::{ChannelId, MessageId, UserId}; -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "observed_channel_messages")] -pub struct Model { - #[sea_orm(primary_key)] - pub user_id: UserId, - pub channel_id: ChannelId, - pub channel_message_id: MessageId, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::channel::Entity", - from = "Column::ChannelId", - to = "super::channel::Column::Id" - )] - Channel, - #[sea_orm( - belongs_to = "super::user::Entity", - from = "Column::UserId", - to = "super::user::Column::Id" - )] - User, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Channel.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::User.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tables/project_repository.rs b/crates/collab/src/db/tables/project_repository.rs index eb653ecee3..190ae8d79c 100644 --- a/crates/collab/src/db/tables/project_repository.rs +++ b/crates/collab/src/db/tables/project_repository.rs @@ -22,6 +22,8 @@ pub struct Model { pub branch_summary: Option, // A JSON object representing the current Head commit values pub head_commit_details: Option, + pub remote_upstream_url: Option, + pub remote_origin_url: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/db/tables/signup.rs b/crates/collab/src/db/tables/signup.rs deleted file mode 100644 index 79d9f0580c..0000000000 --- a/crates/collab/src/db/tables/signup.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::db::{SignupId, UserId}; -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "signups")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: SignupId, - pub email_address: String, - pub email_confirmation_code: String, - pub email_confirmation_sent: bool, - pub created_at: DateTime, - pub device_id: Option, - pub user_id: Option, - pub inviting_user_id: Option, - pub platform_mac: bool, - pub platform_linux: bool, - pub platform_windows: bool, - pub platform_unknown: bool, - pub editor_features: Option>, - pub programming_languages: Option>, - pub added_to_mailing_list: bool, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tables/user.rs b/crates/collab/src/db/tables/user.rs index 8e8c03fafc..3f753954eb 100644 --- a/crates/collab/src/db/tables/user.rs +++ b/crates/collab/src/db/tables/user.rs @@ -39,25 +39,6 @@ pub enum Relation { Contributor, } -impl Model { - /// Returns the timestamp of when the user's account was created. - /// - /// This will be the earlier of the `created_at` and `github_user_created_at` timestamps. - pub fn account_created_at(&self) -> NaiveDateTime { - let mut account_created_at = self.created_at; - if let Some(github_created_at) = self.github_user_created_at { - account_created_at = account_created_at.min(github_created_at); - } - - account_created_at - } - - /// Returns the age of the user's account. - pub fn account_age(&self) -> chrono::Duration { - chrono::Utc::now().naive_utc() - self.account_created_at() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::AccessToken.def() diff --git a/crates/collab/src/db/tables/worktree_entry.rs b/crates/collab/src/db/tables/worktree_entry.rs index d148c63a7f..1a28203977 100644 --- a/crates/collab/src/db/tables/worktree_entry.rs +++ b/crates/collab/src/db/tables/worktree_entry.rs @@ -19,6 +19,7 @@ pub struct Model { pub is_ignored: bool, pub is_external: bool, pub is_deleted: bool, + pub is_hidden: bool, pub scan_id: i64, pub is_fifo: bool, pub canonical_path: Option, diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 141262d5e9..10a32691ed 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -2,27 +2,22 @@ mod buffer_tests; mod channel_tests; mod contributor_tests; mod db_tests; -// we only run postgres tests on macos right now -#[cfg(target_os = "macos")] -mod embedding_tests; mod extension_tests; -mod user_tests; +mod migrations; -use crate::migrations::run_database_migrations; +use std::sync::Arc; +use std::sync::atomic::{AtomicI32, Ordering::SeqCst}; +use std::time::Duration; -use super::*; use gpui::BackgroundExecutor; use parking_lot::Mutex; use rand::prelude::*; use sea_orm::ConnectionTrait; use sqlx::migrate::MigrateDatabase; -use std::{ - sync::{ - Arc, - atomic::{AtomicI32, Ordering::SeqCst}, - }, - time::Duration, -}; + +use self::migrations::run_database_migrations; + +use super::*; pub struct TestDb { pub db: Option>, @@ -196,7 +191,7 @@ fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str)]) -> Vec) { .await .unwrap(); - let mut buffer_a = Buffer::new(0, text::BufferId::new(1).unwrap(), "".to_string()); + let mut buffer_a = Buffer::new( + ReplicaId::new(0), + text::BufferId::new(1).unwrap(), + "".to_string(), + ); let operations = vec![ buffer_a.edit([(0..0, "hello world")]), buffer_a.edit([(5..5, ", cruel")]), @@ -95,7 +99,7 @@ async fn test_channel_buffers(db: &Arc) { .unwrap(); let mut buffer_b = Buffer::new( - 0, + ReplicaId::new(0), text::BufferId::new(1).unwrap(), buffer_response_b.base_text, ); @@ -124,7 +128,7 @@ async fn test_channel_buffers(db: &Arc) { rpc::proto::Collaborator { user_id: a_id.to_proto(), peer_id: Some(rpc::proto::PeerId { id: 1, owner_id }), - replica_id: 0, + replica_id: ReplicaId::FIRST_COLLAB_ID.as_u16() as u32, is_host: false, committer_name: None, committer_email: None, @@ -132,7 +136,7 @@ async fn test_channel_buffers(db: &Arc) { rpc::proto::Collaborator { user_id: b_id.to_proto(), peer_id: Some(rpc::proto::PeerId { id: 2, owner_id }), - replica_id: 1, + replica_id: ReplicaId::FIRST_COLLAB_ID.as_u16() as u32 + 1, is_host: false, committer_name: None, committer_email: None, @@ -228,7 +232,8 @@ async fn test_channel_buffers_last_operations(db: &Database) { .await .unwrap(); - db.join_channel_buffer(channel, user_id, connection_id) + let res = db + .join_channel_buffer(channel, user_id, connection_id) .await .unwrap(); @@ -239,7 +244,7 @@ async fn test_channel_buffers_last_operations(db: &Database) { ); text_buffers.push(Buffer::new( - 0, + ReplicaId::new(res.replica_id as u16), text::BufferId::new(1).unwrap(), "".to_string(), )); @@ -276,7 +281,12 @@ async fn test_channel_buffers_last_operations(db: &Database) { db.join_channel_buffer(buffers[1].channel_id, user_id, connection_id) .await .unwrap(); - text_buffers[1] = Buffer::new(1, text::BufferId::new(1).unwrap(), "def".to_string()); + let replica_id = text_buffers[1].replica_id(); + text_buffers[1] = Buffer::new( + replica_id, + text::BufferId::new(1).unwrap(), + "def".to_string(), + ); update_buffer( buffers[1].channel_id, user_id, @@ -304,20 +314,32 @@ async fn test_channel_buffers_last_operations(db: &Database) { rpc::proto::ChannelBufferVersion { channel_id: buffers[0].channel_id.to_proto(), epoch: 0, - version: serialize_version(&text_buffers[0].version()), + version: serialize_version(&text_buffers[0].version()) + .into_iter() + .filter( + |vector| vector.replica_id == text_buffers[0].replica_id().as_u16() as u32 + ) + .collect::>(), }, rpc::proto::ChannelBufferVersion { channel_id: buffers[1].channel_id.to_proto(), epoch: 1, version: serialize_version(&text_buffers[1].version()) .into_iter() - .filter(|vector| vector.replica_id == text_buffers[1].replica_id() as u32) + .filter( + |vector| vector.replica_id == text_buffers[1].replica_id().as_u16() as u32 + ) .collect::>(), }, rpc::proto::ChannelBufferVersion { channel_id: buffers[2].channel_id.to_proto(), epoch: 0, - version: serialize_version(&text_buffers[2].version()), + version: serialize_version(&text_buffers[2].version()) + .into_iter() + .filter( + |vector| vector.replica_id == text_buffers[2].replica_id().as_u16() as u32 + ) + .collect::>(), }, ] ); diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index def0769c37..2f0bda1cc6 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::test_both_dbs; use chrono::Utc; -use pretty_assertions::{assert_eq, assert_ne}; +use pretty_assertions::assert_eq; use std::sync::Arc; test_both_dbs!( @@ -457,53 +457,6 @@ async fn test_add_contacts(db: &Arc) { ); } -test_both_dbs!( - test_metrics_id, - test_metrics_id_postgres, - test_metrics_id_sqlite -); - -async fn test_metrics_id(db: &Arc) { - let NewUserResult { - user_id: user1, - metrics_id: metrics_id1, - .. - } = db - .create_user( - "person1@example.com", - None, - false, - NewUserParams { - github_login: "person1".into(), - github_user_id: 101, - }, - ) - .await - .unwrap(); - let NewUserResult { - user_id: user2, - metrics_id: metrics_id2, - .. - } = db - .create_user( - "person2@example.com", - None, - false, - NewUserParams { - github_login: "person2".into(), - github_user_id: 102, - }, - ) - .await - .unwrap(); - - assert_eq!(db.get_user_metrics_id(user1).await.unwrap(), metrics_id1); - assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id2); - assert_eq!(metrics_id1.len(), 36); - assert_eq!(metrics_id2.len(), 36); - assert_ne!(metrics_id1, metrics_id2); -} - test_both_dbs!( test_project_count, test_project_count_postgres, diff --git a/crates/collab/src/db/tests/embedding_tests.rs b/crates/collab/src/db/tests/embedding_tests.rs deleted file mode 100644 index 5d8d69c030..0000000000 --- a/crates/collab/src/db/tests/embedding_tests.rs +++ /dev/null @@ -1,87 +0,0 @@ -use super::TestDb; -use crate::db::embedding; -use collections::HashMap; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, sea_query::Expr}; -use std::ops::Sub; -use time::{Duration, OffsetDateTime, PrimitiveDateTime}; - -// SQLite does not support array arguments, so we only test this against a real postgres instance -#[gpui::test] -async fn test_get_embeddings_postgres(cx: &mut gpui::TestAppContext) { - let test_db = TestDb::postgres(cx.executor()); - let db = test_db.db(); - - let provider = "test_model"; - let digest1 = vec![1, 2, 3]; - let digest2 = vec![4, 5, 6]; - let embeddings = HashMap::from_iter([ - (digest1.clone(), vec![0.1, 0.2, 0.3]), - (digest2.clone(), vec![0.4, 0.5, 0.6]), - ]); - - // Save embeddings - db.save_embeddings(provider, &embeddings).await.unwrap(); - - // Retrieve embeddings - let retrieved_embeddings = db - .get_embeddings(provider, &[digest1.clone(), digest2.clone()]) - .await - .unwrap(); - assert_eq!(retrieved_embeddings.len(), 2); - assert!(retrieved_embeddings.contains_key(&digest1)); - assert!(retrieved_embeddings.contains_key(&digest2)); - - // Check if the retrieved embeddings are correct - assert_eq!(retrieved_embeddings[&digest1], vec![0.1, 0.2, 0.3]); - assert_eq!(retrieved_embeddings[&digest2], vec![0.4, 0.5, 0.6]); -} - -#[gpui::test] -async fn test_purge_old_embeddings(cx: &mut gpui::TestAppContext) { - let test_db = TestDb::postgres(cx.executor()); - let db = test_db.db(); - - let model = "test_model"; - let digest = vec![7, 8, 9]; - let embeddings = HashMap::from_iter([(digest.clone(), vec![0.7, 0.8, 0.9])]); - - // Save old embeddings - db.save_embeddings(model, &embeddings).await.unwrap(); - - // Reach into the DB and change the retrieved at to be > 60 days - db.transaction(|tx| { - let digest = digest.clone(); - async move { - let sixty_days_ago = OffsetDateTime::now_utc().sub(Duration::days(61)); - let retrieved_at = PrimitiveDateTime::new(sixty_days_ago.date(), sixty_days_ago.time()); - - embedding::Entity::update_many() - .filter( - embedding::Column::Model - .eq(model) - .and(embedding::Column::Digest.eq(digest)), - ) - .col_expr(embedding::Column::RetrievedAt, Expr::value(retrieved_at)) - .exec(&*tx) - .await - .unwrap(); - - Ok(()) - } - }) - .await - .unwrap(); - - // Purge old embeddings - db.purge_old_embeddings().await.unwrap(); - - // Try to retrieve the purged embeddings - let retrieved_embeddings = db - .get_embeddings(model, std::slice::from_ref(&digest)) - .await - .unwrap(); - assert!( - retrieved_embeddings.is_empty(), - "Old embeddings should have been purged" - ); -} diff --git a/crates/collab/src/db/tests/extension_tests.rs b/crates/collab/src/db/tests/extension_tests.rs index 9396b405fd..cb58f6af2a 100644 --- a/crates/collab/src/db/tests/extension_tests.rs +++ b/crates/collab/src/db/tests/extension_tests.rs @@ -16,6 +16,72 @@ test_both_dbs!( test_extensions_sqlite ); +test_both_dbs!( + test_agent_servers_filter, + test_agent_servers_filter_postgres, + test_agent_servers_filter_sqlite +); + +async fn test_agent_servers_filter(db: &Arc) { + // No extensions initially + let versions = db.get_known_extension_versions().await.unwrap(); + assert!(versions.is_empty()); + + // Shared timestamp + let t0 = time::OffsetDateTime::from_unix_timestamp_nanos(0).unwrap(); + let t0 = time::PrimitiveDateTime::new(t0.date(), t0.time()); + + // Insert two extensions, only one provides AgentServers + db.insert_extension_versions( + &[ + ( + "ext_agent_servers", + vec![NewExtensionVersion { + name: "Agent Servers Provider".into(), + version: semver::Version::parse("1.0.0").unwrap(), + description: "has agent servers".into(), + authors: vec!["author".into()], + repository: "org/agent-servers".into(), + schema_version: 1, + wasm_api_version: None, + provides: BTreeSet::from_iter([ExtensionProvides::AgentServers]), + published_at: t0, + }], + ), + ( + "ext_plain", + vec![NewExtensionVersion { + name: "Plain Extension".into(), + version: semver::Version::parse("0.1.0").unwrap(), + description: "no agent servers".into(), + authors: vec!["author2".into()], + repository: "org/plain".into(), + schema_version: 1, + wasm_api_version: None, + provides: BTreeSet::default(), + published_at: t0, + }], + ), + ] + .into_iter() + .collect(), + ) + .await + .unwrap(); + + // Filter by AgentServers provides + let provides_filter = BTreeSet::from_iter([ExtensionProvides::AgentServers]); + + let filtered = db + .get_extensions(None, Some(&provides_filter), 1, 10) + .await + .unwrap(); + + // Expect only the extension that declared AgentServers + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id.as_ref(), "ext_agent_servers"); +} + async fn test_extensions(db: &Arc) { let versions = db.get_known_extension_versions().await.unwrap(); assert!(versions.is_empty()); diff --git a/crates/collab/src/migrations.rs b/crates/collab/src/db/tests/migrations.rs similarity index 100% rename from crates/collab/src/migrations.rs rename to crates/collab/src/db/tests/migrations.rs diff --git a/crates/collab/src/db/tests/user_tests.rs b/crates/collab/src/db/tests/user_tests.rs deleted file mode 100644 index dd61da55ca..0000000000 --- a/crates/collab/src/db/tests/user_tests.rs +++ /dev/null @@ -1,96 +0,0 @@ -use chrono::Utc; - -use crate::{ - db::{Database, NewUserParams}, - test_both_dbs, -}; -use std::sync::Arc; - -test_both_dbs!( - test_accepted_tos, - test_accepted_tos_postgres, - test_accepted_tos_sqlite -); - -async fn test_accepted_tos(db: &Arc) { - let user_id = db - .create_user( - "user1@example.com", - None, - false, - NewUserParams { - github_login: "user1".to_string(), - github_user_id: 1, - }, - ) - .await - .unwrap() - .user_id; - - let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); - assert!(user.accepted_tos_at.is_none()); - - let accepted_tos_at = Utc::now().naive_utc(); - db.set_user_accepted_tos_at(user_id, Some(accepted_tos_at)) - .await - .unwrap(); - - let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); - assert!(user.accepted_tos_at.is_some()); - assert_eq!(user.accepted_tos_at, Some(accepted_tos_at)); - - db.set_user_accepted_tos_at(user_id, None).await.unwrap(); - - 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/lib.rs b/crates/collab/src/lib.rs index 14573e94b0..08f7e61c02 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -3,8 +3,6 @@ pub mod auth; pub mod db; pub mod env; pub mod executor; -pub mod llm; -pub mod migrations; pub mod rpc; pub mod seed; diff --git a/crates/collab/src/llm.rs b/crates/collab/src/llm.rs deleted file mode 100644 index dec10232bd..0000000000 --- a/crates/collab/src/llm.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod db; diff --git a/crates/collab/src/llm/db.rs b/crates/collab/src/llm/db.rs deleted file mode 100644 index b15d5a42b5..0000000000 --- a/crates/collab/src/llm/db.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::future::Future; -use std::sync::Arc; - -use anyhow::Context; -pub use sea_orm::ConnectOptions; -use sea_orm::{DatabaseConnection, DatabaseTransaction, IsolationLevel, TransactionTrait}; - -use crate::Result; -use crate::db::TransactionHandle; -use crate::executor::Executor; - -/// The database for the LLM service. -pub struct LlmDatabase { - options: ConnectOptions, - pool: DatabaseConnection, - #[allow(unused)] - executor: Executor, - #[cfg(test)] - runtime: Option, -} - -impl LlmDatabase { - /// Connects to the database with the given options - pub async fn new(options: ConnectOptions, executor: Executor) -> Result { - sqlx::any::install_default_drivers(); - Ok(Self { - options: options.clone(), - pool: sea_orm::Database::connect(options).await?, - executor, - #[cfg(test)] - runtime: None, - }) - } - - pub fn options(&self) -> &ConnectOptions { - &self.options - } - - pub async fn transaction(&self, f: F) -> Result - where - F: Send + Fn(TransactionHandle) -> Fut, - Fut: Send + Future>, - { - let body = async { - let (tx, result) = self.with_transaction(&f).await?; - match result { - Ok(result) => match tx.commit().await.map_err(Into::into) { - Ok(()) => Ok(result), - Err(error) => Err(error), - }, - Err(error) => { - tx.rollback().await?; - Err(error) - } - } - }; - - self.run(body).await - } - - async fn with_transaction(&self, f: &F) -> Result<(DatabaseTransaction, Result)> - where - F: Send + Fn(TransactionHandle) -> Fut, - Fut: Send + Future>, - { - let tx = self - .pool - .begin_with_config(Some(IsolationLevel::ReadCommitted), None) - .await?; - - let mut tx = Arc::new(Some(tx)); - let result = f(TransactionHandle(tx.clone())).await; - let tx = Arc::get_mut(&mut tx) - .and_then(|tx| tx.take()) - .context("couldn't complete transaction because it's still in use")?; - - Ok((tx, result)) - } - - async fn run(&self, future: F) -> Result - where - F: Future>, - { - #[cfg(test)] - { - if let Executor::Deterministic(executor) = &self.executor { - executor.simulate_random_delay().await; - } - - self.runtime.as_ref().unwrap().block_on(future) - } - - #[cfg(not(test))] - { - future.await - } - } -} diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 6b94459910..030158c94d 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -1,4 +1,4 @@ -use anyhow::{Context as _, anyhow}; +use anyhow::anyhow; use axum::headers::HeaderMapExt; use axum::{ Extension, Router, @@ -9,17 +9,14 @@ use axum::{ use collab::ServiceMode; use collab::api::CloudflareIpCountryHeader; -use collab::llm::db::LlmDatabase; -use collab::migrations::run_database_migrations; use collab::{ AppState, Config, Result, api::fetch_extensions_from_blob_store_periodically, db, env, - executor::Executor, rpc::ResultExt, + executor::Executor, }; use db::Database; use std::{ env::args, net::{SocketAddr, TcpListener}, - path::Path, sync::Arc, time::Duration, }; @@ -49,10 +46,6 @@ async fn main() -> Result<()> { Some("version") => { println!("collab v{} ({})", VERSION, REVISION.unwrap_or("unknown")); } - Some("migrate") => { - let config = envy::from_env::().expect("error loading config"); - setup_app_database(&config).await?; - } Some("seed") => { let config = envy::from_env::().expect("error loading config"); let db_options = db::ConnectOptions::new(config.database_url.clone()); @@ -69,7 +62,7 @@ async fn main() -> Result<()> { Some("all") => ServiceMode::All, _ => { return Err(anyhow!( - "usage: collab >" + "usage: collab >" ))?; } }; @@ -90,13 +83,10 @@ async fn main() -> Result<()> { if mode.is_collab() || mode.is_api() { setup_app_database(&config).await?; - setup_llm_database(&config).await?; let state = AppState::new(config, Executor::Production).await?; if mode.is_collab() { - state.db.purge_old_embeddings().await.trace_err(); - let epoch = state .db .create_server(&state.config.zed_environment) @@ -213,25 +203,6 @@ async fn setup_app_database(config: &Config) -> Result<()> { let db_options = db::ConnectOptions::new(config.database_url.clone()); let mut db = Database::new(db_options).await?; - let migrations_path = config.migrations_path.as_deref().unwrap_or_else(|| { - #[cfg(feature = "sqlite")] - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations.sqlite"); - #[cfg(not(feature = "sqlite"))] - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); - - Path::new(default_migrations) - }); - - let migrations = run_database_migrations(db.options(), migrations_path).await?; - for (migration, duration) in migrations { - log::info!( - "Migrated {} {} {:?}", - migration.version, - migration.description, - duration - ); - } - db.initialize_notification_kinds().await?; if config.seed_path.is_some() { @@ -241,37 +212,6 @@ async fn setup_app_database(config: &Config) -> Result<()> { Ok(()) } -async fn setup_llm_database(config: &Config) -> Result<()> { - let database_url = config - .llm_database_url - .as_ref() - .context("missing LLM_DATABASE_URL")?; - - let db_options = db::ConnectOptions::new(database_url.clone()); - let db = LlmDatabase::new(db_options, Executor::Production).await?; - - let migrations_path = config - .llm_database_migrations_path - .as_deref() - .unwrap_or_else(|| { - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations_llm"); - - Path::new(default_migrations) - }); - - let migrations = run_database_migrations(db.options(), migrations_path).await?; - for (migration, duration) in migrations { - log::info!( - "Migrated {} {} {:?}", - migration.version, - migration.description, - duration - ); - } - - Ok(()) -} - async fn handle_root(Extension(mode): Extension) -> String { format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown")) } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index fa2ca6a890..9511087af8 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -50,7 +50,7 @@ use rpc::{ RequestMessage, ShareProject, UpdateChannelBufferCollaborators, }, }; -use semantic_version::SemanticVersion; +use semver::Version; use serde::{Serialize, Serializer}; use std::{ any::TypeId, @@ -343,11 +343,12 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) - .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) @@ -395,6 +396,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(create_buffer_for_peer) + .add_message_handler(create_image_for_peer) .add_request_handler(update_buffer) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(broadcast_project_message_from_host::) @@ -451,6 +453,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) @@ -462,8 +465,12 @@ impl Server { .add_message_handler(broadcast_project_message_from_host::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) @@ -980,14 +987,14 @@ impl Server { { let mut pool = self.connection_pool.lock(); - pool.add_connection(connection_id, user.id, user.admin, zed_version); + pool.add_connection(connection_id, user.id, user.admin, zed_version.clone()); self.peer.send( connection_id, build_initial_contacts_update(contacts, &pool), )?; } - if should_auto_subscribe_to_channels(zed_version) { + if should_auto_subscribe_to_channels(&zed_version) { subscribe_user_to_channels(user.id, session).await?; } @@ -1131,7 +1138,7 @@ impl Header for ProtocolVersion { } } -pub struct AppVersionHeader(SemanticVersion); +pub struct AppVersionHeader(Version); impl Header for AppVersionHeader { fn name() -> &'static HeaderName { static ZED_APP_VERSION: OnceLock = OnceLock::new(); @@ -2387,6 +2394,26 @@ async fn create_buffer_for_peer( Ok(()) } +/// Notify other participants that a new image has been created +async fn create_image_for_peer( + request: proto::CreateImageForPeer, + session: MessageContext, +) -> Result<()> { + session + .db() + .await + .check_user_is_project_host( + ProjectId::from_proto(request.project_id), + session.connection_id, + ) + .await?; + let peer_id = request.peer_id.context("invalid peer id")?; + session + .peer + .forward_send(session.connection_id, peer_id.into(), request)?; + Ok(()) +} + /// Notify other participants that a buffer has been updated. This is /// allowed for guests as long as the update is limited to selections. async fn update_buffer( @@ -2809,8 +2836,8 @@ async fn remove_contact( Ok(()) } -fn should_auto_subscribe_to_channels(version: ZedVersion) -> bool { - version.0.minor() < 139 +fn should_auto_subscribe_to_channels(version: &ZedVersion) -> bool { + version.0.minor < 139 } async fn subscribe_to_channels( diff --git a/crates/collab/src/rpc/connection_pool.rs b/crates/collab/src/rpc/connection_pool.rs index 729e7c8533..b119323916 100644 --- a/crates/collab/src/rpc/connection_pool.rs +++ b/crates/collab/src/rpc/connection_pool.rs @@ -2,7 +2,7 @@ use crate::db::{ChannelId, ChannelRole, UserId}; use anyhow::{Context as _, Result}; use collections::{BTreeMap, HashMap, HashSet}; use rpc::ConnectionId; -use semantic_version::SemanticVersion; +use semver::Version; use serde::Serialize; use std::fmt; use tracing::instrument; @@ -19,8 +19,8 @@ struct ConnectedPrincipal { connection_ids: HashSet, } -#[derive(Copy, Clone, Debug, Serialize, PartialOrd, PartialEq, Eq, Ord)] -pub struct ZedVersion(pub SemanticVersion); +#[derive(Clone, Debug, Serialize, PartialOrd, PartialEq, Eq, Ord)] +pub struct ZedVersion(pub Version); impl fmt::Display for ZedVersion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -30,15 +30,15 @@ impl fmt::Display for ZedVersion { impl ZedVersion { pub fn can_collaborate(&self) -> bool { - // v0.198.4 is the first version where we no longer connect to Collab automatically. - // We reject any clients older than that to prevent them from connecting to Collab just for authentication. - if self.0 < SemanticVersion::new(0, 198, 4) { + // v0.204.1 was the first version after the auto-update bug. + // We reject any clients older than that to hope we can persuade them to upgrade. + if self.0 < Version::new(0, 204, 1) { return false; } // Since we hotfixed the changes to no longer connect to Collab automatically to Preview, we also need to reject // versions in the range [v0.199.0, v0.199.1]. - if self.0 >= SemanticVersion::new(0, 199, 0) && self.0 < SemanticVersion::new(0, 199, 2) { + if self.0 >= Version::new(0, 199, 0) && self.0 < Version::new(0, 199, 2) { return false; } diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 7d07360b80..3785ee0b7a 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use call::Room; use client::ChannelId; use gpui::{Entity, TestAppContext}; @@ -18,7 +16,6 @@ mod randomized_test_helpers; mod remote_editing_collaboration_tests; mod test_server; -use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; pub use randomized_test_helpers::{ RandomizedTest, TestError, UserTestPlan, run_randomized_test, save_randomized_test_plan, }; @@ -51,17 +48,3 @@ fn room_participants(room: &Entity, cx: &mut TestAppContext) -> RoomPartic fn channel_id(room: &Entity, cx: &mut TestAppContext) -> Option { cx.read(|cx| room.read(cx).channel_id()) } - -fn rust_lang() -> Arc { - Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) -} diff --git a/crates/collab/src/tests/channel_buffer_tests.rs b/crates/collab/src/tests/channel_buffer_tests.rs index 8e857f4f02..62c61d3cf0 100644 --- a/crates/collab/src/tests/channel_buffer_tests.rs +++ b/crates/collab/src/tests/channel_buffer_tests.rs @@ -7,7 +7,7 @@ use channel::ACKNOWLEDGE_DEBOUNCE_INTERVAL; use client::{Collaborator, ParticipantIndex, UserId}; use collab_ui::channel_view::ChannelView; use collections::HashMap; -use editor::{Anchor, Editor, ToOffset}; +use editor::{Anchor, Editor, MultiBufferOffset, ToOffset}; use futures::future; use gpui::{BackgroundExecutor, Context, Entity, TestAppContext, Window}; use rpc::{RECEIVE_TIMEOUT, proto::PeerId}; @@ -180,7 +180,7 @@ async fn test_channel_notes_participant_indices( notes.editor.update(cx, |editor, cx| { editor.insert("a", window, cx); editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges(vec![0..1]); + selections.select_ranges(vec![MultiBufferOffset(0)..MultiBufferOffset(1)]); }); }); }); @@ -190,7 +190,7 @@ async fn test_channel_notes_participant_indices( editor.move_down(&Default::default(), window, cx); editor.insert("b", window, cx); editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges(vec![1..2]); + selections.select_ranges(vec![MultiBufferOffset(1)..MultiBufferOffset(2)]); }); }); }); @@ -200,7 +200,7 @@ async fn test_channel_notes_participant_indices( editor.move_down(&Default::default(), window, cx); editor.insert("c", window, cx); editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges(vec![2..3]); + selections.select_ranges(vec![MultiBufferOffset(2)..MultiBufferOffset(3)]); }); }); }); @@ -287,12 +287,12 @@ async fn test_channel_notes_participant_indices( editor_a.update_in(cx_a, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges(vec![0..1]); + selections.select_ranges(vec![MultiBufferOffset(0)..MultiBufferOffset(1)]); }); }); editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges(vec![2..3]); + selections.select_ranges(vec![MultiBufferOffset(2)..MultiBufferOffset(3)]); }); }); executor.run_until_parked(); @@ -327,7 +327,7 @@ fn assert_remote_selections( let end = s.selection.end.to_offset(snapshot.buffer_snapshot()); let user_id = collaborators.get(&peer_id).unwrap().user_id; let participant_index = hub.user_participant_indices(cx).get(&user_id).copied(); - (participant_index, start..end) + (participant_index, start.0..end.0) }) .collect::>(); assert_eq!( diff --git a/crates/collab/src/tests/debug_panel_tests.rs b/crates/collab/src/tests/debug_panel_tests.rs index 95a2e80ac4..d1659e5114 100644 --- a/crates/collab/src/tests/debug_panel_tests.rs +++ b/crates/collab/src/tests/debug_panel_tests.rs @@ -23,9 +23,6 @@ pub fn init_test(cx: &mut gpui::TestAppContext) { cx.update(|cx| { theme::init(theme::LoadThemes::JustBase, cx); command_palette_hooks::init(cx); - language::init(cx); - workspace::init_settings(cx); - project::Project::init_settings(cx); debugger_ui::init(cx); editor::init(cx); }); diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 5ead2cd1d1..4e6cdb0e79 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -1,13 +1,12 @@ -use crate::{ - rpc::RECONNECT_TIMEOUT, - tests::{TestServer, rust_lang}, -}; +use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; use call::ActiveCall; use editor::{ - DocumentColorsRenderMode, Editor, RowInfo, SelectionEffects, + DocumentColorsRenderMode, Editor, FETCH_COLORS_DEBOUNCE_TIMEOUT, MultiBufferOffset, RowInfo, + SelectionEffects, actions::{ - ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, - ExpandMacroRecursively, MoveToEnd, Redo, Rename, SelectAll, ToggleCodeActions, Undo, + ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, CopyFileLocation, + CopyFileName, CopyFileNameWithoutExtension, ExpandMacroRecursively, MoveToEnd, Redo, + Rename, SelectAll, ToggleCodeActions, Undo, }, test::{ editor_test_context::{AssertionContextManager, EditorTestContext}, @@ -17,12 +16,15 @@ use editor::{ use fs::Fs; use futures::{SinkExt, StreamExt, channel::mpsc, lock::Mutex}; use git::repository::repo_path; -use gpui::{App, Rgba, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext}; +use gpui::{ + App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext, +}; use indoc::indoc; -use language::FakeLspAdapter; +use language::{FakeLspAdapter, rust_lang}; use lsp::LSP_REQUEST_TIMEOUT; +use pretty_assertions::assert_eq; use project::{ - ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT, + ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT, lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro}, }; use recent_projects::disconnected_overlay::DisconnectedOverlay; @@ -37,6 +39,7 @@ use std::{ Arc, atomic::{self, AtomicBool, AtomicUsize}, }, + time::Duration, }; use text::Point; use util::{path, rel_path::rel_path, uri}; @@ -285,7 +288,7 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor( "}); } -#[gpui::test(iterations = 10)] +#[gpui::test] async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let mut server = TestServer::start(cx_a.executor()).await; let client_a = server.create_client(cx_a, "user_a").await; @@ -304,17 +307,83 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ..lsp::ServerCapabilities::default() }; client_a.language_registry().add(rust_lang()); - let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + let mut fake_language_servers = [ + client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + assert_eq!( + params.text_document_position.position, + lsp::Position::new(0, 14), + ); + + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first_method(…)".into(), + detail: Some("fn(&mut self, B) -> C".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "first_method($1)".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + lsp::CompletionItem { + label: "second_method(…)".into(), + detail: Some("fn(&mut self, C) -> D".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "second_method()".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + ]))) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ), + client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "fake-analyzer", + capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |_, _| async move { Ok(None) }, + ); + })), + ..FakeLspAdapter::default() + }, + ), + ]; + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( "Rust", FakeLspAdapter { capabilities: capabilities.clone(), ..FakeLspAdapter::default() }, ); - client_b.language_registry().add(rust_lang()); client_b.language_registry().register_fake_lsp_adapter( "Rust", FakeLspAdapter { + name: "fake-analyzer", capabilities, ..FakeLspAdapter::default() }, @@ -349,8 +418,10 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu Editor::for_buffer(buffer_b.clone(), Some(project_b.clone()), window, cx) }); - let fake_language_server = fake_language_servers.next().await.unwrap(); + let fake_language_server = fake_language_servers[0].next().await.unwrap(); + let second_fake_language_server = fake_language_servers[1].next().await.unwrap(); cx_a.background_executor.run_until_parked(); + cx_b.background_executor.run_until_parked(); buffer_b.read_with(cx_b, |buffer, _| { assert!(!buffer.completion_triggers().is_empty()) @@ -359,59 +430,15 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu // Type a completion trigger character as the guest. editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input(".", window, cx); }); cx_b.focus(&editor_b); - // Receive a completion request as the host's language server. - // Return some completions from the host's language server. - cx_a.executor().start_waiting(); - fake_language_server - .set_request_handler::(|params, _| async move { - assert_eq!( - params.text_document_position.text_document.uri, - lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), - ); - assert_eq!( - params.text_document_position.position, - lsp::Position::new(0, 14), - ); - - Ok(Some(lsp::CompletionResponse::Array(vec![ - lsp::CompletionItem { - label: "first_method(…)".into(), - detail: Some("fn(&mut self, B) -> C".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "first_method($1)".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - lsp::CompletionItem { - label: "second_method(…)".into(), - detail: Some("fn(&mut self, C) -> D".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "second_method()".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - ]))) - }) - .next() - .await - .unwrap(); - cx_a.executor().finish_waiting(); + // Allow the completion request to propagate from guest to host to LSP. + cx_b.background_executor.run_until_parked(); + cx_a.background_executor.run_until_parked(); // Open the buffer on the host. let buffer_a = project_a @@ -457,6 +484,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu // The additional edit is applied. cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); buffer_a.read_with(cx_a, |buffer, _| { assert_eq!( @@ -476,7 +504,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu // resolved editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([46..46]) + s.select_ranges([MultiBufferOffset(46)..MultiBufferOffset(46)]) }); editor.handle_input("; a", window, cx); editor.handle_input(".", window, cx); @@ -505,7 +533,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu label: "third_method(…)".into(), detail: Some("fn(&mut self, B, C, D) -> E".into()), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - // no snippet placehodlers + // no snippet placeholders new_text: "third_method".to_string(), range: lsp::Range::new( lsp::Position::new(1, 32), @@ -519,6 +547,10 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ]))) }); + // Second language server also needs to handle the request (returns None) + let mut second_completion_response = second_fake_language_server + .set_request_handler::(|_, _| async move { Ok(None) }); + // The completion now gets a new `text_edit.new_text` when resolving the completion item let mut resolve_completion_response = fake_language_server .set_request_handler::(|params, _| async move { @@ -542,6 +574,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu cx_b.executor().run_until_parked(); completion_response.next().await.unwrap(); + second_completion_response.next().await.unwrap(); editor_b.update_in(cx_b, |editor, window, cx| { assert!(editor.context_menu_visible()); @@ -560,6 +593,75 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu "use d::SomeTrait;\nfn main() { a.first_method(); a.third_method(, , ) }" ); }); + + // Ensure buffer is synced before proceeding with the next test + cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); + + // Test completions from the second fake language server + // Add another completion trigger to test the second language server + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(68)..MultiBufferOffset(68)]) + }); + editor.handle_input("; b", window, cx); + editor.handle_input(".", window, cx); + }); + + buffer_b.read_with(cx_b, |buffer, _| { + assert_eq!( + buffer.text(), + "use d::SomeTrait;\nfn main() { a.first_method(); a.third_method(, , ); b. }" + ); + }); + + // Set up completion handlers for both language servers + let mut first_lsp_completion = fake_language_server + .set_request_handler::(|_, _| async move { Ok(None) }); + + let mut second_lsp_completion = second_fake_language_server + .set_request_handler::(|params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + assert_eq!( + params.text_document_position.position, + lsp::Position::new(1, 54), + ); + + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "analyzer_method(…)".into(), + detail: Some("fn(&self) -> Result".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "analyzer_method()".to_string(), + range: lsp::Range::new( + lsp::Position::new(1, 54), + lsp::Position::new(1, 54), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..lsp::CompletionItem::default() + }, + ]))) + }); + + // Await both language server responses + first_lsp_completion.next().await.unwrap(); + second_lsp_completion.next().await.unwrap(); + + cx_b.executor().run_until_parked(); + + // Confirm the completion from the second language server works + editor_b.update_in(cx_b, |editor, window, cx| { + assert!(editor.context_menu_visible()); + editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, window, cx); + assert_eq!( + editor.text(cx), + "use d::SomeTrait;\nfn main() { a.first_method(); a.third_method(, , ); b.analyzer_method() }" + ); + }); } #[gpui::test(iterations = 10)] @@ -847,7 +949,7 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T // Move cursor to a location that can be renamed. let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([7..7]) + s.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)]) }); editor.rename(&Rename, window, cx).unwrap() }); @@ -874,17 +976,17 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T let buffer = editor.buffer().read(cx).snapshot(cx); assert_eq!( rename.range.start.to_offset(&buffer)..rename.range.end.to_offset(&buffer), - 6..9 + MultiBufferOffset(6)..MultiBufferOffset(9) ); rename.editor.update(cx, |rename_editor, cx| { - let rename_selection = rename_editor.selections.newest::(cx); + let rename_selection = rename_editor.selections.newest::(&rename_editor.display_snapshot(cx)); assert_eq!( rename_selection.range(), - 0..3, + MultiBufferOffset(0)..MultiBufferOffset(3), "Rename that was triggered from zero selection caret, should propose the whole word." ); rename_editor.buffer().update(cx, |rename_buffer, cx| { - rename_buffer.edit([(0..3, "THREE")], None, cx); + rename_buffer.edit([(MultiBufferOffset(0)..MultiBufferOffset(3), "THREE")], None, cx); }); }); }); @@ -895,7 +997,7 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T }); let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([7..8]) + s.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(8)]) }); editor.rename(&Rename, window, cx).unwrap() }); @@ -922,16 +1024,16 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T let buffer = editor.buffer().read(cx).snapshot(cx); let lsp_rename_start = rename.range.start.to_offset(&buffer); let lsp_rename_end = rename.range.end.to_offset(&buffer); - assert_eq!(lsp_rename_start..lsp_rename_end, 6..9); + assert_eq!(lsp_rename_start..lsp_rename_end, MultiBufferOffset(6)..MultiBufferOffset(9)); rename.editor.update(cx, |rename_editor, cx| { - let rename_selection = rename_editor.selections.newest::(cx); + let rename_selection = rename_editor.selections.newest::(&rename_editor.display_snapshot(cx)); assert_eq!( rename_selection.range(), - 1..2, + MultiBufferOffset(1)..MultiBufferOffset(2), "Rename that was triggered from a selection, should have the same selection range in the rename proposal" ); rename_editor.buffer().update(cx, |rename_buffer, cx| { - rename_buffer.edit([(0..lsp_rename_end - lsp_rename_start, "THREE")], None, cx); + rename_buffer.edit([(MultiBufferOffset(0)..MultiBufferOffset(lsp_rename_end - lsp_rename_start), "THREE")], None, cx); }); }); }); @@ -1134,7 +1236,7 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte // Move cursor to a location, this should trigger the code lens call. editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([7..7]) + s.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)]) }); }); let () = request_started_rx.next().await.unwrap(); @@ -1156,7 +1258,7 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([1..1]) + s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) }); }); let () = request_started_rx.next().await.unwrap(); @@ -1178,7 +1280,7 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([2..2]) + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(2)]) }); }); let () = request_started_rx.next().await.unwrap(); @@ -1272,7 +1374,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes fake_language_server.start_progress("the-token").await; executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT); - fake_language_server.notify::(&lsp::ProgressParams { + fake_language_server.notify::(lsp::ProgressParams { token: lsp::NumberOrString::String("the-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report( lsp::WorkDoneProgressReport { @@ -1283,12 +1385,14 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }); executor.run_until_parked(); + let token = ProgressToken::String(SharedString::from("the-token")); + project_a.read_with(cx_a, |project, cx| { let status = project.language_server_statuses(cx).next().unwrap().1; assert_eq!(status.name.0, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( - status.pending_work["the-token"].message.as_ref().unwrap(), + status.pending_work[&token].message.as_ref().unwrap(), "the-message" ); }); @@ -1306,7 +1410,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }); executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT); - fake_language_server.notify::(&lsp::ProgressParams { + fake_language_server.notify::(lsp::ProgressParams { token: lsp::NumberOrString::String("the-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report( lsp::WorkDoneProgressReport { @@ -1322,7 +1426,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes assert_eq!(status.name.0, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( - status.pending_work["the-token"].message.as_ref().unwrap(), + status.pending_work[&token].message.as_ref().unwrap(), "the-message-2" ); }); @@ -1332,7 +1436,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes assert_eq!(status.name.0, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( - status.pending_work["the-token"].message.as_ref().unwrap(), + status.pending_work[&token].message.as_ref().unwrap(), "the-message-2" ); }); @@ -1474,7 +1578,10 @@ async fn test_share_project( buffer_a.read_with(cx_a, |buffer, _| { buffer .snapshot() - .selections_in_range(text::Anchor::MIN..text::Anchor::MAX, false) + .selections_in_range( + text::Anchor::min_max_range_for_buffer(buffer.remote_id()), + false, + ) .count() == 1 }); @@ -1515,7 +1622,10 @@ async fn test_share_project( buffer_a.read_with(cx_a, |buffer, _| { buffer .snapshot() - .selections_in_range(text::Anchor::MIN..text::Anchor::MAX, false) + .selections_in_range( + text::Anchor::min_max_range_for_buffer(buffer.remote_id()), + false, + ) .count() == 0 }); @@ -1614,7 +1724,7 @@ async fn test_on_input_format_from_host_to_guest( cx_a.focus(&editor_a); editor_a.update_in(cx_a, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input(">", window, cx); }); @@ -1723,7 +1833,7 @@ async fn test_on_input_format_from_guest_to_host( cx_b.focus(&editor_b); editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input(":", window, cx); }); @@ -1813,14 +1923,7 @@ async fn test_mutual_editor_inlay_hint_cache_update( settings.project.all_languages.defaults.inlay_hints = Some(InlayHintSettingsContent { enabled: Some(true), - show_value_hints: Some(true), - edit_debounce_ms: Some(0), - scroll_debounce_ms: Some(0), - show_type_hints: Some(true), - show_parameter_hints: Some(false), - show_other_hints: Some(true), - show_background: Some(false), - toggle_on_modifiers_press: None, + ..InlayHintSettingsContent::default() }) }); }); @@ -1830,15 +1933,8 @@ async fn test_mutual_editor_inlay_hint_cache_update( store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.inlay_hints = Some(InlayHintSettingsContent { - show_value_hints: Some(true), enabled: Some(true), - edit_debounce_ms: Some(0), - scroll_debounce_ms: Some(0), - show_type_hints: Some(true), - show_parameter_hints: Some(false), - show_other_hints: Some(true), - show_background: Some(false), - toggle_on_modifiers_press: None, + ..InlayHintSettingsContent::default() }) }); }); @@ -1849,10 +1945,40 @@ async fn test_mutual_editor_inlay_hint_cache_update( ..lsp::ServerCapabilities::default() }; client_a.language_registry().add(rust_lang()); + + // Set up the language server to return an additional inlay hint on each request. + let edits_made = Arc::new(AtomicUsize::new(0)); + let closure_edits_made = Arc::clone(&edits_made); let mut fake_language_servers = client_a.language_registry().register_fake_lsp( "Rust", FakeLspAdapter { capabilities: capabilities.clone(), + initializer: Some(Box::new(move |fake_language_server| { + let closure_edits_made = closure_edits_made.clone(); + fake_language_server.set_request_handler::( + move |params, _| { + let edits_made_2 = Arc::clone(&closure_edits_made); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + let edits_made = + AtomicUsize::load(&edits_made_2, atomic::Ordering::Acquire); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, edits_made as u32), + label: lsp::InlayHintLabel::String(edits_made.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + })), ..FakeLspAdapter::default() }, ); @@ -1894,61 +2020,21 @@ async fn test_mutual_editor_inlay_hint_cache_update( .unwrap(); let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); - executor.start_waiting(); // The host opens a rust file. - let _buffer_a = project_a - .update(cx_a, |project, cx| { - project.open_local_buffer(path!("/a/main.rs"), cx) - }) - .await - .unwrap(); - let editor_a = workspace_a - .update_in(cx_a, |workspace, window, cx| { - workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - + let file_a = workspace_a.update_in(cx_a, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) + }); let fake_language_server = fake_language_servers.next().await.unwrap(); - - // Set up the language server to return an additional inlay hint on each request. - let edits_made = Arc::new(AtomicUsize::new(0)); - let closure_edits_made = Arc::clone(&edits_made); - fake_language_server - .set_request_handler::(move |params, _| { - let edits_made_2 = Arc::clone(&closure_edits_made); - async move { - assert_eq!( - params.text_document.uri, - lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), - ); - let edits_made = AtomicUsize::load(&edits_made_2, atomic::Ordering::Acquire); - Ok(Some(vec![lsp::InlayHint { - position: lsp::Position::new(0, edits_made as u32), - label: lsp::InlayHintLabel::String(edits_made.to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) - } - }) - .next() - .await - .unwrap(); - + let editor_a = file_a.await.unwrap().downcast::().unwrap(); + executor.advance_clock(Duration::from_millis(100)); executor.run_until_parked(); let initial_edit = edits_made.load(atomic::Ordering::Acquire); - editor_a.update(cx_a, |editor, _| { + editor_a.update(cx_a, |editor, cx| { assert_eq!( vec![initial_edit.to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), "Host should get its first hints when opens an editor" ); }); @@ -1962,11 +2048,12 @@ async fn test_mutual_editor_inlay_hint_cache_update( .downcast::() .unwrap(); + executor.advance_clock(Duration::from_millis(100)); executor.run_until_parked(); - editor_b.update(cx_b, |editor, _| { + editor_b.update(cx_b, |editor, cx| { assert_eq!( vec![initial_edit.to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), "Client should get its first hints when opens an editor" ); }); @@ -1974,46 +2061,48 @@ async fn test_mutual_editor_inlay_hint_cache_update( let after_client_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1; editor_b.update_in(cx_b, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13].clone()) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)].clone()) }); editor.handle_input(":", window, cx); }); cx_b.focus(&editor_b); + executor.advance_clock(Duration::from_secs(1)); executor.run_until_parked(); - editor_a.update(cx_a, |editor, _| { + editor_a.update(cx_a, |editor, cx| { assert_eq!( vec![after_client_edit.to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), ); }); - editor_b.update(cx_b, |editor, _| { + editor_b.update(cx_b, |editor, cx| { assert_eq!( vec![after_client_edit.to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), ); }); let after_host_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1; editor_a.update_in(cx_a, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input("a change to increment both buffers' versions", window, cx); }); cx_a.focus(&editor_a); + executor.advance_clock(Duration::from_secs(1)); executor.run_until_parked(); - editor_a.update(cx_a, |editor, _| { + editor_a.update(cx_a, |editor, cx| { assert_eq!( vec![after_host_edit.to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), ); }); - editor_b.update(cx_b, |editor, _| { + editor_b.update(cx_b, |editor, cx| { assert_eq!( vec![after_host_edit.to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), ); }); @@ -2024,18 +2113,19 @@ async fn test_mutual_editor_inlay_hint_cache_update( .into_response() .expect("inlay refresh request failed"); + executor.advance_clock(Duration::from_secs(1)); executor.run_until_parked(); - editor_a.update(cx_a, |editor, _| { + editor_a.update(cx_a, |editor, cx| { assert_eq!( vec![after_special_edit_for_refresh.to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), "Host should react to /refresh LSP request" ); }); - editor_b.update(cx_b, |editor, _| { + editor_b.update(cx_b, |editor, cx| { assert_eq!( vec![after_special_edit_for_refresh.to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), "Guest should get a /refresh LSP request propagated by host" ); }); @@ -2184,16 +2274,28 @@ async fn test_inlay_hint_refresh_is_forwarded( } else { "initial hint" }; - Ok(Some(vec![lsp::InlayHint { - position: lsp::Position::new(0, character), - label: lsp::InlayHintLabel::String(label.to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) + Ok(Some(vec![ + lsp::InlayHint { + position: lsp::Position::new(0, character), + label: lsp::InlayHintLabel::String(label.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(1090, 1090), + label: lsp::InlayHintLabel::String("out-of-bounds hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + ])) } }) .next() @@ -2202,18 +2304,18 @@ async fn test_inlay_hint_refresh_is_forwarded( executor.finish_waiting(); executor.run_until_parked(); - editor_a.update(cx_a, |editor, _| { + editor_a.update(cx_a, |editor, cx| { assert!( - extract_hint_labels(editor).is_empty(), + extract_hint_labels(editor, cx).is_empty(), "Host should get no hints due to them turned off" ); }); executor.run_until_parked(); - editor_b.update(cx_b, |editor, _| { + editor_b.update(cx_b, |editor, cx| { assert_eq!( vec!["initial hint".to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), "Client should get its first hints when opens an editor" ); }); @@ -2225,18 +2327,18 @@ async fn test_inlay_hint_refresh_is_forwarded( .into_response() .expect("inlay refresh request failed"); executor.run_until_parked(); - editor_a.update(cx_a, |editor, _| { + editor_a.update(cx_a, |editor, cx| { assert!( - extract_hint_labels(editor).is_empty(), + extract_hint_labels(editor, cx).is_empty(), "Host should get no hints due to them turned off, even after the /refresh" ); }); executor.run_until_parked(); - editor_b.update(cx_b, |editor, _| { + editor_b.update(cx_b, |editor, cx| { assert_eq!( vec!["other hint".to_string()], - extract_hint_labels(editor), + extract_hint_labels(editor, cx), "Guest should get a /refresh LSP request propagated by host despite host hints are off" ); }); @@ -2405,6 +2507,7 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo .unwrap(); color_request_handle.next().await.unwrap(); + executor.advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT); executor.run_until_parked(); assert_eq!( @@ -2422,7 +2525,7 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo editor_a.update_in(cx_a, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13].clone()) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)].clone()) }); editor.handle_input(":", window, cx); }); @@ -2548,6 +2651,27 @@ async fn test_lsp_pull_diagnostics( cx_a.update(editor::init); cx_b.update(editor::init); + let expected_push_diagnostic_main_message = "pushed main diagnostic"; + let expected_push_diagnostic_lib_message = "pushed lib diagnostic"; + let expected_pull_diagnostic_main_message = "pulled main diagnostic"; + let expected_pull_diagnostic_lib_message = "pulled lib diagnostic"; + let expected_workspace_pull_diagnostics_main_message = "pulled workspace main diagnostic"; + let expected_workspace_pull_diagnostics_lib_message = "pulled workspace lib diagnostic"; + + let diagnostics_pulls_result_ids = Arc::new(Mutex::new(BTreeSet::>::new())); + let workspace_diagnostics_pulls_result_ids = Arc::new(Mutex::new(BTreeSet::::new())); + let diagnostics_pulls_made = Arc::new(AtomicUsize::new(0)); + let closure_diagnostics_pulls_made = diagnostics_pulls_made.clone(); + let closure_diagnostics_pulls_result_ids = diagnostics_pulls_result_ids.clone(); + let workspace_diagnostics_pulls_made = Arc::new(AtomicUsize::new(0)); + let closure_workspace_diagnostics_pulls_made = workspace_diagnostics_pulls_made.clone(); + let closure_workspace_diagnostics_pulls_result_ids = + workspace_diagnostics_pulls_result_ids.clone(); + let (workspace_diagnostic_cancel_tx, closure_workspace_diagnostic_cancel_rx) = + smol::channel::bounded::<()>(1); + let (closure_workspace_diagnostic_received_tx, workspace_diagnostic_received_rx) = + smol::channel::bounded::<()>(1); + let capabilities = lsp::ServerCapabilities { diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( lsp::DiagnosticOptions { @@ -2562,13 +2686,195 @@ async fn test_lsp_pull_diagnostics( ..lsp::ServerCapabilities::default() }; client_a.language_registry().add(rust_lang()); + + let pull_diagnostics_handle = Arc::new(parking_lot::Mutex::new(None)); + let workspace_diagnostics_pulls_handle = Arc::new(parking_lot::Mutex::new(None)); + + let closure_pull_diagnostics_handle = pull_diagnostics_handle.clone(); + let closure_workspace_diagnostics_pulls_handle = workspace_diagnostics_pulls_handle.clone(); let mut fake_language_servers = client_a.language_registry().register_fake_lsp( "Rust", FakeLspAdapter { capabilities: capabilities.clone(), + initializer: Some(Box::new(move |fake_language_server| { + let expected_workspace_diagnostic_token = lsp::ProgressToken::String(format!( + "workspace/diagnostic/{}/1", + fake_language_server.server.server_id() + )); + let closure_workspace_diagnostics_pulls_result_ids = closure_workspace_diagnostics_pulls_result_ids.clone(); + let diagnostics_pulls_made = closure_diagnostics_pulls_made.clone(); + let diagnostics_pulls_result_ids = closure_diagnostics_pulls_result_ids.clone(); + let closure_pull_diagnostics_handle = closure_pull_diagnostics_handle.clone(); + let closure_workspace_diagnostics_pulls_handle = closure_workspace_diagnostics_pulls_handle.clone(); + let closure_workspace_diagnostic_cancel_rx = closure_workspace_diagnostic_cancel_rx.clone(); + let closure_workspace_diagnostic_received_tx = closure_workspace_diagnostic_received_tx.clone(); + let pull_diagnostics_handle = fake_language_server + .set_request_handler::( + move |params, _| { + let requests_made = diagnostics_pulls_made.clone(); + let diagnostics_pulls_result_ids = + diagnostics_pulls_result_ids.clone(); + async move { + let message = if lsp::Uri::from_file_path(path!("/a/main.rs")) + .unwrap() + == params.text_document.uri + { + expected_pull_diagnostic_main_message.to_string() + } else if lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap() + == params.text_document.uri + { + expected_pull_diagnostic_lib_message.to_string() + } else { + panic!("Unexpected document: {}", params.text_document.uri) + }; + { + diagnostics_pulls_result_ids + .lock() + .await + .insert(params.previous_result_id); + } + let new_requests_count = + requests_made.fetch_add(1, atomic::Ordering::Release) + 1; + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full( + lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: Some(format!( + "pull-{new_requests_count}" + )), + items: vec![lsp::Diagnostic { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 0, + }, + end: lsp::Position { + line: 0, + character: 2, + }, + }, + severity: Some( + lsp::DiagnosticSeverity::ERROR, + ), + message, + ..lsp::Diagnostic::default() + }], + }, + }, + ), + )) + } + }, + ); + let _ = closure_pull_diagnostics_handle.lock().insert(pull_diagnostics_handle); + + let closure_workspace_diagnostics_pulls_made = closure_workspace_diagnostics_pulls_made.clone(); + let workspace_diagnostics_pulls_handle = fake_language_server.set_request_handler::( + move |params, _| { + let workspace_requests_made = closure_workspace_diagnostics_pulls_made.clone(); + let workspace_diagnostics_pulls_result_ids = + closure_workspace_diagnostics_pulls_result_ids.clone(); + let workspace_diagnostic_cancel_rx = closure_workspace_diagnostic_cancel_rx.clone(); + let workspace_diagnostic_received_tx = closure_workspace_diagnostic_received_tx.clone(); + let expected_workspace_diagnostic_token = expected_workspace_diagnostic_token.clone(); + async move { + let workspace_request_count = + workspace_requests_made.fetch_add(1, atomic::Ordering::Release) + 1; + { + workspace_diagnostics_pulls_result_ids + .lock() + .await + .extend(params.previous_result_ids.into_iter().map(|id| id.value)); + } + if should_stream_workspace_diagnostic && !workspace_diagnostic_cancel_rx.is_closed() + { + assert_eq!( + params.partial_result_params.partial_result_token, + Some(expected_workspace_diagnostic_token) + ); + workspace_diagnostic_received_tx.send(()).await.unwrap(); + workspace_diagnostic_cancel_rx.recv().await.unwrap(); + workspace_diagnostic_cancel_rx.close(); + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#partialResults + // > The final response has to be empty in terms of result values. + return Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )); + } + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { + items: vec![ + lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + version: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: Some(format!( + "workspace_{workspace_request_count}" + )), + items: vec![lsp::Diagnostic { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 1, + }, + end: lsp::Position { + line: 0, + character: 3, + }, + }, + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: + expected_workspace_pull_diagnostics_main_message + .to_string(), + ..lsp::Diagnostic::default() + }], + }, + }, + ), + lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap(), + version: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: Some(format!( + "workspace_{workspace_request_count}" + )), + items: vec![lsp::Diagnostic { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 1, + }, + end: lsp::Position { + line: 0, + character: 3, + }, + }, + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: + expected_workspace_pull_diagnostics_lib_message + .to_string(), + ..lsp::Diagnostic::default() + }], + }, + }, + ), + ], + }, + )) + } + }); + let _ = closure_workspace_diagnostics_pulls_handle.lock().insert(workspace_diagnostics_pulls_handle); + })), ..FakeLspAdapter::default() }, ); + client_b.language_registry().add(rust_lang()); client_b.language_registry().register_fake_lsp_adapter( "Rust", @@ -2626,183 +2932,15 @@ async fn test_lsp_pull_diagnostics( .unwrap(); let fake_language_server = fake_language_servers.next().await.unwrap(); - cx_a.run_until_parked(); - cx_b.run_until_parked(); - let expected_push_diagnostic_main_message = "pushed main diagnostic"; - let expected_push_diagnostic_lib_message = "pushed lib diagnostic"; - let expected_pull_diagnostic_main_message = "pulled main diagnostic"; - let expected_pull_diagnostic_lib_message = "pulled lib diagnostic"; - let expected_workspace_pull_diagnostics_main_message = "pulled workspace main diagnostic"; - let expected_workspace_pull_diagnostics_lib_message = "pulled workspace lib diagnostic"; - - let diagnostics_pulls_result_ids = Arc::new(Mutex::new(BTreeSet::>::new())); - let workspace_diagnostics_pulls_result_ids = Arc::new(Mutex::new(BTreeSet::::new())); - let diagnostics_pulls_made = Arc::new(AtomicUsize::new(0)); - let closure_diagnostics_pulls_made = diagnostics_pulls_made.clone(); - let closure_diagnostics_pulls_result_ids = diagnostics_pulls_result_ids.clone(); - let mut pull_diagnostics_handle = fake_language_server - .set_request_handler::(move |params, _| { - let requests_made = closure_diagnostics_pulls_made.clone(); - let diagnostics_pulls_result_ids = closure_diagnostics_pulls_result_ids.clone(); - async move { - let message = if lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap() - == params.text_document.uri - { - expected_pull_diagnostic_main_message.to_string() - } else if lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap() - == params.text_document.uri - { - expected_pull_diagnostic_lib_message.to_string() - } else { - panic!("Unexpected document: {}", params.text_document.uri) - }; - { - diagnostics_pulls_result_ids - .lock() - .await - .insert(params.previous_result_id); - } - let new_requests_count = requests_made.fetch_add(1, atomic::Ordering::Release) + 1; - Ok(lsp::DocumentDiagnosticReportResult::Report( - lsp::DocumentDiagnosticReport::Full(lsp::RelatedFullDocumentDiagnosticReport { - related_documents: None, - full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { - result_id: Some(format!("pull-{new_requests_count}")), - items: vec![lsp::Diagnostic { - range: lsp::Range { - start: lsp::Position { - line: 0, - character: 0, - }, - end: lsp::Position { - line: 0, - character: 2, - }, - }, - severity: Some(lsp::DiagnosticSeverity::ERROR), - message, - ..lsp::Diagnostic::default() - }], - }, - }), - )) - } - }); - - let workspace_diagnostics_pulls_made = Arc::new(AtomicUsize::new(0)); - let closure_workspace_diagnostics_pulls_made = workspace_diagnostics_pulls_made.clone(); - let closure_workspace_diagnostics_pulls_result_ids = - workspace_diagnostics_pulls_result_ids.clone(); - let (workspace_diagnostic_cancel_tx, closure_workspace_diagnostic_cancel_rx) = - smol::channel::bounded::<()>(1); - let (closure_workspace_diagnostic_received_tx, workspace_diagnostic_received_rx) = - smol::channel::bounded::<()>(1); let expected_workspace_diagnostic_token = lsp::ProgressToken::String(format!( "workspace/diagnostic-{}-1", fake_language_server.server.server_id() )); - let closure_expected_workspace_diagnostic_token = expected_workspace_diagnostic_token.clone(); - let mut workspace_diagnostics_pulls_handle = fake_language_server - .set_request_handler::( - move |params, _| { - let workspace_requests_made = closure_workspace_diagnostics_pulls_made.clone(); - let workspace_diagnostics_pulls_result_ids = - closure_workspace_diagnostics_pulls_result_ids.clone(); - let workspace_diagnostic_cancel_rx = closure_workspace_diagnostic_cancel_rx.clone(); - let workspace_diagnostic_received_tx = closure_workspace_diagnostic_received_tx.clone(); - let expected_workspace_diagnostic_token = - closure_expected_workspace_diagnostic_token.clone(); - async move { - let workspace_request_count = - workspace_requests_made.fetch_add(1, atomic::Ordering::Release) + 1; - { - workspace_diagnostics_pulls_result_ids - .lock() - .await - .extend(params.previous_result_ids.into_iter().map(|id| id.value)); - } - if should_stream_workspace_diagnostic && !workspace_diagnostic_cancel_rx.is_closed() - { - assert_eq!( - params.partial_result_params.partial_result_token, - Some(expected_workspace_diagnostic_token) - ); - workspace_diagnostic_received_tx.send(()).await.unwrap(); - workspace_diagnostic_cancel_rx.recv().await.unwrap(); - workspace_diagnostic_cancel_rx.close(); - // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#partialResults - // > The final response has to be empty in terms of result values. - return Ok(lsp::WorkspaceDiagnosticReportResult::Report( - lsp::WorkspaceDiagnosticReport { items: Vec::new() }, - )); - } - Ok(lsp::WorkspaceDiagnosticReportResult::Report( - lsp::WorkspaceDiagnosticReport { - items: vec![ - lsp::WorkspaceDocumentDiagnosticReport::Full( - lsp::WorkspaceFullDocumentDiagnosticReport { - uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), - version: None, - full_document_diagnostic_report: - lsp::FullDocumentDiagnosticReport { - result_id: Some(format!( - "workspace_{workspace_request_count}" - )), - items: vec![lsp::Diagnostic { - range: lsp::Range { - start: lsp::Position { - line: 0, - character: 1, - }, - end: lsp::Position { - line: 0, - character: 3, - }, - }, - severity: Some(lsp::DiagnosticSeverity::WARNING), - message: - expected_workspace_pull_diagnostics_main_message - .to_string(), - ..lsp::Diagnostic::default() - }], - }, - }, - ), - lsp::WorkspaceDocumentDiagnosticReport::Full( - lsp::WorkspaceFullDocumentDiagnosticReport { - uri: lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap(), - version: None, - full_document_diagnostic_report: - lsp::FullDocumentDiagnosticReport { - result_id: Some(format!( - "workspace_{workspace_request_count}" - )), - items: vec![lsp::Diagnostic { - range: lsp::Range { - start: lsp::Position { - line: 0, - character: 1, - }, - end: lsp::Position { - line: 0, - character: 3, - }, - }, - severity: Some(lsp::DiagnosticSeverity::WARNING), - message: - expected_workspace_pull_diagnostics_lib_message - .to_string(), - ..lsp::Diagnostic::default() - }], - }, - }, - ), - ], - }, - )) - } - }, - ); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + let mut pull_diagnostics_handle = pull_diagnostics_handle.lock().take().unwrap(); + let mut workspace_diagnostics_pulls_handle = + workspace_diagnostics_pulls_handle.lock().take().unwrap(); if should_stream_workspace_diagnostic { workspace_diagnostic_received_rx.recv().await.unwrap(); @@ -2824,7 +2962,7 @@ async fn test_lsp_pull_diagnostics( editor_a_main.update(cx_a, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let all_diagnostics = snapshot - .diagnostics_in_range(0..snapshot.len()) + .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); assert_eq!( all_diagnostics.len(), @@ -2844,7 +2982,7 @@ async fn test_lsp_pull_diagnostics( }); fake_language_server.notify::( - &lsp::PublishDiagnosticsParams { + lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range { @@ -2865,7 +3003,7 @@ async fn test_lsp_pull_diagnostics( }, ); fake_language_server.notify::( - &lsp::PublishDiagnosticsParams { + lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range { @@ -2887,7 +3025,7 @@ async fn test_lsp_pull_diagnostics( ); if should_stream_workspace_diagnostic { - fake_language_server.notify::(&lsp::ProgressParams { + fake_language_server.notify::(lsp::ProgressParams { token: expected_workspace_diagnostic_token.clone(), value: lsp::ProgressParamsValue::WorkspaceDiagnostic( lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { @@ -2953,7 +3091,7 @@ async fn test_lsp_pull_diagnostics( editor_a_main.update(cx_a, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let all_diagnostics = snapshot - .diagnostics_in_range(0..snapshot.len()) + .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); assert_eq!( all_diagnostics.len(), @@ -3000,7 +3138,7 @@ async fn test_lsp_pull_diagnostics( editor_b_main.update(cx_b, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let all_diagnostics = snapshot - .diagnostics_in_range(0..snapshot.len()) + .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); assert_eq!( all_diagnostics.len(), @@ -3047,17 +3185,16 @@ async fn test_lsp_pull_diagnostics( editor_b_lib.update(cx_b, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let all_diagnostics = snapshot - .diagnostics_in_range(0..snapshot.len()) + .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); let expected_messages = [ expected_pull_diagnostic_lib_message, - // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. - // expected_push_diagnostic_lib_message, + expected_push_diagnostic_lib_message, ]; assert_eq!( all_diagnostics.len(), - 1, - "Expected pull diagnostics, but got: {all_diagnostics:?}" + 2, + "Expected pull and push diagnostics, but got: {all_diagnostics:?}" ); for diagnostic in all_diagnostics { assert!( @@ -3069,7 +3206,7 @@ async fn test_lsp_pull_diagnostics( }); if should_stream_workspace_diagnostic { - fake_language_server.notify::(&lsp::ProgressParams { + fake_language_server.notify::(lsp::ProgressParams { token: expected_workspace_diagnostic_token.clone(), value: lsp::ProgressParamsValue::WorkspaceDiagnostic( lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { @@ -3114,17 +3251,18 @@ async fn test_lsp_pull_diagnostics( editor_b_lib.update(cx_b, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let all_diagnostics = snapshot - .diagnostics_in_range(0..snapshot.len()) + .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); let expected_messages = [ - expected_workspace_pull_diagnostics_lib_message, - // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. - // expected_push_diagnostic_lib_message, + // Despite workspace diagnostics provided, + // the currently open file's diagnostics should be preferred, as LSP suggests. + expected_pull_diagnostic_lib_message, + expected_push_diagnostic_lib_message, ]; assert_eq!( all_diagnostics.len(), - 1, - "Expected pull diagnostics, but got: {all_diagnostics:?}" + 2, + "Expected pull and push diagnostics, but got: {all_diagnostics:?}" ); for diagnostic in all_diagnostics { assert!( @@ -3237,8 +3375,9 @@ async fn test_lsp_pull_diagnostics( "Another workspace diagnostics pull should happen after the diagnostics refresh server request" ); { - assert!( - diagnostics_pulls_result_ids.lock().await.len() == diagnostic_pulls_result_ids, + assert_eq!( + diagnostics_pulls_result_ids.lock().await.len(), + diagnostic_pulls_result_ids, "Pulls should not happen hence no extra ids should appear" ); assert!( @@ -3249,14 +3388,14 @@ async fn test_lsp_pull_diagnostics( editor_b_lib.update(cx_b, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let all_diagnostics = snapshot - .diagnostics_in_range(0..snapshot.len()) + .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); let expected_messages = [ expected_workspace_pull_diagnostics_lib_message, expected_pull_diagnostic_lib_message, expected_push_diagnostic_lib_message, ]; - assert_eq!(all_diagnostics.len(), 1); + assert_eq!(all_diagnostics.len(), 2); for diagnostic in &all_diagnostics { assert!( expected_messages.contains(&diagnostic.diagnostic.message.as_str()), @@ -3267,7 +3406,7 @@ async fn test_lsp_pull_diagnostics( editor_b_main.update(cx_b, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let all_diagnostics = snapshot - .diagnostics_in_range(0..snapshot.len()) + .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); assert_eq!(all_diagnostics.len(), 2); @@ -3286,7 +3425,7 @@ async fn test_lsp_pull_diagnostics( editor_a_main.update(cx_a, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let all_diagnostics = snapshot - .diagnostics_in_range(0..snapshot.len()) + .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); assert_eq!(all_diagnostics.len(), 2); let expected_messages = [ @@ -3375,7 +3514,6 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA .into_iter() .map(|(sha, message)| (sha.parse().unwrap(), message.into())) .collect(), - remote_url: Some("git@github.com:zed-industries/zed.git".to_string()), }; client_a.fs().set_blame_for_repo( Path::new(path!("/my-repo/.git")), @@ -3460,10 +3598,6 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA for (idx, (buffer, entry)) in entries.iter().flatten().enumerate() { let details = blame.details_for_entry(*buffer, entry).unwrap(); assert_eq!(details.message, format!("message for idx-{}", idx)); - assert_eq!( - details.permalink.unwrap().to_string(), - format!("https://github.com/zed-industries/zed/commit/{}", entry.sha) - ); } }); }); @@ -4135,6 +4269,288 @@ async fn test_client_can_query_lsp_ext(cx_a: &mut TestAppContext, cx_b: &mut Tes }); } +#[gpui::test] +async fn test_copy_file_name_without_extension( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": indoc! {" + fn main() { + println!(\"Hello, world!\"); + } + "}, + } + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let editor_a = workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some("main".to_string()) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some("main".to_string()) + ); +} + +#[gpui::test] +async fn test_copy_file_name(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": indoc! {" + fn main() { + println!(\"Hello, world!\"); + } + "}, + } + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let editor_a = workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.copy_file_name(&CopyFileName, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some("main.rs".to_string()) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.copy_file_name(&CopyFileName, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some("main.rs".to_string()) + ); +} + +#[gpui::test] +async fn test_copy_file_location(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": indoc! {" + fn main() { + println!(\"Hello, world!\"); + } + "}, + } + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let editor_a = workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(16)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2", path!("src/main.rs"))) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(16)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2", path!("src/main.rs"))) + ); +} + #[track_caller] fn tab_undo_assert( cx_a: &mut EditorTestContext, @@ -4177,15 +4593,35 @@ fn tab_undo_assert( cx_b.assert_editor_state(expected_initial); } -fn extract_hint_labels(editor: &Editor) -> Vec { - let mut labels = Vec::new(); - for hint in editor.inlay_hint_cache().hints() { - match hint.label { - project::InlayHintLabel::String(s) => labels.push(s), - _ => unreachable!(), - } +fn extract_hint_labels(editor: &Editor, cx: &mut App) -> Vec { + let lsp_store = editor.project().unwrap().read(cx).lsp_store(); + + let mut all_cached_labels = Vec::new(); + let mut all_fetched_hints = Vec::new(); + for buffer in editor.buffer().read(cx).all_buffers() { + lsp_store.update(cx, |lsp_store, cx| { + let hints = &lsp_store.latest_lsp_data(&buffer, cx).inlay_hints(); + all_cached_labels.extend(hints.all_cached_hints().into_iter().map(|hint| { + let mut label = hint.text().to_string(); + if hint.padding_left { + label.insert(0, ' '); + } + if hint.padding_right { + label.push_str(" "); + } + label + })); + all_fetched_hints.extend(hints.all_fetched_hints()); + }); } - labels + + assert!( + all_fetched_hints.is_empty(), + "Did not expect background hints fetch tasks, but got {} of them", + all_fetched_hints.len() + ); + + all_cached_labels } #[track_caller] diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index 6f4a819f44..ec654e0634 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -6,7 +6,7 @@ use collab_ui::{ channel_view::ChannelView, notifications::project_shared_notification::ProjectSharedNotification, }; -use editor::{Editor, MultiBuffer, PathKey, SelectionEffects}; +use editor::{Editor, MultiBuffer, MultiBufferOffset, PathKey, SelectionEffects}; use gpui::{ AppContext as _, BackgroundExecutor, BorrowAppContext, Entity, SharedString, TestAppContext, VisualContext, VisualTestContext, point, @@ -122,13 +122,19 @@ async fn test_basic_following( editor.handle_input("b", window, cx); editor.handle_input("c", window, cx); editor.select_left(&Default::default(), window, cx); - assert_eq!(editor.selections.ranges(cx), vec![3..2]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(3)..MultiBufferOffset(2)] + ); }); editor_a2.update_in(cx_a, |editor, window, cx| { editor.handle_input("d", window, cx); editor.handle_input("e", window, cx); editor.select_left(&Default::default(), window, cx); - assert_eq!(editor.selections.ranges(cx), vec![2..1]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(2)..MultiBufferOffset(1)] + ); }); // When client B starts following client A, only the active view state is replicated to client B. @@ -149,12 +155,16 @@ async fn test_basic_following( Some((worktree_id, rel_path("2.txt")).into()) ); assert_eq!( - editor_b2.update(cx_b, |editor, cx| editor.selections.ranges(cx)), - vec![2..1] + editor_b2.update(cx_b, |editor, cx| editor + .selections + .ranges(&editor.display_snapshot(cx))), + vec![MultiBufferOffset(2)..MultiBufferOffset(1)] ); assert_eq!( - editor_b1.update(cx_b, |editor, cx| editor.selections.ranges(cx)), - vec![3..3] + editor_b1.update(cx_b, |editor, cx| editor + .selections + .ranges(&editor.display_snapshot(cx))), + vec![MultiBufferOffset(3)..MultiBufferOffset(3)] ); executor.run_until_parked(); @@ -376,7 +386,10 @@ async fn test_basic_following( // Changes to client A's editor are reflected on client B. editor_a1.update_in(cx_a, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([1..1, 2..2]) + s.select_ranges([ + MultiBufferOffset(1)..MultiBufferOffset(1), + MultiBufferOffset(2)..MultiBufferOffset(2), + ]) }); }); executor.advance_clock(workspace::item::LEADER_UPDATE_THROTTLE); @@ -384,7 +397,13 @@ async fn test_basic_following( cx_b.background_executor.run_until_parked(); editor_b1.update(cx_b, |editor, cx| { - assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + MultiBufferOffset(1)..MultiBufferOffset(1), + MultiBufferOffset(2)..MultiBufferOffset(2) + ] + ); }); editor_a1.update_in(cx_a, |editor, window, cx| { @@ -395,14 +414,17 @@ async fn test_basic_following( editor_a1.update_in(cx_a, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([3..3]) + s.select_ranges([MultiBufferOffset(3)..MultiBufferOffset(3)]) }); editor.set_scroll_position(point(0., 100.), window, cx); }); executor.advance_clock(workspace::item::LEADER_UPDATE_THROTTLE); executor.run_until_parked(); editor_b1.update(cx_b, |editor, cx| { - assert_eq!(editor.selections.ranges(cx), &[3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[MultiBufferOffset(3)..MultiBufferOffset(3)] + ); }); // After unfollowing, client B stops receiving updates from client A. @@ -507,7 +529,7 @@ async fn test_basic_following( }); // Client B activates a panel, and the previously-opened screen-sharing item gets activated. - let panel = cx_b.new(|cx| TestPanel::new(DockPosition::Left, cx)); + let panel = cx_b.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); workspace_b.update_in(cx_b, |workspace, window, cx| { workspace.add_panel(panel, window, cx); workspace.toggle_panel_focus::(window, cx); @@ -760,26 +782,30 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T .unwrap(); // Clients A and B follow each other in split panes - workspace_a.update_in(cx_a, |workspace, window, cx| { - workspace.split_and_clone( - workspace.active_pane().clone(), - SplitDirection::Right, - window, - cx, - ); - }); + workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.split_and_clone( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ) + }) + .await; workspace_a.update_in(cx_a, |workspace, window, cx| { workspace.follow(client_b.peer_id().unwrap(), window, cx) }); executor.run_until_parked(); - workspace_b.update_in(cx_b, |workspace, window, cx| { - workspace.split_and_clone( - workspace.active_pane().clone(), - SplitDirection::Right, - window, - cx, - ); - }); + workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.split_and_clone( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ) + }) + .await; workspace_b.update_in(cx_b, |workspace, window, cx| { workspace.follow(client_a.peer_id().unwrap(), window, cx) }); @@ -1353,9 +1379,11 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont ); // When client B activates a different pane, it continues following client A in the original pane. - workspace_b.update_in(cx_b, |workspace, window, cx| { - workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, window, cx) - }); + workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, window, cx) + }) + .await; assert_eq!( workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), Some(leader_id.into()) @@ -1672,14 +1700,17 @@ async fn test_following_stops_on_unshare(cx_a: &mut TestAppContext, cx_b: &mut T // b should follow a to position 1 editor_a.update_in(cx_a, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([1..1]) + s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) }) }); cx_a.executor() .advance_clock(workspace::item::LEADER_UPDATE_THROTTLE); cx_a.run_until_parked(); editor_b.update(cx_b, |editor, cx| { - assert_eq!(editor.selections.ranges(cx), vec![1..1]) + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(1)..MultiBufferOffset(1)] + ) }); // a unshares the project @@ -1694,14 +1725,17 @@ async fn test_following_stops_on_unshare(cx_a: &mut TestAppContext, cx_b: &mut T // b should not follow a to position 2 editor_a.update_in(cx_a, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([2..2]) + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(2)]) }) }); cx_a.executor() .advance_clock(workspace::item::LEADER_UPDATE_THROTTLE); cx_a.run_until_parked(); editor_b.update(cx_b, |editor, cx| { - assert_eq!(editor.selections.ranges(cx), vec![1..1]) + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(1)..MultiBufferOffset(1)] + ) }); cx_b.update(|_, cx| { let room = ActiveCall::global(cx).read(cx).room().unwrap().read(cx); @@ -1799,13 +1833,19 @@ async fn test_following_into_excluded_file( editor.handle_input("b", window, cx); editor.handle_input("c", window, cx); editor.select_left(&Default::default(), window, cx); - assert_eq!(editor.selections.ranges(cx), vec![3..2]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(3)..MultiBufferOffset(2)] + ); }); editor_for_excluded_a.update_in(cx_a, |editor, window, cx| { editor.select_all(&Default::default(), window, cx); editor.handle_input("new commit message", window, cx); editor.select_left(&Default::default(), window, cx); - assert_eq!(editor.selections.ranges(cx), vec![18..17]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(18)..MultiBufferOffset(17)] + ); }); // When client B starts following client A, currently visible file is replicated @@ -1827,8 +1867,10 @@ async fn test_following_into_excluded_file( Some((worktree_id, rel_path(".git/COMMIT_EDITMSG")).into()) ); assert_eq!( - editor_for_excluded_b.update(cx_b, |editor, cx| editor.selections.ranges(cx)), - vec![18..17] + editor_for_excluded_b.update(cx_b, |editor, cx| editor + .selections + .ranges(&editor.display_snapshot(cx))), + vec![MultiBufferOffset(18)..MultiBufferOffset(17)] ); editor_for_excluded_a.update_in(cx_a, |editor, window, cx| { @@ -2004,7 +2046,7 @@ async fn test_following_to_channel_notes_without_a_shared_project( notes.editor.update(cx, |editor, cx| { editor.insert("Hello from A.", window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - selections.select_ranges(vec![3..4]); + selections.select_ranges(vec![MultiBufferOffset(3)..MultiBufferOffset(4)]); }); }); }); @@ -2037,7 +2079,12 @@ async fn test_following_to_channel_notes_without_a_shared_project( assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); notes.editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), "Hello from A."); - assert_eq!(editor.selections.ranges::(cx), &[3..4]); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + &[MultiBufferOffset(3)..MultiBufferOffset(4)] + ); }) }); diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index d3cd87ad6b..391e7355ea 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2,12 +2,12 @@ use crate::{ rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, tests::{ RoomParticipants, TestClient, TestServer, channel_id, following_tests::join_channel, - room_participants, rust_lang, + room_participants, }, }; use anyhow::{Result, anyhow}; -use assistant_context::ContextStore; use assistant_slash_command::SlashCommandWorkingSet; +use assistant_text_thread::TextThreadStore; use buffer_diff::{DiffHunkSecondaryStatus, DiffHunkStatus, assert_hunks}; use call::{ActiveCall, ParticipantLocation, Room, room}; use client::{RECEIVE_TIMEOUT, User}; @@ -25,8 +25,8 @@ use gpui::{ use language::{ Diagnostic, DiagnosticEntry, DiagnosticSourceKind, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope, - language_settings::{Formatter, FormatterList, SelectedFormatter}, - tree_sitter_rust, tree_sitter_typescript, + language_settings::{Formatter, FormatterList}, + rust_lang, tree_sitter_rust, tree_sitter_typescript, }; use lsp::{LanguageServerId, OneOf}; use parking_lot::Mutex; @@ -39,7 +39,7 @@ use project::{ use prompt_store::PromptBuilder; use rand::prelude::*; use serde_json::json; -use settings::{PrettierSettingsContent, SettingsStore}; +use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore}; use std::{ cell::{Cell, RefCell}, env, future, mem, @@ -4077,7 +4077,7 @@ async fn test_collaborating_with_diagnostics( .receive_notification::() .await; fake_language_server.notify::( - &lsp::PublishDiagnosticsParams { + lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/a/a.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -4097,7 +4097,7 @@ async fn test_collaborating_with_diagnostics( .await .unwrap(); fake_language_server.notify::( - &lsp::PublishDiagnosticsParams { + lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/a/a.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -4171,7 +4171,7 @@ async fn test_collaborating_with_diagnostics( // Simulate a language server reporting more errors for a file. fake_language_server.notify::( - &lsp::PublishDiagnosticsParams { + lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/a/a.rs")).unwrap(), version: None, diagnostics: vec![ @@ -4269,7 +4269,7 @@ async fn test_collaborating_with_diagnostics( // Simulate a language server reporting no errors for a file. fake_language_server.notify::( - &lsp::PublishDiagnosticsParams { + lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/a/a.rs")).unwrap(), version: None, diagnostics: Vec::new(), @@ -4365,7 +4365,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( .await .into_response() .unwrap(); - fake_language_server.notify::(&lsp::ProgressParams { + fake_language_server.notify::(lsp::ProgressParams { token: lsp::NumberOrString::String("the-disk-based-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin( lsp::WorkDoneProgressBegin { @@ -4376,7 +4376,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( }); for file_name in file_names { fake_language_server.notify::( - &lsp::PublishDiagnosticsParams { + lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(Path::new(path!("/test")).join(file_name)).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -4389,7 +4389,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( }, ); } - fake_language_server.notify::(&lsp::ProgressParams { + fake_language_server.notify::(lsp::ProgressParams { token: lsp::NumberOrString::String("the-disk-based-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End( lsp::WorkDoneProgressEnd { message: None }, @@ -4610,14 +4610,13 @@ async fn test_formatting_buffer( cx_a.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |file| { - file.project.all_languages.defaults.formatter = Some(SelectedFormatter::List( - FormatterList::Single(Formatter::External { + file.project.all_languages.defaults.formatter = + Some(FormatterList::Single(Formatter::External { command: "awk".into(), arguments: Some( vec!["{sub(/two/,\"{buffer_path}\")}1".to_string()].into(), ), - }), - )); + })); }); }); }); @@ -4708,7 +4707,7 @@ async fn test_prettier_formatting_buffer( cx_a.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |file| { - file.project.all_languages.defaults.formatter = Some(SelectedFormatter::Auto); + file.project.all_languages.defaults.formatter = Some(FormatterList::default()); file.project.all_languages.defaults.prettier = Some(PrettierSettingsContent { allowed: Some(true), ..Default::default() @@ -4719,8 +4718,8 @@ async fn test_prettier_formatting_buffer( cx_b.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |file| { - file.project.all_languages.defaults.formatter = Some(SelectedFormatter::List( - FormatterList::Single(Formatter::LanguageServer { name: None }), + file.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(LanguageServerFormatterSpecifier::Current), )); file.project.all_languages.defaults.prettier = Some(PrettierSettingsContent { allowed: Some(true), @@ -6552,12 +6551,12 @@ async fn test_pane_split_left(cx: &mut TestAppContext) { assert!(workspace.items(cx).collect::>().len() == 2); }); cx.simulate_keystrokes("cmd-k"); - // sleep for longer than the timeout in keyboard shortcut handling - // to verify that it doesn't fire in this case. + // Sleep past the historical timeout to ensure the multi-stroke binding + // still fires now that unambiguous prefixes no longer auto-expire. cx.executor().advance_clock(Duration::from_secs(2)); cx.simulate_keystrokes("left"); workspace.update(cx, |workspace, cx| { - assert!(workspace.items(cx).collect::>().len() == 2); + assert!(workspace.items(cx).collect::>().len() == 3); }); } @@ -6749,7 +6748,7 @@ async fn test_preview_tabs(cx: &mut TestAppContext) { pane.update(cx, |pane, cx| { pane.split(workspace::SplitDirection::Right, cx); }); - + cx.run_until_parked(); let right_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); pane.update(cx, |pane, cx| { @@ -6878,9 +6877,9 @@ async fn test_context_collaboration_with_reconnect( }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context_store_a = cx_a + let text_thread_store_a = cx_a .update(|cx| { - ContextStore::new( + TextThreadStore::new( project_a.clone(), prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), @@ -6889,9 +6888,9 @@ async fn test_context_collaboration_with_reconnect( }) .await .unwrap(); - let context_store_b = cx_b + let text_thread_store_b = cx_b .update(|cx| { - ContextStore::new( + TextThreadStore::new( project_b.clone(), prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), @@ -6902,60 +6901,60 @@ async fn test_context_collaboration_with_reconnect( .unwrap(); // Client A creates a new chats. - let context_a = context_store_a.update(cx_a, |store, cx| store.create(cx)); + let text_thread_a = text_thread_store_a.update(cx_a, |store, cx| store.create(cx)); executor.run_until_parked(); // Client B retrieves host's contexts and joins one. - let context_b = context_store_b + let text_thread_b = text_thread_store_b .update(cx_b, |store, cx| { - let host_contexts = store.host_contexts().to_vec(); - assert_eq!(host_contexts.len(), 1); - store.open_remote_context(host_contexts[0].id.clone(), cx) + let host_text_threads = store.host_text_threads().collect::>(); + assert_eq!(host_text_threads.len(), 1); + store.open_remote(host_text_threads[0].id.clone(), cx) }) .await .unwrap(); // Host and guest make changes - context_a.update(cx_a, |context, cx| { - context.buffer().update(cx, |buffer, cx| { + text_thread_a.update(cx_a, |text_thread, cx| { + text_thread.buffer().update(cx, |buffer, cx| { buffer.edit([(0..0, "Host change\n")], None, cx) }) }); - context_b.update(cx_b, |context, cx| { - context.buffer().update(cx, |buffer, cx| { + text_thread_b.update(cx_b, |text_thread, cx| { + text_thread.buffer().update(cx, |buffer, cx| { buffer.edit([(0..0, "Guest change\n")], None, cx) }) }); executor.run_until_parked(); assert_eq!( - context_a.read_with(cx_a, |context, cx| context.buffer().read(cx).text()), + text_thread_a.read_with(cx_a, |text_thread, cx| text_thread.buffer().read(cx).text()), "Guest change\nHost change\n" ); assert_eq!( - context_b.read_with(cx_b, |context, cx| context.buffer().read(cx).text()), + text_thread_b.read_with(cx_b, |text_thread, cx| text_thread.buffer().read(cx).text()), "Guest change\nHost change\n" ); // Disconnect client A and make some changes while disconnected. server.disconnect_client(client_a.peer_id().unwrap()); server.forbid_connections(); - context_a.update(cx_a, |context, cx| { - context.buffer().update(cx, |buffer, cx| { + text_thread_a.update(cx_a, |text_thread, cx| { + text_thread.buffer().update(cx, |buffer, cx| { buffer.edit([(0..0, "Host offline change\n")], None, cx) }) }); - context_b.update(cx_b, |context, cx| { - context.buffer().update(cx, |buffer, cx| { + text_thread_b.update(cx_b, |text_thread, cx| { + text_thread.buffer().update(cx, |buffer, cx| { buffer.edit([(0..0, "Guest offline change\n")], None, cx) }) }); executor.run_until_parked(); assert_eq!( - context_a.read_with(cx_a, |context, cx| context.buffer().read(cx).text()), + text_thread_a.read_with(cx_a, |text_thread, cx| text_thread.buffer().read(cx).text()), "Host offline change\nGuest change\nHost change\n" ); assert_eq!( - context_b.read_with(cx_b, |context, cx| context.buffer().read(cx).text()), + text_thread_b.read_with(cx_b, |text_thread, cx| text_thread.buffer().read(cx).text()), "Guest offline change\nGuest change\nHost change\n" ); @@ -6963,11 +6962,11 @@ async fn test_context_collaboration_with_reconnect( server.allow_connections(); executor.advance_clock(RECEIVE_TIMEOUT); assert_eq!( - context_a.read_with(cx_a, |context, cx| context.buffer().read(cx).text()), + text_thread_a.read_with(cx_a, |text_thread, cx| text_thread.buffer().read(cx).text()), "Guest offline change\nHost offline change\nGuest change\nHost change\n" ); assert_eq!( - context_b.read_with(cx_b, |context, cx| context.buffer().read(cx).text()), + text_thread_b.read_with(cx_b, |text_thread, cx| text_thread.buffer().read(cx).text()), "Guest offline change\nHost offline change\nGuest change\nHost change\n" ); @@ -6975,8 +6974,8 @@ async fn test_context_collaboration_with_reconnect( server.forbid_connections(); server.disconnect_client(client_a.peer_id().unwrap()); executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); - context_b.read_with(cx_b, |context, cx| { - assert!(context.buffer().read(cx).read_only()); + text_thread_b.read_with(cx_b, |text_thread, cx| { + assert!(text_thread.buffer().read(cx).read_only()); }); } @@ -7066,7 +7065,7 @@ async fn test_remote_git_branches( // Also try creating a new branch cx_b.update(|cx| { repo_b.update(cx, |repository, _cx| { - repository.create_branch("totally-new-branch".to_string()) + repository.create_branch("totally-new-branch".to_string(), None) }) }) .await diff --git a/crates/collab/src/tests/randomized_test_helpers.rs b/crates/collab/src/tests/randomized_test_helpers.rs index 9a372017e3..11c9f1c338 100644 --- a/crates/collab/src/tests/randomized_test_helpers.rs +++ b/crates/collab/src/tests/randomized_test_helpers.rs @@ -183,9 +183,10 @@ pub async fn run_randomized_test( for (client, cx) in clients { cx.update(|cx| { - let store = cx.remove_global::(); + let settings = cx.remove_global::(); cx.clear_globals(); - cx.set_global(store); + cx.set_global(settings); + theme::init(theme::LoadThemes::JustBase, cx); drop(client); }); } diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 84ee9a3390..04403de9fa 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -7,14 +7,11 @@ use debugger_ui::debugger_panel::DebugPanel; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs as _, RemoveOptions}; use futures::StreamExt as _; -use gpui::{ - AppContext as _, BackgroundExecutor, SemanticVersion, TestAppContext, UpdateGlobal as _, - VisualContext, -}; +use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext}; use http_client::BlockedHttpClient; use language::{ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, - language_settings::{Formatter, FormatterList, SelectedFormatter, language_settings}, + language_settings::{Formatter, FormatterList, language_settings}, tree_sitter_typescript, }; use node_runtime::NodeRuntime; @@ -27,7 +24,7 @@ use remote::RemoteClient; use remote_server::{HeadlessAppState, HeadlessProject}; use rpc::proto; use serde_json::json; -use settings::{PrettierSettingsContent, SettingsStore}; +use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore}; use std::{ path::Path, sync::{Arc, atomic::AtomicUsize}, @@ -43,10 +40,10 @@ async fn test_sharing_an_ssh_remote_project( ) { let executor = cx_a.executor(); cx_a.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); server_cx.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); let mut server = TestServer::start(executor.clone()).await; let client_a = server.create_client(cx_a, "user_a").await; @@ -84,7 +81,6 @@ async fn test_sharing_an_ssh_remote_project( 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, @@ -212,10 +208,10 @@ async fn test_ssh_collaboration_git_branches( server_cx.set_name("server"); cx_a.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); server_cx.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); let mut server = TestServer::start(executor.clone()).await; @@ -245,7 +241,6 @@ async fn test_ssh_collaboration_git_branches( 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, @@ -328,7 +323,7 @@ async fn test_ssh_collaboration_git_branches( // Also try creating a new branch cx_b.update(|cx| { repo_b.update(cx, |repo_b, _cx| { - repo_b.create_branch("totally-new-branch".to_string()) + repo_b.create_branch("totally-new-branch".to_string(), None) }) }) .await @@ -398,10 +393,10 @@ async fn test_ssh_collaboration_formatting_with_prettier( server_cx.set_name("server"); cx_a.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); server_cx.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); let mut server = TestServer::start(executor.clone()).await; @@ -450,7 +445,6 @@ async fn test_ssh_collaboration_formatting_with_prettier( server_cx.update(HeadlessProject::init); let remote_http_client = Arc::new(BlockedHttpClient); let _headless_project = server_cx.new(|cx| { - client::init_settings(cx); HeadlessProject::new( HeadlessAppState { session: server_ssh, @@ -491,7 +485,7 @@ async fn test_ssh_collaboration_formatting_with_prettier( cx_a.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |file| { - file.project.all_languages.defaults.formatter = Some(SelectedFormatter::Auto); + file.project.all_languages.defaults.formatter = Some(FormatterList::default()); file.project.all_languages.defaults.prettier = Some(PrettierSettingsContent { allowed: Some(true), ..Default::default() @@ -502,8 +496,8 @@ async fn test_ssh_collaboration_formatting_with_prettier( cx_b.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |file| { - file.project.all_languages.defaults.formatter = Some(SelectedFormatter::List( - FormatterList::Single(Formatter::LanguageServer { name: None }), + file.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(LanguageServerFormatterSpecifier::Current), )); file.project.all_languages.defaults.prettier = Some(PrettierSettingsContent { allowed: Some(true), @@ -550,7 +544,7 @@ async fn test_ssh_collaboration_formatting_with_prettier( cx_a.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |file| { - file.project.all_languages.defaults.formatter = Some(SelectedFormatter::Auto); + file.project.all_languages.defaults.formatter = Some(FormatterList::default()); file.project.all_languages.defaults.prettier = Some(PrettierSettingsContent { allowed: Some(true), ..Default::default() @@ -586,13 +580,13 @@ async fn test_remote_server_debugger( executor: BackgroundExecutor, ) { cx_a.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); command_palette_hooks::init(cx); zlog::init_test(); dap_adapters::init(cx); }); server_cx.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); dap_adapters::init(cx); }); let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); @@ -612,7 +606,6 @@ async fn test_remote_server_debugger( 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, @@ -695,13 +688,13 @@ async fn test_slow_adapter_startup_retries( executor: BackgroundExecutor, ) { cx_a.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); command_palette_hooks::init(cx); zlog::init_test(); dap_adapters::init(cx); }); server_cx.update(|cx| { - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); dap_adapters::init(cx); }); let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); @@ -721,7 +714,6 @@ async fn test_slow_adapter_startup_retries( 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, diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index fef931c0d8..959d54cf08 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -31,7 +31,6 @@ use rpc::{ RECEIVE_TIMEOUT, proto::{self, ChannelRole}, }; -use semantic_version::SemanticVersion; use serde_json::json; use session::{AppSession, Session}; use settings::SettingsStore; @@ -172,8 +171,8 @@ impl TestServer { } let settings = SettingsStore::test(cx); cx.set_global(settings); - release_channel::init(SemanticVersion::default(), cx); - client::init_settings(cx); + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); let clock = Arc::new(FakeSystemClock::new()); @@ -295,7 +294,7 @@ impl TestServer { server_conn, client_name, Principal::User(user), - ZedVersion(SemanticVersion::new(1, 0, 0)), + ZedVersion(semver::Version::new(1, 0, 0)), Some("test".to_string()), None, None, @@ -344,7 +343,6 @@ impl TestServer { theme::init(theme::LoadThemes::JustBase, cx); Project::init(&client, cx); client::init(&client, cx); - language::init(cx); editor::init(cx); workspace::init(app_state.clone(), cx); call::init(client.clone(), user_store.clone(), cx); @@ -357,8 +355,7 @@ impl TestServer { settings::KeymapFile::load_asset_allow_partial_failure(os_keymap, cx).unwrap(), ); language_model::LanguageModelRegistry::test(cx); - assistant_context::init(client.clone(), cx); - agent_settings::init(cx); + assistant_text_thread::init(client.clone(), cx); }); client diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 24202445a7..4abeb1324c 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -60,7 +60,6 @@ title_bar.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] call = { workspace = true, features = ["test-support"] } diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index e37abbbccd..8959c6ccbe 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -11,7 +11,7 @@ use editor::{ display_map::ToDisplayPoint, scroll::Autoscroll, }; use gpui::{ - AnyView, App, ClipboardItem, Context, Entity, EventEmitter, Focusable, Pixels, Point, Render, + App, ClipboardItem, Context, Entity, EventEmitter, Focusable, Pixels, Point, Render, Subscription, Task, VisualContext as _, WeakEntity, Window, actions, }; use project::Project; @@ -25,7 +25,7 @@ use util::ResultExt; use workspace::{CollaboratorId, item::TabContentParams}; use workspace::{ ItemNavHistory, Pane, SaveIntent, Toast, ViewId, Workspace, WorkspaceId, - item::{FollowableItem, Item, ItemEvent, ItemHandle}, + item::{FollowableItem, Item, ItemEvent}, searchable::SearchableItemHandle, }; use workspace::{item::Dedup, notifications::NotificationId}; @@ -287,9 +287,12 @@ impl ChannelView { } fn copy_link(&mut self, _: &CopyLink, window: &mut Window, cx: &mut Context) { - let position = self - .editor - .update(cx, |editor, cx| editor.selections.newest_display(cx).start); + let position = self.editor.update(cx, |editor, cx| { + editor + .selections + .newest_display(&editor.display_snapshot(cx)) + .start + }); self.copy_link_for_position(position, window, cx) } @@ -438,11 +441,11 @@ impl Item for ChannelView { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.clone().into()) } else { None } @@ -490,13 +493,17 @@ impl Item for ChannelView { None } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _: Option, window: &mut Window, cx: &mut Context, - ) -> Option> { - Some(cx.new(|cx| { + ) -> Task>> { + Task::ready(Some(cx.new(|cx| { Self::new( self.project.clone(), self.workspace.clone(), @@ -505,7 +512,7 @@ impl Item for ChannelView { window, cx, ) - })) + }))) } fn navigate( @@ -534,7 +541,7 @@ impl Item for ChannelView { }) } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index f42c12ac57..2f1e2842cb 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -7,10 +7,11 @@ use anyhow::Context as _; use call::ActiveCall; use channel::{Channel, ChannelEvent, ChannelStore}; use client::{ChannelId, Client, Contact, User, UserStore}; +use collections::{HashMap, HashSet}; use contact_finder::ContactFinder; use db::kvp::KEY_VALUE_STORE; use editor::{Editor, EditorElement, EditorStyle}; -use fuzzy::{StringMatchCandidate, match_strings}; +use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent, Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement, @@ -30,13 +31,13 @@ use smallvec::SmallVec; use std::{mem, sync::Arc}; use theme::{ActiveTheme, ThemeSettings}; use ui::{ - Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Facepile, Icon, IconButton, - IconName, IconSize, Indicator, Label, ListHeader, ListItem, Tooltip, prelude::*, - tooltip_container, + Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Facepile, HighlightedLabel, + Icon, IconButton, IconName, IconSize, Indicator, Label, ListHeader, ListItem, Tab, Tooltip, + prelude::*, tooltip_container, }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ - Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, + CopyRoomId, Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyResultExt}, }; @@ -54,6 +55,10 @@ actions!( CollapseSelectedChannel, /// Expands the selected channel in the tree view. ExpandSelectedChannel, + /// Opens the meeting notes for the selected channel in the panel. + /// + /// Use `collab::OpenChannelNotes` to open the channel notes for the current call. + OpenSelectedChannelNotes, /// Starts moving a channel to a new location. StartMoveChannel, /// Moves the selected item to the current location. @@ -104,25 +109,37 @@ pub fn init(cx: &mut App) { }); // TODO: make it possible to bind this one to a held key for push to talk? // how to make "toggle_on_modifiers_press" contextual? - workspace.register_action(|_, _: &Mute, window, cx| { - let room = ActiveCall::global(cx).read(cx).room().cloned(); - if let Some(room) = room { - window.defer(cx, move |_window, cx| { - room.update(cx, |room, cx| room.toggle_mute(cx)) - }); - } - }); - workspace.register_action(|_, _: &Deafen, window, cx| { - let room = ActiveCall::global(cx).read(cx).room().cloned(); - if let Some(room) = room { - window.defer(cx, move |_window, cx| { - room.update(cx, |room, cx| room.toggle_deafen(cx)) - }); - } - }); + workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx)); + workspace.register_action(|_, _: &Deafen, _, cx| title_bar::collab::toggle_deafen(cx)); workspace.register_action(|_, _: &LeaveCall, window, cx| { CollabPanel::leave_call(window, cx); }); + workspace.register_action(|workspace, _: &CopyRoomId, window, cx| { + use workspace::notifications::{NotificationId, NotifyTaskExt as _}; + + struct RoomIdCopiedToast; + + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let romo_id_fut = room.read(cx).room_id(); + cx.spawn(async move |workspace, cx| { + let room_id = romo_id_fut.await.context("Failed to get livekit room")?; + workspace.update(cx, |workspace, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(room_id)); + workspace.show_toast( + workspace::Toast::new( + NotificationId::unique::(), + "Room ID copied to clipboard", + ) + .autohide(), + cx, + ); + }) + }) + .detach_and_notify_err(window, cx); + } else { + workspace.show_error(&"There’s no active call; join one first.", cx); + } + }); workspace.register_action(|workspace, _: &ShareProject, window, cx| { let project = workspace.project().clone(); println!("{project:?}"); @@ -257,6 +274,8 @@ enum ListEntry { channel: Arc, depth: usize, has_children: bool, + // `None` when the channel is a parent of a matched channel. + string_match: Option, }, ChannelNotes { channel_id: ChannelId, @@ -280,7 +299,7 @@ impl CollabPanel { cx.new(|cx| { let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Filter...", window, cx); + editor.set_placeholder_text("Search channels…", window, cx); editor }); @@ -626,6 +645,10 @@ impl CollabPanel { .enumerate() .map(|(ix, (_, channel))| StringMatchCandidate::new(ix, &channel.name)), ); + let mut channels = channel_store + .ordered_channels() + .map(|(_, chan)| chan) + .collect::>(); let matches = executor.block(match_strings( &self.match_candidates, &query, @@ -635,31 +658,56 @@ impl CollabPanel { &Default::default(), executor.clone(), )); + + let matches_by_id: HashMap<_, _> = matches + .iter() + .map(|mat| (channels[mat.candidate_id].id, mat.clone())) + .collect(); + + let channel_ids_of_matches_or_parents: HashSet<_> = matches + .iter() + .flat_map(|mat| { + let match_channel = channels[mat.candidate_id]; + + match_channel + .parent_path + .iter() + .copied() + .chain(Some(match_channel.id)) + }) + .collect(); + + channels.retain(|chan| channel_ids_of_matches_or_parents.contains(&chan.id)); + if let Some(state) = &self.channel_editing_state && matches!(state, ChannelEditingState::Create { location: None, .. }) { self.entries.push(ListEntry::ChannelEditor { depth: 0 }); } + + let should_respect_collapse = query.is_empty(); let mut collapse_depth = None; - for mat in matches { - let channel = channel_store.channel_at_index(mat.candidate_id).unwrap(); + + for (idx, channel) in channels.into_iter().enumerate() { let depth = channel.parent_path.len(); - if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) { - collapse_depth = Some(depth); - } else if let Some(collapsed_depth) = collapse_depth { - if depth > collapsed_depth { - continue; - } - if self.is_channel_collapsed(channel.id) { + if should_respect_collapse { + if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) { collapse_depth = Some(depth); - } else { - collapse_depth = None; + } else if let Some(collapsed_depth) = collapse_depth { + if depth > collapsed_depth { + continue; + } + if self.is_channel_collapsed(channel.id) { + collapse_depth = Some(depth); + } else { + collapse_depth = None; + } } } let has_children = channel_store - .channel_at_index(mat.candidate_id + 1) + .channel_at_index(idx + 1) .is_some_and(|next_channel| next_channel.parent_path.ends_with(&[channel.id])); match &self.channel_editing_state { @@ -671,6 +719,7 @@ impl CollabPanel { channel: channel.clone(), depth, has_children: false, + string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()), }); self.entries .push(ListEntry::ChannelEditor { depth: depth + 1 }); @@ -686,6 +735,7 @@ impl CollabPanel { channel: channel.clone(), depth, has_children, + string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()), }); } } @@ -1265,6 +1315,13 @@ impl CollabPanel { window.handler_for(&this, move |this, _, cx| { this.copy_channel_link(channel_id, cx) }), + ) + .entry( + "Copy Channel Notes Link", + None, + window.handler_for(&this, move |this, _, cx| { + this.copy_channel_notes_link(channel_id, cx) + }), ); let mut has_destructive_actions = false; @@ -1451,7 +1508,7 @@ impl CollabPanel { fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) -> bool { self.filter_editor.update(cx, |editor, cx| { - if editor.buffer().read(cx).len(cx) > 0 { + if editor.buffer().read(cx).len(cx).0 > 0 { editor.set_text("", window, cx); true } else { @@ -1849,6 +1906,17 @@ impl CollabPanel { } } + fn open_selected_channel_notes( + &mut self, + _: &OpenSelectedChannelNotes, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(channel) = self.selected_channel() { + self.open_channel_notes(channel.id, window, cx); + } + } + fn set_channel_visibility( &mut self, channel_id: ChannelId, @@ -2220,6 +2288,15 @@ impl CollabPanel { cx.write_to_clipboard(item) } + fn copy_channel_notes_link(&mut self, channel_id: ChannelId, cx: &mut Context) { + let channel_store = self.channel_store.read(cx); + let Some(channel) = channel_store.channel_for_id(channel_id) else { + return; + }; + let item = ClipboardItem::new_string(channel.notes_link(None, cx)); + cx.write_to_clipboard(item) + } + fn render_signed_out(&mut self, cx: &mut Context) -> Div { let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more."; @@ -2250,7 +2327,7 @@ impl CollabPanel { })), ) .child( - div().flex().w_full().items_center().child( + v_flex().w_full().items_center().child( Label::new("Sign in to enable collaboration.") .color(Color::Muted) .size(LabelSize::Small), @@ -2290,8 +2367,17 @@ impl CollabPanel { channel, depth, has_children, + string_match, } => self - .render_channel(channel, *depth, *has_children, is_selected, ix, cx) + .render_channel( + channel, + *depth, + *has_children, + is_selected, + ix, + string_match.as_ref(), + cx, + ) .into_any_element(), ListEntry::ChannelEditor { depth } => self .render_channel_editor(*depth, window, cx) @@ -2338,6 +2424,21 @@ impl CollabPanel { }); v_flex() .size_full() + .gap_1() + .child( + h_flex() + .p_2() + .h(Tab::container_height(cx)) + .gap_1p5() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(self.render_filter_input(&self.filter_editor, cx)), + ) .child( list( self.list_state.clone(), @@ -2345,15 +2446,6 @@ impl CollabPanel { ) .size_full(), ) - .child( - v_flex() - .child(div().mx_2().border_primary(cx).border_t_1()) - .child( - v_flex() - .p_2() - .child(self.render_filter_input(&self.filter_editor, cx)), - ), - ) } fn render_filter_input( @@ -2688,6 +2780,7 @@ impl CollabPanel { has_children: bool, is_selected: bool, ix: usize, + string_match: Option<&StringMatch>, cx: &mut Context, ) -> impl IntoElement { let channel_id = channel.id; @@ -2824,7 +2917,14 @@ impl CollabPanel { .child( h_flex() .id(channel_id.0 as usize) - .child(Label::new(channel.name.clone())) + .child(match string_match { + None => Label::new(channel.name.clone()).into_any_element(), + Some(string_match) => HighlightedLabel::new( + channel.name.clone(), + string_match.positions.clone(), + ) + .into_any_element(), + }) .children(face_pile.map(|face_pile| face_pile.p_1())), ), ) @@ -2960,6 +3060,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::remove_selected_channel)) .on_action(cx.listener(CollabPanel::show_inline_context_menu)) .on_action(cx.listener(CollabPanel::rename_selected_channel)) + .on_action(cx.listener(CollabPanel::open_selected_channel_notes)) .on_action(cx.listener(CollabPanel::collapse_selected_channel)) .on_action(cx.listener(CollabPanel::expand_selected_channel)) .on_action(cx.listener(CollabPanel::start_move_selected_channel)) @@ -3037,6 +3138,10 @@ impl Panel for CollabPanel { "CollabPanel" } + fn panel_key() -> &'static str { + COLLABORATION_PANEL_KEY + } + fn activation_priority(&self) -> u32 { 6 } diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index e558835dba..9d882562ca 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -10,7 +10,7 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; use std::sync::Arc; -use ui::{Avatar, CheckboxWithLabel, ContextMenu, ListItem, ListItemSpacing, prelude::*}; +use ui::{Avatar, Checkbox, ContextMenu, ListItem, ListItemSpacing, prelude::*}; use util::TryFutureExt; use workspace::{ModalView, notifications::DetachAndPromptErr}; @@ -165,16 +165,18 @@ impl Render for ChannelModal { .h(rems_from_px(22.)) .justify_between() .line_height(rems(1.25)) - .child(CheckboxWithLabel::new( - "is-public", - Label::new("Public").size(LabelSize::Small), - if visibility == ChannelVisibility::Public { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - cx.listener(Self::set_channel_visibility), - )) + .child( + Checkbox::new( + "is-public", + if visibility == ChannelVisibility::Public { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + ) + .label("Public") + .on_click(cx.listener(Self::set_channel_visibility)), + ) .children( Some( Button::new("copy-link", "Copy Link") diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index c43e865ef2..6a4b2b93d7 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -13,14 +13,10 @@ use gpui::{ }; pub use panel_settings::{CollaborationPanelSettings, NotificationPanelSettings}; use release_channel::ReleaseChannel; -use settings::Settings; use ui::px; use workspace::AppState; pub fn init(app_state: &Arc, cx: &mut App) { - CollaborationPanelSettings::register(cx); - NotificationPanelSettings::register(cx); - channel_view::init(cx); collab_panel::init(cx); notification_panel::init(cx); diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 3d988c4634..99203bc867 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -612,6 +612,10 @@ impl Panel for NotificationPanel { "NotificationPanel" } + fn panel_key() -> &'static str { + NOTIFICATION_PANEL_KEY + } + fn position(&self, _: &Window, cx: &App) -> DockPosition { NotificationPanelSettings::get_global(cx).dock } @@ -734,19 +738,17 @@ impl Render for NotificationToast { .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify())) .child( IconButton::new(close_id, close_icon) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if suppress { Tooltip::for_action( "Suppress.\nClose with click.", &workspace::SuppressNotification, - window, cx, ) } else { Tooltip::for_action( "Close.\nSuppress with shift-click", &menu::Cancel, - window, cx, ) } diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs index 58be0c358b..ebd021be4b 100644 --- a/crates/collab_ui/src/panel_settings.rs +++ b/crates/collab_ui/src/panel_settings.rs @@ -1,16 +1,16 @@ use gpui::Pixels; -use settings::Settings; +use settings::{RegisterSetting, Settings}; use ui::px; use workspace::dock::DockPosition; -#[derive(Debug)] +#[derive(Debug, RegisterSetting)] pub struct CollaborationPanelSettings { pub button: bool, pub dock: DockPosition, pub default_width: Pixels, } -#[derive(Debug)] +#[derive(Debug, RegisterSetting)] pub struct NotificationPanelSettings { pub button: bool, pub dock: DockPosition, @@ -18,7 +18,7 @@ pub struct NotificationPanelSettings { } impl Settings for CollaborationPanelSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut ui::App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let panel = content.collaboration_panel.as_ref().unwrap(); Self { @@ -30,7 +30,7 @@ impl Settings for CollaborationPanelSettings { } impl Settings for NotificationPanelSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut ui::App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let panel = content.notification_panel.as_ref().unwrap(); return Self { button: panel.button.unwrap(), diff --git a/crates/collections/Cargo.toml b/crates/collections/Cargo.toml index c0a6dd8338..8675504347 100644 --- a/crates/collections/Cargo.toml +++ b/crates/collections/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "zed-collections" +name = "collections" version = "0.1.0" edition.workspace = true -publish = true +publish = false license = "Apache-2.0" description = "Standard collection type re-exports used by Zed and GPUI" @@ -19,4 +19,3 @@ test-support = [] [dependencies] indexmap.workspace = true rustc-hash.workspace = true -workspace-hack.workspace = true diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index c97d142152..bd86c10a80 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -8,6 +8,9 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[features] +test-support = ["db/test-support"] + [lib] path = "src/command_palette.rs" doctest = false @@ -20,6 +23,7 @@ command_palette_hooks.workspace = true db.workspace = true fuzzy.workspace = true gpui.workspace = true +menu.workspace = true log.workspace = true picker.workspace = true postage.workspace = true @@ -32,7 +36,6 @@ util.workspace = true telemetry.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 227d246f04..daf97bf676 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -2,14 +2,15 @@ mod persistence; use std::{ cmp::{self, Reverse}, - collections::HashMap, + collections::{HashMap, VecDeque}, sync::Arc, time::Duration, }; use client::parse_zed_link; use command_palette_hooks::{ - CommandInterceptResult, CommandPaletteFilter, CommandPaletteInterceptor, + CommandInterceptItem, CommandInterceptResult, CommandPaletteFilter, + GlobalCommandPaletteInterceptor, }; use fuzzy::{StringMatch, StringMatchCandidate}; @@ -18,16 +19,16 @@ use gpui::{ ParentElement, Render, Styled, Task, WeakEntity, Window, }; use persistence::COMMAND_PALETTE_HISTORY; +use picker::Direction; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, h_flex, prelude::*, v_flex}; +use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; use workspace::{ModalView, Workspace, WorkspaceSettings}; use zed_actions::{OpenZedUrl, command_palette::Toggle}; pub fn init(cx: &mut App) { - client::init_settings(cx); command_palette_hooks::init(cx); cx.observe_new(CommandPalette::register).detach(); } @@ -81,14 +82,17 @@ impl CommandPalette { let Some(previous_focus_handle) = window.focused(cx) else { return; }; + + let entity = cx.weak_entity(); workspace.toggle_modal(window, cx, move |window, cx| { - CommandPalette::new(previous_focus_handle, query, window, cx) + CommandPalette::new(previous_focus_handle, query, entity, window, cx) }); } fn new( previous_focus_handle: FocusHandle, query: &str, + entity: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -109,8 +113,12 @@ impl CommandPalette { }) .collect(); - let delegate = - CommandPaletteDelegate::new(cx.entity().downgrade(), commands, previous_focus_handle); + let delegate = CommandPaletteDelegate::new( + cx.entity().downgrade(), + entity, + commands, + previous_focus_handle, + ); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx); @@ -135,7 +143,7 @@ impl Focusable for CommandPalette { } impl Render for CommandPalette { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .key_context("CommandPalette") .w(rems(34.)) @@ -146,6 +154,7 @@ impl Render for CommandPalette { pub struct CommandPaletteDelegate { latest_query: String, command_palette: WeakEntity, + workspace: WeakEntity, all_commands: Vec, commands: Vec, matches: Vec, @@ -153,8 +162,9 @@ pub struct CommandPaletteDelegate { previous_focus_handle: FocusHandle, updating_matches: Option<( Task<()>, - postage::dispatch::Receiver<(Vec, Vec)>, + postage::dispatch::Receiver<(Vec, Vec, CommandInterceptResult)>, )>, + query_history: QueryHistory, } struct Command { @@ -162,6 +172,91 @@ struct Command { action: Box, } +#[derive(Default)] +struct QueryHistory { + history: Option>, + cursor: Option, + prefix: Option, +} + +impl QueryHistory { + fn history(&mut self) -> &mut VecDeque { + self.history.get_or_insert_with(|| { + COMMAND_PALETTE_HISTORY + .list_recent_queries() + .unwrap_or_default() + .into_iter() + .collect() + }) + } + + fn add(&mut self, query: String) { + if let Some(pos) = self.history().iter().position(|h| h == &query) { + self.history().remove(pos); + } + self.history().push_back(query); + self.cursor = None; + self.prefix = None; + } + + fn validate_cursor(&mut self, current_query: &str) -> Option { + if let Some(pos) = self.cursor { + if self.history().get(pos).map(|s| s.as_str()) != Some(current_query) { + self.cursor = None; + self.prefix = None; + } + } + self.cursor + } + + fn previous(&mut self, current_query: &str) -> Option<&str> { + if self.validate_cursor(current_query).is_none() { + self.prefix = Some(current_query.to_string()); + } + + let prefix = self.prefix.clone().unwrap_or_default(); + let start_index = self.cursor.unwrap_or(self.history().len()); + + for i in (0..start_index).rev() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn next(&mut self, current_query: &str) -> Option<&str> { + let selected = self.validate_cursor(current_query)?; + let prefix = self.prefix.clone().unwrap_or_default(); + + for i in (selected + 1)..self.history().len() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn reset_cursor(&mut self) { + self.cursor = None; + self.prefix = None; + } + + fn is_navigating(&self) -> bool { + self.cursor.is_some() + } +} + impl Clone for Command { fn clone(&self) -> Self { Self { @@ -174,11 +269,13 @@ impl Clone for Command { impl CommandPaletteDelegate { fn new( command_palette: WeakEntity, + workspace: WeakEntity, commands: Vec, previous_focus_handle: FocusHandle, ) -> Self { Self { command_palette, + workspace, all_commands: commands.clone(), matches: vec![], commands, @@ -186,6 +283,7 @@ impl CommandPaletteDelegate { previous_focus_handle, latest_query: String::new(), updating_matches: None, + query_history: Default::default(), } } @@ -194,30 +292,19 @@ impl CommandPaletteDelegate { query: String, mut commands: Vec, mut matches: Vec, - cx: &mut Context>, + intercept_result: CommandInterceptResult, + _: &mut Context>, ) { self.updating_matches.take(); - self.latest_query = query.clone(); - - let mut intercept_results = CommandPaletteInterceptor::try_global(cx) - .map(|interceptor| interceptor.intercept(&query, cx)) - .unwrap_or_default(); - - if parse_zed_link(&query, cx).is_some() { - intercept_results = vec![CommandInterceptResult { - action: OpenZedUrl { url: query.clone() }.boxed_clone(), - string: query, - positions: vec![], - }] - } + self.latest_query = query; let mut new_matches = Vec::new(); - for CommandInterceptResult { + for CommandInterceptItem { action, string, positions, - } in intercept_results + } in intercept_result.results { if let Some(idx) = matches .iter() @@ -236,7 +323,9 @@ impl CommandPaletteDelegate { score: 0.0, }) } - new_matches.append(&mut matches); + if !intercept_result.exclusive { + new_matches.append(&mut matches); + } self.commands = commands; self.matches = new_matches; if self.matches.is_empty() { @@ -259,6 +348,22 @@ impl CommandPaletteDelegate { HashMap::new() } } + + fn selected_command(&self) -> Option<&Command> { + let action_ix = self + .matches + .get(self.selected_ix) + .map(|m| m.candidate_id) + .unwrap_or(self.selected_ix); + // this gets called in headless tests where there are no commands loaded + // so we need to return an Option here + self.commands.get(action_ix) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn seed_history(&mut self, queries: &[&str]) { + self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect()); + } } impl PickerDelegate for CommandPaletteDelegate { @@ -268,6 +373,38 @@ impl PickerDelegate for CommandPaletteDelegate { "Execute a command...".into() } + fn select_history( + &mut self, + direction: Direction, + query: &str, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + match direction { + Direction::Up => { + let should_use_history = + self.selected_ix == 0 || self.query_history.is_navigating(); + if should_use_history { + if let Some(query) = self.query_history.previous(query).map(|s| s.to_string()) { + return Some(query); + } + } + } + Direction::Down => { + if self.query_history.is_navigating() { + if let Some(query) = self.query_history.next(query).map(|s| s.to_string()) { + return Some(query); + } else { + let prefix = self.query_history.prefix.take().unwrap_or_default(); + self.query_history.reset_cursor(); + return Some(prefix); + } + } + } + } + None + } + fn match_count(&self) -> usize { self.matches.len() } @@ -295,12 +432,22 @@ impl PickerDelegate for CommandPaletteDelegate { if let Some(alias) = settings.command_aliases.get(&query) { query = alias.to_string(); } + + let workspace = self.workspace.clone(); + + let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx); + let (mut tx, mut rx) = postage::dispatch::channel(1); + + let query_str = query.as_str(); + let is_zed_link = parse_zed_link(query_str, cx).is_some(); + let task = cx.background_spawn({ let mut commands = self.all_commands.clone(); let hit_counts = self.hit_counts(); let executor = cx.background_executor().clone(); - let query = normalize_action_query(query.as_str()); + let query = normalize_action_query(query_str); + let query_for_link = query_str.to_string(); async move { commands.sort_by_key(|action| { ( @@ -326,13 +473,34 @@ impl PickerDelegate for CommandPaletteDelegate { ) .await; - tx.send((commands, matches)).await.log_err(); + let intercept_result = if is_zed_link { + CommandInterceptResult { + results: vec![CommandInterceptItem { + action: OpenZedUrl { + url: query_for_link.clone(), + } + .boxed_clone(), + string: query_for_link, + positions: vec![], + }], + exclusive: false, + } + } else if let Some(task) = intercept_task { + task.await + } else { + CommandInterceptResult::default() + }; + + tx.send((commands, matches, intercept_result)) + .await + .log_err(); } }); + self.updating_matches = Some((task, rx.clone())); cx.spawn_in(window, async move |picker, cx| { - let Some((commands, matches)) = rx.recv().await else { + let Some((commands, matches, intercept_result)) = rx.recv().await else { return; }; @@ -340,7 +508,7 @@ impl PickerDelegate for CommandPaletteDelegate { .update(cx, |picker, cx| { picker .delegate - .matches_updated(query, commands, matches, cx) + .matches_updated(query, commands, matches, intercept_result, cx) }) .log_err(); }) @@ -361,8 +529,8 @@ impl PickerDelegate for CommandPaletteDelegate { .background_executor() .block_with_timeout(duration, rx.clone().recv()) { - Ok(Some((commands, matches))) => { - self.matches_updated(query, commands, matches, cx); + Ok(Some((commands, matches, interceptor_result))) => { + self.matches_updated(query, commands, matches, interceptor_result, cx); true } _ => { @@ -378,11 +546,30 @@ impl PickerDelegate for CommandPaletteDelegate { .log_err(); } - fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + if secondary { + let Some(selected_command) = self.selected_command() else { + return; + }; + let action_name = selected_command.action.name(); + let open_keymap = Box::new(zed_actions::ChangeKeybinding { + action: action_name.to_string(), + }); + window.dispatch_action(open_keymap, cx); + self.dismissed(window, cx); + return; + } + if self.matches.is_empty() { self.dismissed(window, cx); return; } + + if !self.latest_query.is_empty() { + self.query_history.add(self.latest_query.clone()); + self.query_history.reset_cursor(); + } + let action_ix = self.matches[self.selected_ix].candidate_id; let command = self.commands.swap_remove(action_ix); telemetry::event!( @@ -410,11 +597,12 @@ impl PickerDelegate for CommandPaletteDelegate { &self, ix: usize, selected: bool, - window: &mut Window, + _: &mut Window, cx: &mut Context>, ) -> Option { let matching_command = self.matches.get(ix)?; let command = self.commands.get(matching_command.candidate_id)?; + Some( ListItem::new(ix) .inset(true) @@ -429,15 +617,67 @@ impl PickerDelegate for CommandPaletteDelegate { command.name.clone(), matching_command.positions.clone(), )) - .children(KeyBinding::for_action_in( + .child(KeyBinding::for_action_in( &*command.action, &self.previous_focus_handle, - window, cx, )), ), ) } + + fn render_footer( + &self, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + let selected_command = self.selected_command()?; + let keybind = + KeyBinding::for_action_in(&*selected_command.action, &self.previous_focus_handle, cx); + + let focus_handle = &self.previous_focus_handle; + let keybinding_buttons = if keybind.has_binding(window) { + Button::new("change", "Change Keybinding…") + .key_binding( + KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); + }) + } else { + Button::new("add", "Add Keybinding…") + .key_binding( + KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); + }) + }; + + Some( + h_flex() + .w_full() + .p_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(keybinding_buttons) + .child( + Button::new("run-action", "Run") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + .into_any(), + ) + } } pub fn humanize_action_name(name: &str) -> String { @@ -479,7 +719,7 @@ mod tests { use super::*; use editor::Editor; use go_to_line::GoToLine; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use language::Point; use project::Project; use settings::KeymapFile; @@ -665,7 +905,11 @@ mod tests { editor.update_in(cx, |editor, window, cx| { assert!(editor.focus_handle(cx).is_focused(window)); assert_eq!( - editor.selections.last::(cx).range().start, + editor + .selections + .last::(&editor.display_snapshot(cx)) + .range() + .start, Point::new(2, 0) ); }); @@ -675,20 +919,20 @@ mod tests { cx.update(|cx| { let app_state = AppState::test(cx); theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); editor::init(cx); menu::init(); go_to_line::init(cx); workspace::init(app_state.clone(), cx); init(cx); - Project::init_settings(cx); cx.bind_keys(KeymapFile::load_panic_on_failure( r#"[ { "bindings": { "cmd-n": "workspace::NewFile", "enter": "menu::Confirm", - "cmd-shift-p": "command_palette::Toggle" + "cmd-shift-p": "command_palette::Toggle", + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" } } ]"#, @@ -697,4 +941,264 @@ mod tests { app_state }) } + + fn open_palette_with_history( + workspace: &Entity, + history: &[&str], + cx: &mut VisualTestContext, + ) -> Entity> { + cx.simulate_keystrokes("cmd-shift-p"); + cx.run_until_parked(); + + let palette = workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .unwrap() + .read(cx) + .picker + .clone() + }); + + palette.update(cx, |palette, _cx| { + palette.delegate.seed_history(history); + }); + + palette + } + + #[gpui::test] + async fn test_history_navigation_basic(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx); + + // Query should be empty initially + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + + // Press up - should load most recent query "select all" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should load "backspace" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Press down - should go back to "select all" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down again - should clear query (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } + + #[gpui::test] + async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace"], cx); + + // Press up to enter history mode + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Type something - should append to the history query + cx.simulate_input("x"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspacex"); + }); + } + + #[gpui::test] + async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx); + + // Open palette with a query that has multiple matches + cx.simulate_input("editor"); + cx.background_executor.run_until_parked(); + + // Should have multiple matches, selected_ix should be 0 + palette.read_with(cx, |palette, _| { + assert!(palette.delegate.matches.len() > 1); + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press down - should navigate to next suggestion (not history) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 1); + }); + + // Press up - should go back to first suggestion + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press up again at top - should enter history mode and show previous query + // that matches the "editor" prefix + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "editor: open"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history( + &workspace, + &["open file", "select all", "select line", "backspace"], + cx, + ); + + // Type "sel" as a prefix + cx.simulate_input("sel"); + cx.background_executor.run_until_parked(); + + // Press up - should get "select line" (most recent matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press up again - should get "select all" (next matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should stay at "select all" (no more matches for "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down - should go back to "select line" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press down again - should return to original prefix "sel" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "sel"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = + open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx); + + // Type "xyz" as a prefix that doesn't match anything + cx.simulate_input("xyz"); + cx.background_executor.run_until_parked(); + + // Press up - should stay at "xyz" (no matches) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "xyz"); + }); + } + + #[gpui::test] + async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx); + + // With empty query, press up - should get "gamma" (most recent) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press up - should get "beta" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press up - should get "alpha" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "alpha"); + }); + + // Press down - should get "beta" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press down - should get "gamma" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press down - should return to empty string (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } } diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index feaed72570..4556079b4f 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -123,6 +123,16 @@ impl CommandPaletteDB { ORDER BY COUNT(1) DESC } } + + query! { + pub fn list_recent_queries() -> Result> { + SELECT user_query + FROM command_invocations + WHERE user_query != "" + GROUP BY user_query + ORDER BY MAX(last_invoked) ASC + } + } } #[cfg(test)] diff --git a/crates/command_palette_hooks/Cargo.toml b/crates/command_palette_hooks/Cargo.toml index dd0b44c57d..6ba771562d 100644 --- a/crates/command_palette_hooks/Cargo.toml +++ b/crates/command_palette_hooks/Cargo.toml @@ -16,4 +16,4 @@ doctest = false collections.workspace = true derive_more.workspace = true gpui.workspace = true -workspace-hack.workspace = true +workspace.workspace = true diff --git a/crates/command_palette_hooks/src/command_palette_hooks.rs b/crates/command_palette_hooks/src/command_palette_hooks.rs index f1344c5ba6..bd8f9375b7 100644 --- a/crates/command_palette_hooks/src/command_palette_hooks.rs +++ b/crates/command_palette_hooks/src/command_palette_hooks.rs @@ -2,16 +2,16 @@ #![deny(missing_docs)] -use std::any::TypeId; +use std::{any::TypeId, rc::Rc}; use collections::HashSet; use derive_more::{Deref, DerefMut}; -use gpui::{Action, App, BorrowAppContext, Global}; +use gpui::{Action, App, BorrowAppContext, Global, Task, WeakEntity}; +use workspace::Workspace; /// Initializes the command palette hooks. pub fn init(cx: &mut App) { cx.set_global(GlobalCommandPaletteFilter::default()); - cx.set_global(GlobalCommandPaletteInterceptor::default()); } /// A filter for the command palette. @@ -94,61 +94,60 @@ impl CommandPaletteFilter { /// The result of intercepting a command palette command. #[derive(Debug)] -pub struct CommandInterceptResult { +pub struct CommandInterceptItem { /// The action produced as a result of the interception. pub action: Box, - // TODO: Document this field. - #[allow(missing_docs)] + /// The display string to show in the command palette for this result. pub string: String, - // TODO: Document this field. - #[allow(missing_docs)] + /// The character positions in the string that match the query. + /// Used for highlighting matched characters in the command palette UI. pub positions: Vec, } -/// An interceptor for the command palette. -#[derive(Default)] -pub struct CommandPaletteInterceptor( - Option Vec>>, -); +/// The result of intercepting a command palette command. +#[derive(Default, Debug)] +pub struct CommandInterceptResult { + /// The items + pub results: Vec, + /// Whether or not to continue to show the normal matches + pub exclusive: bool, +} -#[derive(Default)] -struct GlobalCommandPaletteInterceptor(CommandPaletteInterceptor); +/// An interceptor for the command palette. +#[derive(Clone)] +pub struct GlobalCommandPaletteInterceptor( + Rc, &mut App) -> Task>, +); impl Global for GlobalCommandPaletteInterceptor {} -impl CommandPaletteInterceptor { - /// Returns the global [`CommandPaletteInterceptor`], if one is set. - pub fn try_global(cx: &App) -> Option<&CommandPaletteInterceptor> { - cx.try_global::() - .map(|interceptor| &interceptor.0) - } - - /// Updates the global [`CommandPaletteInterceptor`] using the given closure. - pub fn update_global(cx: &mut App, update: F) -> R - where - F: FnOnce(&mut Self, &mut App) -> R, - { - cx.update_global(|this: &mut GlobalCommandPaletteInterceptor, cx| update(&mut this.0, cx)) - } - - /// Intercepts the given query from the command palette. - pub fn intercept(&self, query: &str, cx: &App) -> Vec { - if let Some(handler) = self.0.as_ref() { - (handler)(query, cx) - } else { - Vec::new() - } - } - - /// Clears the global interceptor. - pub fn clear(&mut self) { - self.0 = None; - } - +impl GlobalCommandPaletteInterceptor { /// Sets the global interceptor. /// /// This will override the previous interceptor, if it exists. - pub fn set(&mut self, handler: Box Vec>) { - self.0 = Some(handler); + pub fn set( + cx: &mut App, + interceptor: impl Fn(&str, WeakEntity, &mut App) -> Task + + 'static, + ) { + cx.set_global(Self(Rc::new(interceptor))); + } + + /// Clears the global interceptor. + pub fn clear(cx: &mut App) { + if cx.has_global::() { + cx.remove_global::(); + } + } + + /// Intercepts the given query from the command palette. + pub fn intercept( + query: &str, + workspace: WeakEntity, + cx: &mut App, + ) -> Option> { + let interceptor = cx.try_global::()?; + let handler = interceptor.0.clone(); + Some(handler(query, workspace, cx)) } } diff --git a/crates/component/Cargo.toml b/crates/component/Cargo.toml index 74481834f1..4ca95cbbbd 100644 --- a/crates/component/Cargo.toml +++ b/crates/component/Cargo.toml @@ -18,7 +18,6 @@ inventory.workspace = true parking_lot.workspace = true strum.workspace = true theme.workspace = true -workspace-hack.workspace = true [dev-dependencies] documented.workspace = true diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 1c57454080..cb48b7e6f7 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -12,7 +12,7 @@ workspace = true path = "src/context_server.rs" [features] -test-support = [] +test-support = ["gpui/test-support"] [dependencies] anyhow.workspace = true @@ -20,6 +20,7 @@ async-trait.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true +http_client = { workspace = true, features = ["test-support"] } log.workspace = true net.workspace = true parking_lot.workspace = true @@ -32,4 +33,7 @@ smol.workspace = true tempfile.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true -workspace-hack.workspace = true +terminal.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 52ed524220..553e845df8 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -6,6 +6,8 @@ pub mod test; pub mod transport; pub mod types; +use collections::HashMap; +use http_client::HttpClient; use std::path::Path; use std::sync::Arc; use std::{fmt::Display, path::PathBuf}; @@ -15,6 +17,9 @@ use client::Client; use gpui::AsyncApp; use parking_lot::RwLock; pub use settings::ContextServerCommand; +use url::Url; + +use crate::transport::HttpTransport; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ContextServerId(pub Arc); @@ -52,6 +57,25 @@ impl ContextServer { } } + pub fn http( + id: ContextServerId, + endpoint: &Url, + headers: HashMap, + http_client: Arc, + executor: gpui::BackgroundExecutor, + ) -> Result { + let transport = match endpoint.scheme() { + "http" | "https" => { + log::info!("Using HTTP transport for {}", endpoint); + let transport = + HttpTransport::new(http_client, endpoint.to_string(), headers, executor); + Arc::new(transport) as _ + } + _ => anyhow::bail!("unsupported MCP url scheme {}", endpoint.scheme()), + }; + Ok(Self::new(id, transport)) + } + pub fn new(id: ContextServerId, transport: Arc) -> Self { Self { id, diff --git a/crates/context_server/src/transport.rs b/crates/context_server/src/transport.rs index b4f56b0ef0..a3d6f998d4 100644 --- a/crates/context_server/src/transport.rs +++ b/crates/context_server/src/transport.rs @@ -1,11 +1,12 @@ +pub mod http; mod stdio_transport; -use std::pin::Pin; - use anyhow::Result; use async_trait::async_trait; use futures::Stream; +use std::pin::Pin; +pub use http::*; pub use stdio_transport::*; #[async_trait] diff --git a/crates/context_server/src/transport/http.rs b/crates/context_server/src/transport/http.rs new file mode 100644 index 0000000000..70248f0278 --- /dev/null +++ b/crates/context_server/src/transport/http.rs @@ -0,0 +1,259 @@ +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use collections::HashMap; +use futures::{Stream, StreamExt}; +use gpui::BackgroundExecutor; +use http_client::{AsyncBody, HttpClient, Request, Response, http::Method}; +use parking_lot::Mutex as SyncMutex; +use smol::channel; +use std::{pin::Pin, sync::Arc}; + +use crate::transport::Transport; + +// Constants from MCP spec +const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; +const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; +const JSON_MIME_TYPE: &str = "application/json"; + +/// HTTP Transport with session management and SSE support +pub struct HttpTransport { + http_client: Arc, + endpoint: String, + session_id: Arc>>, + executor: BackgroundExecutor, + response_tx: channel::Sender, + response_rx: channel::Receiver, + error_tx: channel::Sender, + error_rx: channel::Receiver, + // Authentication headers to include in requests + headers: HashMap, +} + +impl HttpTransport { + pub fn new( + http_client: Arc, + endpoint: String, + headers: HashMap, + executor: BackgroundExecutor, + ) -> Self { + let (response_tx, response_rx) = channel::unbounded(); + let (error_tx, error_rx) = channel::unbounded(); + + Self { + http_client, + executor, + endpoint, + session_id: Arc::new(SyncMutex::new(None)), + response_tx, + response_rx, + error_tx, + error_rx, + headers, + } + } + + /// Send a message and handle the response based on content type + async fn send_message(&self, message: String) -> Result<()> { + let is_notification = + !message.contains("\"id\":") || message.contains("notifications/initialized"); + + let mut request_builder = Request::builder() + .method(Method::POST) + .uri(&self.endpoint) + .header("Content-Type", JSON_MIME_TYPE) + .header( + "Accept", + format!("{}, {}", JSON_MIME_TYPE, EVENT_STREAM_MIME_TYPE), + ); + + for (key, value) in &self.headers { + request_builder = request_builder.header(key.as_str(), value.as_str()); + } + + // Add session ID if we have one (except for initialize) + if let Some(ref session_id) = *self.session_id.lock() { + request_builder = request_builder.header(HEADER_SESSION_ID, session_id.as_str()); + } + + let request = request_builder.body(AsyncBody::from(message.into_bytes()))?; + let mut response = self.http_client.send(request).await?; + + // Handle different response types based on status and content-type + match response.status() { + status if status.is_success() => { + // Check content type + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()); + + // Extract session ID from response headers if present + if let Some(session_id) = response + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + { + *self.session_id.lock() = Some(session_id.to_string()); + log::debug!("Session ID set: {}", session_id); + } + + match content_type { + Some(ct) if ct.starts_with(JSON_MIME_TYPE) => { + // JSON response - read and forward immediately + let mut body = String::new(); + futures::AsyncReadExt::read_to_string(response.body_mut(), &mut body) + .await?; + + // Only send non-empty responses + if !body.is_empty() { + self.response_tx + .send(body) + .await + .map_err(|_| anyhow!("Failed to send JSON response"))?; + } + } + Some(ct) if ct.starts_with(EVENT_STREAM_MIME_TYPE) => { + // SSE stream - set up streaming + self.setup_sse_stream(response).await?; + } + _ => { + // For notifications, 202 Accepted with no content type is ok + if is_notification && status.as_u16() == 202 { + log::debug!("Notification accepted"); + } else { + return Err(anyhow!("Unexpected content type: {:?}", content_type)); + } + } + } + } + status if status.as_u16() == 202 => { + // Accepted - notification acknowledged, no response needed + log::debug!("Notification accepted"); + } + _ => { + let mut error_body = String::new(); + futures::AsyncReadExt::read_to_string(response.body_mut(), &mut error_body).await?; + + self.error_tx + .send(format!("HTTP {}: {}", response.status(), error_body)) + .await + .map_err(|_| anyhow!("Failed to send error"))?; + } + } + + Ok(()) + } + + /// Set up SSE streaming from the response + async fn setup_sse_stream(&self, mut response: Response) -> Result<()> { + let response_tx = self.response_tx.clone(); + let error_tx = self.error_tx.clone(); + + // Spawn a task to handle the SSE stream + smol::spawn(async move { + let reader = futures::io::BufReader::new(response.body_mut()); + let mut lines = futures::AsyncBufReadExt::lines(reader); + + let mut data_buffer = Vec::new(); + let mut in_message = false; + + while let Some(line_result) = lines.next().await { + match line_result { + Ok(line) => { + if line.is_empty() { + // Empty line signals end of event + if !data_buffer.is_empty() { + let message = data_buffer.join("\n"); + + // Filter out ping messages and empty data + if !message.trim().is_empty() && message != "ping" { + if let Err(e) = response_tx.send(message).await { + log::error!("Failed to send SSE message: {}", e); + break; + } + } + data_buffer.clear(); + } + in_message = false; + } else if let Some(data) = line.strip_prefix("data: ") { + // Handle data lines + let data = data.trim(); + if !data.is_empty() { + // Check if this is a ping message + if data == "ping" { + log::trace!("Received SSE ping"); + continue; + } + data_buffer.push(data.to_string()); + in_message = true; + } + } else if line.starts_with("event:") + || line.starts_with("id:") + || line.starts_with("retry:") + { + // Ignore other SSE fields + continue; + } else if in_message { + // Continuation of data + data_buffer.push(line); + } + } + Err(e) => { + let _ = error_tx.send(format!("SSE stream error: {}", e)).await; + break; + } + } + } + }) + .detach(); + + Ok(()) + } +} + +#[async_trait] +impl Transport for HttpTransport { + async fn send(&self, message: String) -> Result<()> { + self.send_message(message).await + } + + fn receive(&self) -> Pin + Send>> { + Box::pin(self.response_rx.clone()) + } + + fn receive_err(&self) -> Pin + Send>> { + Box::pin(self.error_rx.clone()) + } +} + +impl Drop for HttpTransport { + fn drop(&mut self) { + // Try to cleanup session on drop + let http_client = self.http_client.clone(); + let endpoint = self.endpoint.clone(); + let session_id = self.session_id.lock().clone(); + let headers = self.headers.clone(); + + if let Some(session_id) = session_id { + self.executor + .spawn(async move { + let mut request_builder = Request::builder() + .method(Method::DELETE) + .uri(&endpoint) + .header(HEADER_SESSION_ID, &session_id); + + // Add authentication headers if present + for (key, value) in headers { + request_builder = request_builder.header(key.as_str(), value.as_str()); + } + + let request = request_builder.body(AsyncBody::empty()); + + if let Ok(request) = request { + let _ = http_client.send(request).await; + } + }) + .detach(); + } + } +} diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 83908b4682..e675770e9e 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -8,9 +8,12 @@ use futures::{ AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, }; use gpui::AsyncApp; +use settings::Settings as _; use smol::channel; use smol::process::Child; +use terminal::terminal_settings::TerminalSettings; use util::TryFutureExt as _; +use util::shell_builder::ShellBuilder; use crate::client::ModelContextServerBinary; use crate::transport::Transport; @@ -28,9 +31,12 @@ impl StdioTransport { working_directory: &Option, cx: &AsyncApp, ) -> Result { - let mut command = util::command::new_smol_command(&binary.executable); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let mut command = + builder.build_command(Some(binary.executable.display().to_string()), &binary.args); + command - .args(&binary.args) .envs(binary.env.unwrap_or_default()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index a0a49d6f25..459abda175 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -26,7 +26,6 @@ test-support = [ [dependencies] anyhow.workspace = true chrono.workspace = true -client.workspace = true collections.workspace = true command_palette_hooks.workspace = true dirs.workspace = true @@ -34,7 +33,7 @@ fs.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true -edit_prediction.workspace = true +edit_prediction_types.workspace = true language.workspace = true log.workspace = true lsp.workspace = true @@ -52,7 +51,6 @@ task.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true itertools.workspace = true [target.'cfg(windows)'.dependencies] diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index ffcae93b40..45f0796bf5 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1,9 +1,10 @@ pub mod copilot_chat; -mod copilot_completion_provider; +mod copilot_edit_prediction_delegate; +pub mod copilot_responses; pub mod request; mod sign_in; -use crate::sign_in::initiate_sign_in_within_workspace; +use crate::sign_in::initiate_sign_out; use ::fs::Fs; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; @@ -27,12 +28,10 @@ use project::DisableAiSettings; use request::StatusNotification; use semver::Version; use serde_json::json; -use settings::Settings; -use settings::SettingsStore; -use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace}; -use std::collections::hash_map::Entry; +use settings::{Settings, SettingsStore}; use std::{ any::TypeId, + collections::hash_map::Entry, env, ffi::OsString, mem, @@ -41,12 +40,14 @@ use std::{ sync::Arc, }; use sum_tree::Dimensions; -use util::rel_path::RelPath; -use util::{ResultExt, fs::remove_matching}; +use util::{ResultExt, fs::remove_matching, rel_path::RelPath}; use workspace::Workspace; -pub use crate::copilot_completion_provider::CopilotCompletionProvider; -pub use crate::sign_in::{CopilotCodeVerification, initiate_sign_in, reinstall_and_sign_in}; +pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate; +pub use crate::sign_in::{ + ConfigurationMode, ConfigurationView, CopilotCodeVerification, initiate_sign_in, + reinstall_and_sign_in, +}; actions!( copilot, @@ -97,21 +98,14 @@ pub fn init( .detach(); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { - workspace.register_action(|workspace, _: &SignIn, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); - } + workspace.register_action(|_, _: &SignIn, window, cx| { + initiate_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &Reinstall, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - } + workspace.register_action(|_, _: &Reinstall, window, cx| { + reinstall_and_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &SignOut, _window, cx| { - if let Some(copilot) = Copilot::global(cx) { - sign_out_within_workspace(workspace, copilot, cx); - } + workspace.register_action(|_, _: &SignOut, window, cx| { + initiate_sign_out(window, cx); }); }) .detach(); @@ -270,7 +264,7 @@ impl RegisteredBuffer { server .lsp .notify::( - &lsp::DidChangeTextDocumentParams { + lsp::DidChangeTextDocumentParams { text_document: lsp::VersionedTextDocumentIdentifier::new( buffer.uri.clone(), buffer.snapshot_version, @@ -374,7 +368,7 @@ impl Copilot { } } - fn start_copilot( + pub fn start_copilot( &mut self, check_edit_prediction_provider: bool, awaiting_sign_in_after_start: bool, @@ -488,7 +482,11 @@ impl Copilot { let node_path = node_runtime.binary_path().await?; ensure_node_version_for_copilot(&node_path).await?; - let arguments: Vec = vec![server_path.into(), "--stdio".into()]; + let arguments: Vec = vec![ + "--experimental-sqlite".into(), + server_path.into(), + "--stdio".into(), + ]; let binary = LanguageServerBinary { path: node_path, arguments, @@ -558,6 +556,14 @@ impl Copilot { let server = start_language_server.await; this.update(cx, |this, cx| { cx.notify(); + + if env::var("ZED_FORCE_COPILOT_ERROR").is_ok() { + this.server = CopilotServer::Error( + "Forced error for testing (ZED_FORCE_COPILOT_ERROR)".into(), + ); + return; + } + match server { Ok((server, status)) => { this.server = CopilotServer::Running(RunningCopilotServer { @@ -579,7 +585,17 @@ impl Copilot { .ok(); } - pub(crate) fn sign_in(&mut self, cx: &mut Context) -> Task> { + pub fn is_authenticated(&self) -> bool { + return matches!( + self.server, + CopilotServer::Running(RunningCopilotServer { + sign_in_status: SignInStatus::Authorized, + .. + }) + ); + } + + pub fn sign_in(&mut self, cx: &mut Context) -> Task> { if let CopilotServer::Running(server) = &mut self.server { let task = match &server.sign_in_status { SignInStatus::Authorized => Task::ready(Ok(())).shared(), @@ -744,7 +760,7 @@ impl Copilot { let snapshot = buffer.read(cx).snapshot(); server .notify::( - &lsp::DidOpenTextDocumentParams { + lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem { uri: uri.clone(), language_id: language_id.clone(), @@ -792,16 +808,17 @@ impl Copilot { server .lsp .notify::( - &lsp::DidSaveTextDocumentParams { + lsp::DidSaveTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new( registered_buffer.uri.clone(), ), text: None, }, - )?; + ) + .ok(); } language::BufferEvent::FileHandleChanged - | language::BufferEvent::LanguageChanged => { + | language::BufferEvent::LanguageChanged(_) => { let new_language_id = id_for_language(buffer.read(cx).language()); let Ok(new_uri) = uri_for_buffer(&buffer, cx) else { return Ok(()); @@ -814,14 +831,15 @@ impl Copilot { server .lsp .notify::( - &lsp::DidCloseTextDocumentParams { + lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new(old_uri), }, - )?; + ) + .ok(); server .lsp .notify::( - &lsp::DidOpenTextDocumentParams { + lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem::new( registered_buffer.uri.clone(), registered_buffer.language_id.clone(), @@ -829,7 +847,8 @@ impl Copilot { registered_buffer.snapshot.text(), ), }, - )?; + ) + .ok(); } } _ => {} @@ -846,7 +865,7 @@ impl Copilot { server .lsp .notify::( - &lsp::DidCloseTextDocumentParams { + lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new(buffer.uri), }, ) @@ -1151,9 +1170,12 @@ fn notify_did_change_config_to_server( } }); - server.notify::(&lsp::DidChangeConfigurationParams { - settings, - }) + server + .notify::(lsp::DidChangeConfigurationParams { + settings, + }) + .ok(); + Ok(()) } async fn clear_copilot_dir() { diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index a6758ce53c..52a3631791 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -15,6 +15,8 @@ use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use itertools::Itertools; use paths::home_dir; use serde::{Deserialize, Serialize}; + +use crate::copilot_responses as responses; use settings::watch_config_dir; pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN"; @@ -42,10 +44,14 @@ impl CopilotChatConfiguration { } } - pub fn api_url_from_endpoint(&self, endpoint: &str) -> String { + pub fn chat_completions_url_from_endpoint(&self, endpoint: &str) -> String { format!("{}/chat/completions", endpoint) } + pub fn responses_url_from_endpoint(&self, endpoint: &str) -> String { + format!("{}/responses", endpoint) + } + pub fn models_url_from_endpoint(&self, endpoint: &str) -> String { format!("{}/models", endpoint) } @@ -71,6 +77,14 @@ pub enum Role { System, } +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub enum ModelSupportedEndpoint { + #[serde(rename = "/chat/completions")] + ChatCompletions, + #[serde(rename = "/responses")] + Responses, +} + #[derive(Deserialize)] struct ModelSchema { #[serde(deserialize_with = "deserialize_models_skip_errors")] @@ -109,6 +123,8 @@ pub struct Model { // reached. Zed does not currently implement this behaviour is_chat_fallback: bool, model_picker_enabled: bool, + #[serde(default)] + supported_endpoints: Vec, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] @@ -224,6 +240,16 @@ impl Model { pub fn tokenizer(&self) -> Option<&str> { self.capabilities.tokenizer.as_deref() } + + pub fn supports_response(&self) -> bool { + self.supported_endpoints.len() > 0 + && !self + .supported_endpoints + .contains(&ModelSupportedEndpoint::ChatCompletions) + && self + .supported_endpoints + .contains(&ModelSupportedEndpoint::Responses) + } } #[derive(Serialize, Deserialize)] @@ -253,7 +279,7 @@ pub enum Tool { Function { function: Function }, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, @@ -268,6 +294,10 @@ pub enum ChatMessage { content: ChatMessageContent, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_opaque: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_text: Option, }, User { content: ChatMessageContent, @@ -327,6 +357,8 @@ pub enum ToolCallContent { pub struct FunctionContent { pub name: String, pub arguments: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thought_signature: Option, } #[derive(Deserialize, Debug)] @@ -346,7 +378,7 @@ pub struct Usage { #[derive(Debug, Deserialize)] pub struct ResponseChoice { - pub index: usize, + pub index: Option, pub finish_reason: Option, pub delta: Option, pub message: Option, @@ -358,11 +390,12 @@ pub struct ResponseDelta { pub role: Option, #[serde(default)] pub tool_calls: Vec, + pub reasoning_opaque: Option, + pub reasoning_text: Option, } - #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct ToolCallChunk { - pub index: usize, + pub index: Option, pub id: Option, pub function: Option, } @@ -371,6 +404,7 @@ pub struct ToolCallChunk { pub struct FunctionChunk { pub name: Option, pub arguments: Option, + pub thought_signature: Option, } #[derive(Deserialize)] @@ -554,13 +588,47 @@ impl CopilotChat { is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { + let (client, token, configuration) = Self::get_auth_details(&mut cx).await?; + + let api_url = configuration.chat_completions_url_from_endpoint(&token.api_endpoint); + stream_completion( + client.clone(), + token.api_key, + api_url.into(), + request, + is_user_initiated, + ) + .await + } + + pub async fn stream_response( + request: responses::Request, + is_user_initiated: bool, + mut cx: AsyncApp, + ) -> Result>> { + let (client, token, configuration) = Self::get_auth_details(&mut cx).await?; + + let api_url = configuration.responses_url_from_endpoint(&token.api_endpoint); + responses::stream_response( + client.clone(), + token.api_key, + api_url, + request, + is_user_initiated, + ) + .await + } + + async fn get_auth_details( + cx: &mut AsyncApp, + ) -> Result<(Arc, ApiToken, CopilotChatConfiguration)> { let this = cx .update(|cx| Self::global(cx)) .ok() .flatten() .context("Copilot chat is not enabled")?; - let (oauth_token, api_token, client, configuration) = this.read_with(&cx, |this, _| { + let (oauth_token, api_token, client, configuration) = this.read_with(cx, |this, _| { ( this.oauth_token.clone(), this.api_token.clone(), @@ -572,12 +640,12 @@ impl CopilotChat { let oauth_token = oauth_token.context("No OAuth token available")?; let token = match api_token { - Some(api_token) if api_token.remaining_seconds() > 5 * 60 => api_token.clone(), + Some(api_token) if api_token.remaining_seconds() > 5 * 60 => api_token, _ => { let token_url = configuration.token_url(); let token = request_api_token(&oauth_token, token_url.into(), client.clone()).await?; - this.update(&mut cx, |this, cx| { + this.update(cx, |this, cx| { this.api_token = Some(token.clone()); cx.notify(); })?; @@ -585,15 +653,7 @@ impl CopilotChat { } }; - let api_url = configuration.api_url_from_endpoint(&token.api_endpoint); - stream_completion( - client.clone(), - token.api_key, - api_url.into(), - request, - is_user_initiated, - ) - .await + Ok((client, token, configuration)) } pub fn set_configuration( @@ -732,13 +792,13 @@ async fn stream_completion( is_user_initiated: bool, ) -> Result>> { let is_vision_request = request.messages.iter().any(|message| match message { - ChatMessage::User { content } - | ChatMessage::Assistant { content, .. } - | ChatMessage::Tool { content, .. } => { - matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. }))) - } - _ => false, - }); + ChatMessage::User { content } + | ChatMessage::Assistant { content, .. } + | ChatMessage::Tool { content, .. } => { + matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. }))) + } + _ => false, + }); let request_initiator = if is_user_initiated { "user" } else { "agent" }; diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs similarity index 96% rename from crates/copilot/src/copilot_completion_provider.rs rename to crates/copilot/src/copilot_edit_prediction_delegate.rs index 6027c081cc..0e0cfe6cdc 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -1,6 +1,6 @@ use crate::{Completion, Copilot}; use anyhow::Result; -use edit_prediction::{Direction, EditPrediction, EditPredictionProvider}; +use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate}; use gpui::{App, Context, Entity, EntityId, Task}; use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings}; use settings::Settings; @@ -8,7 +8,7 @@ use std::{path::Path, time::Duration}; pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); -pub struct CopilotCompletionProvider { +pub struct CopilotEditPredictionDelegate { cycled: bool, buffer_id: Option, completions: Vec, @@ -19,7 +19,7 @@ pub struct CopilotCompletionProvider { copilot: Entity, } -impl CopilotCompletionProvider { +impl CopilotEditPredictionDelegate { pub fn new(copilot: Entity) -> Self { Self { cycled: false, @@ -47,7 +47,7 @@ impl CopilotCompletionProvider { } } -impl EditPredictionProvider for CopilotCompletionProvider { +impl EditPredictionDelegate for CopilotEditPredictionDelegate { fn name() -> &'static str { "copilot" } @@ -56,7 +56,7 @@ impl EditPredictionProvider for CopilotCompletionProvider { "Copilot" } - fn show_completions_in_menu() -> bool { + fn show_predictions_in_menu() -> bool { true } @@ -68,7 +68,7 @@ impl EditPredictionProvider for CopilotCompletionProvider { false } - fn is_refreshing(&self) -> bool { + fn is_refreshing(&self, _cx: &App) -> bool { self.pending_refresh.is_some() && self.completions.is_empty() } @@ -269,8 +269,9 @@ fn common_prefix, T2: Iterator>(a: T1, b: #[cfg(test)] mod tests { use super::*; + use edit_prediction_types::EditPredictionGranularity; use editor::{ - Editor, ExcerptRange, MultiBuffer, SelectionEffects, + Editor, ExcerptRange, MultiBuffer, MultiBufferOffset, SelectionEffects, test::editor_lsp_test_context::EditorLspTestContext, }; use fs::FakeFs; @@ -314,7 +315,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -546,7 +547,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -581,13 +582,15 @@ mod tests { assert!(editor.has_active_edit_prediction()); // Accepting the first word of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); // Accepting next word should accept the non-word and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); @@ -623,7 +626,7 @@ mod tests { assert!(editor.has_active_edit_prediction()); // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n"); assert_eq!( @@ -632,7 +635,7 @@ mod tests { ); // Accepting next word should accept the next word and copilot suggestion should still exist - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n"); assert_eq!( @@ -641,7 +644,7 @@ mod tests { ); // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n"); assert_eq!( @@ -670,7 +673,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -753,7 +756,7 @@ mod tests { window.focus(&editor.focus_handle(cx)); }) .unwrap(); - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor .update(cx, |editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) @@ -848,7 +851,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -1000,7 +1003,7 @@ mod tests { window.focus(&editor.focus_handle(cx)) }) .unwrap(); - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor .update(cx, |editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) @@ -1081,8 +1084,9 @@ mod tests { vec![complete_from_marker, replace_range_marker.clone()], ); + let range = marked_ranges.remove(&replace_range_marker).unwrap()[0].clone(); let replace_range = - cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + cx.to_lsp_range(MultiBufferOffset(range.start)..MultiBufferOffset(range.end)); let mut request = cx.set_request_handler::(move |url, params, _| { @@ -1115,11 +1119,6 @@ mod tests { let store = SettingsStore::test(cx); cx.set_global(store); theme::init(theme::LoadThemes::JustBase, cx); - client::init_settings(cx); - language::init(cx); - editor::init_settings(cx); - Project::init_settings(cx); - workspace::init_settings(cx); SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| f(&mut settings.project.all_languages)); }); diff --git a/crates/copilot/src/copilot_responses.rs b/crates/copilot/src/copilot_responses.rs new file mode 100644 index 0000000000..2da2eb394b --- /dev/null +++ b/crates/copilot/src/copilot_responses.rs @@ -0,0 +1,419 @@ +use super::*; +use anyhow::{Result, anyhow}; +use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; +use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +pub use settings::OpenAiReasoningEffort as ReasoningEffort; + +#[derive(Serialize, Debug)] +pub struct Request { + pub model: String, + pub input: Vec, + #[serde(default)] + pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ResponseIncludable { + #[serde(rename = "reasoning.encrypted_content")] + ReasoningEncryptedContent, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolDefinition { + Function { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + parameters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + strict: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ToolChoice { + Auto, + Any, + None, + #[serde(untagged)] + Other(ToolDefinition), +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningSummary { + Auto, + Concise, + Detailed, +} + +#[derive(Serialize, Debug)] +pub struct ReasoningConfig { + pub effort: ReasoningEffort, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum ResponseImageDetail { + Low, + High, + #[default] + Auto, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseInputContent { + InputText { + text: String, + }, + OutputText { + text: String, + }, + InputImage { + #[serde(skip_serializing_if = "Option::is_none")] + image_url: Option, + #[serde(default)] + detail: ResponseImageDetail, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ItemStatus { + InProgress, + Completed, + Incomplete, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum ResponseFunctionOutput { + Text(String), + Content(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseInputItem { + Message { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + }, + FunctionCall { + call_id: String, + name: String, + arguments: String, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + thought_signature: Option, + }, + FunctionCallOutput { + call_id: String, + output: ResponseFunctionOutput, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + }, + Reasoning { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + summary: Vec, + encrypted_content: String, + }, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum IncompleteReason { + #[serde(rename = "max_output_tokens")] + MaxOutputTokens, + #[serde(rename = "content_filter")] + ContentFilter, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct IncompleteDetails { + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ResponseReasoningItem { + #[serde(rename = "type")] + pub kind: String, + pub text: String, +} + +#[derive(Deserialize, Debug)] +#[serde(tag = "type")] +pub enum StreamEvent { + #[serde(rename = "error")] + GenericError { error: ResponseError }, + + #[serde(rename = "response.created")] + Created { response: Response }, + + #[serde(rename = "response.output_item.added")] + OutputItemAdded { + output_index: usize, + #[serde(default)] + sequence_number: Option, + item: ResponseOutputItem, + }, + + #[serde(rename = "response.output_text.delta")] + OutputTextDelta { + item_id: String, + output_index: usize, + delta: String, + }, + + #[serde(rename = "response.output_item.done")] + OutputItemDone { + output_index: usize, + #[serde(default)] + sequence_number: Option, + item: ResponseOutputItem, + }, + + #[serde(rename = "response.incomplete")] + Incomplete { response: Response }, + + #[serde(rename = "response.completed")] + Completed { response: Response }, + + #[serde(rename = "response.failed")] + Failed { response: Response }, + + #[serde(other)] + Unknown, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ResponseError { + pub code: String, + pub message: String, +} + +#[derive(Deserialize, Debug, Default, Clone)] +pub struct Response { + pub id: Option, + pub status: Option, + pub usage: Option, + pub output: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub incomplete_details: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Deserialize, Debug, Default, Clone)] +pub struct ResponseUsage { + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseOutputItem { + Message { + id: String, + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option>, + }, + FunctionCall { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + call_id: String, + name: String, + arguments: String, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + thought_signature: Option, + }, + Reasoning { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + encrypted_content: Option, + }, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseOutputContent { + OutputText { text: String }, + Refusal { refusal: String }, +} + +pub async fn stream_response( + client: Arc, + api_key: String, + api_url: String, + request: Request, + is_user_initiated: bool, +) -> Result>> { + let is_vision_request = request.input.iter().any(|item| match item { + ResponseInputItem::Message { + content: Some(parts), + .. + } => parts + .iter() + .any(|p| matches!(p, ResponseInputContent::InputImage { .. })), + _ => false, + }); + + let request_initiator = if is_user_initiated { "user" } else { "agent" }; + + let request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(&api_url) + .header( + "Editor-Version", + format!( + "Zed/{}", + option_env!("CARGO_PKG_VERSION").unwrap_or("unknown") + ), + ) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .header("Copilot-Integration-Id", "vscode-chat") + .header("X-Initiator", request_initiator); + + let request_builder = if is_vision_request { + request_builder.header("Copilot-Vision-Request", "true") + } else { + request_builder + }; + + let is_streaming = request.stream; + let json = serde_json::to_string(&request)?; + let request = request_builder.body(AsyncBody::from(json))?; + let mut response = client.send(request).await?; + + if !response.status().is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + anyhow::bail!("Failed to connect to API: {} {}", response.status(), body); + } + + if is_streaming { + let reader = BufReader::new(response.into_body()); + Ok(reader + .lines() + .filter_map(|line| async move { + match line { + Ok(line) => { + let line = line.strip_prefix("data: ")?; + if line.starts_with("[DONE]") || line.is_empty() { + return None; + } + + match serde_json::from_str::(line) { + Ok(event) => Some(Ok(event)), + Err(error) => { + log::error!( + "Failed to parse Copilot responses stream event: `{}`\nResponse: `{}`", + error, + line, + ); + Some(Err(anyhow!(error))) + } + } + } + Err(error) => Some(Err(anyhow!(error))), + } + }) + .boxed()) + } else { + // Simulate streaming this makes the mapping of this function return more straight-forward to handle if all callers assume it streams. + // Removes the need of having a method to map StreamEvent and another to map Response to a LanguageCompletionEvent + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + match serde_json::from_str::(&body) { + Ok(response) => { + let events = vec![StreamEvent::Created { + response: response.clone(), + }]; + + let mut all_events = events; + for (output_index, item) in response.output.iter().enumerate() { + all_events.push(StreamEvent::OutputItemAdded { + output_index, + sequence_number: None, + item: item.clone(), + }); + + if let ResponseOutputItem::Message { + id, + content: Some(content), + .. + } = item + { + for part in content { + if let ResponseOutputContent::OutputText { text } = part { + all_events.push(StreamEvent::OutputTextDelta { + item_id: id.clone(), + output_index, + delta: text.clone(), + }); + } + } + } + + all_events.push(StreamEvent::OutputItemDone { + output_index, + sequence_number: None, + item: item.clone(), + }); + } + + let final_event = if response.error.is_some() { + StreamEvent::Failed { response } + } else if response.incomplete_details.is_some() { + StreamEvent::Incomplete { response } + } else { + StreamEvent::Completed { response } + }; + all_events.push(final_event); + + Ok(futures::stream::iter(all_events.into_iter().map(Ok)).boxed()) + } + Err(error) => { + log::error!( + "Failed to parse Copilot non-streaming response: `{}`\nResponse: `{}`", + error, + body, + ); + Err(anyhow!(error)) + } + } + } +} diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index 464a114d4e..0bcb11e18b 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -1,160 +1,151 @@ use crate::{Copilot, Status, request::PromptUserDeviceFlow}; +use anyhow::Context as _; use gpui::{ - Animation, AnimationExt, App, ClipboardItem, Context, DismissEvent, Element, Entity, - EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent, - ParentElement, Render, Styled, Subscription, Transformation, Window, div, percentage, svg, + App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled, + Subscription, Window, WindowBounds, WindowOptions, div, point, }; -use std::time::Duration; -use ui::{Button, Label, Vector, VectorName, prelude::*}; +use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; use util::ResultExt as _; -use workspace::notifications::NotificationId; -use workspace::{ModalView, Toast, Workspace}; +use workspace::{Toast, Workspace, notifications::NotificationId}; const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot"; +const ERROR_LABEL: &str = + "Copilot had issues starting. You can try reinstalling it and signing in again."; struct CopilotStatusToast; pub fn initiate_sign_in(window: &mut Window, cx: &mut App) { + let is_reinstall = false; + initiate_sign_in_impl(is_reinstall, window, cx) +} + +pub fn initiate_sign_out(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; - let Some(workspace) = window.root::().flatten() else { - return; - }; - workspace.update(cx, |workspace, cx| { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx) - }); + + copilot_toast(Some("Signing out of Copilot…"), window, cx); + + let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); + window + .spawn(cx, async move |cx| match sign_out_task.await { + Ok(()) => { + cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx)) + } + Err(err) => cx.update(|window, cx| { + if let Some(workspace) = window.root::().flatten() { + workspace.update(cx, |workspace, cx| { + workspace.show_error(&err, cx); + }) + } else { + log::error!("{:?}", err); + } + }), + }) + .detach(); } pub fn reinstall_and_sign_in(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; + let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); + let is_reinstall = true; + initiate_sign_in_impl(is_reinstall, window, cx); +} + +fn open_copilot_code_verification_window(copilot: &Entity, window: &Window, cx: &mut App) { + let current_window_center = window.bounds().center(); + let height = px(450.); + let width = px(350.); + let window_bounds = WindowBounds::Windowed(gpui::bounds( + current_window_center - point(height / 2.0, width / 2.0), + gpui::size(height, width), + )); + cx.open_window( + WindowOptions { + kind: gpui::WindowKind::PopUp, + window_bounds: Some(window_bounds), + is_resizable: false, + is_movable: true, + titlebar: Some(gpui::TitlebarOptions { + appears_transparent: true, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)), + ) + .context("Failed to open Copilot code verification window") + .log_err(); +} + +fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) { + const NOTIFICATION_ID: NotificationId = NotificationId::unique::(); + let Some(workspace) = window.root::().flatten() else { return; }; - workspace.update(cx, |workspace, cx| { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); + + workspace.update(cx, |workspace, cx| match message { + Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx), + None => workspace.dismiss_toast(&NOTIFICATION_ID, cx), }); } -pub fn reinstall_and_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - window: &mut Window, - cx: &mut Context, -) { - let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); - let is_reinstall = true; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); -} - -pub fn initiate_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - is_reinstall: bool, - window: &mut Window, - cx: &mut Context, -) { +pub fn initiate_sign_in_impl(is_reinstall: bool, window: &mut Window, cx: &mut App) { + let Some(copilot) = Copilot::global(cx) else { + return; + }; if matches!(copilot.read(cx).status(), Status::Disabled) { copilot.update(cx, |copilot, cx| copilot.start_copilot(false, true, cx)); } match copilot.read(cx).status() { Status::Starting { task } => { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - if is_reinstall { - "Copilot is reinstalling..." - } else { - "Copilot is starting..." - }, - ), + copilot_toast( + Some(if is_reinstall { + "Copilot is reinstalling…" + } else { + "Copilot is starting…" + }), + window, cx, ); - cx.spawn_in(window, async move |workspace, cx| { - task.await; - if let Some(copilot) = cx.update(|_window, cx| Copilot::global(cx)).ok().flatten() { - workspace - .update_in(cx, |workspace, window, cx| { - match copilot.read(cx).status() { - Status::Authorized => workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Copilot has started.", - ), - cx, - ), - _ => { - workspace.dismiss_toast( - &NotificationId::unique::(), - cx, - ); - copilot - .update(cx, |copilot, cx| copilot.sign_in(cx)) - .detach_and_log_err(cx); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); - } + window + .spawn(cx, async move |cx| { + task.await; + cx.update(|window, cx| { + let Some(copilot) = Copilot::global(cx) else { + return; + }; + match copilot.read(cx).status() { + Status::Authorized => { + copilot_toast(Some("Copilot has started."), window, cx) } - }) - .log_err(); - } - }) - .detach(); + _ => { + copilot_toast(None, window, cx); + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + open_copilot_code_verification_window(&copilot, window, cx); + } + } + }) + .log_err(); + }) + .detach(); } _ => { copilot .update(cx, |copilot, cx| copilot.sign_in(cx)) .detach(); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); + open_copilot_code_verification_window(&copilot, window, cx); } } } -pub fn sign_out_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - cx: &mut Context, -) { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signing out of Copilot...", - ), - cx, - ); - let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); - cx.spawn(async move |workspace, cx| match sign_out_task.await { - Ok(()) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signed out of Copilot.", - ), - cx, - ) - }) - .ok(); - } - Err(err) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_error(&err, cx); - }) - .ok(); - } - }) - .detach(); -} - pub struct CopilotCodeVerification { status: Status, connect_clicked: bool, @@ -170,23 +161,27 @@ impl Focusable for CopilotCodeVerification { } impl EventEmitter for CopilotCodeVerification {} -impl ModalView for CopilotCodeVerification { - fn on_before_dismiss( - &mut self, - _: &mut Window, - cx: &mut Context, - ) -> workspace::DismissDecision { - self.copilot.update(cx, |copilot, cx| { - if matches!(copilot.status(), Status::SigningIn { .. }) { - copilot.sign_out(cx).detach_and_log_err(cx); - } - }); - workspace::DismissDecision::Dismiss(true) - } -} impl CopilotCodeVerification { - pub fn new(copilot: &Entity, cx: &mut Context) -> Self { + pub fn new(copilot: &Entity, window: &mut Window, cx: &mut Context) -> Self { + window.on_window_should_close(cx, |window, cx| { + if let Some(this) = window.root::().flatten() { + this.update(cx, |this, cx| { + this.before_dismiss(cx); + }); + } + true + }); + cx.subscribe_in( + &cx.entity(), + window, + |this, _, _: &DismissEvent, window, cx| { + window.remove_window(); + this.before_dismiss(cx); + }, + ) + .detach(); + let status = copilot.read(cx).status(); Self { status, @@ -215,45 +210,45 @@ impl CopilotCodeVerification { .read_from_clipboard() .map(|item| item.text().as_ref() == Some(&data.user_code)) .unwrap_or(false); - h_flex() - .w_full() - .p_1() - .border_1() - .border_muted(cx) - .rounded_sm() - .cursor_pointer() - .justify_between() - .on_mouse_down(gpui::MouseButton::Left, { + + ButtonLike::new("copy-button") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .p_1() + .justify_between() + .child(Label::new(data.user_code.clone())) + .child(Label::new(if copied { "Copied!" } else { "Copy" })), + ) + .on_click({ let user_code = data.user_code.clone(); move |_, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone())); window.refresh(); } }) - .child(div().flex_1().child(Label::new(data.user_code.clone()))) - .child(div().flex_none().px_1().child(Label::new(if copied { - "Copied!" - } else { - "Copy" - }))) } fn render_prompting_modal( connect_clicked: bool, data: &PromptUserDeviceFlow, - cx: &mut Context, ) -> impl Element { let connect_button_label = if connect_clicked { - "Waiting for connection..." + "Waiting for connection…" } else { "Connect to GitHub" }; + v_flex() .flex_1() - .gap_2() + .gap_2p5() .items_center() - .child(Headline::new("Use GitHub Copilot in Zed.").size(HeadlineSize::Large)) + .text_center() + .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large)) .child( Label::new("Using Copilot requires an active subscription on GitHub.") .color(Color::Muted), @@ -261,83 +256,119 @@ impl CopilotCodeVerification { .child(Self::render_device_code(data, cx)) .child( Label::new("Paste this code into GitHub after clicking the button below.") - .size(ui::LabelSize::Small), + .color(Color::Muted), ) .child( - Button::new("connect-button", connect_button_label) - .on_click({ - let verification_uri = data.verification_uri.clone(); - cx.listener(move |this, _, _window, cx| { - cx.open_url(&verification_uri); - this.connect_clicked = true; - }) - }) - .full_width() - .style(ButtonStyle::Filled), - ) - .child( - Button::new("copilot-enable-cancel-button", "Cancel") - .full_width() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })), + v_flex() + .w_full() + .gap_1() + .child( + Button::new("connect-button", connect_button_label) + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click({ + let verification_uri = data.verification_uri.clone(); + cx.listener(move |this, _, _window, cx| { + cx.open_url(&verification_uri); + this.connect_clicked = true; + }) + }), + ) + .child( + Button::new("copilot-enable-cancel-button", "Cancel") + .full_width() + .size(ButtonSize::Medium) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); + })), + ), ) } fn render_enabled_modal(cx: &mut Context) -> impl Element { v_flex() .gap_2() + .text_center() + .justify_center() .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) - .child(Label::new( - "You can update your settings or sign out from the Copilot menu in the status bar.", - )) + .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted)) .child( Button::new("copilot-enabled-done-button", "Done") .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } fn render_unauthorized_modal(cx: &mut Context) -> impl Element { - v_flex() - .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large)) + let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription."; - .child(Label::new( - "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.", - ).color(Color::Warning)) + v_flex() + .gap_2() + .text_center() + .justify_center() + .child( + Headline::new("You must have an active GitHub Copilot subscription.") + .size(HeadlineSize::Large), + ) + .child(Label::new(description).color(Color::Warning)) .child( Button::new("copilot-subscribe-button", "Subscribe on GitHub") .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)), ) .child( Button::new("copilot-subscribe-cancel-button", "Cancel") .full_width() + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } - fn render_loading(window: &mut Window, _: &mut Context) -> impl Element { - let loading_icon = svg() - .size_8() - .path(IconName::ArrowCircle.path()) - .text_color(window.text_style().color) - .with_animation( - "icon_circle_arrow", - Animation::new(Duration::from_secs(2)).repeat(), - |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))), - ); + fn render_error_modal(_cx: &mut Context) -> impl Element { + v_flex() + .gap_2() + .text_center() + .justify_center() + .child(Headline::new("An Error Happened").size(HeadlineSize::Large)) + .child(Label::new(ERROR_LABEL).color(Color::Muted)) + .child( + Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In") + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)), + ) + } - h_flex().justify_center().child(loading_icon) + fn before_dismiss( + &mut self, + cx: &mut Context<'_, CopilotCodeVerification>, + ) -> workspace::DismissDecision { + self.copilot.update(cx, |copilot, cx| { + if matches!(copilot.status(), Status::SigningIn { .. }) { + copilot.sign_out(cx).detach_and_log_err(cx); + } + }); + workspace::DismissDecision::Dismiss(true) } } impl Render for CopilotCodeVerification { - 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 prompt = match &self.status { - Status::SigningIn { prompt: None } => { - Self::render_loading(window, cx).into_any_element() - } + Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), Status::SigningIn { prompt: Some(prompt), } => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(), @@ -349,17 +380,20 @@ impl Render for CopilotCodeVerification { self.connect_clicked = false; Self::render_enabled_modal(cx).into_any_element() } + Status::Error(..) => Self::render_error_modal(cx).into_any_element(), _ => div().into_any_element(), }; v_flex() - .id("copilot code verification") + .id("copilot_code_verification") .track_focus(&self.focus_handle(cx)) - .elevation_3(cx) - .w_96() - .items_center() - .p_4() + .size_full() + .px_4() + .py_8() .gap_2() + .items_center() + .justify_center() + .elevation_3(cx) .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { cx.emit(DismissEvent); })) @@ -373,3 +407,243 @@ impl Render for CopilotCodeVerification { .child(prompt) } } + +pub struct ConfigurationView { + copilot_status: Option, + is_authenticated: fn(cx: &App) -> bool, + edit_prediction: bool, + _subscription: Option, +} + +pub enum ConfigurationMode { + Chat, + EditPrediction, +} + +impl ConfigurationView { + pub fn new( + is_authenticated: fn(cx: &App) -> bool, + mode: ConfigurationMode, + cx: &mut Context, + ) -> Self { + let copilot = Copilot::global(cx); + + Self { + copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()), + is_authenticated, + edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), + _subscription: copilot.as_ref().map(|copilot| { + cx.observe(copilot, |this, model, cx| { + this.copilot_status = Some(model.read(cx).status()); + cx.notify(); + }) + }), + } + } +} + +impl ConfigurationView { + fn is_starting(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Starting { .. })) + } + + fn is_signing_in(&self) -> bool { + matches!( + &self.copilot_status, + Some(Status::SigningIn { .. }) + | Some(Status::SignedOut { + awaiting_signing_in: true + }) + ) + } + + fn is_error(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Error(_))) + } + + fn has_no_status(&self) -> bool { + self.copilot_status.is_none() + } + + fn loading_message(&self) -> Option { + if self.is_starting() { + Some("Starting Copilot…".into()) + } else if self.is_signing_in() { + Some("Signing into Copilot…".into()) + } else { + None + } + } + + fn render_loading_button( + &self, + label: impl Into, + edit_prediction: bool, + ) -> impl IntoElement { + ButtonLike::new("loading_button") + .disabled(true) + .style(ButtonStyle::Outlined) + .when(edit_prediction, |this| this.size(ButtonSize::Medium)) + .child( + h_flex() + .w_full() + .gap_1() + .justify_center() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(4), + ) + .child(Label::new(label)), + ) + } + + fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Sign in to GitHub" + } else { + "Sign in to use GitHub Copilot" + }; + + Button::new("sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Github) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| initiate_sign_in(window, cx)) + } + + fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Reinstall and Sign in" + } else { + "Reinstall Copilot and Sign in" + }; + + Button::new("reinstall_and_sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)) + } + + fn render_for_edit_prediction(&self) -> impl IntoElement { + let container = |description: SharedString, action: AnyElement| { + h_flex() + .pt_2p5() + .w_full() + .justify_between() + .child( + v_flex() + .w_full() + .max_w_1_2() + .child(Label::new("Authenticate To Use")) + .child( + Label::new(description) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child(action) + }; + + let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into(); + let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into(); + + if let Some(msg) = self.loading_message() { + container( + start_label, + self.render_loading_button(msg, true).into_any_element(), + ) + .into_any_element() + } else if self.is_error() { + container( + ERROR_LABEL.into(), + self.render_reinstall_button(true).into_any_element(), + ) + .into_any_element() + } else if self.has_no_status() { + container( + no_status_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } else { + container( + start_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } + } + + fn render_for_chat(&self) -> impl IntoElement { + let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; + let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider."; + + if let Some(msg) = self.loading_message() { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_loading_button(msg, false)) + .into_any_element() + } else if self.is_error() { + v_flex() + .gap_2() + .child(Label::new(ERROR_LABEL)) + .child(self.render_reinstall_button(false)) + .into_any_element() + } else if self.has_no_status() { + v_flex() + .gap_2() + .child(Label::new(no_status_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } else { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_authenticated = self.is_authenticated; + + if is_authenticated(cx) { + return ConfiguredApiCard::new("Authorized") + .button_label("Sign Out") + .on_click(|_, window, cx| { + initiate_sign_out(window, cx); + }) + .into_any_element(); + } + + if self.edit_prediction { + self.render_for_edit_prediction().into_any_element() + } else { + self.render_for_chat().into_any_element() + } + } +} diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index 0f8147038d..bd1c112184 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -9,6 +9,7 @@ license = "GPL-3.0-or-later" bincode.workspace = true cfg-if.workspace = true crash-handler.workspace = true +extension_host.workspace = true log.workspace = true minidumper.workspace = true paths.workspace = true @@ -17,12 +18,14 @@ smol.workspace = true serde.workspace = true serde_json.workspace = true system_specs.workspace = true -workspace-hack.workspace = true zstd.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + [lints] workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 4c8c6ec45e..4c601c3930 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -3,6 +3,8 @@ use log::info; use minidumper::{Client, LoopAction, MinidumpBinary}; use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use serde::{Deserialize, Serialize}; + +#[cfg(not(target_os = "windows"))] use smol::process::Command; #[cfg(target_os = "macos")] @@ -33,17 +35,33 @@ const CRASH_HANDLER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); static PANIC_THREAD_ID: AtomicU32 = AtomicU32::new(0); pub async fn init(crash_init: InitCrashHandler) { - if *RELEASE_CHANNEL == ReleaseChannel::Dev && env::var("ZED_GENERATE_MINIDUMPS").is_err() { - let old_hook = panic::take_hook(); - panic::set_hook(Box::new(move |info| { - unsafe { env::set_var("RUST_BACKTRACE", "1") }; - old_hook(info); - // prevent the macOS crash dialog from popping up - std::process::exit(1); - })); - return; - } else { - panic::set_hook(Box::new(panic_hook)); + let gen_var = match env::var("ZED_GENERATE_MINIDUMPS") { + Ok(v) => { + if v == "false" || v == "0" { + Some(false) + } else { + Some(true) + } + } + Err(_) => None, + }; + + match (gen_var, *RELEASE_CHANNEL) { + (Some(false), _) | (None, ReleaseChannel::Dev) => { + let old_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + unsafe { env::set_var("RUST_BACKTRACE", "1") }; + old_hook(info); + // prevent the macOS crash dialog from popping up + if cfg!(target_os = "macos") { + std::process::exit(1); + } + })); + return; + } + _ => { + panic::set_hook(Box::new(panic_hook)); + } } let exe = env::current_exe().expect("unable to find ourselves"); @@ -54,11 +72,16 @@ pub async fn init(crash_init: InitCrashHandler) { // used by the crash handler isn't destroyed correctly which causes it to stay on the file // system and block further attempts to initialize crash handlers with that socket path. let socket_name = paths::temp_dir().join(format!("zed-crash-handler-{zed_pid}")); + #[cfg(not(target_os = "windows"))] let _crash_handler = Command::new(exe) .arg("--crash-handler") .arg(&socket_name) .spawn() .expect("unable to spawn server process"); + + #[cfg(target_os = "windows")] + spawn_crash_handler_windows(&exe, &socket_name); + #[cfg(target_os = "linux")] let server_pid = _crash_handler.id(); info!("spawning crash handler process"); @@ -92,7 +115,10 @@ pub async fn init(crash_init: InitCrashHandler) { #[cfg(target_os = "macos")] suspend_all_other_threads(); - client.ping().unwrap(); + // on macos this "ping" is needed to ensure that all our + // `client.send_message` calls have been processed before we trigger the + // minidump request. + client.ping().ok(); client.request_dump(crash_context).is_ok() } else { true @@ -248,9 +274,10 @@ impl minidumper::ServerHandler for CrashServer { 3 => { let gpu_specs: system_specs::GpuSpecs = bincode::deserialize(&buffer).expect("gpu specs"); - self.active_gpu - .set(gpu_specs) - .expect("already set active gpu"); + // we ignore the case where it was already set because this message is sent + // on each new window. in theory all zed windows should be using the same + // GPU so this is fine. + self.active_gpu.set(gpu_specs).ok(); } _ => { panic!("invalid message kind"); @@ -269,23 +296,31 @@ impl minidumper::ServerHandler for CrashServer { } pub fn panic_hook(info: &PanicHookInfo) { - let message = info - .payload() - .downcast_ref::<&str>() - .map(|s| s.to_string()) - .or_else(|| info.payload().downcast_ref::().cloned()) - .unwrap_or_else(|| "Box".to_string()); + // Don't handle a panic on threads that are not relevant to the main execution. + if extension_host::wasm_host::IS_WASM_THREAD.with(|v| v.load(Ordering::Acquire)) { + log::error!("wasm thread panicked!"); + return; + } + + let message = info.payload_as_str().unwrap_or("Box").to_owned(); let span = info .location() .map(|loc| format!("{}:{}", loc.file(), loc.line())) .unwrap_or_default(); + let current_thread = std::thread::current(); + let thread_name = current_thread.name().unwrap_or(""); + // wait 500ms for the crash handler process to start up // if it's still not there just write panic info and no minidump let retry_frequency = Duration::from_millis(100); for _ in 0..5 { if let Some(client) = CRASH_HANDLER.get() { + let location = info + .location() + .map_or_else(|| "".to_owned(), |location| location.to_string()); + log::error!("thread '{thread_name}' panicked at {location}:\n{message}..."); client .send_message( 2, @@ -314,6 +349,57 @@ pub fn panic_hook(info: &PanicHookInfo) { } } +#[cfg(target_os = "windows")] +fn spawn_crash_handler_windows(exe: &Path, socket_name: &Path) { + use std::ffi::OsStr; + use std::iter::once; + use std::os::windows::ffi::OsStrExt; + use windows::Win32::System::Threading::{ + CreateProcessW, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_FORCEOFFFEEDBACK, + STARTUPINFOW, + }; + use windows::core::PWSTR; + + let mut command_line: Vec = OsStr::new(&format!( + "\"{}\" --crash-handler \"{}\"", + exe.display(), + socket_name.display() + )) + .encode_wide() + .chain(once(0)) + .collect(); + + let mut startup_info = STARTUPINFOW::default(); + startup_info.cb = std::mem::size_of::() as u32; + + // By default, Windows enables a "busy" cursor when a GUI application is launched. + // This cursor is disabled once the application starts processing window messages. + // Since the crash handler process doesn't process messages, this "busy" cursor stays enabled for a long time. + // Disable the cursor feedback to prevent this from happening. + startup_info.dwFlags = STARTF_FORCEOFFFEEDBACK; + + let mut process_info = PROCESS_INFORMATION::default(); + + unsafe { + CreateProcessW( + None, + Some(PWSTR(command_line.as_mut_ptr())), + None, + None, + false, + PROCESS_CREATION_FLAGS(0), + None, + None, + &startup_info, + &mut process_info, + ) + .expect("unable to spawn server process"); + + windows::Win32::Foundation::CloseHandle(process_info.hProcess).ok(); + windows::Win32::Foundation::CloseHandle(process_info.hThread).ok(); + } +} + pub fn crash_server(socket: &Path) { let Ok(mut server) = minidumper::Server::with_name(socket) else { log::info!("Couldn't create socket, there may already be a running crash server"); diff --git a/crates/credentials_provider/Cargo.toml b/crates/credentials_provider/Cargo.toml index 3233b68c60..bf47bb24b1 100644 --- a/crates/credentials_provider/Cargo.toml +++ b/crates/credentials_provider/Cargo.toml @@ -19,4 +19,3 @@ paths.workspace = true release_channel.workspace = true serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true diff --git a/crates/dap/Cargo.toml b/crates/dap/Cargo.toml index ee963a4f83..d856ae0164 100644 --- a/crates/dap/Cargo.toml +++ b/crates/dap/Cargo.toml @@ -49,7 +49,6 @@ smol.workspace = true task.workspace = true telemetry.workspace = true util.workspace = true -workspace-hack.workspace = true [target.'cfg(not(windows))'.dependencies] libc.workspace = true diff --git a/crates/dap/src/adapters.rs b/crates/dap/src/adapters.rs index 403c0034ff..96a35bc8ab 100644 --- a/crates/dap/src/adapters.rs +++ b/crates/dap/src/adapters.rs @@ -46,6 +46,7 @@ pub trait DapDelegate: Send + Sync + 'static { async fn which(&self, command: &OsStr) -> Option; async fn read_text_file(&self, path: &RelPath) -> Result; async fn shell_env(&self) -> collections::HashMap; + fn is_headless(&self) -> bool; } #[derive( @@ -305,7 +306,7 @@ pub async fn download_adapter_from_github( anyhow::ensure!( response.status().is_success(), "download failed with status {}", - response.status().to_string() + response.status() ); delegate.output_to_console("Download complete".to_owned()); @@ -323,6 +324,7 @@ pub async fn download_adapter_from_github( extract_zip(&version_path, file) .await // we cannot check the status as some adapter include files with names that trigger `Illegal byte sequence` + .inspect_err(|e| log::warn!("ZIP extraction error: {}. Ignoring...", e)) .ok(); util::fs::remove_matching(&adapter_path, |entry| { @@ -355,6 +357,7 @@ pub trait DebugAdapter: 'static + Send + Sync { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, cx: &mut AsyncApp, ) -> Result; @@ -454,6 +457,7 @@ impl DebugAdapter for FakeAdapter { task_definition: &DebugTaskDefinition, _: Option, _: Option>, + _: Option>, _: &mut AsyncApp, ) -> Result { let connection = task_definition diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs index 15801e9891..0e3db35107 100644 --- a/crates/dap/src/client.rs +++ b/crates/dap/src/client.rs @@ -256,7 +256,7 @@ impl DebugAdapterClient { #[cfg(test)] mod tests { use super::*; - use crate::{client::DebugAdapterClient, debugger_settings::DebuggerSettings}; + use crate::client::DebugAdapterClient; use dap_types::{ Capabilities, InitializeRequestArguments, InitializeRequestArgumentsPathFormat, RunInTerminalRequestArguments, StartDebuggingRequestArguments, @@ -265,7 +265,7 @@ mod tests { }; use gpui::TestAppContext; use serde_json::json; - use settings::{Settings, SettingsStore}; + use settings::SettingsStore; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -277,7 +277,6 @@ mod tests { cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); - DebuggerSettings::register(cx); }); } diff --git a/crates/dap/src/debugger_settings.rs b/crates/dap/src/debugger_settings.rs index 114f858eec..8b2f1bf8d6 100644 --- a/crates/dap/src/debugger_settings.rs +++ b/crates/dap/src/debugger_settings.rs @@ -1,7 +1,7 @@ use dap_types::SteppingGranularity; -use gpui::App; -use settings::{Settings, SettingsContent}; +use settings::{RegisterSetting, Settings, SettingsContent}; +#[derive(Debug, RegisterSetting)] pub struct DebuggerSettings { /// Determines the stepping granularity. /// @@ -34,7 +34,7 @@ pub struct DebuggerSettings { } impl Settings for DebuggerSettings { - fn from_settings(content: &SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &SettingsContent) -> Self { let content = content.debugger.clone().unwrap(); Self { stepping_granularity: dap_granularity_from_settings( diff --git a/crates/dap/src/transport.rs b/crates/dap/src/transport.rs index 50ffb4b782..e6f8d0bce1 100644 --- a/crates/dap/src/transport.rs +++ b/crates/dap/src/transport.rs @@ -262,11 +262,15 @@ impl TransportDelegate { break; } } + + // Clean up logs by trimming unnecessary whitespace/newlines before inserting into log. + let line = line.trim(); + log::debug!("stderr: {line}"); for (kind, handler) in log_handlers.lock().iter_mut() { if matches!(kind, LogKind::Adapter) { - handler(iokind, None, line.as_str()); + handler(iokind, None, line); } } } @@ -649,7 +653,7 @@ impl Drop for TcpTransport { } pub struct StdioTransport { - process: Mutex>, + process: Mutex, _stderr_task: Option>, } @@ -676,7 +680,7 @@ impl StdioTransport { let mut process = Child::spawn(command, Stdio::piped())?; - let err_task = process.stderr.take().map(|stderr| { + let _stderr_task = process.stderr.take().map(|stderr| { cx.background_spawn(TransportDelegate::handle_adapter_log( stderr, IoKind::StdErr, @@ -684,24 +688,22 @@ impl StdioTransport { )) }); - let process = Mutex::new(Some(process)); + let process = Mutex::new(process); Ok(Self { process, - _stderr_task: err_task, + _stderr_task, }) } } impl Transport for StdioTransport { fn has_adapter_logs(&self) -> bool { - false + true } fn kill(&mut self) { - if let Some(process) = &mut *self.process.lock() { - process.kill(); - } + self.process.lock().kill(); } fn connect( @@ -713,8 +715,7 @@ impl Transport for StdioTransport { )>, > { let result = util::maybe!({ - let mut guard = self.process.lock(); - let process = guard.as_mut().context("oops")?; + let mut process = self.process.lock(); Ok(( Box::new(process.stdin.take().context("Cannot reconnect")?) as _, Box::new(process.stdout.take().context("Cannot reconnect")?) as _, @@ -730,9 +731,7 @@ impl Transport for StdioTransport { impl Drop for StdioTransport { fn drop(&mut self) { - if let Some(process) = &mut *self.process.lock() { - process.kill(); - } + self.process.lock().kill(); } } diff --git a/crates/dap_adapters/Cargo.toml b/crates/dap_adapters/Cargo.toml index e7366785c8..7bdf39c74a 100644 --- a/crates/dap_adapters/Cargo.toml +++ b/crates/dap_adapters/Cargo.toml @@ -35,14 +35,16 @@ log.workspace = true paths.workspace = true serde.workspace = true serde_json.workspace = true -shlex.workspace = true smol.workspace = true task.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] dap = { workspace = true, features = ["test-support"] } +fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } +http_client.workspace = true +node_runtime.workspace = true +settings = { workspace = true, features = ["test-support"] } task = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } diff --git a/crates/dap_adapters/src/codelldb.rs b/crates/dap_adapters/src/codelldb.rs index 64c32b387d..05aca2225a 100644 --- a/crates/dap_adapters/src/codelldb.rs +++ b/crates/dap_adapters/src/codelldb.rs @@ -1,7 +1,8 @@ -use std::{collections::HashMap, path::PathBuf, sync::OnceLock}; +use std::{path::PathBuf, sync::OnceLock}; use anyhow::{Context as _, Result}; use async_trait::async_trait; +use collections::HashMap; use dap::adapters::{DebugTaskDefinition, latest_github_release}; use futures::StreamExt; use gpui::AsyncApp; @@ -329,6 +330,7 @@ impl DebugAdapter for CodeLldbDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, _: &mut AsyncApp, ) -> Result { let mut command = user_installed_path @@ -377,6 +379,7 @@ impl DebugAdapter for CodeLldbDebugAdapter { command = Some(path); }; let mut json_config = config.config.clone(); + Ok(DebugAdapterBinary { command: Some(command.unwrap()), cwd: Some(delegate.worktree_root_path().to_path_buf()), @@ -401,7 +404,7 @@ impl DebugAdapter for CodeLldbDebugAdapter { request_args: self .request_args(delegate, json_config, &config.label) .await?, - envs: HashMap::default(), + envs: user_env.unwrap_or_default(), connection: None, }) } diff --git a/crates/dap_adapters/src/dap_adapters.rs b/crates/dap_adapters/src/dap_adapters.rs index a4e6beb249..2ab9cabc19 100644 --- a/crates/dap_adapters/src/dap_adapters.rs +++ b/crates/dap_adapters/src/dap_adapters.rs @@ -4,6 +4,8 @@ mod go; mod javascript; mod python; +#[cfg(test)] +use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; @@ -38,3 +40,65 @@ pub fn init(cx: &mut App) { } }) } + +#[cfg(test)] +mod test_mocks { + use super::*; + + pub(crate) struct MockDelegate { + worktree_root: PathBuf, + } + + impl MockDelegate { + pub(crate) fn new() -> Arc { + Arc::new(Self { + worktree_root: PathBuf::from("/tmp/test"), + }) + } + } + + #[async_trait::async_trait] + impl adapters::DapDelegate for MockDelegate { + fn worktree_id(&self) -> settings::WorktreeId { + settings::WorktreeId::from_usize(0) + } + + fn worktree_root_path(&self) -> &std::path::Path { + &self.worktree_root + } + + fn http_client(&self) -> Arc { + unimplemented!("Not needed for tests") + } + + fn node_runtime(&self) -> node_runtime::NodeRuntime { + unimplemented!("Not needed for tests") + } + + fn toolchain_store(&self) -> Arc { + unimplemented!("Not needed for tests") + } + + fn fs(&self) -> Arc { + unimplemented!("Not needed for tests") + } + + fn output_to_console(&self, _msg: String) {} + + async fn which(&self, _command: &std::ffi::OsStr) -> Option { + None + } + + async fn read_text_file(&self, _path: &util::rel_path::RelPath) -> Result { + Ok(String::new()) + } + + async fn shell_env(&self) -> collections::HashMap { + collections::HashMap::default() + } + + fn is_headless(&self) -> bool { + false + } + } +} diff --git a/crates/dap_adapters/src/gdb.rs b/crates/dap_adapters/src/gdb.rs index 17b7a65911..46488cd550 100644 --- a/crates/dap_adapters/src/gdb.rs +++ b/crates/dap_adapters/src/gdb.rs @@ -1,9 +1,9 @@ -use std::{collections::HashMap, ffi::OsStr}; - use anyhow::{Context as _, Result, bail}; use async_trait::async_trait; +use collections::HashMap; use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition}; use gpui::AsyncApp; +use std::ffi::OsStr; use task::{DebugScenario, ZedDebugConfig}; use crate::*; @@ -15,6 +15,14 @@ impl GdbDebugAdapter { const ADAPTER_NAME: &'static str = "GDB"; } +/// Ensures that "-i=dap" is present in the GDB argument list. +fn ensure_dap_interface(mut gdb_args: Vec) -> Vec { + if !gdb_args.iter().any(|arg| arg.trim() == "-i=dap") { + gdb_args.insert(0, "-i=dap".to_string()); + } + gdb_args +} + #[async_trait(?Send)] impl DebugAdapter for GdbDebugAdapter { fn name(&self) -> DebugAdapterName { @@ -98,6 +106,18 @@ impl DebugAdapter for GdbDebugAdapter { "type": "string", "description": "Working directory for the debugged program. GDB will change its working directory to this directory." }, + "gdb_path": { + "type": "string", + "description": "Alternative path to the GDB executable, if the one in standard path is not desirable" + }, + "gdb_args": { + "type": "array", + "items": { + "type":"string" + }, + "description": "additional arguments given to GDB at startup, not the program debugged", + "default": [] + }, "env": { "type": "object", "description": "Environment variables for the debugged program. Each key is the name of an environment variable; each value is the value of that variable." @@ -160,23 +180,52 @@ impl DebugAdapter for GdbDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, _: &mut AsyncApp, ) -> Result { - let user_setting_path = user_installed_path - .filter(|p| p.exists()) - .and_then(|p| p.to_str().map(|s| s.to_string())); + // Try to get gdb_path from config + let gdb_path_from_config = config + .config + .get("gdb_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); - let gdb_path = delegate - .which(OsStr::new("gdb")) - .await - .and_then(|p| p.to_str().map(|s| s.to_string())) - .context("Could not find gdb in path"); + let gdb_path = if let Some(path) = gdb_path_from_config { + path + } else { + // Original logic: use user_installed_path or search in system path + let user_setting_path = user_installed_path + .filter(|p| p.exists()) + .and_then(|p| p.to_str().map(|s| s.to_string())); - if gdb_path.is_err() && user_setting_path.is_none() { - bail!("Could not find gdb path or it's not installed"); - } + let gdb_path_result = delegate + .which(OsStr::new("gdb")) + .await + .and_then(|p| p.to_str().map(|s| s.to_string())) + .context("Could not find gdb in path"); - let gdb_path = user_setting_path.unwrap_or(gdb_path?); + if gdb_path_result.is_err() && user_setting_path.is_none() { + bail!("Could not find gdb path or it's not installed"); + } + + user_setting_path.unwrap_or_else(|| gdb_path_result.unwrap()) + }; + + // Arguments: use gdb_args from config if present, else user_args, else default + let gdb_args = { + let args = config + .config + .get("gdb_args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>() + }) + .or(user_args.clone()) + .unwrap_or_else(|| vec!["-i=dap".into()]); + ensure_dap_interface(args) + }; let mut configuration = config.config.clone(); if let Some(configuration) = configuration.as_object_mut() { @@ -185,10 +234,26 @@ impl DebugAdapter for GdbDebugAdapter { .or_insert_with(|| delegate.worktree_root_path().to_string_lossy().into()); } + let mut base_env = delegate.shell_env().await; + base_env.extend(user_env.unwrap_or_default()); + + let config_env: HashMap = config + .config + .get("env") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect::>() + }) + .unwrap_or_else(HashMap::default); + + base_env.extend(config_env); + Ok(DebugAdapterBinary { command: Some(gdb_path), - arguments: user_args.unwrap_or_else(|| vec!["-i=dap".into()]), - envs: HashMap::default(), + arguments: gdb_args, + envs: base_env, cwd: Some(delegate.worktree_root_path().to_path_buf()), connection: None, request_args: StartDebuggingRequestArguments { diff --git a/crates/dap_adapters/src/go.rs b/crates/dap_adapters/src/go.rs index 999909ad44..d3253d5fe2 100644 --- a/crates/dap_adapters/src/go.rs +++ b/crates/dap_adapters/src/go.rs @@ -366,7 +366,7 @@ impl DebugAdapter for GoDebugAdapter { dap::DebugRequest::Attach(attach_config) => { json!({ "request": "attach", - "mode": "debug", + "mode": "local", "processId": attach_config.process_id, }) } @@ -409,6 +409,7 @@ impl DebugAdapter for GoDebugAdapter { task_definition: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, _cx: &mut AsyncApp, ) -> Result { let adapter_path = paths::debug_adapters_dir().join(&Self::ADAPTER_NAME); @@ -460,7 +461,7 @@ impl DebugAdapter for GoDebugAdapter { let connection; let mut configuration = task_definition.config.clone(); - let mut envs = HashMap::default(); + let mut envs = user_env.unwrap_or_default(); if let Some(configuration) = configuration.as_object_mut() { configuration diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs index 9a19b95949..68f5ca7e79 100644 --- a/crates/dap_adapters/src/javascript.rs +++ b/crates/dap_adapters/src/javascript.rs @@ -6,7 +6,7 @@ use gpui::AsyncApp; use serde_json::Value; use std::{path::PathBuf, sync::OnceLock}; use task::DebugRequest; -use util::{ResultExt, maybe}; +use util::{ResultExt, maybe, shell::ShellKind}; use crate::*; @@ -52,12 +52,13 @@ impl JsDebugAdapter { task_definition: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, _: &mut AsyncApp, ) -> Result { let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default(); let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?; - let mut envs = HashMap::default(); + let mut envs = user_env.unwrap_or_default(); let mut configuration = task_definition.config.clone(); if let Some(configuration) = configuration.as_object_mut() { @@ -66,7 +67,7 @@ impl JsDebugAdapter { .get("type") .filter(|value| value == &"node-terminal")?; let command = configuration.get("command")?.as_str()?.to_owned(); - let mut args = shlex::split(&command)?.into_iter(); + let mut args = ShellKind::Posix.split(&command)?.into_iter(); let program = args.next()?; configuration.insert("runtimeExecutable".to_owned(), program.into()); configuration.insert( @@ -100,9 +101,9 @@ impl JsDebugAdapter { } if let Some(env) = configuration.get("env").cloned() - && let Ok(env) = serde_json::from_value(env) + && let Ok(env) = serde_json::from_value::>(env) { - envs = env; + envs.extend(env.into_iter()); } configuration @@ -120,6 +121,13 @@ impl JsDebugAdapter { configuration .entry("sourceMapRenames") .or_insert(true.into()); + + // Set up remote browser debugging + if delegate.is_headless() { + configuration + .entry("browserLaunchLocation") + .or_insert("ui".into()); + } } let adapter_path = if let Some(user_installed_path) = user_installed_path { @@ -497,6 +505,7 @@ impl DebugAdapter for JsDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, cx: &mut AsyncApp, ) -> Result { if self.checked.set(()).is_ok() { @@ -514,8 +523,15 @@ impl DebugAdapter for JsDebugAdapter { } } - self.get_installed_binary(delegate, config, user_installed_path, user_args, cx) - .await + self.get_installed_binary( + delegate, + config, + user_installed_path, + user_args, + user_env, + cx, + ) + .await } fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option { diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index 47aec4aa5b..a45e16dc32 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -1,12 +1,13 @@ use crate::*; -use anyhow::Context as _; +use anyhow::{Context as _, bail}; +use collections::HashMap; use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition}; use fs::RemoveOptions; use futures::{StreamExt, TryStreamExt}; use gpui::http_client::AsyncBody; use gpui::{AsyncApp, SharedString}; use json_dotpath::DotPaths; -use language::LanguageName; +use language::{LanguageName, Toolchain}; use paths::debug_adapters_dir; use serde_json::Value; use smol::fs::File; @@ -16,11 +17,16 @@ use std::ffi::OsString; use std::net::Ipv4Addr; use std::str::FromStr; use std::{ - collections::HashMap, ffi::OsStr, path::{Path, PathBuf}, }; -use util::{ResultExt, maybe, paths::PathStyle, rel_path::RelPath}; +use util::command::new_smol_command; +use util::{ResultExt, paths::PathStyle, rel_path::RelPath}; + +enum DebugpyLaunchMode<'a> { + Normal, + AttachWithConnect { host: Option<&'a str> }, +} #[derive(Default)] pub(crate) struct PythonDebugAdapter { @@ -35,10 +41,11 @@ impl PythonDebugAdapter { const LANGUAGE_NAME: &'static str = "Python"; - async fn generate_debugpy_arguments( - host: &Ipv4Addr, + async fn generate_debugpy_arguments<'a>( + host: &'a Ipv4Addr, port: u16, - user_installed_path: Option<&Path>, + launch_mode: DebugpyLaunchMode<'a>, + user_installed_path: Option<&'a Path>, user_args: Option>, ) -> Result> { let mut args = if let Some(user_installed_path) = user_installed_path { @@ -61,7 +68,20 @@ impl PythonDebugAdapter { args.extend(if let Some(args) = user_args { args } else { - vec![format!("--host={}", host), format!("--port={}", port)] + match launch_mode { + DebugpyLaunchMode::Normal => { + vec![format!("--host={}", host), format!("--port={}", port)] + } + DebugpyLaunchMode::AttachWithConnect { host } => { + let mut args = vec!["connect".to_string()]; + + if let Some(host) = host { + args.push(format!("{host}:")); + } + args.push(format!("{port}")); + args + } + } }); Ok(args) } @@ -92,12 +112,16 @@ impl PythonDebugAdapter { }) } - async fn fetch_wheel(&self, delegate: &Arc) -> Result, String> { + async fn fetch_wheel( + &self, + toolchain: Option, + delegate: &Arc, + ) -> Result> { let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels"); - std::fs::create_dir_all(&download_dir).map_err(|e| e.to_string())?; - let system_python = self.base_venv_path(delegate).await?; + std::fs::create_dir_all(&download_dir)?; + let venv_python = self.base_venv_path(toolchain, delegate).await?; - let installation_succeeded = util::command::new_smol_command(system_python.as_ref()) + let installation_succeeded = util::command::new_smol_command(venv_python.as_ref()) .args([ "-m", "pip", @@ -109,36 +133,36 @@ impl PythonDebugAdapter { ]) .output() .await - .map_err(|e| format!("{e}"))? + .context("spawn system python")? .status .success(); if !installation_succeeded { - return Err("debugpy installation failed (could not fetch Debugpy's wheel)".into()); + bail!("debugpy installation failed (could not fetch Debugpy's wheel)"); } - let wheel_path = std::fs::read_dir(&download_dir) - .map_err(|e| e.to_string())? + let wheel_path = std::fs::read_dir(&download_dir)? .find_map(|entry| { entry.ok().filter(|e| { e.file_type().is_ok_and(|typ| typ.is_file()) && Path::new(&e.file_name()).extension() == Some("whl".as_ref()) }) }) - .ok_or_else(|| String::from("Did not find a .whl in {download_dir}"))?; + .with_context(|| format!("Did not find a .whl in {download_dir:?}"))?; util::archive::extract_zip( &debug_adapters_dir().join(Self::ADAPTER_NAME), - File::open(&wheel_path.path()) - .await - .map_err(|e| e.to_string())?, + File::open(&wheel_path.path()).await?, ) - .await - .map_err(|e| e.to_string())?; + .await?; Ok(Arc::from(wheel_path.path())) } - async fn maybe_fetch_new_wheel(&self, delegate: &Arc) { + async fn maybe_fetch_new_wheel( + &self, + toolchain: Option, + delegate: &Arc, + ) -> Result<()> { let latest_release = delegate .http_client() .get( @@ -148,62 +172,61 @@ impl PythonDebugAdapter { ) .await .log_err(); - maybe!(async move { - let response = latest_release.filter(|response| response.status().is_success())?; + let response = latest_release + .filter(|response| response.status().is_success()) + .context("getting latest release")?; - let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME); - std::fs::create_dir_all(&download_dir).ok()?; + let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME); + std::fs::create_dir_all(&download_dir)?; - let mut output = String::new(); - response - .into_body() - .read_to_string(&mut output) - .await - .ok()?; - let as_json = serde_json::Value::from_str(&output).ok()?; - let latest_version = as_json.get("info").and_then(|info| { + let mut output = String::new(); + response.into_body().read_to_string(&mut output).await?; + let as_json = serde_json::Value::from_str(&output)?; + let latest_version = as_json + .get("info") + .and_then(|info| { info.get("version") .and_then(|version| version.as_str()) .map(ToOwned::to_owned) - })?; - let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into(); - let is_up_to_date = delegate - .fs() - .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME)) - .await - .ok()? - .into_stream() - .any(async |entry| { - entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname)) - }) - .await; + }) + .context("parsing latest release information")?; + let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into(); + let is_up_to_date = delegate + .fs() + .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME)) + .await? + .into_stream() + .any(async |entry| { + entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname)) + }) + .await; - if !is_up_to_date { - delegate - .fs() - .remove_dir( - &debug_adapters_dir().join(Self::ADAPTER_NAME), - RemoveOptions { - recursive: true, - ignore_if_not_exists: true, - }, - ) - .await - .ok()?; - self.fetch_wheel(delegate).await.ok()?; - } - Some(()) - }) - .await; + if !is_up_to_date { + delegate + .fs() + .remove_dir( + &debug_adapters_dir().join(Self::ADAPTER_NAME), + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await?; + self.fetch_wheel(toolchain, delegate).await?; + } + anyhow::Ok(()) } async fn fetch_debugpy_whl( &self, + toolchain: Option, delegate: &Arc, ) -> Result, String> { self.debugpy_whl_base_path .get_or_init(|| async move { - self.maybe_fetch_new_wheel(delegate).await; + self.maybe_fetch_new_wheel(toolchain, delegate) + .await + .map_err(|e| format!("{e}"))?; Ok(Arc::from( debug_adapters_dir() .join(Self::ADAPTER_NAME) @@ -216,58 +239,88 @@ impl PythonDebugAdapter { .clone() } - async fn base_venv_path(&self, delegate: &Arc) -> Result, String> { - self.base_venv_path + async fn base_venv_path( + &self, + toolchain: Option, + delegate: &Arc, + ) -> Result> { + let result = self.base_venv_path .get_or_init(|| async { - let base_python = Self::system_python_name(delegate) - .await - .ok_or_else(|| String::from("Could not find a Python installation"))?; + let base_python = if let Some(toolchain) = toolchain { + toolchain.path.to_string() + } else { + Self::system_python_name(delegate).await.ok_or_else(|| { + let mut message = "Could not find a Python installation".to_owned(); + if cfg!(windows){ + message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.") + } + message + })? + }; - let did_succeed = util::command::new_smol_command(base_python) + let debug_adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref()); + let output = util::command::new_smol_command(&base_python) .args(["-m", "venv", "zed_base_venv"]) .current_dir( - paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref()), + &debug_adapter_path, ) .spawn() .map_err(|e| format!("{e:#?}"))? - .status() + .output() .await - .map_err(|e| format!("{e:#?}"))? - .success(); + .map_err(|e| format!("{e:#?}"))?; - if !did_succeed { - return Err("Failed to create base virtual environment".into()); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let debug_adapter_path = debug_adapter_path.display(); + return Err(format!("Failed to create base virtual environment with {base_python} in:\n{debug_adapter_path}\nstderr:\n{stderr}\nstdout:\n{stdout}\n")); } - const DIR: &str = if cfg!(target_os = "windows") { - "Scripts" + const PYTHON_PATH: &str = if cfg!(target_os = "windows") { + "Scripts/python.exe" } else { - "bin" + "bin/python3" }; Ok(Arc::from( paths::debug_adapters_dir() .join(Self::DEBUG_ADAPTER_NAME.as_ref()) .join("zed_base_venv") - .join(DIR) - .join("python3") + .join(PYTHON_PATH) .as_ref(), )) }) .await - .clone() + .clone(); + match result { + Ok(path) => Ok(path), + Err(e) => Err(anyhow::anyhow!("{e}")), + } } async fn system_python_name(delegate: &Arc) -> Option { const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"]; let mut name = None; for cmd in BINARY_NAMES { - name = delegate - .which(OsStr::new(cmd)) + let Some(path) = delegate.which(OsStr::new(cmd)).await else { + continue; + }; + // Try to detect situations where `python3` exists but is not a real Python interpreter. + // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app + // when run with no arguments, and just fails otherwise. + let Some(output) = new_smol_command(&path) + .args(["-c", "print(1 + 2)"]) + .output() .await - .map(|path| path.to_string_lossy().into_owned()); - if name.is_some() { - break; + .ok() + else { + continue; + }; + if output.stdout.trim_ascii() != b"3" { + continue; } + name = Some(path.to_string_lossy().into_owned()); + break; } name } @@ -278,9 +331,52 @@ impl PythonDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, python_from_toolchain: Option, ) -> Result { - let tcp_connection = config.tcp_connection.clone().unwrap_or_default(); + let mut tcp_connection = config.tcp_connection.clone().unwrap_or_default(); + + let (config_port, config_host) = config + .config + .get("connect") + .map(|value| { + ( + value + .get("port") + .and_then(|val| val.as_u64().map(|p| p as u16)), + value.get("host").and_then(|val| val.as_str()), + ) + }) + .unwrap_or_else(|| { + ( + config + .config + .get("port") + .and_then(|port| port.as_u64().map(|p| p as u16)), + config.config.get("host").and_then(|host| host.as_str()), + ) + }); + + let is_attach_with_connect = if config + .config + .get("request") + .is_some_and(|val| val.as_str().is_some_and(|request| request == "attach")) + { + if tcp_connection.host.is_some() && config_host.is_some() { + bail!("Cannot have two different hosts in debug configuration") + } else if tcp_connection.port.is_some() && config_port.is_some() { + bail!("Cannot have two different ports in debug configuration") + } + + if let Some(hostname) = config_host { + tcp_connection.host = Some(hostname.parse().context("hostname must be IPv4")?); + } + tcp_connection.port = config_port; + DebugpyLaunchMode::AttachWithConnect { host: config_host } + } else { + DebugpyLaunchMode::Normal + }; + let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?; let python_path = if let Some(toolchain) = python_from_toolchain { @@ -295,6 +391,7 @@ impl PythonDebugAdapter { let arguments = Self::generate_debugpy_arguments( &host, port, + is_attach_with_connect, user_installed_path.as_deref(), user_args, ) @@ -315,7 +412,7 @@ impl PythonDebugAdapter { timeout, }), cwd: Some(delegate.worktree_root_path().to_path_buf()), - envs: HashMap::default(), + envs: user_env.unwrap_or_default(), request_args: self.request_args(delegate, config).await?, }) } @@ -710,6 +807,7 @@ impl DebugAdapter for PythonDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, cx: &mut AsyncApp, ) -> Result { if let Some(local_path) = &user_installed_path { @@ -718,55 +816,87 @@ impl DebugAdapter for PythonDebugAdapter { local_path.display() ); return self - .get_installed_binary(delegate, config, Some(local_path.clone()), user_args, None) + .get_installed_binary( + delegate, + config, + Some(local_path.clone()), + user_args, + user_env, + None, + ) .await; } - let base_path = config - .config - .get("cwd") - .and_then(|cwd| { - RelPath::new( - cwd.as_str() - .map(Path::new)? - .strip_prefix(delegate.worktree_root_path()) - .ok()?, - PathStyle::local(), - ) - .ok() + let base_paths = ["cwd", "program", "module"] + .into_iter() + .filter_map(|key| { + config.config.get(key).and_then(|cwd| { + RelPath::new( + cwd.as_str() + .map(Path::new)? + .strip_prefix(delegate.worktree_root_path()) + .ok()?, + PathStyle::local(), + ) + .ok() + }) }) - .unwrap_or_else(|| RelPath::empty().into()); - let toolchain = delegate - .toolchain_store() - .active_toolchain( - delegate.worktree_id(), - base_path.into_arc(), - language::LanguageName::new(Self::LANGUAGE_NAME), - cx, + .chain( + // While Debugpy's wiki saids absolute paths are required, but it actually supports relative paths when cwd is passed in. + // (Which should always be the case because Zed defaults to the cwd worktree root) + // So we want to check that these relative paths find toolchains as well. Otherwise, they won't be checked + // because the strip prefix in the iteration above will return an error + config + .config + .get("cwd") + .map(|_| { + ["program", "module"].into_iter().filter_map(|key| { + config.config.get(key).and_then(|value| { + let path = Path::new(value.as_str()?); + RelPath::new(path, PathStyle::local()).ok() + }) + }) + }) + .into_iter() + .flatten(), ) - .await; + .chain([RelPath::empty().into()]); - let debugpy_path = self - .fetch_debugpy_whl(delegate) + let mut toolchain = None; + + for base_path in base_paths { + if let Some(found_toolchain) = delegate + .toolchain_store() + .active_toolchain( + delegate.worktree_id(), + base_path.into_arc(), + language::LanguageName::new_static(Self::LANGUAGE_NAME), + cx, + ) + .await + { + toolchain = Some(found_toolchain); + break; + } + } + + self.fetch_debugpy_whl(toolchain.clone(), delegate) .await .map_err(|e| anyhow::anyhow!("{e}"))?; if let Some(toolchain) = &toolchain { - log::debug!( - "Found debugpy in toolchain environment: {}", - debugpy_path.display() - ); return self .get_installed_binary( delegate, config, None, user_args, + user_env, Some(toolchain.path.to_string()), ) .await; } - self.get_installed_binary(delegate, config, None, user_args, None) + self.get_installed_binary(delegate, config, None, user_args, user_env, None) .await } @@ -785,7 +915,148 @@ mod tests { use util::path; use super::*; - use std::{net::Ipv4Addr, path::PathBuf}; + use task::TcpArgumentsTemplate; + + #[gpui::test] + async fn test_tcp_connection_conflict_with_connect_args() { + let adapter = PythonDebugAdapter { + base_venv_path: OnceCell::new(), + debugpy_whl_base_path: OnceCell::new(), + }; + + let config_with_port_conflict = json!({ + "request": "attach", + "connect": { + "port": 5679 + } + }); + + let tcp_connection = TcpArgumentsTemplate { + host: None, + port: Some(5678), + timeout: None, + }; + + let task_def = DebugTaskDefinition { + label: "test".into(), + adapter: PythonDebugAdapter::ADAPTER_NAME.into(), + config: config_with_port_conflict, + tcp_connection: Some(tcp_connection.clone()), + }; + + let result = adapter + .get_installed_binary( + &test_mocks::MockDelegate::new(), + &task_def, + None, + None, + None, + Some("python3".to_string()), + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Cannot have two different ports") + ); + + let host = Ipv4Addr::new(127, 0, 0, 1); + let config_with_host_conflict = json!({ + "request": "attach", + "connect": { + "host": "192.168.1.1", + "port": 5678 + } + }); + + let tcp_connection_with_host = TcpArgumentsTemplate { + host: Some(host), + port: None, + timeout: None, + }; + + let task_def_host = DebugTaskDefinition { + label: "test".into(), + adapter: PythonDebugAdapter::ADAPTER_NAME.into(), + config: config_with_host_conflict, + tcp_connection: Some(tcp_connection_with_host), + }; + + let result_host = adapter + .get_installed_binary( + &test_mocks::MockDelegate::new(), + &task_def_host, + None, + None, + None, + Some("python3".to_string()), + ) + .await; + + assert!(result_host.is_err()); + assert!( + result_host + .unwrap_err() + .to_string() + .contains("Cannot have two different hosts") + ); + } + + #[gpui::test] + async fn test_attach_with_connect_mode_generates_correct_arguments() { + let host = Ipv4Addr::new(127, 0, 0, 1); + let port = 5678; + + let args_without_host = PythonDebugAdapter::generate_debugpy_arguments( + &host, + port, + DebugpyLaunchMode::AttachWithConnect { host: None }, + None, + None, + ) + .await + .unwrap(); + + let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter"); + assert!(args_without_host[0].ends_with(expected_suffix)); + assert_eq!(args_without_host[1], "connect"); + assert_eq!(args_without_host[2], "5678"); + + let args_with_host = PythonDebugAdapter::generate_debugpy_arguments( + &host, + port, + DebugpyLaunchMode::AttachWithConnect { + host: Some("192.168.1.100"), + }, + None, + None, + ) + .await + .unwrap(); + + assert!(args_with_host[0].ends_with(expected_suffix)); + assert_eq!(args_with_host[1], "connect"); + assert_eq!(args_with_host[2], "192.168.1.100:"); + assert_eq!(args_with_host[3], "5678"); + + let args_normal = PythonDebugAdapter::generate_debugpy_arguments( + &host, + port, + DebugpyLaunchMode::Normal, + None, + None, + ) + .await + .unwrap(); + + assert!(args_normal[0].ends_with(expected_suffix)); + assert_eq!(args_normal[1], "--host=127.0.0.1"); + assert_eq!(args_normal[2], "--port=5678"); + assert!(!args_normal.contains(&"connect".to_string())); + } #[gpui::test] async fn test_debugpy_install_path_cases() { @@ -794,15 +1065,25 @@ mod tests { // Case 1: User-defined debugpy path (highest precedence) let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter"); - let user_args = - PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), None) - .await - .unwrap(); + let user_args = PythonDebugAdapter::generate_debugpy_arguments( + &host, + port, + DebugpyLaunchMode::Normal, + Some(&user_path), + None, + ) + .await + .unwrap(); - // Case 2: Venv-installed debugpy (uses -m debugpy.adapter) - let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None) - .await - .unwrap(); + let venv_args = PythonDebugAdapter::generate_debugpy_arguments( + &host, + port, + DebugpyLaunchMode::Normal, + None, + None, + ) + .await + .unwrap(); assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter"); assert_eq!(user_args[1], "--host=127.0.0.1"); @@ -817,6 +1098,7 @@ mod tests { let user_args = PythonDebugAdapter::generate_debugpy_arguments( &host, port, + DebugpyLaunchMode::Normal, Some(&user_path), Some(vec!["foo".into()]), ) @@ -825,6 +1107,7 @@ mod tests { let venv_args = PythonDebugAdapter::generate_debugpy_arguments( &host, port, + DebugpyLaunchMode::Normal, None, Some(vec!["foo".into()]), ) diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index de449cd38f..3bcfefec03 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -26,7 +26,6 @@ smol.workspace = true sqlez.workspace = true sqlez_macros.workspace = true util.workspace = true -workspace-hack.workspace = true zed_env_vars.workspace = true [dev-dependencies] diff --git a/crates/debug_adapter_extension/Cargo.toml b/crates/debug_adapter_extension/Cargo.toml index 78d7cbaba3..08f916eb9e 100644 --- a/crates/debug_adapter_extension/Cargo.toml +++ b/crates/debug_adapter_extension/Cargo.toml @@ -8,13 +8,13 @@ edition.workspace = true [dependencies] anyhow.workspace = true async-trait.workspace = true +collections.workspace = true dap.workspace = true extension.workspace = true gpui.workspace = true serde_json.workspace = true util.workspace = true task.workspace = true -workspace-hack = { version = "0.1", path = "../../tooling/workspace-hack" } [lints] workspace = true diff --git a/crates/debug_adapter_extension/src/extension_dap_adapter.rs b/crates/debug_adapter_extension/src/extension_dap_adapter.rs index 3a39027b62..abc0fbac19 100644 --- a/crates/debug_adapter_extension/src/extension_dap_adapter.rs +++ b/crates/debug_adapter_extension/src/extension_dap_adapter.rs @@ -6,6 +6,7 @@ use std::{ use anyhow::{Context, Result}; use async_trait::async_trait; +use collections::HashMap; use dap::{ StartDebuggingRequestArgumentsRequest, adapters::{ @@ -91,6 +92,8 @@ impl DebugAdapter for ExtensionDapAdapter { user_installed_path: Option, // TODO support user args in the extension API _user_args: Option>, + // TODO support user env in the extension API + _user_env: Option>, _cx: &mut AsyncApp, ) -> Result { self.extension diff --git a/crates/debugger_tools/Cargo.toml b/crates/debugger_tools/Cargo.toml index d91f43182d..c3f6dd9338 100644 --- a/crates/debugger_tools/Cargo.toml +++ b/crates/debugger_tools/Cargo.toml @@ -27,4 +27,3 @@ settings.workspace = true smol.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index c4338c6d00..317ce8b4c6 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -963,26 +963,21 @@ pub fn init(cx: &mut App) { }; let project = workspace.project(); - if project.read(cx).is_local() { - log_store.update(cx, |store, cx| { - store.add_project(project, cx); - }); - } + log_store.update(cx, |store, cx| { + store.add_project(project, cx); + }); let log_store = log_store.clone(); workspace.register_action(move |workspace, _: &OpenDebugAdapterLogs, window, cx| { - let project = workspace.project().read(cx); - if project.is_local() { - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - DapLogView::new(workspace.project().clone(), log_store.clone(), window, cx) - })), - None, - true, - window, - cx, - ); - } + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + DapLogView::new(workspace.project().clone(), log_store.clone(), window, cx) + })), + None, + true, + window, + cx, + ); }); }) .detach(); @@ -1003,7 +998,11 @@ impl Item for DapLogView { None } - fn as_searchable(&self, handle: &Entity) -> Option> { + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { Some(Box::new(handle.clone())) } } @@ -1018,11 +1017,13 @@ impl SearchableItem for DapLogView { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |e, cx| e.update_matches(matches, window, cx)) + self.editor.update(cx, |e, cx| { + e.update_matches(matches, active_match_index, window, cx) + }) } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index df4125860f..fb79b1b079 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -37,6 +37,7 @@ dap_adapters = { workspace = true, optional = true } db.workspace = true debugger_tools.workspace = true editor.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true @@ -60,7 +61,6 @@ serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true settings.workspace = true -shlex.workspace = true sysinfo.workspace = true task.workspace = true tasks_ui.workspace = true @@ -71,9 +71,9 @@ theme.workspace = true tree-sitter-json.workspace = true tree-sitter.workspace = true ui.workspace = true +ui_input.workspace = true unindent = { workspace = true, optional = true } util.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true @@ -83,6 +83,7 @@ dap_adapters = { workspace = true, features = ["test-support"] } debugger_tools = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } tree-sitter-go.workspace = true unindent.workspace = true diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index daa83f71b1..6e537ae0c6 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -1,4 +1,5 @@ use dap::{DapRegistry, DebugRequest}; +use futures::channel::oneshot; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render, Task}; use gpui::{Subscription, WeakEntity}; @@ -9,7 +10,8 @@ use task::ZedDebugConfig; use util::debug_panic; use std::sync::Arc; -use sysinfo::System; + +use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind}; use ui::{Context, Tooltip, prelude::*}; use ui::{ListItem, ListItemSpacing}; use workspace::{ModalView, Workspace}; @@ -23,11 +25,16 @@ pub(super) struct Candidate { pub(super) command: Vec, } +pub(crate) enum ModalIntent { + ResolveProcessId(Option>>), + AttachToProcess(ZedDebugConfig), +} + pub(crate) struct AttachModalDelegate { selected_index: usize, matches: Vec, placeholder_text: Arc, - pub(crate) definition: ZedDebugConfig, + pub(crate) intent: ModalIntent, workspace: WeakEntity, candidates: Arc<[Candidate]>, } @@ -35,13 +42,13 @@ pub(crate) struct AttachModalDelegate { impl AttachModalDelegate { fn new( workspace: WeakEntity, - definition: ZedDebugConfig, + intent: ModalIntent, candidates: Arc<[Candidate]>, ) -> Self { Self { workspace, - definition, candidates, + intent, selected_index: 0, matches: Vec::default(), placeholder_text: Arc::from("Select the process you want to attach the debugger to"), @@ -55,8 +62,8 @@ pub struct AttachModal { } impl AttachModal { - pub fn new( - definition: ZedDebugConfig, + pub(crate) fn new( + intent: ModalIntent, workspace: WeakEntity, project: Entity, modal: bool, @@ -65,7 +72,7 @@ impl AttachModal { ) -> Self { let processes_task = get_processes_for_project(&project, cx); - let modal = Self::with_processes(workspace, definition, Arc::new([]), modal, window, cx); + let modal = Self::with_processes(workspace, Arc::new([]), modal, intent, window, cx); cx.spawn_in(window, async move |this, cx| { let processes = processes_task.await; @@ -84,15 +91,15 @@ impl AttachModal { pub(super) fn with_processes( workspace: WeakEntity, - definition: ZedDebugConfig, processes: Arc<[Candidate]>, modal: bool, + intent: ModalIntent, window: &mut Window, cx: &mut Context, ) -> Self { let picker = cx.new(|cx| { Picker::uniform_list( - AttachModalDelegate::new(workspace, definition, processes), + AttachModalDelegate::new(workspace, intent, processes), window, cx, ) @@ -207,7 +214,7 @@ impl PickerDelegate for AttachModalDelegate { }) } - fn confirm(&mut self, secondary: 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()) @@ -216,69 +223,86 @@ impl PickerDelegate for AttachModalDelegate { self.candidates.get(ix) }); - let Some(candidate) = candidate else { - return cx.emit(DismissEvent); - }; - - match &mut self.definition.request { - DebugRequest::Attach(config) => { - config.process_id = Some(candidate.pid); - } - DebugRequest::Launch(_) => { - debug_panic!("Debugger attach modal used on launch debug config"); - return; - } - } - - 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 definition = self.definition.clone(); - cx.spawn_in(window, async move |this, cx| { - let Ok(scenario) = adapter.config_from_zed_format(definition).await else { - return; - }; - - panel - .update_in(cx, |panel, window, cx| { - panel.start_session(scenario, Default::default(), None, None, window, cx); - }) - .ok(); - this.update(cx, |_, cx| { + match &mut self.intent { + ModalIntent::ResolveProcessId(sender) => { cx.emit(DismissEvent); - }) - .ok(); - }) - .detach(); + + if let Some(sender) = sender.take() { + sender + .send(candidate.map(|candidate| candidate.pid as i32)) + .ok(); + } + } + ModalIntent::AttachToProcess(definition) => { + let Some(candidate) = candidate else { + return cx.emit(DismissEvent); + }; + + match &mut definition.request { + DebugRequest::Attach(config) => { + config.process_id = Some(candidate.pid); + } + DebugRequest::Launch(_) => { + debug_panic!("Debugger attach modal used on launch debug config"); + return; + } + } + + let workspace = self.workspace.clone(); + let Some(panel) = workspace + .update(cx, |workspace, cx| workspace.panel::(cx)) + .ok() + .flatten() + else { + return; + }; + + let Some(adapter) = cx.read_global::(|registry, _| { + registry.adapter(&definition.adapter) + }) else { + return; + }; + + let definition = definition.clone(); + cx.spawn_in(window, async move |this, cx| { + let Ok(scenario) = adapter.config_from_zed_format(definition).await else { + return; + }; + + 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); + }) + .ok(); + }) + .detach(); + } + } } fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { self.selected_index = 0; + match &mut self.intent { + ModalIntent::ResolveProcessId(sender) => { + if let Some(sender) = sender.take() { + sender.send(None).ok(); + } + } + ModalIntent::AttachToProcess(_) => {} + } + cx.emit(DismissEvent); } @@ -293,7 +317,7 @@ impl PickerDelegate for AttachModalDelegate { let candidate = self.candidates.get(hit.candidate_id)?; Some( - ListItem::new(SharedString::from(format!("process-entry-{ix}"))) + ListItem::new(format!("process-entry-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) @@ -303,7 +327,7 @@ impl PickerDelegate for AttachModalDelegate { .child(Label::new(format!("{} {}", candidate.name, candidate.pid))) .child( div() - .id(SharedString::from(format!("process-entry-{ix}-command"))) + .id(format!("process-entry-{ix}-command")) .tooltip(Tooltip::text( candidate .command @@ -338,7 +362,7 @@ fn get_processes_for_project(project: &Entity, cx: &mut App) -> Task, cx: &mut App) -> Task = System::new_all() + let refresh_kind = RefreshKind::nothing().with_processes( + ProcessRefreshKind::nothing() + .without_tasks() + .with_cmd(UpdateKind::Always), + ); + let mut processes: Box<[_]> = System::new_with_specifics(refresh_kind) .processes() .values() .map(|process| { @@ -384,8 +413,21 @@ fn get_processes_for_project(project: &Entity, cx: &mut App) -> Task) -> Vec { +#[cfg(test)] +pub(crate) fn set_candidates( + modal: &AttachModal, + candidates: Arc<[Candidate]>, + window: &mut Window, + cx: &mut Context, +) { + modal.picker.update(cx, |picker, cx| { + picker.delegate.candidates = candidates; + picker.refresh(window, cx); + }); +} + +#[cfg(test)] +pub(crate) fn process_names(modal: &AttachModal, cx: &mut Context) -> Vec { modal.picker.read_with(cx, |picker, _| { picker .delegate diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 9154047aa5..104a85dc09 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -14,13 +14,13 @@ use collections::IndexMap; use dap::adapters::DebugAdapterName; use dap::{DapRegistry, StartDebuggingRequestArguments}; use dap::{client::SessionId, debugger_settings::DebuggerSettings}; -use editor::Editor; +use editor::{Editor, MultiBufferOffset, ToPoint}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; use gpui::{ - Action, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task, - WeakEntity, anchored, deferred, + Action, App, AsyncWindowContext, ClipboardItem, Context, Corner, 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; @@ -32,7 +32,9 @@ use settings::Settings; use std::sync::{Arc, LazyLock}; use task::{DebugScenario, TaskContext}; use tree_sitter::{Query, StreamingIterator as _}; -use ui::{ContextMenu, Divider, PopoverMenuHandle, Tab, Tooltip, prelude::*}; +use ui::{ + ContextMenu, Divider, PopoverMenu, PopoverMenuHandle, SplitButton, Tab, Tooltip, prelude::*, +}; use util::rel_path::RelPath; use util::{ResultExt, debug_panic, maybe}; use workspace::SplitDirection; @@ -43,6 +45,14 @@ use workspace::{ }; use zed_actions::ToggleFocus; +pub struct DebuggerHistoryFeatureFlag; + +impl FeatureFlag for DebuggerHistoryFeatureFlag { + const NAME: &'static str = "debugger-history"; +} + +const DEBUG_PANEL_KEY: &str = "DebugPanel"; + pub struct DebugPanel { size: Pixels, active_session: Option>, @@ -268,12 +278,12 @@ impl DebugPanel { async move |_, cx| { if let Err(error) = task.await { - log::error!("{error}"); + log::error!("{error:#}"); session .update(cx, |session, cx| { session .console_output(cx) - .unbounded_send(format!("error: {}", error)) + .unbounded_send(format!("error: {:#}", error)) .ok(); session.shutdown(cx) })? @@ -283,7 +293,7 @@ impl DebugPanel { } }); - session.update(cx, |session, _| match &mut session.mode { + session.update(cx, |session, _| match &mut session.state { SessionState::Booting(state_task) => { *state_task = Some(boot_task); } @@ -614,12 +624,11 @@ impl DebugPanel { }) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Start Debug Session", &crate::Start, &focus_handle, - window, cx, ) } @@ -651,6 +660,23 @@ impl DebugPanel { .tooltip(Tooltip::text("Open Debug Adapter Logs")) }; + let close_bottom_panel_button = { + h_flex().pl_0p5().gap_1().child(Divider::vertical()).child( + IconButton::new("debug-close-panel", IconName::Close) + .icon_size(IconSize::Small) + .on_click(move |_, window, cx| { + window.dispatch_action(workspace::ToggleBottomDock.boxed_clone(), cx) + }) + .tooltip(Tooltip::text("Close Panel")), + ) + }; + + let thread_status = active_session + .as_ref() + .map(|session| session.read(cx).running_state()) + .and_then(|state| state.read(cx).thread_status(cx)) + .unwrap_or(project::debugger::session::ThreadStatus::Exited); + Some( div.w_full() .py_1() @@ -658,7 +684,7 @@ impl DebugPanel { .justify_between() .border_b_1() .border_color(cx.theme().colors().border) - .when(is_side, |this| this.gap_1()) + .when(is_side, |this| this.gap_1().h(Tab::container_height(cx))) .child( h_flex() .justify_between() @@ -668,10 +694,6 @@ impl DebugPanel { .as_ref() .map(|session| session.read(cx).running_state()), |this, running_state| { - let thread_status = - running_state.read(cx).thread_status(cx).unwrap_or( - project::debugger::session::ThreadStatus::Exited, - ); let capabilities = running_state.read(cx).capabilities(cx); let supports_detach = running_state.read(cx).session().read(cx).is_attached(); @@ -692,12 +714,11 @@ impl DebugPanel { )) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Pause Program", &Pause, &focus_handle, - window, cx, ) } @@ -717,12 +738,11 @@ impl DebugPanel { .disabled(thread_status != ThreadStatus::Stopped) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Continue Program", &Continue, &focus_handle, - window, cx, ) } @@ -731,7 +751,7 @@ impl DebugPanel { } }) .child( - IconButton::new("debug-step-over", IconName::ArrowRight) + IconButton::new("step-over", IconName::DebugStepOver) .icon_size(IconSize::Small) .on_click(window.listener_for( running_state, @@ -742,45 +762,40 @@ impl DebugPanel { .disabled(thread_status != ThreadStatus::Stopped) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Step Over", &StepOver, &focus_handle, - window, cx, ) } }), ) .child( - IconButton::new( - "debug-step-into", - IconName::ArrowDownRight, - ) - .icon_size(IconSize::Small) - .on_click(window.listener_for( - running_state, - |this, _, _window, cx| { - this.step_in(cx); - }, - )) - .disabled(thread_status != ThreadStatus::Stopped) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Step In", - &StepInto, - &focus_handle, - window, - cx, - ) - } - }), + IconButton::new("step-into", IconName::DebugStepInto) + .icon_size(IconSize::Small) + .on_click(window.listener_for( + running_state, + |this, _, _window, cx| { + this.step_in(cx); + }, + )) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Step In", + &StepInto, + &focus_handle, + cx, + ) + } + }), ) .child( - IconButton::new("debug-step-out", IconName::ArrowUpRight) + IconButton::new("step-out", IconName::DebugStepOut) .icon_size(IconSize::Small) .on_click(window.listener_for( running_state, @@ -791,12 +806,11 @@ impl DebugPanel { .disabled(thread_status != ThreadStatus::Stopped) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Step Out", &StepOut, &focus_handle, - window, cx, ) } @@ -814,12 +828,11 @@ impl DebugPanel { )) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Rerun Session", &RerunSession, &focus_handle, - window, cx, ) } @@ -859,48 +872,63 @@ impl DebugPanel { } else { "Terminate All Threads" }; - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( label, &Stop, &focus_handle, - window, cx, ) } }), ) + .when(supports_detach, |div| { + div.child( + IconButton::new( + "debug-disconnect", + IconName::DebugDetach, + ) + .disabled( + thread_status != ThreadStatus::Stopped + && thread_status != ThreadStatus::Running, + ) + .icon_size(IconSize::Small) + .on_click(window.listener_for( + running_state, + |this, _, _, cx| { + this.detach_client(cx); + }, + )) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Detach", + &Detach, + &focus_handle, + cx, + ) + } + }), + ) + }) .when( - supports_detach, - |div| { - div.child( - IconButton::new( - "debug-disconnect", - IconName::DebugDetach, + cx.has_flag::(), + |this| { + this.child(Divider::vertical()).child( + SplitButton::new( + self.render_history_button( + &running_state, + thread_status, + window, + ), + self.render_history_toggle_button( + thread_status, + &running_state, + ) + .into_any_element(), ) - .disabled( - thread_status != ThreadStatus::Stopped - && thread_status != ThreadStatus::Running, - ) - .icon_size(IconSize::Small) - .on_click(window.listener_for( - running_state, - |this, _, _, cx| { - this.detach_client(cx); - }, - )) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Detach", - &Detach, - &focus_handle, - window, - cx, - ) - } - }), + .style(ui::SplitButtonStyle::Outlined), ) }, ) @@ -965,6 +993,7 @@ impl DebugPanel { .child(edit_debug_json_button()) .child(documentation_button()) .child(logs_button()) + .child(close_bottom_panel_button) }), ), ), @@ -1223,11 +1252,11 @@ impl DebugPanel { 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) + last_offset = Some(MultiBufferOffset(pos)) } } let mut edits = Vec::new(); - let mut cursor_position = 0; + let mut cursor_position = MultiBufferOffset(0); if let Some(pos) = last_offset { edits.push((pos..pos, format!(",\n{new_scenario}"))); @@ -1241,24 +1270,25 @@ impl DebugPanel { 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(); + edits.push(( + MultiBufferOffset(pos)..MultiBufferOffset(pos), + format!("\n{new_scenario}\n"), + )); + cursor_position = MultiBufferOffset(pos) + "\n ".len(); } } else { - edits.push((0..0, format!("[\n{}\n]", new_scenario))); - cursor_position = "[\n ".len(); + edits.push(( + MultiBufferOffset(0)..MultiBufferOffset(0), + format!("[\n{}\n]", new_scenario), + )); + cursor_position = MultiBufferOffset("[\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 snapshot = editor.buffer().read(cx).read(cx); let point = cursor_position.to_point(&snapshot); + drop(snapshot); editor.go_to_singleton_buffer_point(point, window, cx); }); Ok(editor.save(SaveOptions::default(), project, window, cx)) @@ -1315,6 +1345,97 @@ impl DebugPanel { }); } } + + fn render_history_button( + &self, + running_state: &Entity, + thread_status: ThreadStatus, + window: &mut Window, + ) -> IconButton { + IconButton::new("debug-back-in-history", IconName::HistoryRerun) + .icon_size(IconSize::Small) + .on_click(window.listener_for(running_state, |this, _, _window, cx| { + this.session().update(cx, |session, cx| { + let ix = session + .active_snapshot_index() + .unwrap_or_else(|| session.historic_snapshots().len()); + + session.select_historic_snapshot(Some(ix.saturating_sub(1)), cx); + }) + })) + .disabled( + thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, + ) + } + + fn render_history_toggle_button( + &self, + thread_status: ThreadStatus, + running_state: &Entity, + ) -> impl IntoElement { + PopoverMenu::new("debug-back-in-history-menu") + .trigger( + ui::ButtonLike::new_rounded_right("debug-back-in-history-menu-trigger") + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::None) + .child( + div() + .px_1() + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), + ) + .disabled( + thread_status == ThreadStatus::Running + || thread_status == ThreadStatus::Stepping, + ), + ) + .menu({ + let running_state = running_state.clone(); + move |window, cx| { + let handler = + |ix: Option, running_state: Entity, cx: &mut App| { + running_state.update(cx, |state, cx| { + state.session().update(cx, |session, cx| { + session.select_historic_snapshot(ix, cx); + }) + }) + }; + + let running_state = running_state.clone(); + Some(ContextMenu::build( + window, + cx, + move |mut context_menu, _window, cx| { + let history = running_state + .read(cx) + .session() + .read(cx) + .historic_snapshots(); + + context_menu = context_menu.entry("Current State", None, { + let running_state = running_state.clone(); + move |_window, cx| { + handler(None, running_state.clone(), cx); + } + }); + context_menu = context_menu.separator(); + + for (ix, _) in history.iter().enumerate().rev() { + context_menu = + context_menu.entry(format!("history-{}", ix + 1), None, { + let running_state = running_state.clone(); + move |_window, cx| { + handler(Some(ix), running_state.clone(), cx); + } + }); + } + + context_menu + }, + )) + } + }) + .anchor(Corner::TopRight) + } } async fn register_session_inner( @@ -1414,6 +1535,10 @@ impl Panel for DebugPanel { "DebugPanel" } + fn panel_key() -> &'static str { + DEBUG_PANEL_KEY + } + fn position(&self, _window: &Window, cx: &App) -> DockPosition { DebuggerSettings::get_global(cx).dock.into() } @@ -1432,7 +1557,7 @@ impl Panel for DebugPanel { self.sessions_with_children.keys().for_each(|session_item| { session_item.update(cx, |item, cx| { item.running_state() - .update(cx, |state, _| state.invert_axies()) + .update(cx, |state, cx| state.invert_axies(cx)) }) }); } @@ -1695,7 +1820,7 @@ impl Render for DebugPanel { .child( Button::new("spawn-new-session-empty-state", "New Session") .icon(IconName::Plus) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(|_, window, cx| { @@ -1705,8 +1830,7 @@ impl Render for DebugPanel { .child( Button::new("edit-debug-settings", "Edit debug.json") .icon(IconName::Code) - .icon_size(IconSize::XSmall) - .color(Color::Muted) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(|_, window, cx| { @@ -1719,8 +1843,7 @@ impl Render for DebugPanel { .child( Button::new("open-debugger-docs", "Debugger Docs") .icon(IconName::Book) - .color(Color::Muted) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(|_, _, cx| cx.open_url("https://zed.dev/docs/debugger")), @@ -1731,8 +1854,7 @@ impl Render for DebugPanel { "Debugger Extensions", ) .icon(IconName::Blocks) - .color(Color::Muted) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(|_, window, cx| { @@ -1749,6 +1871,15 @@ impl Render for DebugPanel { }), ); + let has_breakpoints = self + .project + .read(cx) + .breakpoint_store() + .read(cx) + .all_source_breakpoints(cx) + .values() + .any(|breakpoints| !breakpoints.is_empty()); + let breakpoint_list = v_flex() .group("base-breakpoint-list") .when_else( @@ -1772,11 +1903,23 @@ impl Render for DebugPanel { ), ), ) - .child(self.breakpoint_list.clone()); + .when(has_breakpoints, |this| { + this.child(self.breakpoint_list.clone()) + }) + .when(!has_breakpoints, |this| { + this.child( + v_flex().size_full().items_center().justify_center().child( + Label::new("No Breakpoints Set") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }); this.child( v_flex() .size_full() + .overflow_hidden() .gap_1() .items_center() .justify_center() diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index 689e3cd878..bd5a7cda4a 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -1,8 +1,7 @@ use std::any::TypeId; -use dap::debugger_settings::DebuggerSettings; use debugger_panel::DebugPanel; -use editor::Editor; +use editor::{Editor, MultiBufferOffsetUtf16}; use gpui::{Action, App, DispatchPhase, EntityInputHandler, actions}; use new_process_modal::{NewProcessModal, NewProcessMode}; use onboarding_modal::DebuggerOnboardingModal; @@ -10,7 +9,6 @@ use project::debugger::{self, breakpoint_store::SourceBreakpoint, session::Threa use schemars::JsonSchema; use serde::Deserialize; use session::DebugSession; -use settings::Settings; use stack_trace_view::StackTraceView; use tasks_ui::{Spawn, TaskOverrides}; use ui::{FluentBuilder, InteractiveElement}; @@ -115,7 +113,6 @@ actions!( ); pub fn init(cx: &mut App) { - DebuggerSettings::register(cx); workspace::FollowableViewRegistry::register::(cx); cx.observe_new(|workspace: &mut Workspace, _, _| { @@ -341,8 +338,10 @@ pub fn init(cx: &mut App) { maybe!({ let (buffer, position, _) = editor .update(cx, |editor, cx| { - let cursor_point: language::Point = - editor.selections.newest(cx).head(); + let cursor_point: language::Point = editor + .selections + .newest(&editor.display_snapshot(cx)) + .head(); editor .buffer() @@ -388,11 +387,17 @@ pub fn init(cx: &mut App) { window.on_action( TypeId::of::(), move |_, _, window, cx| { - maybe!({ + let status = maybe!({ let text = editor .update(cx, |editor, cx| { + let range = editor + .selections + .newest::( + &editor.display_snapshot(cx), + ) + .range(); editor.text_for_range( - editor.selections.newest(cx).range(), + range.start.0.0..range.end.0.0, &mut None, window, cx, @@ -406,7 +411,13 @@ pub fn init(cx: &mut App) { state.session().update(cx, |session, cx| { session - .evaluate(text, None, stack_id, None, cx) + .evaluate( + text, + Some(dap::EvaluateArgumentsContext::Repl), + stack_id, + None, + cx, + ) .detach(); }); }); @@ -414,6 +425,9 @@ pub fn init(cx: &mut App) { Some(()) }); + if status.is_some() { + cx.stop_propagation(); + } }, ); }) diff --git a/crates/debugger_ui/src/dropdown_menus.rs b/crates/debugger_ui/src/dropdown_menus.rs index 376a4a41ce..e0c3628f4f 100644 --- a/crates/debugger_ui/src/dropdown_menus.rs +++ b/crates/debugger_ui/src/dropdown_menus.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use collections::HashMap; -use gpui::{Entity, WeakEntity}; +use gpui::{Corner, Entity, WeakEntity}; use project::debugger::session::{ThreadId, ThreadStatus}; use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*}; use util::{maybe, truncate_and_trailoff}; @@ -211,6 +211,7 @@ impl DebugPanel { this }), ) + .attach(Corner::BottomLeft) .style(DropdownStyle::Ghost) .handle(self.session_picker_menu_handle.clone()); @@ -322,6 +323,7 @@ impl DebugPanel { this }), ) + .attach(Corner::BottomLeft) .disabled(session_terminated) .style(DropdownStyle::Ghost) .handle(self.thread_picker_menu_handle.clone()), diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 56c4a69032..8aaa61aad6 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -12,30 +12,29 @@ use tasks_ui::{TaskOverrides, TasksModal}; use dap::{ DapRegistry, DebugRequest, TelemetrySpawnLocation, adapters::DebugAdapterName, send_telemetry, }; -use editor::{Editor, EditorElement, EditorStyle}; +use editor::Editor; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ Action, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - KeyContext, Render, Subscription, Task, TextStyle, WeakEntity, + KeyContext, Render, Subscription, Task, WeakEntity, }; use itertools::Itertools as _; use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch}; use project::{DebugScenarioContext, Project, TaskContexts, TaskSourceKind, task_store::TaskStore}; -use settings::Settings; use task::{DebugScenario, RevealTarget, VariableName, ZedDebugConfig}; -use theme::ThemeSettings; use ui::{ - 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, + ContextMenu, DropdownMenu, FluentBuilder, IconWithIndicator, Indicator, KeyBinding, ListItem, + ListItemSpacing, Switch, SwitchLabelPosition, ToggleButtonGroup, ToggleButtonSimple, + ToggleState, Tooltip, prelude::*, }; -use util::{ResultExt, rel_path::RelPath}; +use ui_input::InputField; +use util::{ResultExt, debug_panic, rel_path::RelPath, shell::ShellKind}; use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr, pane}; -use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel}; +use crate::{ + attach_modal::{AttachModal, ModalIntent}, + debugger_panel::DebugPanel, +}; pub(super) struct NewProcessModal { workspace: WeakEntity, @@ -96,7 +95,9 @@ impl NewProcessModal { let debug_picker = cx.new(|cx| { let delegate = DebugDelegate::new(debug_panel.downgrade(), task_store.clone()); - Picker::uniform_list(delegate, window, cx).modal(false) + Picker::list(delegate, window, cx) + .modal(false) + .list_measure_all() }); let configure_mode = ConfigureMode::new(window, cx); @@ -396,8 +397,15 @@ impl NewProcessModal { this.attach_picker.update(cx, |this, cx| { this.picker.update(cx, |this, cx| { - this.delegate.definition.adapter = adapter.0.clone(); - this.focus(window, cx); + match &mut this.delegate.intent { + ModalIntent::AttachToProcess(definition) => { + definition.adapter = adapter.0.clone(); + this.focus(window, cx); + }, + ModalIntent::ResolveProcessId(_) => { + debug_panic!("Attach picker attempted to update config when in resolve Process ID mode"); + } + } }) }); } @@ -439,7 +447,7 @@ impl NewProcessModal { &mut self, window: &mut Window, cx: &mut Context, - ) -> ui::DropdownMenu { + ) -> DropdownMenu { let workspace = self.workspace.clone(); let weak = cx.weak_entity(); let active_buffer = self.task_contexts(cx).and_then(|tc| { @@ -499,6 +507,13 @@ impl NewProcessModal { menu }), ) + .style(ui::DropdownStyle::Outlined) + .tab_index(0) + .attach(gpui::Corner::BottomLeft) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }) } } @@ -531,44 +546,6 @@ impl Focusable for NewProcessMode { } } -fn render_editor(editor: &Entity, window: &mut Window, cx: &App) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let theme = cx.theme(); - - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: settings.buffer_font_size(cx).into(), - font_weight: settings.buffer_font.weight, - line_height: relative(settings.buffer_line_height.value()), - background_color: Some(theme.colors().editor_background), - ..Default::default() - }; - - let element = EditorElement::new( - editor, - EditorStyle { - background: theme.colors().editor_background, - local_player: theme.players().local(), - text: text_style, - ..Default::default() - }, - ); - - div() - .rounded_md() - .p_1() - .border_1() - .border_color(theme.colors().border_variant) - .when( - editor.focus_handle(cx).contains_focused(window, cx), - |this| this.border_color(theme.colors().border_focused), - ) - .child(element) - .bg(theme.colors().editor_background) -} - impl Render for NewProcessModal { fn render( &mut self, @@ -618,72 +595,64 @@ impl Render for NewProcessModal { .border_b_1() .border_color(cx.theme().colors().border_variant) .child( - ToggleButton::new( - "debugger-session-ui-tasks-button", - NewProcessMode::Task.to_string(), - ) - .size(ButtonSize::Default) - .toggle_state(matches!(self.mode, NewProcessMode::Task)) - .style(ui::ButtonStyle::Subtle) - .on_click(cx.listener(|this, _, window, cx| { - this.mode = NewProcessMode::Task; - this.mode_focus_handle(cx).focus(window); - cx.notify(); - })) - .tooltip(Tooltip::text("Run predefined task")) - .first(), - ) - .child( - ToggleButton::new( - "debugger-session-ui-launch-button", - NewProcessMode::Debug.to_string(), - ) - .size(ButtonSize::Default) - .style(ui::ButtonStyle::Subtle) - .toggle_state(matches!(self.mode, NewProcessMode::Debug)) - .on_click(cx.listener(|this, _, window, cx| { - this.mode = NewProcessMode::Debug; - this.mode_focus_handle(cx).focus(window); - cx.notify(); - })) - .tooltip(Tooltip::text("Start a predefined debug scenario")) - .middle(), - ) - .child( - ToggleButton::new( - "debugger-session-ui-attach-button", - NewProcessMode::Attach.to_string(), - ) - .size(ButtonSize::Default) - .toggle_state(matches!(self.mode, NewProcessMode::Attach)) - .style(ui::ButtonStyle::Subtle) - .on_click(cx.listener(|this, _, window, cx| { - this.mode = NewProcessMode::Attach; + ToggleButtonGroup::single_row( + "debugger-mode-buttons", + [ + ToggleButtonSimple::new( + NewProcessMode::Task.to_string(), + cx.listener(|this, _, window, cx| { + this.mode = NewProcessMode::Task; + this.mode_focus_handle(cx).focus(window); + cx.notify(); + }), + ) + .tooltip(Tooltip::text("Run predefined task")), + ToggleButtonSimple::new( + NewProcessMode::Debug.to_string(), + cx.listener(|this, _, window, cx| { + this.mode = NewProcessMode::Debug; + this.mode_focus_handle(cx).focus(window); + cx.notify(); + }), + ) + .tooltip(Tooltip::text("Start a predefined debug scenario")), + ToggleButtonSimple::new( + NewProcessMode::Attach.to_string(), + cx.listener(|this, _, window, cx| { + this.mode = NewProcessMode::Attach; - if let Some(debugger) = this.debugger.as_ref() { - Self::update_attach_picker(&this.attach_mode, debugger, window, cx); - } - this.mode_focus_handle(cx).focus(window); - cx.notify(); - })) - .tooltip(Tooltip::text("Attach the debugger to a running process")) - .middle(), - ) - .child( - ToggleButton::new( - "debugger-session-ui-custom-button", - NewProcessMode::Launch.to_string(), + if let Some(debugger) = this.debugger.as_ref() { + Self::update_attach_picker( + &this.attach_mode, + debugger, + window, + cx, + ); + } + this.mode_focus_handle(cx).focus(window); + cx.notify(); + }), + ) + .tooltip(Tooltip::text("Attach the debugger to a running process")), + ToggleButtonSimple::new( + NewProcessMode::Launch.to_string(), + cx.listener(|this, _, window, cx| { + this.mode = NewProcessMode::Launch; + this.mode_focus_handle(cx).focus(window); + cx.notify(); + }), + ) + .tooltip(Tooltip::text("Launch a new process with a debugger")), + ], ) - .size(ButtonSize::Default) - .toggle_state(matches!(self.mode, NewProcessMode::Launch)) - .style(ui::ButtonStyle::Subtle) - .on_click(cx.listener(|this, _, window, cx| { - this.mode = NewProcessMode::Launch; - this.mode_focus_handle(cx).focus(window); - cx.notify(); - })) - .tooltip(Tooltip::text("Launch a new process with a debugger")) - .last(), + .label_size(LabelSize::Default) + .auto_width() + .selected_index(match self.mode { + NewProcessMode::Task => 0, + NewProcessMode::Debug => 1, + NewProcessMode::Attach => 2, + NewProcessMode::Launch => 3, + }), ), ) .child(v_flex().child(self.render_mode(window, cx))) @@ -695,6 +664,7 @@ impl Render for NewProcessModal { .justify_between() .border_t_1() .border_color(cx.theme().colors().border_variant); + let secondary_action = menu::SecondaryConfirm.boxed_clone(); match self.mode { NewProcessMode::Launch => el.child( container @@ -704,6 +674,7 @@ impl Render for NewProcessModal { .on_click(cx.listener(|this, _, window, cx| { this.save_debug_scenario(window, cx); })) + .key_binding(KeyBinding::for_action(&*secondary_action, cx)) .disabled( self.debugger.is_none() || self @@ -745,22 +716,14 @@ impl Render for NewProcessModal { == 0; let secondary_action = menu::SecondaryConfirm.boxed_clone(); container - .child(div().children( - KeyBinding::for_action(&*secondary_action, window, cx).map( - |keybind| { - Button::new("edit-attach-task", "Edit in debug.json") - .label_size(LabelSize::Small) - .key_binding(keybind) - .on_click(move |_, window, cx| { - window.dispatch_action( - secondary_action.boxed_clone(), - cx, - ) - }) - .disabled(disabled) - }, - ), - )) + .child(div().child({ + Button::new("edit-attach-task", "Edit in debug.json") + .key_binding(KeyBinding::for_action(&*secondary_action, cx)) + .on_click(move |_, window, cx| { + window.dispatch_action(secondary_action.boxed_clone(), cx) + }) + .disabled(disabled) + })) .child( h_flex() .child(div().child(self.adapter_drop_down_menu(window, cx))), @@ -793,22 +756,26 @@ impl RenderOnce for AttachMode { #[derive(Clone)] pub(super) struct ConfigureMode { - program: Entity, - cwd: Entity, + program: Entity, + cwd: Entity, stop_on_entry: ToggleState, save_to_debug_json: ToggleState, } impl ConfigureMode { pub(super) fn new(window: &mut Window, cx: &mut App) -> Entity { - let program = cx.new(|cx| Editor::single_line(window, cx)); - program.update(cx, |this, cx| { - this.set_placeholder_text("ENV=Zed ~/bin/program --option", window, cx); + let program = cx.new(|cx| { + InputField::new(window, cx, "ENV=Zed ~/bin/program --option") + .label("Program") + .tab_stop(true) + .tab_index(1) }); - let cwd = cx.new(|cx| Editor::single_line(window, cx)); - cwd.update(cx, |this, cx| { - this.set_placeholder_text("Ex: $ZED_WORKTREE_ROOT", window, cx); + let cwd = cx.new(|cx| { + InputField::new(window, cx, "Ex: $ZED_WORKTREE_ROOT") + .label("Working Directory") + .tab_stop(true) + .tab_index(2) }); cx.new(|_| Self { @@ -820,9 +787,9 @@ impl ConfigureMode { } fn load(&mut self, cwd: PathBuf, window: &mut Window, cx: &mut App) { - self.cwd.update(cx, |editor, cx| { - if editor.is_empty(cx) { - editor.set_text(cwd.to_string_lossy(), window, cx); + self.cwd.update(cx, |input_field, cx| { + if input_field.is_empty(cx) { + input_field.set_text(cwd.to_string_lossy(), window, cx); } }); } @@ -844,7 +811,11 @@ impl ConfigureMode { }; } let command = self.program.read(cx).text(cx); - let mut args = shlex::split(&command).into_iter().flatten().peekable(); + let mut args = ShellKind::Posix + .split(&command) + .into_iter() + .flatten() + .peekable(); let mut env = FxHashMap::default(); while args.peek().is_some_and(|arg| arg.contains('=')) { let arg = args.next().unwrap(); @@ -869,55 +840,48 @@ impl ConfigureMode { } } + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { + window.focus_next(); + } + + fn on_tab_prev( + &mut self, + _: &menu::SelectPrevious, + window: &mut Window, + _: &mut Context, + ) { + window.focus_prev(); + } + fn render( &mut self, adapter_menu: DropdownMenu, - window: &mut Window, + _: &mut Window, cx: &mut ui::Context, ) -> impl IntoElement { v_flex() + .tab_group() + .track_focus(&self.program.focus_handle(cx)) + .on_action(cx.listener(Self::on_tab)) + .on_action(cx.listener(Self::on_tab_prev)) .p_2() .w_full() - .gap_2() - .track_focus(&self.program.focus_handle(cx)) + .gap_3() .child( h_flex() - .gap_2() - .child( - Label::new("Debugger") - .size(LabelSize::Small) - .color(Color::Muted), - ) + .gap_1() + .child(Label::new("Debugger:").color(Color::Muted)) .child(adapter_menu), ) + .child(self.program.clone()) + .child(self.cwd.clone()) .child( - v_flex() - .gap_0p5() - .child( - Label::new("Program") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(render_editor(&self.program, window, cx)), - ) - .child( - v_flex() - .gap_0p5() - .child( - Label::new("Working Directory") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(render_editor(&self.cwd, window, cx)), - ) - .child( - CheckboxWithLabel::new( - "debugger-stop-on-entry", - Label::new("Stop on Entry") - .size(LabelSize::Small) - .color(Color::Muted), - self.stop_on_entry, - { + Switch::new("debugger-stop-on-entry", self.stop_on_entry) + .tab_index(3_isize) + .label("Stop on Entry") + .label_position(SwitchLabelPosition::Start) + .label_size(LabelSize::Default) + .on_click({ let this = cx.weak_entity(); move |state, _, cx| { this.update(cx, |this, _| { @@ -925,9 +889,7 @@ impl ConfigureMode { }) .ok(); } - }, - ) - .checkbox_position(ui::IconPosition::End), + }), ) } } @@ -953,7 +915,14 @@ impl AttachMode { stop_on_entry: Some(false), }; let attach_picker = cx.new(|cx| { - let modal = AttachModal::new(definition.clone(), workspace, project, false, window, cx); + let modal = AttachModal::new( + ModalIntent::AttachToProcess(definition.clone()), + workspace, + project, + false, + window, + cx, + ); window.focus(&modal.focus_handle(cx)); modal @@ -1053,7 +1022,7 @@ impl DebugDelegate { Some(TaskSourceKind::Lsp { language_name, .. }) => { Some(format!("LSP: {language_name}")) } - Some(TaskSourceKind::Language { .. }) => None, + Some(TaskSourceKind::Language { name }) => Some(format!("Language: {name}")), _ => context.clone().and_then(|ctx| { ctx.task_context .task_variables @@ -1193,7 +1162,7 @@ impl PickerDelegate for DebugDelegate { } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc { - "Find a debug task, or debug a command.".into() + "Find a debug task, or debug a command".into() } fn update_matches( @@ -1270,7 +1239,11 @@ impl PickerDelegate for DebugDelegate { }) .unwrap_or_default(); - let mut args = shlex::split(&text).into_iter().flatten().peekable(); + let mut args = ShellKind::Posix + .split(&text) + .into_iter() + .flatten() + .peekable(); let mut env = HashMap::default(); while args.peek().is_some_and(|arg| arg.contains('=')) { let arg = args.next().unwrap(); @@ -1447,56 +1420,47 @@ impl PickerDelegate for DebugDelegate { .justify_between() .border_t_1() .border_color(cx.theme().colors().border_variant) - .children({ + .child({ let action = menu::SecondaryConfirm.boxed_clone(); if self.matches.is_empty() { - Some( - Button::new("edit-debug-json", "Edit debug.json") - .label_size(LabelSize::Small) - .on_click(cx.listener(|_picker, _, window, cx| { - window.dispatch_action( - zed_actions::OpenProjectDebugTasks.boxed_clone(), - cx, - ); - cx.emit(DismissEvent); - })), - ) + Button::new("edit-debug-json", "Edit debug.json").on_click(cx.listener( + |_picker, _, window, cx| { + window.dispatch_action( + zed_actions::OpenProjectDebugTasks.boxed_clone(), + cx, + ); + cx.emit(DismissEvent); + }, + )) } else { - 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) - }) - }) + Button::new("edit-debug-task", "Edit in debug.json") + .key_binding(KeyBinding::for_action(&*action, cx)) + .on_click(move |_, window, cx| { + window.dispatch_action(action.boxed_clone(), cx) + }) } }) .map(|this| { if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty() { let action = picker::ConfirmInput { secondary: false }.boxed_clone(); - this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| { + this.child({ Button::new("launch-custom", "Launch Custom") - .key_binding(keybind) + .key_binding(KeyBinding::for_action(&*action, cx)) .on_click(move |_, window, cx| { window.dispatch_action(action.boxed_clone(), cx) }) - })) + }) } else { - this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map( - |keybind| { - let is_recent_selected = - self.divider_index >= Some(self.selected_index); - let run_entry_label = - if is_recent_selected { "Rerun" } else { "Spawn" }; + this.child({ + let is_recent_selected = self.divider_index >= Some(self.selected_index); + let run_entry_label = if is_recent_selected { "Rerun" } else { "Spawn" }; - Button::new("spawn", run_entry_label) - .key_binding(keybind) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx); - }) - }, - )) + Button::new("spawn", run_entry_label) + .key_binding(KeyBinding::for_action(&menu::Confirm, cx)) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + }) + }) } }); Some(footer.into_any_element()) @@ -1555,7 +1519,7 @@ impl PickerDelegate for DebugDelegate { }); Some( - ListItem::new(SharedString::from(format!("debug-scenario-selection-{ix}"))) + ListItem::new(format!("debug-scenario-selection-{ix}")) .inset(true) .start_slot::(icon) .spacing(ListItemSpacing::Sparse) diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index fe8cf083fa..4898ec95ca 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -5,16 +5,23 @@ 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 std::{ + any::Any, + ops::ControlFlow, + path::PathBuf, + sync::{Arc, LazyLock}, + time::Duration, +}; use crate::{ ToggleExpandItem, + attach_modal::{AttachModal, ModalIntent}, new_process_modal::resolve_path, persistence::{self, DebuggerPaneItem, SerializedLayout}, session::running::memory_view::MemoryView, }; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, bail}; use breakpoint_list::BreakpointList; use collections::{HashMap, IndexMap}; use console::Console; @@ -56,6 +63,9 @@ use workspace::{ Workspace, item::TabContentParams, move_item, pane::Event, }; +static PROCESS_ID_PLACEHOLDER: LazyLock = + LazyLock::new(|| task::VariableName::PickProcessId.template_value()); + pub struct RunningState { session: Entity, thread_id: Option, @@ -276,10 +286,10 @@ impl Item for SubView { impl Render for SubView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .id(SharedString::from(format!( + .id(format!( "subview-container-{}", self.kind.to_shared_string() - ))) + )) .on_hover(cx.listener(|this, hovered, _, cx| { this.hovered = *hovered; cx.notify(); @@ -338,7 +348,7 @@ pub(crate) fn new_debugger_pane( debug_assert!(_previous_subscription.is_none()); running .panes - .split(&this_pane, &new_pane, split_direction)?; + .split(&this_pane, &new_pane, split_direction, cx)?; anyhow::Ok(new_pane) }) }) @@ -386,6 +396,7 @@ pub(crate) fn new_debugger_pane( Default::default(), None, NoAction.boxed_clone(), + true, window, cx, ); @@ -473,10 +484,7 @@ pub(crate) fn new_debugger_pane( let deemphasized = !pane.has_focus(window, cx); let item_ = item.boxed_clone(); div() - .id(SharedString::from(format!( - "debugger_tab_{}", - item.item_id().as_u64() - ))) + .id(format!("debugger_tab_{}", item.item_id().as_u64())) .p_1() .rounded_md() .cursor_pointer() @@ -565,14 +573,13 @@ pub(crate) fn new_debugger_pane( })) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { let zoomed_text = if zoomed { "Minimize" } else { "Expand" }; Tooltip::for_action_in( zoomed_text, &ToggleExpandItem, &focus_handle, - window, cx, ) } @@ -653,6 +660,40 @@ impl RunningState { } } + pub(crate) fn contains_substring(config: &serde_json::Value, substring: &str) -> bool { + match config { + serde_json::Value::Object(obj) => obj + .values() + .any(|value| Self::contains_substring(value, substring)), + serde_json::Value::Array(array) => array + .iter() + .any(|value| Self::contains_substring(value, substring)), + serde_json::Value::String(s) => s.contains(substring), + _ => false, + } + } + + pub(crate) fn substitute_process_id_in_config(config: &mut serde_json::Value, process_id: i32) { + match config { + serde_json::Value::Object(obj) => { + obj.values_mut().for_each(|value| { + Self::substitute_process_id_in_config(value, process_id); + }); + } + serde_json::Value::Array(array) => { + array.iter_mut().for_each(|value| { + Self::substitute_process_id_in_config(value, process_id); + }); + } + serde_json::Value::String(s) => { + if s.contains(PROCESS_ID_PLACEHOLDER.as_str()) { + *s = s.replace(PROCESS_ID_PLACEHOLDER.as_str(), &process_id.to_string()); + } + } + _ => {} + } + } + pub(crate) fn relativize_paths( key: Option<&str>, config: &mut serde_json::Value, @@ -937,6 +978,7 @@ impl RunningState { let task_store = project.read(cx).task_store().downgrade(); let weak_project = project.downgrade(); let weak_workspace = workspace.downgrade(); + let is_windows = project.read(cx).path_style(cx).is_windows(); let remote_shell = project .read(cx) .remote_client() @@ -954,6 +996,31 @@ impl RunningState { Self::relativize_paths(None, &mut config, &task_context); Self::substitute_variables_in_config(&mut config, &task_context); + if Self::contains_substring(&config, PROCESS_ID_PLACEHOLDER.as_str()) || label.as_ref().contains(PROCESS_ID_PLACEHOLDER.as_str()) { + let (tx, rx) = futures::channel::oneshot::channel::>(); + + let weak_workspace_clone = weak_workspace.clone(); + weak_workspace.update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + workspace.toggle_modal(window, cx, |window, cx| { + AttachModal::new( + ModalIntent::ResolveProcessId(Some(tx)), + weak_workspace_clone, + project, + true, + window, + cx, + ) + }); + }).ok(); + + let Some(process_id) = rx.await.ok().flatten() else { + bail!("No process selected with config that contains {}", PROCESS_ID_PLACEHOLDER.as_str()) + }; + + Self::substitute_process_id_in_config(&mut config, process_id); + } + let request_type = match dap_registry .adapter(&adapter) .with_context(|| format!("{}: is not a valid adapter name", &adapter)) { @@ -1029,7 +1096,7 @@ impl RunningState { task.resolved.shell = Shell::Program(remote_shell); } - let builder = ShellBuilder::new(&task.resolved.shell); + let builder = ShellBuilder::new(&task.resolved.shell, is_windows); let command_label = builder.command_label(task.resolved.command.as_deref().unwrap_or("")); let (command, args) = builder.build(task.resolved.command.clone(), &task.resolved.args); @@ -1395,7 +1462,7 @@ impl RunningState { this.serialize_layout(window, cx); match event { Event::Remove { .. } => { - let _did_find_pane = this.panes.remove(source_pane).is_ok(); + let _did_find_pane = this.panes.remove(source_pane, cx).is_ok(); debug_assert!(_did_find_pane); cx.notify(); } @@ -1673,7 +1740,7 @@ impl RunningState { let is_building = self.session.update(cx, |session, cx| { session.shutdown(cx).detach(); - matches!(session.mode, session::SessionState::Booting(_)) + matches!(session.state, session::SessionState::Booting(_)) }); if is_building { @@ -1822,9 +1889,9 @@ impl RunningState { Member::Axis(group_root) } - pub(crate) fn invert_axies(&mut self) { + pub(crate) fn invert_axies(&mut self, cx: &mut App) { self.dock_axis = self.dock_axis.invert(); - self.panes.invert_axies(); + self.panes.invert_axies(cx); } } diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index cec906e293..2c7e207467 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -12,6 +12,7 @@ use gpui::{ Action, AppContext, ClickEvent, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, actions, uniform_list, }; +use itertools::Itertools; use language::Point; use project::{ Project, @@ -24,7 +25,7 @@ use project::{ }; use ui::{ Divider, DividerColor, FluentBuilder as _, Indicator, IntoElement, ListItem, Render, - StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, + ScrollAxes, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, }; use util::rel_path::RelPath; use workspace::Workspace; @@ -55,6 +56,7 @@ pub(crate) struct BreakpointList { focus_handle: FocusHandle, scroll_handle: UniformListScrollHandle, selected_ix: Option, + max_width_index: Option, input: Entity, strip_mode: Option, serialize_exception_breakpoints_task: Option>>, @@ -95,6 +97,7 @@ impl BreakpointList { dap_store, worktree_store, breakpoints: Default::default(), + max_width_index: None, workspace, session, focus_handle, @@ -546,7 +549,7 @@ impl BreakpointList { .session .as_ref() .map(|session| SupportedBreakpointProperties::from(session.read(cx).capabilities())) - .unwrap_or_else(SupportedBreakpointProperties::empty); + .unwrap_or_else(SupportedBreakpointProperties::all); let strip_mode = self.strip_mode; uniform_list( @@ -570,7 +573,9 @@ impl BreakpointList { .collect() }), ) - .track_scroll(self.scroll_handle.clone()) + .with_horizontal_sizing_behavior(gpui::ListHorizontalSizingBehavior::Unconstrained) + .with_width_from_item(self.max_width_index) + .track_scroll(&self.scroll_handle) .flex_1() } @@ -607,13 +612,12 @@ impl BreakpointList { .when_some(toggle_label, |this, (label, meta)| { this.tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::with_meta_in( label, Some(&ToggleEnableBreakpoint), meta, &focus_handle, - window, cx, ) } @@ -634,13 +638,12 @@ impl BreakpointList { .when_some(remove_breakpoint_tooltip, |this, tooltip| { this.tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::with_meta_in( "Remove Breakpoint", Some(&UnsetBreakpoint), tooltip, &focus_handle, - window, cx, ) } @@ -734,6 +737,26 @@ impl Render for BreakpointList { .chain(exception_breakpoints), ); + let text_pixels = ui::TextSize::Default.pixels(cx).to_f64() as f32; + + self.max_width_index = self + .breakpoints + .iter() + .map(|entry| match &entry.kind { + BreakpointEntryKind::LineBreakpoint(line_bp) => { + let name_and_line = format!("{}:{}", line_bp.name, line_bp.line); + let dir_len = line_bp.dir.as_ref().map(|d| d.len()).unwrap_or(0); + (name_and_line.len() + dir_len) as f32 * text_pixels + } + BreakpointEntryKind::ExceptionBreakpoint(exc_bp) => { + exc_bp.data.label.len() as f32 * text_pixels + } + BreakpointEntryKind::DataBreakpoint(data_bp) => { + data_bp.0.context.human_readable_label().len() as f32 * text_pixels + } + }) + .position_max_by(|left, right| left.total_cmp(right)); + v_flex() .id("breakpoint-list") .key_context("BreakpointList") @@ -751,7 +774,14 @@ impl Render for BreakpointList { .size_full() .pt_1() .child(self.render_list(cx)) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) + .custom_scrollbars( + ui::Scrollbars::new(ScrollAxes::Both) + .tracked_scroll_handle(&self.scroll_handle) + .with_track_along(ScrollAxes::Both, cx.theme().colors().panel_background) + .tracked_entity(cx.entity_id()), + window, + cx, + ) .when_some(self.strip_mode, |this, _| { this.child(Divider::horizontal().color(DividerColor::Border)) .child( @@ -819,7 +849,7 @@ impl LineBreakpoint { ) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( if is_enabled { "Disable Breakpoint" @@ -828,7 +858,6 @@ impl LineBreakpoint { }, &ToggleEnableBreakpoint, &focus_handle, - window, cx, ) } @@ -980,7 +1009,7 @@ impl DataBreakpoint { ) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( if is_enabled { "Disable Data Breakpoint" @@ -989,7 +1018,6 @@ impl DataBreakpoint { }, &ToggleEnableBreakpoint, &focus_handle, - window, cx, ) } @@ -1085,7 +1113,7 @@ impl ExceptionBreakpoint { ) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( if is_enabled { "Disable Exception Breakpoint" @@ -1094,7 +1122,6 @@ impl ExceptionBreakpoint { }, &ToggleEnableBreakpoint, &focus_handle, - window, cx, ) } @@ -1380,9 +1407,10 @@ impl RenderOnce for BreakpointOptionsStrip { h_flex() .gap_px() - .mr_3() // Space to avoid overlapping with the scrollbar - .child( - div() + .justify_end() + .when(has_logs || self.is_selected, |this| { + this.child( + div() .map(self.add_focus_styles( ActiveBreakpointStripMode::Log, supports_logs, @@ -1402,56 +1430,55 @@ impl RenderOnce for BreakpointOptionsStrip { .disabled(!supports_logs) .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Log)) .on_click(self.on_click_callback(ActiveBreakpointStripMode::Log)) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Set Log Message", None, "Set log message to display (instead of stopping) when a breakpoint is hit.", - window, cx, ) }), ) - .when(!has_logs && !self.is_selected, |this| this.invisible()), - ) - .child( - div() - .map(self.add_focus_styles( - ActiveBreakpointStripMode::Condition, - supports_condition, - window, - cx, - )) - .child( - IconButton::new( - SharedString::from(format!("{id}-condition-toggle")), - IconName::SplitAlt, - ) - .shape(ui::IconButtonShape::Square) - .style(style_for_toggle( + ) + }) + .when(has_condition || self.is_selected, |this| { + this.child( + div() + .map(self.add_focus_styles( ActiveBreakpointStripMode::Condition, - has_condition, + supports_condition, + window, + cx, )) - .icon_size(IconSize::Small) - .icon_color(color_for_toggle(has_condition)) - .when(has_condition, |this| this.indicator(Indicator::dot().color(Color::Info))) - .disabled(!supports_condition) - .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Condition)) - .on_click(self.on_click_callback(ActiveBreakpointStripMode::Condition)) - .tooltip(|window, cx| { - Tooltip::with_meta( - "Set Condition", - None, - "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met.", - window, - cx, + .child( + IconButton::new( + SharedString::from(format!("{id}-condition-toggle")), + IconName::SplitAlt, ) - }), - ) - .when(!has_condition && !self.is_selected, |this| this.invisible()), - ) - .child( - div() + .shape(ui::IconButtonShape::Square) + .style(style_for_toggle( + ActiveBreakpointStripMode::Condition, + has_condition, + )) + .icon_size(IconSize::Small) + .icon_color(color_for_toggle(has_condition)) + .when(has_condition, |this| this.indicator(Indicator::dot().color(Color::Info))) + .disabled(!supports_condition) + .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Condition)) + .on_click(self.on_click_callback(ActiveBreakpointStripMode::Condition)) + .tooltip(|_window, cx| { + Tooltip::with_meta( + "Set Condition", + None, + "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met.", + cx, + ) + }), + ) + ) + }) + .when(has_hit_condition || self.is_selected, |this| { + this.child(div() .map(self.add_focus_styles( ActiveBreakpointStripMode::HitCondition, supports_hit_condition, @@ -1474,19 +1501,16 @@ impl RenderOnce for BreakpointOptionsStrip { .disabled(!supports_hit_condition) .toggle_state(self.is_toggled(ActiveBreakpointStripMode::HitCondition)) .on_click(self.on_click_callback(ActiveBreakpointStripMode::HitCondition)) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Set Hit Condition", None, "Set expression that controls how many hits of the breakpoint are ignored.", - window, cx, ) }), - ) - .when(!has_hit_condition && !self.is_selected, |this| { - this.invisible() - }), - ) + )) + + }) } } diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index cf7b59f2fe..927a57dc8b 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -6,7 +6,10 @@ use alacritty_terminal::vte::ansi; use anyhow::Result; use collections::HashMap; use dap::{CompletionItem, CompletionItemType, OutputEvent}; -use editor::{Bias, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId}; +use editor::{ + Bias, CompletionProvider, Editor, EditorElement, EditorMode, EditorStyle, ExcerptId, + MultiBufferOffset, SizingBehavior, +}; use fuzzy::StringMatchCandidate; use gpui::{ Action as _, AppContext, Context, Corner, Entity, FocusHandle, Focusable, HighlightStyle, Hsla, @@ -15,14 +18,14 @@ use gpui::{ use language::{Anchor, Buffer, CharScopeContext, CodeLabel, TextBufferSnapshot, ToOffset}; use menu::{Confirm, SelectNext, SelectPrevious}; use project::{ - Completion, CompletionDisplayOptions, CompletionResponse, + CompletionDisplayOptions, CompletionResponse, 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 std::{ops::Range, rc::Rc, usize}; use theme::{Theme, ThemeSettings}; use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*}; use util::ResultExt; @@ -59,6 +62,11 @@ impl Console { ) -> Self { let console = cx.new(|cx| { let mut editor = Editor::multi_line(window, cx); + editor.set_mode(EditorMode::Full { + scale_ui_elements_with_buffer_font_size: true, + show_active_line_background: true, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, + }); editor.move_to_end(&editor::actions::MoveToEnd, window, cx); editor.set_read_only(true); editor.disable_scrollbars_and_minimap(window, cx); @@ -153,7 +161,9 @@ impl Console { ) -> 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 mut len = console + .update(cx, |this, cx| this.buffer().read(cx).len(cx))? + .0; let (output, spans, background_spans) = cx .background_spawn(async move { let mut all_spans = Vec::new(); @@ -219,8 +229,8 @@ impl Console { 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 range = buffer.anchor_after(MultiBufferOffset(range.start)) + ..buffer.anchor_before(MultiBufferOffset(range.end)); let style = HighlightStyle { color: Some(terminal_view::terminal_element::convert_color( &color, @@ -232,6 +242,7 @@ impl Console { start_offset, vec![range], style, + false, cx, ); } @@ -239,12 +250,13 @@ impl Console { 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); + let range = buffer.anchor_after(MultiBufferOffset(range.start)) + ..buffer.anchor_before(MultiBufferOffset(range.end)); + let color_fn = color_fetcher(color); console.highlight_background_key::( start_offset, &[range], - color_fetcher(color), + move |_, theme| color_fn(theme), cx, ); } @@ -484,12 +496,11 @@ impl Render for Console { .tooltip({ let query_focus_handle = query_focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Evaluate", &Confirm, &query_focus_handle, - window, cx, ) } @@ -543,24 +554,12 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { } } - fn apply_additional_edits_for_completion( - &self, - _buffer: Entity, - _completions: Rc>>, - _completion_index: usize, - _push_to_history: bool, - _cx: &mut Context, - ) -> gpui::Task>> { - Task::ready(Ok(None)) - } - 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 mut chars = text.chars(); @@ -571,9 +570,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { }; 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) @@ -669,11 +665,9 @@ impl ConsoleQueryBarCompletionProvider { &snapshot, ), new_text: string_match.string.clone(), - label: CodeLabel { - filter_range: 0..string_match.string.len(), - text: string_match.string.clone(), - runs: Vec::new(), - }, + label: CodeLabel::plain(string_match.string.clone(), None), + match_start: None, + snippet_deduplication_key: None, icon_path: None, documentation: Some(CompletionDocumentation::MultiLineMarkdown( variable_value.into(), @@ -782,15 +776,13 @@ impl ConsoleQueryBarCompletionProvider { &snapshot, ), new_text, - label: CodeLabel { - filter_range: 0..completion.label.len(), - text: completion.label, - runs: Vec::new(), - }, + label: CodeLabel::plain(completion.label, None), icon_path: None, documentation: completion.detail.map(|detail| { CompletionDocumentation::MultiLineMarkdown(detail.into()) }), + match_start: None, + snippet_deduplication_key: None, confirm: None, source: project::CompletionSource::Dap { sort_text }, insert_text_mode: None, @@ -958,7 +950,7 @@ fn color_fetcher(color: ansi::Color) -> fn(&Theme) -> Hsla { mod tests { use super::*; use crate::tests::init_test; - use editor::test::editor_test_context::EditorTestContext; + use editor::{MultiBufferOffset, test::editor_test_context::EditorTestContext}; use gpui::TestAppContext; use language::Point; @@ -971,8 +963,12 @@ mod tests { ) { cx.set_state(input); - let buffer_position = - cx.editor(|editor, _, cx| editor.selections.newest::(cx).start); + let buffer_position = cx.editor(|editor, _, cx| { + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .start + }); let snapshot = &cx.buffer_snapshot(); @@ -986,8 +982,8 @@ mod tests { cx.update_editor(|editor, _, cx| { editor.edit( vec![( - snapshot.offset_for_anchor(&replace_range.start) - ..snapshot.offset_for_anchor(&replace_range.end), + MultiBufferOffset(snapshot.offset_for_anchor(&replace_range.start)) + ..MultiBufferOffset(snapshot.offset_for_anchor(&replace_range.end)), replacement, )], cx, diff --git a/crates/debugger_ui/src/session/running/loaded_source_list.rs b/crates/debugger_ui/src/session/running/loaded_source_list.rs index 921ebd8b5f..e55fad336b 100644 --- a/crates/debugger_ui/src/session/running/loaded_source_list.rs +++ b/crates/debugger_ui/src/session/running/loaded_source_list.rs @@ -17,7 +17,9 @@ impl LoadedSourceList { let list = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); let _subscription = cx.subscribe(&session, |this, _, event, cx| match event { - SessionEvent::Stopped(_) | SessionEvent::LoadedSources => { + SessionEvent::Stopped(_) + | SessionEvent::HistoricSnapshotSelected + | SessionEvent::LoadedSources => { this.invalidate = true; cx.notify(); } diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index bc6e90ed09..55a8e8429e 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -10,8 +10,9 @@ use std::{ use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ Action, Along, AppContext, Axis, DismissEvent, DragMoveEvent, Empty, Entity, FocusHandle, - Focusable, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Subscription, Task, TextStyle, - UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, + Focusable, ListHorizontalSizingBehavior, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, + Subscription, Task, TextStyle, UniformList, UniformListScrollHandle, WeakEntity, actions, + anchored, deferred, uniform_list, }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session}; @@ -228,7 +229,8 @@ impl MemoryView { rows }, ) - .track_scroll(view_state.scroll_handle) + .track_scroll(&view_state.scroll_handle) + .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) .on_scroll_wheel(cx.listener(|this, evt: &ScrollWheelEvent, window, _| { let mut view_state = this.view_state(); let delta = evt.delta.pixel_delta(window.line_height()); @@ -917,7 +919,17 @@ impl Render for MemoryView { ) .with_priority(1) })) - .vertical_scrollbar_for(self.view_state_handle.clone(), window, cx), + .custom_scrollbars( + ui::Scrollbars::new(ui::ScrollAxes::Both) + .tracked_scroll_handle(&self.view_state_handle) + .with_track_along( + ui::ScrollAxes::Both, + cx.theme().colors().panel_background, + ) + .tracked_entity(cx.entity_id()), + window, + cx, + ), ) } } diff --git a/crates/debugger_ui/src/session/running/module_list.rs b/crates/debugger_ui/src/session/running/module_list.rs index 545d839274..7d0228fc68 100644 --- a/crates/debugger_ui/src/session/running/module_list.rs +++ b/crates/debugger_ui/src/session/running/module_list.rs @@ -32,7 +32,9 @@ impl ModuleList { let focus_handle = cx.focus_handle(); let _subscription = cx.subscribe(&session, |this, _, event, cx| match event { - SessionEvent::Stopped(_) | SessionEvent::Modules => { + SessionEvent::Stopped(_) + | SessionEvent::HistoricSnapshotSelected + | SessionEvent::Modules => { if this._rebuild_task.is_some() { this.schedule_rebuild(cx); } @@ -253,7 +255,7 @@ impl ModuleList { range.map(|ix| this.render_entry(ix, cx)).collect() }), ) - .track_scroll(self.scroll_handle.clone()) + .track_scroll(&self.scroll_handle) .size_full() } } @@ -279,6 +281,6 @@ impl Render for ModuleList { .size_full() .p_1() .child(self.render_list(window, cx)) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) } } diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index 309b58e7de..4dffb57a79 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -4,6 +4,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow}; use dap::StackFrameId; +use dap::adapters::DebugAdapterName; use db::kvp::KEY_VALUE_STORE; use gpui::{ Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, @@ -20,7 +21,7 @@ use project::debugger::breakpoint_store::ActiveStackFrame; use project::debugger::session::{Session, SessionEvent, StackFrame, ThreadStatus}; use project::{ProjectItem, ProjectPath}; use ui::{Tooltip, WithScrollbar, prelude::*}; -use workspace::{ItemHandle, Workspace}; +use workspace::{ItemHandle, Workspace, WorkspaceId}; use super::RunningState; @@ -58,6 +59,14 @@ impl From for String { } } +pub(crate) fn stack_frame_filter_key( + adapter_name: &DebugAdapterName, + workspace_id: WorkspaceId, +) -> String { + let database_id: i64 = workspace_id.into(); + format!("stack-frame-list-filter-{}-{}", adapter_name.0, database_id) +} + pub struct StackFrameList { focus_handle: FocusHandle, _subscription: Subscription, @@ -97,7 +106,9 @@ impl StackFrameList { SessionEvent::Threads => { this.schedule_refresh(false, window, cx); } - SessionEvent::Stopped(..) | SessionEvent::StackTrace => { + SessionEvent::Stopped(..) + | SessionEvent::StackTrace + | SessionEvent::HistoricSnapshotSelected => { this.schedule_refresh(true, window, cx); } _ => {} @@ -105,14 +116,18 @@ impl StackFrameList { let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); - let list_filter = KEY_VALUE_STORE - .read_kvp(&format!( - "stack-frame-list-filter-{}", - session.read(cx).adapter().0 - )) + let list_filter = workspace + .read_with(cx, |workspace, _| workspace.database_id()) .ok() .flatten() - .map(StackFrameFilter::from_str_or_default) + .and_then(|database_id| { + let key = stack_frame_filter_key(&session.read(cx).adapter(), database_id); + KEY_VALUE_STORE + .read_kvp(&key) + .ok() + .flatten() + .map(StackFrameFilter::from_str_or_default) + }) .unwrap_or(StackFrameFilter::All); let mut this = Self { @@ -225,7 +240,6 @@ impl StackFrameList { } this.update_in(cx, |this, window, cx| { this.build_entries(select_first, window, cx); - cx.notify(); }) .ok(); }) @@ -566,6 +580,7 @@ impl StackFrameList { this.activate_selected_entry(window, cx); })) .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer()) + .overflow_x_scroll() .child( v_flex() .gap_0p5() @@ -805,15 +820,8 @@ impl StackFrameList { .ok() .flatten() { - let database_id: i64 = database_id.into(); - let save_task = KEY_VALUE_STORE.write_kvp( - format!( - "stack-frame-list-filter-{}-{}", - self.session.read(cx).adapter().0, - database_id, - ), - self.list_filter.into(), - ); + let key = stack_frame_filter_key(&self.session.read(cx).adapter(), database_id); + let save_task = KEY_VALUE_STORE.write_kvp(key, self.list_filter.into()); cx.background_spawn(save_task).detach(); } @@ -872,8 +880,8 @@ impl StackFrameList { "filter-by-visible-worktree-stack-frame-list", IconName::ListFilter, ) - .tooltip(move |window, cx| { - Tooltip::for_action(tooltip_title, &ToggleUserFrames, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action(tooltip_title, &ToggleUserFrames, cx) }) .toggle_state(self.list_filter == StackFrameFilter::OnlyUserFrames) .icon_size(IconSize::Small) @@ -912,7 +920,7 @@ impl Render for StackFrameList { ) }) .child(self.render_list(window, cx)) - .vertical_scrollbar_for(self.list_state.clone(), window, cx) + .vertical_scrollbar_for(&self.list_state, window, cx) } } diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index aa8cb143ac..7b23cd685d 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -11,15 +11,18 @@ use gpui::{ FocusHandle, Focusable, Hsla, MouseDownEvent, Point, Subscription, TextStyleRefinement, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, }; +use itertools::Itertools; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::debugger::{ dap_command::DataBreakpointContext, session::{Session, SessionEvent, Watcher}, }; use std::{collections::HashMap, ops::Range, sync::Arc}; -use ui::{ContextMenu, ListItem, ScrollableHandle, Tooltip, WithScrollbar, prelude::*}; +use ui::{ContextMenu, ListItem, ScrollAxes, ScrollableHandle, Tooltip, WithScrollbar, prelude::*}; use util::{debug_panic, maybe}; +static INDENT_STEP_SIZE: Pixels = px(10.0); + actions!( variable_list, [ @@ -185,6 +188,7 @@ struct VariableColor { pub struct VariableList { entries: Vec, + max_width_index: Option, entry_states: HashMap, selected_stack_frame_id: Option, list_handle: UniformListScrollHandle, @@ -213,6 +217,12 @@ impl VariableList { let _subscriptions = vec![ cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events), cx.subscribe(&session, |this, _, event, cx| match event { + SessionEvent::HistoricSnapshotSelected => { + this.selection.take(); + this.edited_path.take(); + this.selected_stack_frame_id.take(); + this.build_entries(cx); + } SessionEvent::Stopped(_) => { this.selection.take(); this.edited_path.take(); @@ -221,7 +231,6 @@ impl VariableList { SessionEvent::Variables | SessionEvent::Watchers => { this.build_entries(cx); } - _ => {} }), cx.on_focus_out(&focus_handle, window, |this, _, _, cx| { @@ -243,6 +252,7 @@ impl VariableList { disabled: false, edited_path: None, entries: Default::default(), + max_width_index: None, entry_states: Default::default(), weak_running, memory_view, @@ -368,6 +378,26 @@ impl VariableList { } self.entries = entries; + + let text_pixels = ui::TextSize::Default.pixels(cx).to_f64() as f32; + let indent_size = INDENT_STEP_SIZE.to_f64() as f32; + + self.max_width_index = self + .entries + .iter() + .map(|entry| match &entry.entry { + DapEntry::Scope(scope) => scope.name.len() as f32 * text_pixels, + DapEntry::Variable(variable) => { + (variable.value.len() + variable.name.len()) as f32 * text_pixels + + (entry.path.indices.len() as f32 * indent_size) + } + DapEntry::Watcher(watcher) => { + (watcher.value.len() + watcher.expression.len()) as f32 * text_pixels + + (entry.path.indices.len() as f32 * indent_size) + } + }) + .position_max_by(|left, right| left.total_cmp(right)); + cx.notify(); } @@ -1129,6 +1159,7 @@ impl VariableList { this.color(Color::from(color)) }), ) + .tooltip(Tooltip::text(value)) } }) .into_any_element() @@ -1243,7 +1274,7 @@ impl VariableList { .disabled(self.disabled) .selectable(false) .indent_level(state.depth) - .indent_step_size(px(10.)) + .indent_step_size(INDENT_STEP_SIZE) .always_show_disclosure_icon(true) .when(var_ref > 0, |list_item| { list_item.toggle(state.is_expanded).on_toggle(cx.listener({ @@ -1306,14 +1337,8 @@ impl VariableList { .ok(); } }) - .tooltip(move |window, cx| { - Tooltip::for_action_in( - "Remove Watch", - &RemoveWatch, - &focus_handle, - window, - cx, - ) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Remove Watch", &RemoveWatch, &focus_handle, cx) }) .icon_size(ui::IconSize::Indicator), ), @@ -1384,6 +1409,7 @@ impl VariableList { div() .text_ui(cx) .w_full() + .truncate() .when(self.disabled, |this| { this.text_color(Color::Disabled.color(cx)) }) @@ -1450,7 +1476,7 @@ impl VariableList { .disabled(self.disabled) .selectable(false) .indent_level(state.depth) - .indent_step_size(px(10.)) + .indent_step_size(INDENT_STEP_SIZE) .always_show_disclosure_icon(true) .when(var_ref > 0, |list_item| { list_item.toggle(state.is_expanded).on_toggle(cx.listener({ @@ -1512,7 +1538,6 @@ impl Render for VariableList { .key_context("VariableList") .id("variable-list") .group("variable-list") - .overflow_y_scroll() .size_full() .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) @@ -1537,7 +1562,10 @@ impl Render for VariableList { this.render_entries(range, window, cx) }), ) - .track_scroll(self.list_handle.clone()) + .track_scroll(&self.list_handle) + .with_width_from_item(self.max_width_index) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .with_horizontal_sizing_behavior(gpui::ListHorizontalSizingBehavior::Unconstrained) .gap_1_5() .size_full() .flex_grow(), @@ -1551,7 +1579,15 @@ impl Render for VariableList { ) .with_priority(1) })) - .vertical_scrollbar_for(self.list_handle.clone(), window, cx) + // .vertical_scrollbar_for(&self.list_handle, window, cx) + .custom_scrollbars( + ui::Scrollbars::new(ScrollAxes::Both) + .tracked_scroll_handle(&self.list_handle) + .with_track_along(ScrollAxes::Both, cx.theme().colors().panel_background) + .tracked_entity(cx.entity_id()), + window, + cx, + ) } } diff --git a/crates/debugger_ui/src/stack_trace_view.rs b/crates/debugger_ui/src/stack_trace_view.rs index 3806e77b6e..70b88d203e 100644 --- a/crates/debugger_ui/src/stack_trace_view.rs +++ b/crates/debugger_ui/src/stack_trace_view.rs @@ -7,7 +7,7 @@ use editor::{ RowHighlightOptions, SelectionEffects, ToPoint, scroll::Autoscroll, }; use gpui::{ - AnyView, App, AppContext, Entity, EventEmitter, Focusable, IntoElement, Render, SharedString, + App, AppContext, Entity, EventEmitter, Focusable, IntoElement, Render, SharedString, Subscription, Task, WeakEntity, Window, }; use language::{BufferSnapshot, Capability, Point, Selection, SelectionGoal, TreeSitterOptions}; @@ -55,7 +55,10 @@ impl StackTraceView { cx.subscribe_in(&editor, window, |this, editor, event, window, cx| { if let EditorEvent::SelectionsChanged { local: true } = event { let excerpt_id = editor.update(cx, |editor, cx| { - let position: Point = editor.selections.newest(cx).head(); + let position: Point = editor + .selections + .newest(&editor.display_snapshot(cx)) + .head(); editor .snapshot(window, cx) @@ -415,17 +418,17 @@ impl Item for StackTraceView { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.clone().into()) } else { None } } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index ac3fdf1f18..4f020e7cd4 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -43,9 +43,6 @@ pub fn init_test(cx: &mut gpui::TestAppContext) { terminal_view::init(cx); theme::init(theme::LoadThemes::JustBase, cx); command_palette_hooks::init(cx); - language::init(cx); - workspace::init_settings(cx); - Project::init_settings(cx); editor::init(cx); crate::init(cx); dap_adapters::init(cx); diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs index 80e2b73d5a..4df3ebf519 100644 --- a/crates/debugger_ui/src/tests/attach_modal.rs +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -1,4 +1,8 @@ -use crate::{attach_modal::Candidate, tests::start_debug_session_with, *}; +use crate::{ + attach_modal::{Candidate, ModalIntent}, + tests::start_debug_session_with, + *, +}; use attach_modal::AttachModal; use dap::{FakeAdapter, adapters::DebugTaskDefinition}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; @@ -98,12 +102,6 @@ async fn test_show_attach_modal_and_select_process( workspace.toggle_modal(window, cx, |window, cx| { AttachModal::with_processes( workspace_handle, - task::ZedDebugConfig { - adapter: FakeAdapter::ADAPTER_NAME.into(), - request: dap::DebugRequest::Attach(AttachRequest::default()), - label: "attach example".into(), - stop_on_entry: None, - }, vec![ Candidate { pid: 0, @@ -124,6 +122,12 @@ async fn test_show_attach_modal_and_select_process( .into_iter() .collect(), true, + ModalIntent::AttachToProcess(task::ZedDebugConfig { + adapter: FakeAdapter::ADAPTER_NAME.into(), + request: dap::DebugRequest::Attach(AttachRequest::default()), + label: "attach example".into(), + stop_on_entry: None, + }), window, cx, ) @@ -138,8 +142,7 @@ async fn test_show_attach_modal_and_select_process( // assert we got the expected processes workspace .update(cx, |_, window, cx| { - let names = - attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx)); + let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx)); // Initially all processes are visible. assert_eq!(3, names.len()); attach_modal.update(cx, |this, cx| { @@ -153,8 +156,7 @@ async fn test_show_attach_modal_and_select_process( // assert we got the expected processes workspace .update(cx, |_, _, cx| { - let names = - attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx)); + let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx)); // Initially all processes are visible. assert_eq!(2, names.len()); }) @@ -171,3 +173,139 @@ async fn test_show_attach_modal_and_select_process( }) .unwrap(); } + +#[gpui::test] +async fn test_attach_with_pick_pid_variable(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + path!("/project"), + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let _initialize_subscription = + project::debugger::test::intercept_debug_sessions(cx, |client| { + client.on_request::(move |_, args| { + let raw = &args.raw; + assert_eq!(raw["request"], "attach"); + assert_eq!( + raw["process_id"], "42", + "verify process id has been replaced" + ); + + Ok(()) + }); + }); + + let pick_pid_placeholder = task::VariableName::PickProcessId.template_value(); + workspace + .update(cx, |workspace, window, cx| { + workspace.start_debug_session( + DebugTaskDefinition { + adapter: FakeAdapter::ADAPTER_NAME.into(), + label: "attach with picker".into(), + config: json!({ + "request": "attach", + "process_id": pick_pid_placeholder, + }), + tcp_connection: None, + } + .to_scenario(), + task::TaskContext::default(), + None, + None, + window, + cx, + ) + }) + .unwrap(); + + cx.run_until_parked(); + + let attach_modal = workspace + .update(cx, |workspace, _window, cx| { + workspace.active_modal::(cx) + }) + .unwrap(); + + assert!( + attach_modal.is_some(), + "Attach modal should open when config contains ZED_PICK_PID" + ); + + let attach_modal = attach_modal.unwrap(); + + workspace + .update(cx, |_, window, cx| { + attach_modal.update(cx, |modal, cx| { + attach_modal::set_candidates( + modal, + vec![ + Candidate { + pid: 10, + name: "process-1".into(), + command: vec![], + }, + Candidate { + pid: 42, + name: "target-process".into(), + command: vec![], + }, + Candidate { + pid: 99, + name: "process-3".into(), + command: vec![], + }, + ] + .into_iter() + .collect(), + window, + cx, + ) + }) + }) + .unwrap(); + + cx.run_until_parked(); + + workspace + .update(cx, |_, window, cx| { + attach_modal.update(cx, |modal, cx| { + modal.picker.update(cx, |picker, cx| { + picker.set_query("target", window, cx); + }) + }) + }) + .unwrap(); + + cx.run_until_parked(); + + workspace + .update(cx, |_, _, cx| { + let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx)); + assert_eq!(names.len(), 1); + assert_eq!(names[0], " 42 target-process"); + }) + .unwrap(); + + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + workspace + .update(cx, |workspace, _window, cx| { + assert!( + workspace.active_modal::(cx).is_none(), + "Attach modal should be dismissed after selection" + ); + }) + .unwrap(); +} diff --git a/crates/debugger_ui/src/tests/inline_values.rs b/crates/debugger_ui/src/tests/inline_values.rs index 8ca3061f57..379bc4c98f 100644 --- a/crates/debugger_ui/src/tests/inline_values.rs +++ b/crates/debugger_ui/src/tests/inline_values.rs @@ -3,7 +3,10 @@ use std::{path::Path, sync::Arc}; use dap::{Scope, StackFrame, Variable, requests::Variables}; use editor::{Editor, EditorMode, MultiBuffer}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; -use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_python, tree_sitter_rust}; +use language::{ + Language, LanguageConfig, LanguageMatcher, rust_lang, tree_sitter_python, + tree_sitter_typescript, +}; use project::{FakeFs, Project}; use serde_json::json; use unindent::Unindent as _; @@ -221,7 +224,7 @@ fn main() { .unwrap(); buffer.update(cx, |buffer, cx| { - buffer.set_language(Some(Arc::new(rust_lang())), cx); + buffer.set_language(Some(rust_lang()), cx); }); let (editor, cx) = cx.add_window_view(|window, cx| { @@ -1518,23 +1521,6 @@ fn main() { }); } -fn rust_lang() -> Language { - let debug_variables_query = include_str!("../../../languages/src/rust/debugger.scm"); - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_debug_variables_query(debug_variables_query) - .unwrap() -} - #[gpui::test] async fn test_python_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx); @@ -1856,21 +1842,23 @@ fn python_lang() -> Language { .unwrap() } -fn go_lang() -> Language { +fn go_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/go/debugger.scm"); - Language::new( - LanguageConfig { - name: "Go".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["go".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "Go".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["go".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_go::LANGUAGE.into()), + Some(tree_sitter_go::LANGUAGE.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } /// Test utility function for inline values testing @@ -1888,7 +1876,7 @@ async fn test_inline_values_util( before: &str, after: &str, active_debug_line: Option, - language: Language, + language: Arc, executor: BackgroundExecutor, cx: &mut TestAppContext, ) { @@ -2088,7 +2076,7 @@ async fn test_inline_values_util( .unwrap(); buffer.update(cx, |buffer, cx| { - buffer.set_language(Some(Arc::new(language)), cx); + buffer.set_language(Some(language), cx); }); let (editor, cx) = cx.add_window_view(|window, cx| { @@ -2272,3 +2260,263 @@ fn main() { ) .await; } + +fn javascript_lang() -> Arc { + let debug_variables_query = include_str!("../../../languages/src/javascript/debugger.scm"); + Arc::new( + Language::new( + LanguageConfig { + name: "JavaScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["js".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), + ) +} + +fn typescript_lang() -> Arc { + let debug_variables_query = include_str!("../../../languages/src/typescript/debugger.scm"); + Arc::new( + Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), + ) +} + +fn tsx_lang() -> Arc { + let debug_variables_query = include_str!("../../../languages/src/tsx/debugger.scm"); + Arc::new( + Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), + ) +} + +#[gpui::test] +async fn test_javascript_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [ + ("x", "10"), + ("y", "20"), + ("sum", "30"), + ("message", "Hello"), + ]; + + let before = r#" +function calculate() { + const x = 10; + const y = 20; + const sum = x + y; + const message = "Hello"; + console.log(message, "Sum:", sum); +} +"# + .unindent(); + + let after = r#" +function calculate() { + const x: 10 = 10; + const y: 20 = 20; + const sum: 30 = x: 10 + y: 20; + const message: Hello = "Hello"; + console.log(message, "Sum:", sum); +} +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + javascript_lang(), + executor, + cx, + ) + .await; +} + +#[gpui::test] +async fn test_typescript_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [ + ("count", "42"), + ("name", "Alice"), + ("result", "84"), + ("i", "3"), + ]; + + let before = r#" +function processData(count: number, name: string): number { + let result = count * 2; + for (let i = 0; i < 5; i++) { + console.log(i); + } + return result; +} +"# + .unindent(); + + let after = r#" +function processData(count: number, name: string): number { + let result: 84 = count: 42 * 2; + for (let i: 3 = 0; i: 3 < 5; i: 3++) { + console.log(i); + } + return result: 84; +} +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + typescript_lang(), + executor, + cx, + ) + .await; +} + +#[gpui::test] +async fn test_tsx_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [("count", "5"), ("message", "Hello React")]; + + let before = r#" +const Counter = () => { + const count = 5; + const message = "Hello React"; + return ( +
+

{message}

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

{message: Hello React}

+ {count} +
+ ); +}; +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + tsx_lang(), + executor, + cx, + ) + .await; +} + +#[gpui::test] +async fn test_javascript_arrow_functions(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [("x", "42"), ("result", "84")]; + + let before = r#" +const double = (x) => { + const result = x * 2; + return result; +}; +"# + .unindent(); + + let after = r#" +const double = (x) => { + const result: 84 = x: 42 * 2; + return result: 84; +}; +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + javascript_lang(), + executor, + cx, + ) + .await; +} + +#[gpui::test] +async fn test_typescript_for_in_loop(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [("key", "name"), ("obj", "{name: 'test'}")]; + + let before = r#" +function iterate() { + const obj = {name: 'test'}; + for (const key in obj) { + console.log(key); + } +} +"# + .unindent(); + + let after = r#" +function iterate() { + const obj: {name: 'test'} = {name: 'test'}; + for (const key: name in obj) { + console.log(key); + } +} +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + typescript_lang(), + executor, + cx, + ) + .await; +} diff --git a/crates/debugger_ui/src/tests/new_process_modal.rs b/crates/debugger_ui/src/tests/new_process_modal.rs index 80e27ee6bd..2f470560d5 100644 --- a/crates/debugger_ui/src/tests/new_process_modal.rs +++ b/crates/debugger_ui/src/tests/new_process_modal.rs @@ -231,7 +231,10 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut editor.update(cx, |editor, cx| { assert_eq!( - editor.selections.newest::(cx).head(), + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(), Point::new(5, 2) ) }); diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 05e638e232..445d5a01d9 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1,12 +1,15 @@ use crate::{ debugger_panel::DebugPanel, - session::running::stack_frame_list::{StackFrameEntry, StackFrameFilter}, + session::running::stack_frame_list::{ + StackFrameEntry, StackFrameFilter, stack_frame_filter_key, + }, tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session}, }; use dap::{ StackFrame, requests::{Scopes, StackTrace, Threads}, }; +use db::kvp::KEY_VALUE_STORE; use editor::{Editor, ToPoint as _}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; use project::{FakeFs, Project}; @@ -1085,3 +1088,180 @@ async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppC ); }); } + +#[gpui::test] +async fn test_stack_frame_filter_persistence( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": "function main() { console.log('hello'); }", + } + }), + ) + .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, _, _| { + workspace.set_random_database_id(); + }) + .unwrap(); + + let threads_response = dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }; + + let stack_trace_response = dap::StackTraceResponse { + stack_frames: vec![StackFrame { + id: 1, + name: "main".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }], + total_frames: None, + }; + + let stopped_event = 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, + }; + + let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + let adapter_name = session.update(cx, |session, _| session.adapter()); + + client.on_request::({ + let threads_response = threads_response.clone(); + move |_, _| Ok(threads_response.clone()) + }); + + client.on_request::(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] })); + + client.on_request::({ + let stack_trace_response = stack_trace_response.clone(); + move |_, _| Ok(stack_trace_response.clone()) + }); + + client + .fake_event(dap::messages::Events::Stopped(stopped_event.clone())) + .await; + + cx.run_until_parked(); + + let stack_frame_list = + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()) + }); + + stack_frame_list.update(cx, |stack_frame_list, _cx| { + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::All, + "Initial filter should be All" + ); + }); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames, + "Filter should be OnlyUserFrames after toggle" + ); + }); + + cx.run_until_parked(); + + let workspace_id = workspace + .update(cx, |workspace, _window, _cx| workspace.database_id()) + .ok() + .flatten() + .expect("workspace id has to be some for this test to work properly"); + + let key = stack_frame_filter_key(&adapter_name, workspace_id); + let stored_value = KEY_VALUE_STORE.read_kvp(&key).unwrap(); + assert_eq!( + stored_value, + Some(StackFrameFilter::OnlyUserFrames.into()), + "Filter should be persisted in KVP store with key: {}", + key + ); + + client + .fake_event(dap::messages::Events::Terminated(None)) + .await; + cx.run_until_parked(); + + let session2 = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client2 = session2.update(cx, |session, _| session.adapter_client().unwrap()); + + client2.on_request::({ + let threads_response = threads_response.clone(); + move |_, _| Ok(threads_response.clone()) + }); + + client2.on_request::(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] })); + + client2.on_request::({ + let stack_trace_response = stack_trace_response.clone(); + move |_, _| Ok(stack_trace_response.clone()) + }); + + client2 + .fake_event(dap::messages::Events::Stopped(stopped_event.clone())) + .await; + + cx.run_until_parked(); + + let stack_frame_list2 = + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()) + }); + + stack_frame_list2.update(cx, |stack_frame_list, _cx| { + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames, + "Filter should be restored from KVP store in new session" + ); + }); +} diff --git a/crates/deepseek/Cargo.toml b/crates/deepseek/Cargo.toml index f294e946d8..25e8f2f25c 100644 --- a/crates/deepseek/Cargo.toml +++ b/crates/deepseek/Cargo.toml @@ -22,4 +22,3 @@ http_client.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true diff --git a/crates/deepseek/src/deepseek.rs b/crates/deepseek/src/deepseek.rs index 64a1cbe5d9..e978aa0804 100644 --- a/crates/deepseek/src/deepseek.rs +++ b/crates/deepseek/src/deepseek.rs @@ -155,6 +155,8 @@ pub enum RequestMessage { content: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_content: Option, }, User { content: String, diff --git a/crates/denoise/Cargo.toml b/crates/denoise/Cargo.toml index a2f43cdfee..7d4644a610 100644 --- a/crates/denoise/Cargo.toml +++ b/crates/denoise/Cargo.toml @@ -18,4 +18,3 @@ rodio = { workspace = true, features = ["wav_output"] } rustfft = { version = "6.2.0", features = ["avx"] } realfft = "3.4.0" thiserror.workspace = true -workspace-hack.workspace = true diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index fd678078e8..0eccf44c35 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -34,7 +34,7 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true +itertools.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index e25d3a7702..0fd8783dd5 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -1,5 +1,5 @@ use crate::{ - DIAGNOSTICS_UPDATE_DELAY, IncludeWarnings, ToggleWarnings, context_range_for_entry, + DIAGNOSTICS_UPDATE_DEBOUNCE, IncludeWarnings, ToggleWarnings, context_range_for_entry, diagnostic_renderer::{DiagnosticBlock, DiagnosticRenderer}, toolbar_controls::DiagnosticsToolbarEditor, }; @@ -23,7 +23,7 @@ use project::{ use settings::Settings; use std::{ any::{Any, TypeId}, - cmp::Ordering, + cmp::{self, Ordering}, sync::Arc, }; use text::{Anchor, BufferSnapshot, OffsetRangeExt}; @@ -283,7 +283,7 @@ impl BufferDiagnosticsEditor { self.update_excerpts_task = Some(cx.spawn_in(window, async move |editor, cx| { cx.background_executor() - .timer(DIAGNOSTICS_UPDATE_DELAY) + .timer(DIAGNOSTICS_UPDATE_DEBOUNCE) .await; if let Some(buffer) = buffer { @@ -370,11 +370,16 @@ impl BufferDiagnosticsEditor { continue; } + let languages = buffer_diagnostics_editor + .read_with(cx, |b, cx| b.project.read(cx).languages().clone()) + .ok(); + let diagnostic_blocks = cx.update(|_window, cx| { DiagnosticRenderer::diagnostic_blocks_for_group( group, buffer_snapshot.remote_id(), Some(Arc::new(buffer_diagnostics_editor.clone())), + languages, cx, ) })?; @@ -410,7 +415,7 @@ impl BufferDiagnosticsEditor { // in the editor. // This is done by iterating over the list of diagnostic blocks and // determine what range does the diagnostic block span. - let mut excerpt_ranges: Vec> = Vec::new(); + let mut excerpt_ranges: Vec> = Vec::new(); for diagnostic_block in blocks.iter() { let excerpt_range = context_range_for_entry( @@ -420,30 +425,43 @@ impl BufferDiagnosticsEditor { &mut cx, ) .await; + let initial_range = buffer_snapshot + .anchor_after(diagnostic_block.initial_range.start) + ..buffer_snapshot.anchor_before(diagnostic_block.initial_range.end); - let index = excerpt_ranges - .binary_search_by(|probe| { + let bin_search = |probe: &ExcerptRange| { + let context_start = || { probe .context .start - .cmp(&excerpt_range.start) - .then(probe.context.end.cmp(&excerpt_range.end)) - .then( - probe - .primary - .start - .cmp(&diagnostic_block.initial_range.start), - ) - .then(probe.primary.end.cmp(&diagnostic_block.initial_range.end)) - .then(Ordering::Greater) - }) - .unwrap_or_else(|index| index); + .cmp(&excerpt_range.start, &buffer_snapshot) + }; + let context_end = + || probe.context.end.cmp(&excerpt_range.end, &buffer_snapshot); + let primary_start = || { + probe + .primary + .start + .cmp(&initial_range.start, &buffer_snapshot) + }; + let primary_end = + || probe.primary.end.cmp(&initial_range.end, &buffer_snapshot); + context_start() + .then_with(context_end) + .then_with(primary_start) + .then_with(primary_end) + .then(cmp::Ordering::Greater) + }; + + let index = excerpt_ranges + .binary_search_by(bin_search) + .unwrap_or_else(|i| i); excerpt_ranges.insert( index, ExcerptRange { context: excerpt_range, - primary: diagnostic_block.initial_range.clone(), + primary: initial_range, }, ) } @@ -466,6 +484,13 @@ impl BufferDiagnosticsEditor { buffer_diagnostics_editor .multibuffer .update(cx, |multibuffer, cx| { + let excerpt_ranges = excerpt_ranges + .into_iter() + .map(|range| ExcerptRange { + context: range.context.to_point(&buffer_snapshot), + primary: range.primary.to_point(&buffer_snapshot), + }) + .collect(); multibuffer.set_excerpt_ranges_for_path( PathKey::for_buffer(&buffer, cx), buffer.clone(), @@ -655,11 +680,11 @@ impl Item for BufferDiagnosticsEditor { type_id: std::any::TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.clone().into()) } else { None } @@ -688,16 +713,20 @@ impl Item for BufferDiagnosticsEditor { true } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| { + Task::ready(Some(cx.new(|cx| { BufferDiagnosticsEditor::new( self.project_path.clone(), self.project.clone(), @@ -706,7 +735,7 @@ impl Item for BufferDiagnosticsEditor { window, cx, ) - })) + }))) } fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { @@ -934,10 +963,6 @@ impl DiagnosticsToolbarEditor for WeakEntity { .unwrap_or(false) } - fn has_stale_excerpts(&self, _cx: &App) -> bool { - false - } - fn is_updating(&self, cx: &App) -> bool { self.read_with(cx, |buffer_diagnostics_editor, cx| { buffer_diagnostics_editor.update_excerpts_task.is_some() diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 5eda81faf9..72ad7b5914 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -6,7 +6,7 @@ use editor::{ hover_popover::diagnostics_markdown_style, }; use gpui::{AppContext, Entity, Focusable, WeakEntity}; -use language::{BufferId, Diagnostic, DiagnosticEntryRef}; +use language::{BufferId, Diagnostic, DiagnosticEntryRef, LanguageRegistry}; use lsp::DiagnosticSeverity; use markdown::{Markdown, MarkdownElement}; use settings::Settings; @@ -27,6 +27,7 @@ impl DiagnosticRenderer { diagnostic_group: Vec>, buffer_id: BufferId, diagnostics_editor: Option>, + language_registry: Option>, cx: &mut App, ) -> Vec { let Some(primary_ix) = diagnostic_group @@ -39,8 +40,8 @@ impl DiagnosticRenderer { let group_id = primary.diagnostic.group_id; let mut results = vec![]; for entry in diagnostic_group.iter() { + let mut markdown = Self::markdown(&entry.diagnostic); if entry.diagnostic.is_primary { - let mut markdown = Self::markdown(&entry.diagnostic); let diagnostic = &primary.diagnostic; if diagnostic.source.is_some() || diagnostic.code.is_some() { markdown.push_str(" ("); @@ -75,32 +76,28 @@ impl DiagnosticRenderer { )) } } + results.push(DiagnosticBlock { initial_range: primary.range.clone(), severity: primary.diagnostic.severity, diagnostics_editor: diagnostics_editor.clone(), - markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)), - }); - } else if entry.range.start.row.abs_diff(primary.range.start.row) < 5 { - let markdown = Self::markdown(&entry.diagnostic); - - results.push(DiagnosticBlock { - initial_range: entry.range.clone(), - severity: entry.diagnostic.severity, - diagnostics_editor: diagnostics_editor.clone(), - markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)), + markdown: cx.new(|cx| { + Markdown::new(markdown.into(), language_registry.clone(), None, cx) + }), }); } else { - let mut markdown = Self::markdown(&entry.diagnostic); - markdown.push_str(&format!( - " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))" - )); - + if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 { + markdown.push_str(&format!( + " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))" + )); + } results.push(DiagnosticBlock { initial_range: entry.range.clone(), severity: entry.diagnostic.severity, diagnostics_editor: diagnostics_editor.clone(), - markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)), + markdown: cx.new(|cx| { + Markdown::new(markdown.into(), language_registry.clone(), None, cx) + }), }); } } @@ -127,9 +124,16 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer { buffer_id: BufferId, snapshot: EditorSnapshot, editor: WeakEntity, + language_registry: Option>, cx: &mut App, ) -> Vec> { - let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx); + let blocks = Self::diagnostic_blocks_for_group( + diagnostic_group, + buffer_id, + None, + language_registry, + cx, + ); blocks .into_iter() @@ -155,9 +159,16 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer { diagnostic_group: Vec>, range: Range, buffer_id: BufferId, + language_registry: Option>, cx: &mut App, ) -> Option> { - let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx); + let blocks = Self::diagnostic_blocks_for_group( + diagnostic_group, + buffer_id, + None, + language_registry, + cx, + ); blocks .into_iter() .find_map(|block| (block.initial_range == range).then(|| block.markdown)) @@ -215,6 +226,11 @@ impl DiagnosticBlock { self.markdown.clone(), diagnostics_markdown_style(bcx.window, cx), ) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) .on_url_click({ move |link, window, cx| { editor @@ -268,7 +284,7 @@ impl DiagnosticBlock { if range.context.overlaps(&diagnostic.range, &snapshot) { Self::jump_to( editor, - Anchor::range_in_buffer(excerpt_id, buffer_id, diagnostic.range), + Anchor::range_in_buffer(excerpt_id, diagnostic.range), window, cx, ); diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 5fbd958141..76edf4f9b4 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -9,7 +9,7 @@ mod diagnostics_tests; use anyhow::Result; use buffer_diagnostics::BufferDiagnosticsEditor; -use collections::{BTreeSet, HashMap}; +use collections::{BTreeSet, HashMap, HashSet}; use diagnostic_renderer::DiagnosticBlock; use editor::{ Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, @@ -17,10 +17,11 @@ use editor::{ multibuffer_context_lines, }; use gpui::{ - AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, - Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, - Subscription, Task, WeakEntity, Window, actions, div, + AnyElement, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, FocusOutEvent, + Focusable, Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + Styled, Subscription, Task, WeakEntity, Window, actions, div, }; +use itertools::Itertools as _; use language::{ Bias, Buffer, BufferRow, BufferSnapshot, DiagnosticEntry, DiagnosticEntryRef, Point, ToTreeSitterPoint, @@ -32,7 +33,7 @@ use project::{ use settings::Settings; use std::{ any::{Any, TypeId}, - cmp::{self, Ordering}, + cmp, ops::{Range, RangeInclusive}, sync::Arc, time::Duration, @@ -72,7 +73,7 @@ pub fn init(cx: &mut App) { } pub(crate) struct ProjectDiagnosticsEditor { - project: Entity, + pub project: Entity, workspace: WeakEntity, focus_handle: FocusHandle, editor: Entity, @@ -89,8 +90,8 @@ pub(crate) struct ProjectDiagnosticsEditor { impl EventEmitter for ProjectDiagnosticsEditor {} -const DIAGNOSTICS_UPDATE_DELAY: Duration = Duration::from_millis(50); -const DIAGNOSTICS_SUMMARY_UPDATE_DELAY: Duration = Duration::from_millis(30); +const DIAGNOSTICS_UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +const DIAGNOSTICS_SUMMARY_UPDATE_DEBOUNCE: Duration = Duration::from_millis(30); impl Render for ProjectDiagnosticsEditor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -149,6 +150,12 @@ impl Render for ProjectDiagnosticsEditor { } } +#[derive(PartialEq, Eq, Copy, Clone, Debug)] +enum RetainExcerpts { + All, + Dirty, +} + impl ProjectDiagnosticsEditor { pub fn register( workspace: &mut Workspace, @@ -165,14 +172,20 @@ impl ProjectDiagnosticsEditor { window: &mut Window, cx: &mut Context, ) -> Self { - let project_event_subscription = - cx.subscribe_in(&project_handle, window, |this, _project, event, window, cx| match event { + let project_event_subscription = cx.subscribe_in( + &project_handle, + window, + |this, _project, event, window, cx| match event { project::Event::DiskBasedDiagnosticsStarted { .. } => { cx.notify(); } project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { log::debug!("disk based diagnostics finished for server {language_server_id}"); - this.update_stale_excerpts(window, cx); + this.close_diagnosticless_buffers( + cx, + this.editor.focus_handle(cx).contains_focused(window, cx) + || this.focus_handle.contains_focused(window, cx), + ); } project::Event::DiagnosticsUpdated { language_server_id, @@ -181,34 +194,29 @@ impl ProjectDiagnosticsEditor { this.paths_to_update.extend(paths.clone()); this.diagnostic_summary_update = cx.spawn(async move |this, cx| { cx.background_executor() - .timer(DIAGNOSTICS_SUMMARY_UPDATE_DELAY) + .timer(DIAGNOSTICS_SUMMARY_UPDATE_DEBOUNCE) .await; this.update(cx, |this, cx| { this.update_diagnostic_summary(cx); }) .log_err(); }); - cx.emit(EditorEvent::TitleChanged); - if this.editor.focus_handle(cx).contains_focused(window, cx) || this.focus_handle.contains_focused(window, cx) { - log::debug!("diagnostics updated for server {language_server_id}, paths {paths:?}. recording change"); - } else { - log::debug!("diagnostics updated for server {language_server_id}, paths {paths:?}. updating excerpts"); - this.update_stale_excerpts(window, cx); - } + log::debug!( + "diagnostics updated for server {language_server_id}, \ + paths {paths:?}. updating excerpts" + ); + this.update_stale_excerpts(window, cx); } _ => {} - }); + }, + ); let focus_handle = cx.focus_handle(); - cx.on_focus_in(&focus_handle, window, |this, window, cx| { - this.focus_in(window, cx) - }) - .detach(); - cx.on_focus_out(&focus_handle, window, |this, _event, window, cx| { - this.focus_out(window, cx) - }) - .detach(); + cx.on_focus_in(&focus_handle, window, Self::focus_in) + .detach(); + cx.on_focus_out(&focus_handle, window, Self::focus_out) + .detach(); let excerpts = cx.new(|cx| MultiBuffer::new(project_handle.read(cx).capability())); let editor = cx.new(|cx| { @@ -238,8 +246,11 @@ impl ProjectDiagnosticsEditor { window.focus(&this.focus_handle); } } - EditorEvent::Blurred => this.update_stale_excerpts(window, cx), - EditorEvent::Saved => this.update_stale_excerpts(window, cx), + EditorEvent::Blurred => this.close_diagnosticless_buffers(cx, false), + EditorEvent::Saved => this.close_diagnosticless_buffers(cx, true), + EditorEvent::SelectionsChanged { .. } => { + this.close_diagnosticless_buffers(cx, true) + } _ => {} } }, @@ -258,8 +269,7 @@ impl ProjectDiagnosticsEditor { cx, ) }); - this.diagnostics.clear(); - this.update_all_excerpts(window, cx); + this.refresh(window, cx); }) .detach(); @@ -279,19 +289,62 @@ impl ProjectDiagnosticsEditor { diagnostic_summary_update: Task::ready(()), _subscription: project_event_subscription, }; - this.update_all_excerpts(window, cx); + this.refresh(window, cx); this } + /// Closes all excerpts of buffers that: + /// - have no diagnostics anymore + /// - are saved (not dirty) + /// - and, if `retain_selections` is true, do not have selections within them + fn close_diagnosticless_buffers(&mut self, cx: &mut Context, retain_selections: bool) { + let snapshot = self + .editor + .update(cx, |editor, cx| editor.display_snapshot(cx)); + let buffer = self.multibuffer.read(cx); + let buffer_ids = buffer.all_buffer_ids(); + let selected_buffers = self.editor.update(cx, |editor, _| { + editor + .selections + .all_anchors(&snapshot) + .iter() + .filter_map(|anchor| anchor.start.text_anchor.buffer_id) + .collect::>() + }); + for buffer_id in buffer_ids { + if retain_selections && selected_buffers.contains(&buffer_id) { + continue; + } + let has_no_blocks = self + .blocks + .get(&buffer_id) + .is_none_or(|blocks| blocks.is_empty()); + if !has_no_blocks { + continue; + } + let is_dirty = self + .multibuffer + .read(cx) + .buffer(buffer_id) + .is_none_or(|buffer| buffer.read(cx).is_dirty()); + if is_dirty { + continue; + } + self.multibuffer.update(cx, |b, cx| { + b.remove_excerpts_for_buffer(buffer_id, cx); + }); + } + } + fn update_stale_excerpts(&mut self, window: &mut Window, cx: &mut Context) { - if self.update_excerpts_task.is_some() || self.multibuffer.read(cx).is_dirty(cx) { + if self.update_excerpts_task.is_some() { return; } let project_handle = self.project.clone(); self.update_excerpts_task = Some(cx.spawn_in(window, async move |this, cx| { cx.background_executor() - .timer(DIAGNOSTICS_UPDATE_DELAY) + .timer(DIAGNOSTICS_UPDATE_DEBOUNCE) .await; loop { let Some(path) = this.update(cx, |this, cx| { @@ -312,7 +365,14 @@ impl ProjectDiagnosticsEditor { .log_err() { this.update_in(cx, |this, window, cx| { - this.update_excerpts(buffer, window, cx) + let focused = this.editor.focus_handle(cx).contains_focused(window, cx) + || this.focus_handle.contains_focused(window, cx); + let retain_excerpts = if focused { + RetainExcerpts::All + } else { + RetainExcerpts::Dirty + }; + this.update_excerpts(buffer, retain_excerpts, window, cx) })? .await?; } @@ -367,7 +427,7 @@ impl ProjectDiagnosticsEditor { if self.update_excerpts_task.is_some() { self.update_excerpts_task = None; } else { - self.update_all_excerpts(window, cx); + self.refresh(window, cx); } cx.notify(); } @@ -378,34 +438,29 @@ impl ProjectDiagnosticsEditor { } } - fn focus_out(&mut self, window: &mut Window, cx: &mut Context) { + fn focus_out(&mut self, _: FocusOutEvent, window: &mut Window, cx: &mut Context) { if !self.focus_handle.is_focused(window) && !self.editor.focus_handle(cx).is_focused(window) { - self.update_stale_excerpts(window, cx); + self.close_diagnosticless_buffers(cx, false); } } - /// Enqueue an update of all excerpts. Updates all paths that either - /// currently have diagnostics or are currently present in this view. - fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context) { + /// Clears all diagnostics in this view, and refetches them from the project. + fn refresh(&mut self, window: &mut Window, cx: &mut Context) { + self.diagnostics.clear(); + self.editor.update(cx, |editor, cx| { + for (_, block_ids) in self.blocks.drain() { + editor.display_map.update(cx, |display_map, cx| { + display_map.remove_blocks(block_ids.into_iter().collect(), cx) + }); + } + }); + self.close_diagnosticless_buffers(cx, false); self.project.update(cx, |project, cx| { - let mut project_paths = project + self.paths_to_update = project .diagnostic_summaries(false, cx) .map(|(project_path, _, _)| project_path) .collect::>(); - - self.multibuffer.update(cx, |multibuffer, cx| { - for buffer in multibuffer.all_buffers() { - if let Some(file) = buffer.read(cx).file() { - project_paths.insert(ProjectPath { - path: file.path().clone(), - worktree_id: file.worktree_id(cx), - }); - } - } - }); - - self.paths_to_update = project_paths; }); self.update_stale_excerpts(window, cx); @@ -431,6 +486,7 @@ impl ProjectDiagnosticsEditor { fn update_excerpts( &mut self, buffer: Entity, + retain_excerpts: RetainExcerpts, window: &mut Window, cx: &mut Context, ) -> Task> { @@ -483,38 +539,62 @@ impl ProjectDiagnosticsEditor { } let mut blocks: Vec = Vec::new(); + let diagnostics_toolbar_editor = Arc::new(this.clone()); for (_, group) in grouped { let group_severity = group.iter().map(|d| d.diagnostic.severity).min(); if group_severity.is_none_or(|s| s > max_severity) { continue; } + let languages = this + .read_with(cx, |t, cx| t.project.read(cx).languages().clone()) + .ok(); let more = cx.update(|_, cx| { crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( group, buffer_snapshot.remote_id(), - Some(Arc::new(this.clone())), + Some(diagnostics_toolbar_editor.clone()), + languages, cx, ) })?; - for item in more { - let i = blocks - .binary_search_by(|probe| { - probe - .initial_range - .start - .cmp(&item.initial_range.start) - .then(probe.initial_range.end.cmp(&item.initial_range.end)) - .then(Ordering::Greater) - }) - .unwrap_or_else(|i| i); - blocks.insert(i, item); - } + blocks.extend(more); } - let mut excerpt_ranges: Vec> = Vec::new(); + let cmp_excerpts = |buffer_snapshot: &BufferSnapshot, + a: &ExcerptRange, + b: &ExcerptRange| { + let context_start = || a.context.start.cmp(&b.context.start, buffer_snapshot); + let context_end = || a.context.end.cmp(&b.context.end, buffer_snapshot); + let primary_start = || a.primary.start.cmp(&b.primary.start, buffer_snapshot); + let primary_end = || a.primary.end.cmp(&b.primary.end, buffer_snapshot); + context_start() + .then_with(context_end) + .then_with(primary_start) + .then_with(primary_end) + .then(cmp::Ordering::Greater) + }; + + let mut excerpt_ranges: Vec> = this.update(cx, |this, cx| { + this.multibuffer.update(cx, |multi_buffer, cx| { + let is_dirty = multi_buffer + .buffer(buffer_id) + .is_none_or(|buffer| buffer.read(cx).is_dirty()); + match retain_excerpts { + RetainExcerpts::Dirty if !is_dirty => Vec::new(), + RetainExcerpts::All | RetainExcerpts::Dirty => multi_buffer + .excerpts_for_buffer(buffer_id, cx) + .into_iter() + .map(|(_, range)| range) + .sorted_by(|a, b| cmp_excerpts(&buffer_snapshot, a, b)) + .collect(), + } + }) + })?; + + let mut result_blocks = vec![None; excerpt_ranges.len()]; let context_lines = cx.update(|_, cx| multibuffer_context_lines(cx))?; - for b in blocks.iter() { + for b in blocks { let excerpt_range = context_range_for_entry( b.initial_range.clone(), context_lines, @@ -522,26 +602,17 @@ impl ProjectDiagnosticsEditor { cx, ) .await; - + let initial_range = buffer_snapshot.anchor_after(b.initial_range.start) + ..buffer_snapshot.anchor_before(b.initial_range.end); + let excerpt_range = ExcerptRange { + context: excerpt_range, + primary: initial_range, + }; let i = excerpt_ranges - .binary_search_by(|probe| { - probe - .context - .start - .cmp(&excerpt_range.start) - .then(probe.context.end.cmp(&excerpt_range.end)) - .then(probe.primary.start.cmp(&b.initial_range.start)) - .then(probe.primary.end.cmp(&b.initial_range.end)) - .then(cmp::Ordering::Greater) - }) + .binary_search_by(|probe| cmp_excerpts(&buffer_snapshot, probe, &excerpt_range)) .unwrap_or_else(|i| i); - excerpt_ranges.insert( - i, - ExcerptRange { - context: excerpt_range, - primary: b.initial_range.clone(), - }, - ) + excerpt_ranges.insert(i, excerpt_range); + result_blocks.insert(i, Some(b)); } this.update_in(cx, |this, window, cx| { @@ -553,6 +624,13 @@ impl ProjectDiagnosticsEditor { }) } let (anchor_ranges, _) = this.multibuffer.update(cx, |multi_buffer, cx| { + let excerpt_ranges = excerpt_ranges + .into_iter() + .map(|range| ExcerptRange { + context: range.context.to_point(&buffer_snapshot), + primary: range.primary.to_point(&buffer_snapshot), + }) + .collect(); multi_buffer.set_excerpt_ranges_for_path( PathKey::for_buffer(&buffer, cx), buffer.clone(), @@ -562,7 +640,7 @@ impl ProjectDiagnosticsEditor { ) }); #[cfg(test)] - let cloned_blocks = blocks.clone(); + let cloned_blocks = result_blocks.clone(); if was_empty && let Some(anchor_range) = anchor_ranges.first() { let range_to_select = anchor_range.start..anchor_range.start; @@ -576,22 +654,20 @@ impl ProjectDiagnosticsEditor { } } - let editor_blocks = - anchor_ranges - .into_iter() - .zip(blocks.into_iter()) - .map(|(anchor, block)| { - let editor = this.editor.downgrade(); - BlockProperties { - placement: BlockPlacement::Near(anchor.start), - height: Some(1), - style: BlockStyle::Flex, - render: Arc::new(move |bcx| { - block.render_block(editor.clone(), bcx) - }), - priority: 1, - } - }); + let editor_blocks = anchor_ranges + .into_iter() + .zip_eq(result_blocks.into_iter()) + .filter_map(|(anchor, block)| { + let block = block?; + let editor = this.editor.downgrade(); + Some(BlockProperties { + placement: BlockPlacement::Near(anchor.start), + height: Some(1), + style: BlockStyle::Flex, + render: Arc::new(move |bcx| block.render_block(editor.clone(), bcx)), + priority: 1, + }) + }); let block_ids = this.editor.update(cx, |editor, cx| { editor.display_map.update(cx, |display_map, cx| { @@ -601,7 +677,9 @@ impl ProjectDiagnosticsEditor { #[cfg(test)] { - for (block_id, block) in block_ids.iter().zip(cloned_blocks.iter()) { + for (block_id, block) in + block_ids.iter().zip(cloned_blocks.into_iter().flatten()) + { let markdown = block.markdown.clone(); editor::test::set_block_content_for_tests( &this.editor, @@ -626,6 +704,7 @@ impl ProjectDiagnosticsEditor { fn update_diagnostic_summary(&mut self, cx: &mut Context) { self.summary = self.project.read(cx).diagnostic_summary(false, cx); + cx.emit(EditorEvent::TitleChanged); } } @@ -727,16 +806,20 @@ impl Item for ProjectDiagnosticsEditor { }); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| { + Task::ready(Some(cx.new(|cx| { ProjectDiagnosticsEditor::new( self.include_warnings, self.project.clone(), @@ -744,7 +827,7 @@ impl Item for ProjectDiagnosticsEditor { window, cx, ) - })) + }))) } fn is_dirty(&self, cx: &App) -> bool { @@ -797,17 +880,17 @@ impl Item for ProjectDiagnosticsEditor { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.clone().into()) } else { None } } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } @@ -839,13 +922,6 @@ impl DiagnosticsToolbarEditor for WeakEntity { .unwrap_or(false) } - fn has_stale_excerpts(&self, cx: &App) -> bool { - self.read_with(cx, |project_diagnostics_editor, _cx| { - !project_diagnostics_editor.paths_to_update.is_empty() - }) - .unwrap_or(false) - } - fn is_updating(&self, cx: &App) -> bool { self.read_with(cx, |project_diagnostics_editor, cx| { project_diagnostics_editor.update_excerpts_task.is_some() @@ -868,7 +944,7 @@ impl DiagnosticsToolbarEditor for WeakEntity { fn refresh_diagnostics(&self, window: &mut Window, cx: &mut App) { let _ = self.update(cx, |project_diagnostics_editor, cx| { - project_diagnostics_editor.update_all_excerpts(window, cx); + project_diagnostics_editor.refresh(window, cx); }); } @@ -900,8 +976,8 @@ async fn context_range_for_entry( context: u32, snapshot: BufferSnapshot, cx: &mut AsyncApp, -) -> Range { - if let Some(rows) = heuristic_syntactic_expand( +) -> Range { + let range = if let Some(rows) = heuristic_syntactic_expand( range.clone(), DIAGNOSTIC_EXPANSION_ROW_LIMIT, snapshot.clone(), @@ -909,15 +985,17 @@ async fn context_range_for_entry( ) .await { - return Range { + Range { start: Point::new(*rows.start(), 0), end: snapshot.clip_point(Point::new(*rows.end(), u32::MAX), Bias::Left), - }; - } - Range { - start: Point::new(range.start.row.saturating_sub(context), 0), - end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left), - } + } + } else { + Range { + start: Point::new(range.start.row.saturating_sub(context), 0), + end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left), + } + }; + snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end) } /// Expands the input range using syntax information from TreeSitter. This expansion will be limited @@ -931,11 +1009,14 @@ async fn heuristic_syntactic_expand( snapshot: BufferSnapshot, cx: &mut AsyncApp, ) -> Option> { + let start = snapshot.clip_point(input_range.start, Bias::Right); + let end = snapshot.clip_point(input_range.end, Bias::Left); let input_row_count = input_range.end.row - input_range.start.row; if input_row_count > max_row_count { return None; } + let input_range = start..end; // If the outline node contains the diagnostic and is small enough, just use that. let outline_range = snapshot.outline_range_containing(input_range.clone()); if let Some(outline_range) = outline_range.clone() { @@ -964,53 +1045,47 @@ async fn heuristic_syntactic_expand( let node_range = node_start..node_end; let row_count = node_end.row - node_start.row + 1; let mut ancestor_range = None; - let reached_outline_node = cx.background_executor().scoped({ - let node_range = node_range.clone(); - let outline_range = outline_range.clone(); - let ancestor_range = &mut ancestor_range; - |scope| {scope.spawn(async move { - // Stop if we've exceeded the row count or reached an outline node. Then, find the interval - // of node children which contains the query range. For example, this allows just returning - // the header of a declaration rather than the entire declaration. - if row_count > max_row_count || outline_range == Some(node_range.clone()) { - let mut cursor = node.walk(); - let mut included_child_start = None; - let mut included_child_end = None; - let mut previous_end = node_start; - if cursor.goto_first_child() { - loop { - let child_node = cursor.node(); - let child_range = previous_end..Point::from_ts_point(child_node.end_position()); - if included_child_start.is_none() && child_range.contains(&input_range.start) { - included_child_start = Some(child_range.start); - } - if child_range.contains(&input_range.end) { - included_child_end = Some(child_range.end); - } - previous_end = child_range.end; - if !cursor.goto_next_sibling() { - break; - } + cx.background_executor() + .await_on_background(async { + // Stop if we've exceeded the row count or reached an outline node. Then, find the interval + // of node children which contains the query range. For example, this allows just returning + // the header of a declaration rather than the entire declaration. + if row_count > max_row_count || outline_range == Some(node_range.clone()) { + let mut cursor = node.walk(); + let mut included_child_start = None; + let mut included_child_end = None; + let mut previous_end = node_start; + if cursor.goto_first_child() { + loop { + let child_node = cursor.node(); + let child_range = + previous_end..Point::from_ts_point(child_node.end_position()); + if included_child_start.is_none() + && child_range.contains(&input_range.start) + { + included_child_start = Some(child_range.start); + } + if child_range.contains(&input_range.end) { + included_child_end = Some(child_range.end); + } + previous_end = child_range.end; + if !cursor.goto_next_sibling() { + break; } } - let end = included_child_end.unwrap_or(node_range.end); - if let Some(start) = included_child_start { - let row_count = end.row - start.row; - if row_count < max_row_count { - *ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); - return; - } - } - - log::info!( - "Expanding to ancestor started on {} node exceeding row limit of {max_row_count}.", - node.grammar_name() - ); - *ancestor_range = Some(None); } - }) - }}); - reached_outline_node.await; + let end = included_child_end.unwrap_or(node_range.end); + if let Some(start) = included_child_start { + let row_count = end.row - start.row; + if row_count < max_row_count { + ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); + return; + } + } + ancestor_range = Some(None); + } + }) + .await; if let Some(node) = ancestor_range { return node; } diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 2d86361df0..d2504fde4a 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -1,9 +1,9 @@ use super::*; use collections::{HashMap, HashSet}; use editor::{ - DisplayPoint, EditorSettings, + DisplayPoint, EditorSettings, Inlay, MultiBufferOffset, actions::{GoToDiagnostic, GoToPreviousDiagnostic, Hover, MoveToBeginning}, - display_map::{DisplayRow, Inlay}, + display_map::DisplayRow, test::{ editor_content_with_blocks, editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext, @@ -119,7 +119,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { let editor = diagnostics.update(cx, |diagnostics, _| diagnostics.editor.clone()); diagnostics - .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx) + .next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) .await; pretty_assertions::assert_eq!( @@ -156,7 +156,9 @@ async fn test_diagnostics(cx: &mut TestAppContext) { // Cursor is at the first diagnostic editor.update(cx, |editor, cx| { assert_eq!( - editor.selections.display_ranges(cx), + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), [DisplayPoint::new(DisplayRow(3), 8)..DisplayPoint::new(DisplayRow(3), 8)] ); }); @@ -190,7 +192,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { }); diagnostics - .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx) + .next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) .await; pretty_assertions::assert_eq!( @@ -232,7 +234,9 @@ async fn test_diagnostics(cx: &mut TestAppContext) { // Cursor keeps its position. editor.update(cx, |editor, cx| { assert_eq!( - editor.selections.display_ranges(cx), + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), [DisplayPoint::new(DisplayRow(8), 8)..DisplayPoint::new(DisplayRow(8), 8)] ); }); @@ -277,7 +281,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { }); diagnostics - .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx) + .next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) .await; pretty_assertions::assert_eq!( @@ -391,7 +395,7 @@ async fn test_diagnostics_with_folds(cx: &mut TestAppContext) { // Only the first language server's diagnostics are shown. cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); editor.update_in(cx, |editor, window, cx| { editor.fold_ranges(vec![Point::new(0, 0)..Point::new(3, 0)], false, window, cx); @@ -490,7 +494,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { // Only the first language server's diagnostics are shown. cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); pretty_assertions::assert_eq!( @@ -530,7 +534,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { // Both language server's diagnostics are shown. cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); pretty_assertions::assert_eq!( @@ -587,7 +591,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { // Only the first language server's diagnostics are updated. cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); pretty_assertions::assert_eq!( @@ -629,7 +633,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { // Both language servers' diagnostics are updated. cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); pretty_assertions::assert_eq!( @@ -760,7 +764,7 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng .unwrap() }); cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.run_until_parked(); } @@ -777,7 +781,7 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) }); cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.run_until_parked(); let mutated_excerpts = @@ -789,7 +793,12 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng // The mutated view may contain more than the reference view as // we don't currently shrink excerpts when diagnostics were removed. - let mut ref_iter = reference_excerpts.lines().filter(|line| *line != "§ -----"); + let mut ref_iter = reference_excerpts.lines().filter(|line| { + // ignore $ ---- and $ .rs + !line.starts_with('§') + || line.starts_with("§ diagnostic") + || line.starts_with("§ related info") + }); let mut next_ref_line = ref_iter.next(); let mut skipped_block = false; @@ -797,7 +806,12 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng if let Some(ref_line) = next_ref_line { if mut_line == ref_line { next_ref_line = ref_iter.next(); - } else if mut_line.contains('§') && mut_line != "§ -----" { + } else if mut_line.contains('§') + // ignore $ ---- and $ .rs + && (!mut_line.starts_with('§') + || mut_line.starts_with("§ diagnostic") + || mut_line.starts_with("§ related info")) + { skipped_block = true; } } @@ -864,7 +878,8 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S diagnostics.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); if !snapshot.buffer_snapshot().is_empty() { - let position = rng.random_range(0..snapshot.buffer_snapshot().len()); + let position = rng + .random_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()); let position = snapshot.buffer_snapshot().clip_offset(position, Bias::Left); log::info!( "adding inlay at {position}/{}: {:?}", @@ -949,7 +964,7 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S .unwrap() }); cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.run_until_parked(); } @@ -962,7 +977,7 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S }); cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.run_until_parked(); } @@ -1341,7 +1356,7 @@ async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) range: Some(range), })) }); - let delay = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay + 1); + let delay = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay.0 + 1); cx.background_executor .advance_clock(Duration::from_millis(delay)); @@ -1427,7 +1442,7 @@ async fn test_diagnostics_with_code(cx: &mut TestAppContext) { let editor = diagnostics.update(cx, |diagnostics, _| diagnostics.editor.clone()); diagnostics - .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx) + .next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) .await; // Verify that the diagnostic codes are displayed correctly @@ -1704,7 +1719,7 @@ async fn test_buffer_diagnostics(cx: &mut TestAppContext) { // wait a little bit to ensure that the buffer diagnostic's editor content // is rendered. cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), @@ -1837,7 +1852,7 @@ async fn test_buffer_diagnostics_without_warnings(cx: &mut TestAppContext) { // wait a little bit to ensure that the buffer diagnostic's editor content // is rendered. cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), @@ -1971,7 +1986,7 @@ async fn test_buffer_diagnostics_multiple_servers(cx: &mut TestAppContext) { // wait a little bit to ensure that the buffer diagnostic's editor content // is rendered. cx.executor() - .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), @@ -2007,10 +2022,6 @@ fn init_test(cx: &mut TestAppContext) { let settings = SettingsStore::test(cx); cx.set_global(settings); theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); - client::init_settings(cx); - workspace::init_settings(cx); - Project::init_settings(cx); crate::init(cx); editor::init(cx); }); diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index afbef4427f..b4ca52ea72 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use editor::Editor; +use editor::{Editor, MultiBufferOffset}; use gpui::{ Context, Entity, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, Task, WeakEntity, Window, @@ -30,7 +30,7 @@ impl Render for DiagnosticIndicator { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let indicator = h_flex().gap_2(); if !ProjectSettings::get_global(cx).diagnostics.button { - return indicator; + return indicator.hidden(); } let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) { @@ -67,11 +67,10 @@ impl Render for DiagnosticIndicator { Some( Button::new("diagnostic_message", SharedString::new(message)) .label_size(LabelSize::Small) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::for_action( "Next Diagnostic", &editor::actions::GoToDiagnostic::default(), - window, cx, ) }) @@ -87,8 +86,8 @@ impl Render for DiagnosticIndicator { .child( ButtonLike::new("diagnostic-indicator") .child(diagnostic_indicator) - .tooltip(|window, cx| { - Tooltip::for_action("Project Diagnostics", &Deploy, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action("Project Diagnostics", &Deploy, cx) }) .on_click(cx.listener(|this, _, window, cx| { if let Some(workspace) = this.workspace.upgrade() { @@ -170,13 +169,21 @@ impl DiagnosticIndicator { fn update(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { let (buffer, cursor_position) = editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); - let cursor_position = editor.selections.newest::(cx).head(); + let cursor_position = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); (buffer, cursor_position) }); let new_diagnostic = buffer - .diagnostics_in_range::(cursor_position..cursor_position) + .diagnostics_in_range::(cursor_position..cursor_position) .filter(|entry| !entry.range.is_empty()) - .min_by_key(|entry| (entry.diagnostic.severity, entry.range.len())) + .min_by_key(|entry| { + ( + entry.diagnostic.severity, + entry.range.end - entry.range.start, + ) + }) .map(|entry| entry.diagnostic); if new_diagnostic != self.current_diagnostic.as_ref() { let new_diagnostic = new_diagnostic.cloned(); diff --git a/crates/diagnostics/src/toolbar_controls.rs b/crates/diagnostics/src/toolbar_controls.rs index b55fa5783d..2ba64d39df 100644 --- a/crates/diagnostics/src/toolbar_controls.rs +++ b/crates/diagnostics/src/toolbar_controls.rs @@ -16,9 +16,6 @@ pub(crate) trait DiagnosticsToolbarEditor: Send + Sync { /// Toggles whether warning diagnostics should be displayed by the /// diagnostics editor. fn toggle_warnings(&self, window: &mut Window, cx: &mut App); - /// Indicates whether any of the excerpts displayed by the diagnostics - /// editor are stale. - fn has_stale_excerpts(&self, cx: &App) -> bool; /// Indicates whether the diagnostics editor is currently updating the /// diagnostics. fn is_updating(&self, cx: &App) -> bool; @@ -37,14 +34,12 @@ pub(crate) trait DiagnosticsToolbarEditor: Send + Sync { impl Render for ToolbarControls { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let mut has_stale_excerpts = false; let mut include_warnings = false; let mut is_updating = false; match &self.editor { Some(editor) => { include_warnings = editor.include_warnings(cx); - has_stale_excerpts = editor.has_stale_excerpts(cx); is_updating = editor.is_updating(cx); } None => {} @@ -86,7 +81,6 @@ impl Render for ToolbarControls { IconButton::new("refresh-diagnostics", IconName::ArrowCircle) .icon_color(Color::Info) .shape(IconButtonShape::Square) - .disabled(!has_stale_excerpts) .tooltip(Tooltip::for_action_title( "Refresh diagnostics", &ToggleDiagnosticsRefresh, diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index e46ceb18db..e71f9ae3f3 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -17,9 +17,10 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true util.workspace = true -workspace-hack.workspace = true zed.workspace = true zlog.workspace = true +task.workspace = true +theme.workspace = true [lints] workspace = true diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index e8f81812cc..b614a82511 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -53,9 +53,20 @@ fn main() -> Result<()> { #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum PreprocessorError { - ActionNotFound { action_name: String }, - DeprecatedActionUsed { used: String, should_be: String }, + ActionNotFound { + action_name: String, + }, + DeprecatedActionUsed { + used: String, + should_be: String, + }, InvalidFrontmatterLine(String), + InvalidSettingsJson { + file: std::path::PathBuf, + line: usize, + snippet: String, + error: String, + }, } impl PreprocessorError { @@ -72,6 +83,20 @@ impl PreprocessorError { } PreprocessorError::ActionNotFound { action_name } } + + fn new_for_invalid_settings_json( + chapter: &Chapter, + location: usize, + snippet: String, + error: String, + ) -> Self { + PreprocessorError::InvalidSettingsJson { + file: chapter.path.clone().expect("chapter has path"), + line: chapter.content[..location].lines().count() + 1, + snippet, + error, + } + } } impl std::fmt::Display for PreprocessorError { @@ -88,6 +113,21 @@ impl std::fmt::Display for PreprocessorError { "Deprecated action used: {} should be {}", used, should_be ), + PreprocessorError::InvalidSettingsJson { + file, + line, + snippet, + error, + } => { + write!( + f, + "Invalid settings JSON at {}:{}\nError: {}\n\n{}", + file.display(), + line, + error, + snippet + ) + } } } } @@ -100,11 +140,11 @@ fn handle_preprocessing() -> Result<()> { let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?; let mut errors = HashSet::::new(); - handle_frontmatter(&mut book, &mut errors); template_big_table_of_actions(&mut book); template_and_validate_keybindings(&mut book, &mut errors); template_and_validate_actions(&mut book, &mut errors); + template_and_validate_json_snippets(&mut book, &mut errors); if !errors.is_empty() { const ANSI_RED: &str = "\x1b[31m"; @@ -163,6 +203,10 @@ fn template_big_table_of_actions(book: &mut Book) { }); } +fn format_binding(binding: String) -> String { + binding.replace("\\", "\\\\") +} + fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { let regex = Regex::new(r"\{#kb (.*?)\}").unwrap(); @@ -183,7 +227,10 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSetNo default binding
".to_string(); } - format!("{macos_binding}|{linux_binding}") + let formatted_macos_binding = format_binding(macos_binding); + let formatted_linux_binding = format_binding(linux_binding); + + format!("{formatted_macos_binding}|{formatted_linux_binding}") }) .into_owned() }); @@ -235,6 +282,161 @@ fn find_binding(os: &str, action: &str) -> Option { }) } +fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { + fn for_each_labeled_code_block_mut( + book: &mut Book, + errors: &mut HashSet, + f: impl Fn(&str, &str) -> anyhow::Result<()>, + ) { + const TAGGED_JSON_BLOCK_START: &'static str = "```json ["; + const JSON_BLOCK_END: &'static str = "```"; + + for_each_chapter_mut(book, |chapter| { + let mut offset = 0; + while let Some(loc) = chapter.content[offset..].find(TAGGED_JSON_BLOCK_START) { + let loc = loc + offset; + let tag_start = loc + TAGGED_JSON_BLOCK_START.len(); + offset = tag_start; + let Some(tag_end) = chapter.content[tag_start..].find(']') else { + errors.insert(PreprocessorError::new_for_invalid_settings_json( + chapter, + loc, + chapter.content[loc..tag_start].to_string(), + "Unclosed JSON block tag".to_string(), + )); + continue; + }; + let tag_end = tag_end + tag_start; + + let tag = &chapter.content[tag_start..tag_end]; + + if tag.contains('\n') { + errors.insert(PreprocessorError::new_for_invalid_settings_json( + chapter, + loc, + chapter.content[loc..tag_start].to_string(), + "Unclosed JSON block tag".to_string(), + )); + continue; + } + + let snippet_start = tag_end + 1; + offset = snippet_start; + + let Some(snippet_end) = chapter.content[snippet_start..].find(JSON_BLOCK_END) + else { + errors.insert(PreprocessorError::new_for_invalid_settings_json( + chapter, + loc, + chapter.content[loc..tag_end + 1].to_string(), + "Missing closing code block".to_string(), + )); + continue; + }; + let snippet_end = snippet_start + snippet_end; + let snippet_json = &chapter.content[snippet_start..snippet_end]; + offset = snippet_end + 3; + + if let Err(err) = f(tag, snippet_json) { + errors.insert(PreprocessorError::new_for_invalid_settings_json( + chapter, + loc, + chapter.content[loc..snippet_end + 3].to_string(), + err.to_string(), + )); + continue; + }; + let tag_range_complete = tag_start - 1..tag_end + 1; + offset -= tag_range_complete.len(); + chapter.content.replace_range(tag_range_complete, ""); + } + }); + } + + for_each_labeled_code_block_mut(book, errors, |label, snippet_json| { + let mut snippet_json_fixed = snippet_json + .to_string() + .replace("\n>", "\n") + .trim() + .to_string(); + while snippet_json_fixed.starts_with("//") { + if let Some(line_end) = snippet_json_fixed.find('\n') { + snippet_json_fixed.replace_range(0..line_end, ""); + snippet_json_fixed = snippet_json_fixed.trim().to_string(); + } + } + match label { + "settings" => { + if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') { + snippet_json_fixed.insert(0, '{'); + snippet_json_fixed.push_str("\n}"); + } + settings::parse_json_with_comments::( + &snippet_json_fixed, + )?; + } + "keymap" => { + if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') { + snippet_json_fixed.insert(0, '['); + snippet_json_fixed.push_str("\n]"); + } + + let keymap = settings::KeymapFile::parse(&snippet_json_fixed) + .context("Failed to parse keymap JSON")?; + for section in keymap.sections() { + for (keystrokes, action) in section.bindings() { + keystrokes + .split_whitespace() + .map(|source| gpui::Keystroke::parse(source)) + .collect::, _>>() + .context("Failed to parse keystroke")?; + if let Some((action_name, _)) = settings::KeymapFile::parse_action(action) + .map_err(|err| anyhow::format_err!(err)) + .context("Failed to parse action")? + { + anyhow::ensure!( + find_action_by_name(action_name).is_some(), + "Action not found: {}", + action_name + ); + } + } + } + } + "debug" => { + if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') { + snippet_json_fixed.insert(0, '['); + snippet_json_fixed.push_str("\n]"); + } + + settings::parse_json_with_comments::(&snippet_json_fixed)?; + } + "tasks" => { + if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') { + snippet_json_fixed.insert(0, '['); + snippet_json_fixed.push_str("\n]"); + } + + settings::parse_json_with_comments::(&snippet_json_fixed)?; + } + "icon-theme" => { + if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') { + snippet_json_fixed.insert(0, '{'); + snippet_json_fixed.push_str("\n}"); + } + + settings::parse_json_with_comments::( + &snippet_json_fixed, + )?; + } + label => { + anyhow::bail!("Unexpected JSON code block tag: {}", label) + } + }; + Ok(()) + }); +} + /// Removes any configurable options from the stringified action if existing, /// ensuring that only the actual action name is returned. If the action consists /// only of a string and nothing else, the string is returned as-is. @@ -334,6 +536,7 @@ fn handle_postprocessing() -> Result<()> { .as_str() .expect("Default title not a string") .to_string(); + let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default(); output.insert("html".to_string(), zed_html); mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?; @@ -402,6 +605,7 @@ fn handle_postprocessing() -> Result<()> { let meta_title = format!("{} | {}", page_title, meta_title); zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir)); let contents = contents.replace("#description#", meta_description); + let contents = contents.replace("#amplitude_key#", &litude_key); let contents = title_regex() .replace(&contents, |_: ®ex::Captures| { format!("{}", meta_title) diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 0195bdb06d..2d5fb36a58 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -11,8 +11,67 @@ workspace = true [lib] path = "src/edit_prediction.rs" +[features] +cli-support = [] + [dependencies] +ai_onboarding.workspace = true +anyhow.workspace = true +arrayvec.workspace = true +brotli.workspace = true client.workspace = true +cloud_llm_client.workspace = true +collections.workspace = true +copilot.workspace = true +db.workspace = true +edit_prediction_types.workspace = true +edit_prediction_context.workspace = true +feature_flags.workspace = true +fs.workspace = true +futures.workspace = true gpui.workspace = true +indoc.workspace = true +itertools.workspace = true language.workspace = true -workspace-hack.workspace = true +language_model.workspace = true +log.workspace = true +lsp.workspace = true +menu.workspace = true +open_ai.workspace = true +postage.workspace = true +pretty_assertions.workspace = true +project.workspace = true +pulldown-cmark.workspace = true +rand.workspace = true +regex.workspace = true +release_channel.workspace = true +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +strum.workspace = true +telemetry.workspace = true +telemetry_events.workspace = true +thiserror.workspace = true +ui.workspace = true +util.workspace = true +uuid.workspace = true +workspace.workspace = true +worktree.workspace = true +zed_actions.workspace = true +zeta_prompt.workspace = true + +[dev-dependencies] +clock = { workspace = true, features = ["test-support"] } +cloud_api_types.workspace = true +cloud_llm_client = { workspace = true, features = ["test-support"] } +ctor.workspace = true +gpui = { workspace = true, features = ["test-support"] } +indoc.workspace = true +language = { workspace = true, features = ["test-support"] } +language_model = { workspace = true, features = ["test-support"] } +lsp.workspace = true +parking_lot.workspace = true +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/zeta/license_examples/0bsd.txt b/crates/edit_prediction/license_examples/0bsd.txt similarity index 100% rename from crates/zeta/license_examples/0bsd.txt rename to crates/edit_prediction/license_examples/0bsd.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex0.txt b/crates/edit_prediction/license_examples/apache-2.0-ex0.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex0.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex0.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex1.txt b/crates/edit_prediction/license_examples/apache-2.0-ex1.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex1.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex1.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex2.txt b/crates/edit_prediction/license_examples/apache-2.0-ex2.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex2.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex2.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex3.txt b/crates/edit_prediction/license_examples/apache-2.0-ex3.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex3.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex3.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex4.txt b/crates/edit_prediction/license_examples/apache-2.0-ex4.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex4.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex4.txt diff --git a/crates/zeta/license_examples/bsd-1-clause.txt b/crates/edit_prediction/license_examples/bsd-1-clause.txt similarity index 100% rename from crates/zeta/license_examples/bsd-1-clause.txt rename to crates/edit_prediction/license_examples/bsd-1-clause.txt diff --git a/crates/zeta/license_examples/bsd-2-clause-ex0.txt b/crates/edit_prediction/license_examples/bsd-2-clause-ex0.txt similarity index 100% rename from crates/zeta/license_examples/bsd-2-clause-ex0.txt rename to crates/edit_prediction/license_examples/bsd-2-clause-ex0.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex0.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex0.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex0.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex0.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex1.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex1.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex1.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex1.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex2.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex2.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex2.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex2.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex3.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex3.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex3.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex3.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex4.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex4.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex4.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex4.txt diff --git a/crates/zeta/license_examples/isc.txt b/crates/edit_prediction/license_examples/isc.txt similarity index 100% rename from crates/zeta/license_examples/isc.txt rename to crates/edit_prediction/license_examples/isc.txt diff --git a/crates/zeta/license_examples/mit-ex0.txt b/crates/edit_prediction/license_examples/mit-ex0.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex0.txt rename to crates/edit_prediction/license_examples/mit-ex0.txt diff --git a/crates/zeta/license_examples/mit-ex1.txt b/crates/edit_prediction/license_examples/mit-ex1.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex1.txt rename to crates/edit_prediction/license_examples/mit-ex1.txt diff --git a/crates/zeta/license_examples/mit-ex2.txt b/crates/edit_prediction/license_examples/mit-ex2.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex2.txt rename to crates/edit_prediction/license_examples/mit-ex2.txt diff --git a/crates/zeta/license_examples/mit-ex3.txt b/crates/edit_prediction/license_examples/mit-ex3.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex3.txt rename to crates/edit_prediction/license_examples/mit-ex3.txt diff --git a/crates/zeta/license_examples/upl-1.0.txt b/crates/edit_prediction/license_examples/upl-1.0.txt similarity index 100% rename from crates/zeta/license_examples/upl-1.0.txt rename to crates/edit_prediction/license_examples/upl-1.0.txt diff --git a/crates/zeta/license_examples/zlib-ex0.txt b/crates/edit_prediction/license_examples/zlib-ex0.txt similarity index 100% rename from crates/zeta/license_examples/zlib-ex0.txt rename to crates/edit_prediction/license_examples/zlib-ex0.txt diff --git a/crates/zeta/license_patterns/0bsd-pattern b/crates/edit_prediction/license_patterns/0bsd-pattern similarity index 100% rename from crates/zeta/license_patterns/0bsd-pattern rename to crates/edit_prediction/license_patterns/0bsd-pattern diff --git a/crates/zeta/license_patterns/apache-2.0-pattern b/crates/edit_prediction/license_patterns/apache-2.0-pattern similarity index 100% rename from crates/zeta/license_patterns/apache-2.0-pattern rename to crates/edit_prediction/license_patterns/apache-2.0-pattern diff --git a/crates/zeta/license_patterns/apache-2.0-reference-pattern b/crates/edit_prediction/license_patterns/apache-2.0-reference-pattern similarity index 100% rename from crates/zeta/license_patterns/apache-2.0-reference-pattern rename to crates/edit_prediction/license_patterns/apache-2.0-reference-pattern diff --git a/crates/zeta/license_patterns/bsd-pattern b/crates/edit_prediction/license_patterns/bsd-pattern similarity index 100% rename from crates/zeta/license_patterns/bsd-pattern rename to crates/edit_prediction/license_patterns/bsd-pattern diff --git a/crates/zeta/license_patterns/isc-pattern b/crates/edit_prediction/license_patterns/isc-pattern similarity index 100% rename from crates/zeta/license_patterns/isc-pattern rename to crates/edit_prediction/license_patterns/isc-pattern diff --git a/crates/zeta/license_patterns/mit-pattern b/crates/edit_prediction/license_patterns/mit-pattern similarity index 100% rename from crates/zeta/license_patterns/mit-pattern rename to crates/edit_prediction/license_patterns/mit-pattern diff --git a/crates/zeta/license_patterns/upl-1.0-pattern b/crates/edit_prediction/license_patterns/upl-1.0-pattern similarity index 100% rename from crates/zeta/license_patterns/upl-1.0-pattern rename to crates/edit_prediction/license_patterns/upl-1.0-pattern diff --git a/crates/zeta/license_patterns/zlib-pattern b/crates/edit_prediction/license_patterns/zlib-pattern similarity index 100% rename from crates/zeta/license_patterns/zlib-pattern rename to crates/edit_prediction/license_patterns/zlib-pattern diff --git a/crates/edit_prediction/src/cursor_excerpt.rs b/crates/edit_prediction/src/cursor_excerpt.rs new file mode 100644 index 0000000000..1f2f1d32eb --- /dev/null +++ b/crates/edit_prediction/src/cursor_excerpt.rs @@ -0,0 +1,78 @@ +use language::{BufferSnapshot, Point}; +use std::ops::Range; + +pub fn editable_and_context_ranges_for_cursor_position( + position: Point, + snapshot: &BufferSnapshot, + editable_region_token_limit: usize, + context_token_limit: usize, +) -> (Range, Range) { + let mut scope_range = position..position; + let mut remaining_edit_tokens = editable_region_token_limit; + + while let Some(parent) = snapshot.syntax_ancestor(scope_range.clone()) { + let parent_tokens = guess_token_count(parent.byte_range().len()); + let parent_point_range = Point::new( + parent.start_position().row as u32, + parent.start_position().column as u32, + ) + ..Point::new( + parent.end_position().row as u32, + parent.end_position().column as u32, + ); + if parent_point_range == scope_range { + break; + } else if parent_tokens <= editable_region_token_limit { + scope_range = parent_point_range; + remaining_edit_tokens = editable_region_token_limit - parent_tokens; + } else { + break; + } + } + + let editable_range = expand_range(snapshot, scope_range, remaining_edit_tokens); + let context_range = expand_range(snapshot, editable_range.clone(), context_token_limit); + (editable_range, context_range) +} + +fn expand_range( + snapshot: &BufferSnapshot, + range: Range, + mut remaining_tokens: usize, +) -> Range { + let mut expanded_range = range; + expanded_range.start.column = 0; + expanded_range.end.column = snapshot.line_len(expanded_range.end.row); + loop { + let mut expanded = false; + + if remaining_tokens > 0 && expanded_range.start.row > 0 { + expanded_range.start.row -= 1; + let line_tokens = + guess_token_count(snapshot.line_len(expanded_range.start.row) as usize); + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + + if remaining_tokens > 0 && expanded_range.end.row < snapshot.max_point().row { + expanded_range.end.row += 1; + expanded_range.end.column = snapshot.line_len(expanded_range.end.row); + let line_tokens = guess_token_count(expanded_range.end.column as usize); + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + + if !expanded { + break; + } + } + expanded_range +} + +/// Typical number of string bytes per token for the purposes of limiting model input. This is +/// intentionally low to err on the side of underestimating limits. +pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3; + +pub fn guess_token_count(bytes: usize) -> usize { + bytes / BYTES_PER_TOKEN_GUESS +} diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 90cad9f922..f5ea7590fc 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,244 +1,2132 @@ +use anyhow::Result; +use arrayvec::ArrayVec; +use client::{Client, EditPredictionUsage, UserStore}; +use cloud_llm_client::predict_edits_v3::{self, PromptFormat}; +use cloud_llm_client::{ + AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, EditPredictionRejectReason, + EditPredictionRejection, MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, + MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBodyRef, + ZED_VERSION_HEADER_NAME, +}; +use collections::{HashMap, HashSet}; +use db::kvp::{Dismissable, KEY_VALUE_STORE}; +use edit_prediction_context::EditPredictionExcerptOptions; +use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use futures::{ + AsyncReadExt as _, FutureExt as _, StreamExt as _, + channel::mpsc::{self, UnboundedReceiver}, + select_biased, +}; +use gpui::BackgroundExecutor; +use gpui::http_client::Url; +use gpui::{ + App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, + http_client::{self, AsyncBody, Method}, + prelude::*, +}; +use language::language_settings::all_language_settings; +use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToPoint}; +use language::{BufferSnapshot, OffsetRangeExt}; +use language_model::{LlmApiToken, RefreshLlmTokenListener}; +use project::{Project, ProjectPath, WorktreeId}; +use release_channel::AppVersion; +use semver::Version; +use serde::de::DeserializeOwned; +use settings::{EditPredictionProvider, SettingsStore, update_settings_file}; +use std::collections::{VecDeque, hash_map}; +use workspace::Workspace; + use std::ops::Range; +use std::path::Path; +use std::rc::Rc; +use std::str::FromStr as _; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; +use std::{env, mem}; +use thiserror::Error; +use util::{RangeExt as _, ResultExt as _}; +use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; -use client::EditPredictionUsage; -use gpui::{App, Context, Entity, SharedString}; -use language::Buffer; +pub mod cursor_excerpt; +pub mod example_spec; +mod license_detection; +pub mod mercury; +mod onboarding_modal; +pub mod open_ai_response; +mod prediction; +pub mod sweep_ai; -// TODO: Find a better home for `Direction`. -// -// This should live in an ancestor crate of `editor` and `edit_prediction`, -// but at time of writing there isn't an obvious spot. -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum Direction { - Prev, - Next, +#[cfg(any(test, feature = "test-support", feature = "cli-support"))] +pub mod udiff; + +mod zed_edit_prediction_delegate; +pub mod zeta1; +pub mod zeta2; + +#[cfg(test)] +mod edit_prediction_tests; + +use crate::license_detection::LicenseDetectionWatcher; +use crate::mercury::Mercury; +use crate::onboarding_modal::ZedPredictModal; +pub use crate::prediction::EditPrediction; +pub use crate::prediction::EditPredictionId; +use crate::prediction::EditPredictionResult; +pub use crate::sweep_ai::SweepAi; +pub use language_model::ApiKeyState; +pub use telemetry_events::EditPredictionRating; +pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate; + +actions!( + edit_prediction, + [ + /// Resets the edit prediction onboarding state. + ResetOnboarding, + /// Clears the edit prediction history. + ClearHistory, + ] +); + +/// Maximum number of events to track. +const EVENT_COUNT_MAX: usize = 6; +const CHANGE_GROUPING_LINE_SPAN: u32 = 8; +const LAST_CHANGE_GROUPING_TIME: Duration = Duration::from_secs(1); +const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; +const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); + +pub struct SweepFeatureFlag; + +impl FeatureFlag for SweepFeatureFlag { + const NAME: &str = "sweep-ai"; +} + +pub struct MercuryFeatureFlag; + +impl FeatureFlag for MercuryFeatureFlag { + const NAME: &str = "mercury"; +} + +pub const DEFAULT_OPTIONS: ZetaOptions = ZetaOptions { + context: EditPredictionExcerptOptions { + max_bytes: 512, + min_bytes: 128, + target_before_cursor_over_total_bytes: 0.5, + }, + prompt_format: PromptFormat::DEFAULT, +}; + +static USE_OLLAMA: LazyLock = + LazyLock::new(|| env::var("ZED_ZETA2_OLLAMA").is_ok_and(|var| !var.is_empty())); + +static EDIT_PREDICTIONS_MODEL_ID: LazyLock = LazyLock::new(|| { + match env::var("ZED_ZETA2_MODEL").as_deref() { + Ok("zeta2-exp") => "4w5n28vw", // Fine-tuned model @ Baseten + Ok(model) => model, + Err(_) if *USE_OLLAMA => "qwen3-coder:30b", + Err(_) => "yqvev8r3", // Vanilla qwen3-coder @ Baseten + } + .to_string() +}); + +pub struct Zeta2FeatureFlag; + +impl FeatureFlag for Zeta2FeatureFlag { + const NAME: &'static str = "zeta2"; + + fn enabled_for_staff() -> bool { + true + } } #[derive(Clone)] -pub enum EditPrediction { - /// Edits within the buffer that requested the prediction - Local { - id: Option, - edits: Vec<(Range, String)>, - edit_preview: Option, - }, - /// Jump to a different file from the one that requested the prediction - Jump { - id: Option, - snapshot: language::BufferSnapshot, - target: language::Anchor, - }, +struct EditPredictionStoreGlobal(Entity); + +impl Global for EditPredictionStoreGlobal {} + +pub struct EditPredictionStore { + client: Arc, + user_store: Entity, + llm_token: LlmApiToken, + _llm_token_subscription: Subscription, + projects: HashMap, + use_context: bool, + options: ZetaOptions, + update_required: bool, + #[cfg(feature = "cli-support")] + eval_cache: Option>, + edit_prediction_model: EditPredictionModel, + pub sweep_ai: SweepAi, + pub mercury: Mercury, + data_collection_choice: DataCollectionChoice, + reject_predictions_tx: mpsc::UnboundedSender, + shown_predictions: VecDeque, + rated_predictions: HashSet, + custom_predict_edits_url: Option>, } -pub enum DataCollectionState { - /// The provider doesn't support data collection. - Unsupported, - /// Data collection is enabled. - Enabled { is_project_open_source: bool }, - /// Data collection is disabled or unanswered. - Disabled { is_project_open_source: bool }, +#[derive(Copy, Clone, Default, PartialEq, Eq)] +pub enum EditPredictionModel { + #[default] + Zeta1, + Zeta2, + Sweep, + Mercury, } -impl DataCollectionState { - pub fn is_supported(&self) -> bool { - !matches!(self, DataCollectionState::Unsupported) +pub struct EditPredictionModelInput { + project: Entity, + buffer: Entity, + snapshot: BufferSnapshot, + position: Anchor, + events: Vec>, + related_files: Arc<[RelatedFile]>, + recent_paths: VecDeque, + trigger: PredictEditsRequestTrigger, + diagnostic_search_range: Range, + debug_tx: Option>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ZetaOptions { + pub context: EditPredictionExcerptOptions, + pub prompt_format: predict_edits_v3::PromptFormat, +} + +#[derive(Debug)] +pub enum DebugEvent { + ContextRetrievalStarted(ContextRetrievalStartedDebugEvent), + ContextRetrievalFinished(ContextRetrievalFinishedDebugEvent), + EditPredictionStarted(EditPredictionStartedDebugEvent), + EditPredictionFinished(EditPredictionFinishedDebugEvent), +} + +#[derive(Debug)] +pub struct ContextRetrievalStartedDebugEvent { + pub project_entity_id: EntityId, + pub timestamp: Instant, + pub search_prompt: String, +} + +#[derive(Debug)] +pub struct ContextRetrievalFinishedDebugEvent { + pub project_entity_id: EntityId, + pub timestamp: Instant, + pub metadata: Vec<(&'static str, SharedString)>, +} + +#[derive(Debug)] +pub struct EditPredictionStartedDebugEvent { + pub buffer: WeakEntity, + pub position: Anchor, + pub prompt: Option, +} + +#[derive(Debug)] +pub struct EditPredictionFinishedDebugEvent { + pub buffer: WeakEntity, + pub position: Anchor, + pub model_output: Option, +} + +pub type RequestDebugInfo = predict_edits_v3::DebugInfo; + +struct ProjectState { + events: VecDeque>, + last_event: Option, + recent_paths: VecDeque, + registered_buffers: HashMap, + current_prediction: Option, + next_pending_prediction_id: usize, + pending_predictions: ArrayVec, + debug_tx: Option>, + last_prediction_refresh: Option<(EntityId, Instant)>, + cancelled_predictions: HashSet, + context: Entity, + license_detection_watchers: HashMap>, + _subscription: gpui::Subscription, +} + +impl ProjectState { + pub fn events(&self, cx: &App) -> Vec> { + self.events + .iter() + .cloned() + .chain( + self.last_event + .as_ref() + .and_then(|event| event.finalize(&self.license_detection_watchers, cx)), + ) + .collect() } - pub fn is_enabled(&self) -> bool { - matches!(self, DataCollectionState::Enabled { .. }) + pub fn events_split_by_pause(&self, cx: &App) -> Vec> { + self.events + .iter() + .cloned() + .chain(self.last_event.as_ref().iter().flat_map(|event| { + let (one, two) = event.split_by_pause(); + let one = one.finalize(&self.license_detection_watchers, cx); + let two = two.and_then(|two| two.finalize(&self.license_detection_watchers, cx)); + one.into_iter().chain(two) + })) + .collect() } - pub fn is_project_open_source(&self) -> bool { - match self { - Self::Enabled { - is_project_open_source, - } - | Self::Disabled { - is_project_open_source, - } => *is_project_open_source, - _ => false, + fn cancel_pending_prediction( + &mut self, + pending_prediction: PendingPrediction, + cx: &mut Context, + ) { + self.cancelled_predictions.insert(pending_prediction.id); + + cx.spawn(async move |this, cx| { + let Some(prediction_id) = pending_prediction.task.await else { + return; + }; + + this.update(cx, |this, _cx| { + this.reject_prediction(prediction_id, EditPredictionRejectReason::Canceled, false); + }) + .ok(); + }) + .detach() + } + + fn active_buffer( + &self, + project: &Entity, + cx: &App, + ) -> Option<(Entity, Option)> { + let project = project.read(cx); + let active_path = project.path_for_entry(project.active_entry()?, cx)?; + let active_buffer = project.buffer_store().read(cx).get_by_path(&active_path)?; + let registered_buffer = self.registered_buffers.get(&active_buffer.entity_id())?; + Some((active_buffer, registered_buffer.last_position)) + } +} + +#[derive(Debug, Clone)] +struct CurrentEditPrediction { + pub requested_by: PredictionRequestedBy, + pub prediction: EditPrediction, + pub was_shown: bool, +} + +impl CurrentEditPrediction { + fn should_replace_prediction(&self, old_prediction: &Self, cx: &App) -> bool { + let Some(new_edits) = self + .prediction + .interpolate(&self.prediction.buffer.read(cx)) + else { + return false; + }; + + if self.prediction.buffer != old_prediction.prediction.buffer { + return true; + } + + let Some(old_edits) = old_prediction + .prediction + .interpolate(&old_prediction.prediction.buffer.read(cx)) + else { + return true; + }; + + let requested_by_buffer_id = self.requested_by.buffer_id(); + + // This reduces the occurrence of UI thrash from replacing edits + // + // TODO: This is fairly arbitrary - should have a more general heuristic that handles multiple edits. + if requested_by_buffer_id == Some(self.prediction.buffer.entity_id()) + && requested_by_buffer_id == Some(old_prediction.prediction.buffer.entity_id()) + && old_edits.len() == 1 + && new_edits.len() == 1 + { + let (old_range, old_text) = &old_edits[0]; + let (new_range, new_text) = &new_edits[0]; + new_range == old_range && new_text.starts_with(old_text.as_ref()) + } else { + true } } } -pub trait EditPredictionProvider: 'static + Sized { - fn name() -> &'static str; - fn display_name() -> &'static str; - fn show_completions_in_menu() -> bool; - fn show_tab_accept_marker() -> bool { - false +#[derive(Debug, Clone)] +enum PredictionRequestedBy { + DiagnosticsUpdate, + Buffer(EntityId), +} + +impl PredictionRequestedBy { + pub fn buffer_id(&self) -> Option { + match self { + PredictionRequestedBy::DiagnosticsUpdate => None, + PredictionRequestedBy::Buffer(buffer_id) => Some(*buffer_id), + } } - fn supports_jump_to_edit() -> bool { +} + +#[derive(Debug)] +struct PendingPrediction { + id: usize, + task: Task>, +} + +/// A prediction from the perspective of a buffer. +#[derive(Debug)] +enum BufferEditPrediction<'a> { + Local { prediction: &'a EditPrediction }, + Jump { prediction: &'a EditPrediction }, +} + +#[cfg(test)] +impl std::ops::Deref for BufferEditPrediction<'_> { + type Target = EditPrediction; + + fn deref(&self) -> &Self::Target { + match self { + BufferEditPrediction::Local { prediction } => prediction, + BufferEditPrediction::Jump { prediction } => prediction, + } + } +} + +struct RegisteredBuffer { + file: Option>, + snapshot: TextBufferSnapshot, + last_position: Option, + _subscriptions: [gpui::Subscription; 2], +} + +#[derive(Clone)] +struct LastEvent { + old_snapshot: TextBufferSnapshot, + new_snapshot: TextBufferSnapshot, + old_file: Option>, + new_file: Option>, + end_edit_anchor: Option, + snapshot_after_last_editing_pause: Option, + last_edit_time: Option, +} + +impl LastEvent { + pub fn finalize( + &self, + license_detection_watchers: &HashMap>, + cx: &App, + ) -> Option> { + let path = buffer_path_with_id_fallback(self.new_file.as_ref(), &self.new_snapshot, cx); + let old_path = buffer_path_with_id_fallback(self.old_file.as_ref(), &self.old_snapshot, cx); + + let in_open_source_repo = + [self.new_file.as_ref(), self.old_file.as_ref()] + .iter() + .all(|file| { + file.is_some_and(|file| { + license_detection_watchers + .get(&file.worktree_id(cx)) + .is_some_and(|watcher| watcher.is_project_open_source()) + }) + }); + + let diff = language::unified_diff(&self.old_snapshot.text(), &self.new_snapshot.text()); + + if path == old_path && diff.is_empty() { + None + } else { + Some(Arc::new(zeta_prompt::Event::BufferChange { + old_path, + path, + diff, + in_open_source_repo, + // TODO: Actually detect if this edit was predicted or not + predicted: false, + })) + } + } + + pub fn split_by_pause(&self) -> (LastEvent, Option) { + let Some(boundary_snapshot) = self.snapshot_after_last_editing_pause.as_ref() else { + return (self.clone(), None); + }; + + let before = LastEvent { + old_snapshot: self.old_snapshot.clone(), + new_snapshot: boundary_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + let after = LastEvent { + old_snapshot: boundary_snapshot.clone(), + new_snapshot: self.new_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + (before, Some(after)) + } +} + +fn buffer_path_with_id_fallback( + file: Option<&Arc>, + snapshot: &TextBufferSnapshot, + cx: &App, +) -> Arc { + if let Some(file) = file { + file.full_path(cx).into() + } else { + Path::new(&format!("untitled-{}", snapshot.remote_id())).into() + } +} + +impl EditPredictionStore { + pub fn try_global(cx: &App) -> Option> { + cx.try_global::() + .map(|global| global.0.clone()) + } + + pub fn global( + client: &Arc, + user_store: &Entity, + cx: &mut App, + ) -> Entity { + cx.try_global::() + .map(|global| global.0.clone()) + .unwrap_or_else(|| { + let ep_store = cx.new(|cx| Self::new(client.clone(), user_store.clone(), cx)); + cx.set_global(EditPredictionStoreGlobal(ep_store.clone())); + ep_store + }) + } + + pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { + let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let data_collection_choice = Self::load_data_collection_choice(); + + let llm_token = LlmApiToken::default(); + + let (reject_tx, reject_rx) = mpsc::unbounded(); + cx.background_spawn({ + let client = client.clone(); + let llm_token = llm_token.clone(); + let app_version = AppVersion::global(cx); + let background_executor = cx.background_executor().clone(); + async move { + Self::handle_rejected_predictions( + reject_rx, + client, + llm_token, + app_version, + background_executor, + ) + .await + } + }) + .detach(); + + let mut this = Self { + projects: HashMap::default(), + client, + user_store, + options: DEFAULT_OPTIONS, + use_context: false, + llm_token, + _llm_token_subscription: cx.subscribe( + &refresh_llm_token_listener, + |this, _listener, _event, cx| { + let client = this.client.clone(); + let llm_token = this.llm_token.clone(); + cx.spawn(async move |_this, _cx| { + llm_token.refresh(&client).await?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + }, + ), + update_required: false, + #[cfg(feature = "cli-support")] + eval_cache: None, + edit_prediction_model: EditPredictionModel::Zeta2, + sweep_ai: SweepAi::new(cx), + mercury: Mercury::new(cx), + data_collection_choice, + reject_predictions_tx: reject_tx, + rated_predictions: Default::default(), + shown_predictions: Default::default(), + custom_predict_edits_url: match env::var("ZED_PREDICT_EDITS_URL") { + Ok(custom_url) => Url::parse(&custom_url).log_err().map(Into::into), + Err(_) => { + if *USE_OLLAMA { + Some( + Url::parse("http://localhost:11434/v1/chat/completions") + .unwrap() + .into(), + ) + } else { + None + } + } + }, + }; + + this.configure_context_retrieval(cx); + let weak_this = cx.weak_entity(); + cx.on_flags_ready(move |_, cx| { + weak_this + .update(cx, |this, cx| this.configure_context_retrieval(cx)) + .ok(); + }) + .detach(); + cx.observe_global::(|this, cx| { + this.configure_context_retrieval(cx); + }) + .detach(); + + this + } + + #[cfg(test)] + pub fn set_custom_predict_edits_url(&mut self, url: Url) { + self.custom_predict_edits_url = Some(url.into()); + } + + pub fn set_edit_prediction_model(&mut self, model: EditPredictionModel) { + self.edit_prediction_model = model; + } + + pub fn has_sweep_api_token(&self, cx: &App) -> bool { + self.sweep_ai.api_token.read(cx).has_key() + } + + pub fn has_mercury_api_token(&self, cx: &App) -> bool { + self.mercury.api_token.read(cx).has_key() + } + + #[cfg(feature = "cli-support")] + pub fn with_eval_cache(&mut self, cache: Arc) { + self.eval_cache = Some(cache); + } + + pub fn options(&self) -> &ZetaOptions { + &self.options + } + + pub fn set_options(&mut self, options: ZetaOptions) { + self.options = options; + } + + pub fn set_use_context(&mut self, use_context: bool) { + self.use_context = use_context; + } + + pub fn clear_history(&mut self) { + for project_state in self.projects.values_mut() { + project_state.events.clear(); + } + } + + pub fn clear_history_for_project(&mut self, project: &Entity) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + project_state.events.clear(); + } + } + + pub fn edit_history_for_project( + &self, + project: &Entity, + cx: &App, + ) -> Vec> { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events(cx)) + .unwrap_or_default() + } + + pub fn edit_history_for_project_with_pause_split_last_event( + &self, + project: &Entity, + cx: &App, + ) -> Vec> { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events_split_by_pause(cx)) + .unwrap_or_default() + } + + pub fn context_for_project<'a>( + &'a self, + project: &Entity, + cx: &'a App, + ) -> Arc<[RelatedFile]> { + self.projects + .get(&project.entity_id()) + .map(|project| project.context.read(cx).related_files()) + .unwrap_or_else(|| vec![].into()) + } + + pub fn context_for_project_with_buffers<'a>( + &'a self, + project: &Entity, + cx: &'a App, + ) -> Option)>> { + self.projects + .get(&project.entity_id()) + .map(|project| project.context.read(cx).related_files_with_buffers()) + } + + pub fn usage(&self, cx: &App) -> Option { + if self.edit_prediction_model == EditPredictionModel::Zeta2 { + self.user_store.read(cx).edit_prediction_usage() + } else { + None + } + } + + pub fn register_project(&mut self, project: &Entity, cx: &mut Context) { + self.get_or_init_project(project, cx); + } + + pub fn register_buffer( + &mut self, + buffer: &Entity, + project: &Entity, + cx: &mut Context, + ) { + let project_state = self.get_or_init_project(project, cx); + Self::register_buffer_impl(project_state, buffer, project, cx); + } + + fn get_or_init_project( + &mut self, + project: &Entity, + cx: &mut Context, + ) -> &mut ProjectState { + let entity_id = project.entity_id(); + self.projects + .entry(entity_id) + .or_insert_with(|| ProjectState { + context: { + let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(project, cx)); + cx.subscribe(&related_excerpt_store, move |this, _, event, _| { + this.handle_excerpt_store_event(entity_id, event); + }) + .detach(); + related_excerpt_store + }, + events: VecDeque::new(), + last_event: None, + recent_paths: VecDeque::new(), + debug_tx: None, + registered_buffers: HashMap::default(), + current_prediction: None, + cancelled_predictions: HashSet::default(), + pending_predictions: ArrayVec::new(), + next_pending_prediction_id: 0, + last_prediction_refresh: None, + license_detection_watchers: HashMap::default(), + _subscription: cx.subscribe(&project, Self::handle_project_event), + }) + } + + pub fn remove_project(&mut self, project: &Entity) { + self.projects.remove(&project.entity_id()); + } + + fn handle_excerpt_store_event( + &mut self, + project_entity_id: EntityId, + event: &RelatedExcerptStoreEvent, + ) { + if let Some(project_state) = self.projects.get(&project_entity_id) { + if let Some(debug_tx) = project_state.debug_tx.clone() { + match event { + RelatedExcerptStoreEvent::StartedRefresh => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalStarted( + ContextRetrievalStartedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + search_prompt: String::new(), + }, + )) + .ok(); + } + RelatedExcerptStoreEvent::FinishedRefresh { + cache_hit_count, + cache_miss_count, + mean_definition_latency, + max_definition_latency, + } => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalFinished( + ContextRetrievalFinishedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + metadata: vec![ + ( + "Cache Hits", + format!( + "{}/{}", + cache_hit_count, + cache_hit_count + cache_miss_count + ) + .into(), + ), + ( + "Max LSP Time", + format!("{} ms", max_definition_latency.as_millis()) + .into(), + ), + ( + "Mean LSP Time", + format!("{} ms", mean_definition_latency.as_millis()) + .into(), + ), + ], + }, + )) + .ok(); + } + } + } + } + } + + pub fn debug_info( + &mut self, + project: &Entity, + cx: &mut Context, + ) -> mpsc::UnboundedReceiver { + let project_state = self.get_or_init_project(project, cx); + let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); + project_state.debug_tx = Some(debug_watch_tx); + debug_watch_rx + } + + fn handle_project_event( + &mut self, + project: Entity, + event: &project::Event, + cx: &mut Context, + ) { + // TODO [zeta2] init with recent paths + match event { + project::Event::ActiveEntryChanged(Some(active_entry_id)) => { + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + let path = project.read(cx).path_for_entry(*active_entry_id, cx); + if let Some(path) = path { + if let Some(ix) = project_state + .recent_paths + .iter() + .position(|probe| probe == &path) + { + project_state.recent_paths.remove(ix); + } + project_state.recent_paths.push_front(path); + } + } + project::Event::DiagnosticsUpdated { .. } => { + if cx.has_flag::() { + self.refresh_prediction_from_diagnostics(project, cx); + } + } + _ => (), + } + } + + fn register_buffer_impl<'a>( + project_state: &'a mut ProjectState, + buffer: &Entity, + project: &Entity, + cx: &mut Context, + ) -> &'a mut RegisteredBuffer { + let buffer_id = buffer.entity_id(); + + if let Some(file) = buffer.read(cx).file() { + let worktree_id = file.worktree_id(cx); + if let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) { + project_state + .license_detection_watchers + .entry(worktree_id) + .or_insert_with(|| { + let project_entity_id = project.entity_id(); + cx.observe_release(&worktree, move |this, _worktree, _cx| { + let Some(project_state) = this.projects.get_mut(&project_entity_id) + else { + return; + }; + project_state + .license_detection_watchers + .remove(&worktree_id); + }) + .detach(); + Rc::new(LicenseDetectionWatcher::new(&worktree, cx)) + }); + } + } + + match project_state.registered_buffers.entry(buffer_id) { + hash_map::Entry::Occupied(entry) => entry.into_mut(), + hash_map::Entry::Vacant(entry) => { + let buf = buffer.read(cx); + let snapshot = buf.text_snapshot(); + let file = buf.file().cloned(); + let project_entity_id = project.entity_id(); + entry.insert(RegisteredBuffer { + snapshot, + file, + last_position: None, + _subscriptions: [ + cx.subscribe(buffer, { + let project = project.downgrade(); + move |this, buffer, event, cx| { + if let language::BufferEvent::Edited = event + && let Some(project) = project.upgrade() + { + this.report_changes_for_buffer(&buffer, &project, cx); + } + } + }), + cx.observe_release(buffer, move |this, _buffer, _cx| { + let Some(project_state) = this.projects.get_mut(&project_entity_id) + else { + return; + }; + project_state.registered_buffers.remove(&buffer_id); + }), + ], + }) + } + } + } + + fn report_changes_for_buffer( + &mut self, + buffer: &Entity, + project: &Entity, + cx: &mut Context, + ) { + let project_state = self.get_or_init_project(project, cx); + let registered_buffer = Self::register_buffer_impl(project_state, buffer, project, cx); + + let buf = buffer.read(cx); + let new_file = buf.file().cloned(); + let new_snapshot = buf.text_snapshot(); + if new_snapshot.version == registered_buffer.snapshot.version { + return; + } + + let old_file = mem::replace(&mut registered_buffer.file, new_file.clone()); + let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); + let end_edit_anchor = new_snapshot + .anchored_edits_since::(&old_snapshot.version) + .last() + .map(|(_, range)| range.end); + let events = &mut project_state.events; + + let now = cx.background_executor().now(); + if let Some(last_event) = project_state.last_event.as_mut() { + let is_next_snapshot_of_same_buffer = old_snapshot.remote_id() + == last_event.new_snapshot.remote_id() + && old_snapshot.version == last_event.new_snapshot.version; + + let should_coalesce = is_next_snapshot_of_same_buffer + && end_edit_anchor + .as_ref() + .zip(last_event.end_edit_anchor.as_ref()) + .is_some_and(|(a, b)| { + let a = a.to_point(&new_snapshot); + let b = b.to_point(&new_snapshot); + a.row.abs_diff(b.row) <= CHANGE_GROUPING_LINE_SPAN + }); + + if should_coalesce { + let pause_elapsed = last_event + .last_edit_time + .map(|t| now.duration_since(t) >= LAST_CHANGE_GROUPING_TIME) + .unwrap_or(false); + if pause_elapsed { + last_event.snapshot_after_last_editing_pause = + Some(last_event.new_snapshot.clone()); + } + + last_event.end_edit_anchor = end_edit_anchor; + last_event.new_snapshot = new_snapshot; + last_event.last_edit_time = Some(now); + return; + } + } + + if events.len() + 1 >= EVENT_COUNT_MAX { + events.pop_front(); + } + + if let Some(event) = project_state.last_event.take() { + events.extend(event.finalize(&project_state.license_detection_watchers, cx)); + } + + project_state.last_event = Some(LastEvent { + old_file, + new_file, + old_snapshot, + new_snapshot, + end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: Some(now), + }); + } + + fn prediction_at( + &mut self, + buffer: &Entity, + position: Option, + project: &Entity, + cx: &App, + ) -> Option> { + let project_state = self.projects.get_mut(&project.entity_id())?; + if let Some(position) = position + && let Some(buffer) = project_state + .registered_buffers + .get_mut(&buffer.entity_id()) + { + buffer.last_position = Some(position); + } + + let CurrentEditPrediction { + requested_by, + prediction, + .. + } = project_state.current_prediction.as_ref()?; + + if prediction.targets_buffer(buffer.read(cx)) { + Some(BufferEditPrediction::Local { prediction }) + } else { + let show_jump = match requested_by { + PredictionRequestedBy::Buffer(requested_by_buffer_id) => { + requested_by_buffer_id == &buffer.entity_id() + } + PredictionRequestedBy::DiagnosticsUpdate => true, + }; + + if show_jump { + Some(BufferEditPrediction::Jump { prediction }) + } else { + None + } + } + } + + fn accept_current_prediction(&mut self, project: &Entity, cx: &mut Context) { + let custom_accept_url = env::var("ZED_ACCEPT_PREDICTION_URL").ok(); + match self.edit_prediction_model { + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() && custom_accept_url.is_none() { + return; + } + } + EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, + } + + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + + let Some(prediction) = project_state.current_prediction.take() else { + return; + }; + let request_id = prediction.prediction.id.to_string(); + for pending_prediction in mem::take(&mut project_state.pending_predictions) { + project_state.cancel_pending_prediction(pending_prediction, cx); + } + + let client = self.client.clone(); + let llm_token = self.llm_token.clone(); + let app_version = AppVersion::global(cx); + cx.spawn(async move |this, cx| { + let (url, require_auth) = if let Some(accept_edits_url) = custom_accept_url { + (http_client::Url::parse(&accept_edits_url)?, false) + } else { + ( + client + .http_client() + .build_zed_llm_url("/predict_edits/accept", &[])?, + true, + ) + }; + + let response = cx + .background_spawn(Self::send_api_request::<()>( + move |builder| { + let req = builder.uri(url.as_ref()).body( + serde_json::to_string(&AcceptEditPredictionBody { + request_id: request_id.clone(), + })? + .into(), + ); + Ok(req?) + }, + client, + llm_token, + app_version, + require_auth, + )) + .await; + + Self::handle_api_response(&this, response, cx)?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + async fn handle_rejected_predictions( + rx: UnboundedReceiver, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + background_executor: BackgroundExecutor, + ) { + let mut rx = std::pin::pin!(rx.peekable()); + let mut batched = Vec::new(); + + while let Some(rejection) = rx.next().await { + batched.push(rejection); + + if batched.len() < MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST / 2 { + select_biased! { + next = rx.as_mut().peek().fuse() => { + if next.is_some() { + continue; + } + } + () = background_executor.timer(REJECT_REQUEST_DEBOUNCE).fuse() => {}, + } + } + + let url = client + .http_client() + .build_zed_llm_url("/predict_edits/reject", &[]) + .unwrap(); + + let flush_count = batched + .len() + // in case items have accumulated after failure + .min(MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST); + let start = batched.len() - flush_count; + + let body = RejectEditPredictionsBodyRef { + rejections: &batched[start..], + }; + + let result = Self::send_api_request::<()>( + |builder| { + let req = builder + .uri(url.as_ref()) + .body(serde_json::to_string(&body)?.into()); + anyhow::Ok(req?) + }, + client.clone(), + llm_token.clone(), + app_version.clone(), + true, + ) + .await; + + if result.log_err().is_some() { + batched.drain(start..); + } + } + } + + fn reject_current_prediction( + &mut self, + reason: EditPredictionRejectReason, + project: &Entity, + ) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + project_state.pending_predictions.clear(); + if let Some(prediction) = project_state.current_prediction.take() { + self.reject_prediction(prediction.prediction.id, reason, prediction.was_shown); + } + }; + } + + fn did_show_current_prediction(&mut self, project: &Entity, _cx: &mut Context) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + if let Some(current_prediction) = project_state.current_prediction.as_mut() { + if !current_prediction.was_shown { + current_prediction.was_shown = true; + self.shown_predictions + .push_front(current_prediction.prediction.clone()); + if self.shown_predictions.len() > 50 { + let completion = self.shown_predictions.pop_back().unwrap(); + self.rated_predictions.remove(&completion.id); + } + } + } + } + } + + fn reject_prediction( + &mut self, + prediction_id: EditPredictionId, + reason: EditPredictionRejectReason, + was_shown: bool, + ) { + match self.edit_prediction_model { + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() { + return; + } + } + EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, + } + + self.reject_predictions_tx + .unbounded_send(EditPredictionRejection { + request_id: prediction_id.to_string(), + reason, + was_shown, + }) + .log_err(); + } + + fn is_refreshing(&self, project: &Entity) -> bool { + self.projects + .get(&project.entity_id()) + .is_some_and(|project_state| !project_state.pending_predictions.is_empty()) + } + + pub fn refresh_prediction_from_buffer( + &mut self, + project: Entity, + buffer: Entity, + position: language::Anchor, + cx: &mut Context, + ) { + self.queue_prediction_refresh(project.clone(), buffer.entity_id(), cx, move |this, cx| { + let Some(request_task) = this + .update(cx, |this, cx| { + this.request_prediction( + &project, + &buffer, + position, + PredictEditsRequestTrigger::Other, + cx, + ) + }) + .log_err() + else { + return Task::ready(anyhow::Ok(None)); + }; + + cx.spawn(async move |_cx| { + request_task.await.map(|prediction_result| { + prediction_result.map(|prediction_result| { + ( + prediction_result, + PredictionRequestedBy::Buffer(buffer.entity_id()), + ) + }) + }) + }) + }) + } + + pub fn refresh_prediction_from_diagnostics( + &mut self, + project: Entity, + cx: &mut Context, + ) { + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + + // Prefer predictions from buffer + if project_state.current_prediction.is_some() { + return; + }; + + self.queue_prediction_refresh(project.clone(), project.entity_id(), cx, move |this, cx| { + let Some((active_buffer, snapshot, cursor_point)) = this + .read_with(cx, |this, cx| { + let project_state = this.projects.get(&project.entity_id())?; + let (buffer, position) = project_state.active_buffer(&project, cx)?; + let snapshot = buffer.read(cx).snapshot(); + + if !Self::predictions_enabled_at(&snapshot, position, cx) { + return None; + } + + let cursor_point = position + .map(|pos| pos.to_point(&snapshot)) + .unwrap_or_default(); + + Some((buffer, snapshot, cursor_point)) + }) + .log_err() + .flatten() + else { + return Task::ready(anyhow::Ok(None)); + }; + + cx.spawn(async move |cx| { + let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( + active_buffer, + &snapshot, + Default::default(), + cursor_point, + &project, + cx, + ) + .await? + else { + return anyhow::Ok(None); + }; + + let Some(prediction_result) = this + .update(cx, |this, cx| { + this.request_prediction( + &project, + &jump_buffer, + jump_position, + PredictEditsRequestTrigger::Diagnostics, + cx, + ) + })? + .await? + else { + return anyhow::Ok(None); + }; + + this.update(cx, |this, cx| { + Some(( + if this + .get_or_init_project(&project, cx) + .current_prediction + .is_none() + { + prediction_result + } else { + EditPredictionResult { + id: prediction_result.id, + prediction: Err(EditPredictionRejectReason::CurrentPreferred), + } + }, + PredictionRequestedBy::DiagnosticsUpdate, + )) + }) + }) + }); + } + + fn predictions_enabled_at( + snapshot: &BufferSnapshot, + position: Option, + cx: &App, + ) -> bool { + let file = snapshot.file(); + let all_settings = all_language_settings(file, cx); + if !all_settings.show_edit_predictions(snapshot.language(), cx) + || file.is_some_and(|file| !all_settings.edit_predictions_enabled_for_file(file, cx)) + { + return false; + } + + if let Some(last_position) = position { + let settings = snapshot.settings_at(last_position, cx); + + if !settings.edit_predictions_disabled_in.is_empty() + && let Some(scope) = snapshot.language_scope_at(last_position) + && let Some(scope_name) = scope.override_name() + && settings + .edit_predictions_disabled_in + .iter() + .any(|s| s == scope_name) + { + return false; + } + } + true } - fn data_collection_state(&self, _cx: &App) -> DataCollectionState { - DataCollectionState::Unsupported + #[cfg(not(test))] + pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); + #[cfg(test)] + pub const THROTTLE_TIMEOUT: Duration = Duration::ZERO; + + fn queue_prediction_refresh( + &mut self, + project: Entity, + throttle_entity: EntityId, + cx: &mut Context, + do_refresh: impl FnOnce( + WeakEntity, + &mut AsyncApp, + ) + -> Task>> + + 'static, + ) { + let project_state = self.get_or_init_project(&project, cx); + let pending_prediction_id = project_state.next_pending_prediction_id; + project_state.next_pending_prediction_id += 1; + let last_request = project_state.last_prediction_refresh; + + let task = cx.spawn(async move |this, cx| { + if let Some((last_entity, last_timestamp)) = last_request + && throttle_entity == last_entity + && let Some(timeout) = + (last_timestamp + Self::THROTTLE_TIMEOUT).checked_duration_since(Instant::now()) + { + cx.background_executor().timer(timeout).await; + } + + // If this task was cancelled before the throttle timeout expired, + // do not perform a request. + let mut is_cancelled = true; + this.update(cx, |this, cx| { + let project_state = this.get_or_init_project(&project, cx); + if !project_state + .cancelled_predictions + .remove(&pending_prediction_id) + { + project_state.last_prediction_refresh = Some((throttle_entity, Instant::now())); + is_cancelled = false; + } + }) + .ok(); + if is_cancelled { + return None; + } + + let new_prediction_result = do_refresh(this.clone(), cx).await.log_err().flatten(); + let new_prediction_id = new_prediction_result + .as_ref() + .map(|(prediction, _)| prediction.id.clone()); + + // When a prediction completes, remove it from the pending list, and cancel + // any pending predictions that were enqueued before it. + this.update(cx, |this, cx| { + let project_state = this.get_or_init_project(&project, cx); + + let is_cancelled = project_state + .cancelled_predictions + .remove(&pending_prediction_id); + + let new_current_prediction = if !is_cancelled + && let Some((prediction_result, requested_by)) = new_prediction_result + { + match prediction_result.prediction { + Ok(prediction) => { + let new_prediction = CurrentEditPrediction { + requested_by, + prediction, + was_shown: false, + }; + + if let Some(current_prediction) = + project_state.current_prediction.as_ref() + { + if new_prediction.should_replace_prediction(¤t_prediction, cx) + { + this.reject_current_prediction( + EditPredictionRejectReason::Replaced, + &project, + ); + + Some(new_prediction) + } else { + this.reject_prediction( + new_prediction.prediction.id, + EditPredictionRejectReason::CurrentPreferred, + false, + ); + None + } + } else { + Some(new_prediction) + } + } + Err(reject_reason) => { + this.reject_prediction(prediction_result.id, reject_reason, false); + None + } + } + } else { + None + }; + + let project_state = this.get_or_init_project(&project, cx); + + if let Some(new_prediction) = new_current_prediction { + project_state.current_prediction = Some(new_prediction); + } + + let mut pending_predictions = mem::take(&mut project_state.pending_predictions); + for (ix, pending_prediction) in pending_predictions.iter().enumerate() { + if pending_prediction.id == pending_prediction_id { + pending_predictions.remove(ix); + for pending_prediction in pending_predictions.drain(0..ix) { + project_state.cancel_pending_prediction(pending_prediction, cx) + } + break; + } + } + this.get_or_init_project(&project, cx).pending_predictions = pending_predictions; + cx.notify(); + }) + .ok(); + + new_prediction_id + }); + + if project_state.pending_predictions.len() <= 1 { + project_state.pending_predictions.push(PendingPrediction { + id: pending_prediction_id, + task, + }); + } else if project_state.pending_predictions.len() == 2 { + let pending_prediction = project_state.pending_predictions.pop().unwrap(); + project_state.pending_predictions.push(PendingPrediction { + id: pending_prediction_id, + task, + }); + project_state.cancel_pending_prediction(pending_prediction, cx); + } } - fn usage(&self, _cx: &App) -> Option { - None + pub fn request_prediction( + &mut self, + project: &Entity, + active_buffer: &Entity, + position: language::Anchor, + trigger: PredictEditsRequestTrigger, + cx: &mut Context, + ) -> Task>> { + self.request_prediction_internal( + project.clone(), + active_buffer.clone(), + position, + trigger, + cx.has_flag::(), + cx, + ) } - fn toggle_data_collection(&mut self, _cx: &mut App) {} - fn is_enabled( - &self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &App, - ) -> bool; - fn is_refreshing(&self) -> bool; - fn refresh( + fn request_prediction_internal( &mut self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, + project: Entity, + active_buffer: Entity, + position: language::Anchor, + trigger: PredictEditsRequestTrigger, + allow_jump: bool, cx: &mut Context, - ); - fn cycle( + ) -> Task>> { + const DIAGNOSTIC_LINES_RANGE: u32 = 20; + + self.get_or_init_project(&project, cx); + let project_state = self.projects.get(&project.entity_id()).unwrap(); + let events = project_state.events(cx); + let has_events = !events.is_empty(); + let debug_tx = project_state.debug_tx.clone(); + + let snapshot = active_buffer.read(cx).snapshot(); + let cursor_point = position.to_point(&snapshot); + let diagnostic_search_start = cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE); + let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE; + let diagnostic_search_range = + Point::new(diagnostic_search_start, 0)..Point::new(diagnostic_search_end, 0); + + let related_files = if self.use_context { + self.context_for_project(&project, cx) + } else { + Vec::new().into() + }; + + let inputs = EditPredictionModelInput { + project: project.clone(), + buffer: active_buffer.clone(), + snapshot: snapshot.clone(), + position, + events, + related_files, + recent_paths: project_state.recent_paths.clone(), + trigger, + diagnostic_search_range: diagnostic_search_range.clone(), + debug_tx, + }; + + let task = match self.edit_prediction_model { + EditPredictionModel::Zeta1 => zeta1::request_prediction_with_zeta1(self, inputs, cx), + EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2(self, inputs, cx), + EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx), + EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), + }; + + cx.spawn(async move |this, cx| { + let prediction = task.await?; + + if prediction.is_none() && allow_jump { + let cursor_point = position.to_point(&snapshot); + if has_events + && let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( + active_buffer.clone(), + &snapshot, + diagnostic_search_range, + cursor_point, + &project, + cx, + ) + .await? + { + return this + .update(cx, |this, cx| { + this.request_prediction_internal( + project, + jump_buffer, + jump_position, + trigger, + false, + cx, + ) + })? + .await; + } + + return anyhow::Ok(None); + } + + Ok(prediction) + }) + } + + async fn next_diagnostic_location( + active_buffer: Entity, + active_buffer_snapshot: &BufferSnapshot, + active_buffer_diagnostic_search_range: Range, + active_buffer_cursor_point: Point, + project: &Entity, + cx: &mut AsyncApp, + ) -> Result, language::Anchor)>> { + // find the closest diagnostic to the cursor that wasn't close enough to be included in the last request + let mut jump_location = active_buffer_snapshot + .diagnostic_groups(None) + .into_iter() + .filter_map(|(_, group)| { + let range = &group.entries[group.primary_ix] + .range + .to_point(&active_buffer_snapshot); + if range.overlaps(&active_buffer_diagnostic_search_range) { + None + } else { + Some(range.start) + } + }) + .min_by_key(|probe| probe.row.abs_diff(active_buffer_cursor_point.row)) + .map(|position| { + ( + active_buffer.clone(), + active_buffer_snapshot.anchor_before(position), + ) + }); + + if jump_location.is_none() { + let active_buffer_path = active_buffer.read_with(cx, |buffer, cx| { + let file = buffer.file()?; + + Some(ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }) + })?; + + let buffer_task = project.update(cx, |project, cx| { + let (path, _, _) = project + .diagnostic_summaries(false, cx) + .filter(|(path, _, _)| Some(path) != active_buffer_path.as_ref()) + .max_by_key(|(path, _, _)| { + // find the buffer with errors that shares most parent directories + path.path + .components() + .zip( + active_buffer_path + .as_ref() + .map(|p| p.path.components()) + .unwrap_or_default(), + ) + .take_while(|(a, b)| a == b) + .count() + })?; + + Some(project.open_buffer(path, cx)) + })?; + + if let Some(buffer_task) = buffer_task { + let closest_buffer = buffer_task.await?; + + jump_location = closest_buffer + .read_with(cx, |buffer, _cx| { + buffer + .buffer_diagnostics(None) + .into_iter() + .min_by_key(|entry| entry.diagnostic.severity) + .map(|entry| entry.range.start) + })? + .map(|position| (closest_buffer, position)); + } + } + + anyhow::Ok(jump_location) + } + + async fn send_raw_llm_request( + request: open_ai::Request, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + #[cfg(feature = "cli-support")] eval_cache: Option>, + #[cfg(feature = "cli-support")] eval_cache_kind: EvalCacheEntryKind, + ) -> Result<(open_ai::Response, Option)> { + let url = client + .http_client() + .build_zed_llm_url("/predict_edits/raw", &[])?; + + #[cfg(feature = "cli-support")] + let cache_key = if let Some(cache) = eval_cache { + use collections::FxHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = FxHasher::default(); + url.hash(&mut hasher); + let request_str = serde_json::to_string_pretty(&request)?; + request_str.hash(&mut hasher); + let hash = hasher.finish(); + + let key = (eval_cache_kind, hash); + if let Some(response_str) = cache.read(key) { + return Ok((serde_json::from_str(&response_str)?, None)); + } + + Some((cache, request_str, key)) + } else { + None + }; + + let (response, usage) = Self::send_api_request( + |builder| { + let req = builder + .uri(url.as_ref()) + .body(serde_json::to_string(&request)?.into()); + Ok(req?) + }, + client, + llm_token, + app_version, + true, + ) + .await?; + + #[cfg(feature = "cli-support")] + if let Some((cache, request, key)) = cache_key { + cache.write(key, &request, &serde_json::to_string_pretty(&response)?); + } + + Ok((response, usage)) + } + + fn handle_api_response( + this: &WeakEntity, + response: Result<(T, Option)>, + cx: &mut gpui::AsyncApp, + ) -> Result { + match response { + Ok((data, usage)) => { + if let Some(usage) = usage { + this.update(cx, |this, cx| { + this.user_store.update(cx, |user_store, cx| { + user_store.update_edit_prediction_usage(usage, cx); + }); + }) + .ok(); + } + Ok(data) + } + Err(err) => { + if err.is::() { + cx.update(|cx| { + this.update(cx, |this, _cx| { + this.update_required = true; + }) + .ok(); + + let error_message: SharedString = err.to_string().into(); + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + cx.new(|cx| { + ErrorMessagePrompt::new(error_message.clone(), cx) + .with_link_button("Update Zed", "https://zed.dev/releases") + }) + }, + ); + }) + .ok(); + } + Err(err) + } + } + } + + async fn send_api_request( + build: impl Fn(http_client::http::request::Builder) -> Result>, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + require_auth: bool, + ) -> Result<(Res, Option)> + where + Res: DeserializeOwned, + { + let http_client = client.http_client(); + + let mut token = if require_auth { + Some(llm_token.acquire(&client).await?) + } else { + llm_token.acquire(&client).await.ok() + }; + let mut did_retry = false; + + loop { + let request_builder = http_client::Request::builder().method(Method::POST); + + let mut request_builder = request_builder + .header("Content-Type", "application/json") + .header(ZED_VERSION_HEADER_NAME, app_version.to_string()); + + // Only add Authorization header if we have a token + if let Some(ref token_value) = token { + request_builder = + request_builder.header("Authorization", format!("Bearer {}", token_value)); + } + + let request = build(request_builder)?; + + let mut response = http_client.send(request).await?; + + if let Some(minimum_required_version) = response + .headers() + .get(MINIMUM_REQUIRED_VERSION_HEADER_NAME) + .and_then(|version| Version::from_str(version.to_str().ok()?).ok()) + { + anyhow::ensure!( + app_version >= minimum_required_version, + ZedUpdateRequiredError { + minimum_version: minimum_required_version + } + ); + } + + if response.status().is_success() { + let usage = EditPredictionUsage::from_headers(response.headers()).ok(); + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + return Ok((serde_json::from_slice(&body)?, usage)); + } else if !did_retry + && token.is_some() + && response + .headers() + .get(EXPIRED_LLM_TOKEN_HEADER_NAME) + .is_some() + { + did_retry = true; + token = Some(llm_token.refresh(&client).await?); + } else { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + anyhow::bail!( + "Request failed with status: {:?}\nBody: {}", + response.status(), + body + ); + } + } + } + + pub fn refresh_context( &mut self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut Context, - ); - fn accept(&mut self, cx: &mut Context); - fn discard(&mut self, cx: &mut Context); - fn suggest( - &mut self, - buffer: &Entity, + project: &Entity, + buffer: &Entity, cursor_position: language::Anchor, cx: &mut Context, - ) -> Option; -} + ) { + if self.use_context { + self.get_or_init_project(project, cx) + .context + .update(cx, |store, cx| { + store.refresh(buffer.clone(), cursor_position, cx); + }); + } + } -pub trait EditPredictionProviderHandle { - fn name(&self) -> &'static str; - fn display_name(&self) -> &'static str; - fn is_enabled( + #[cfg(feature = "cli-support")] + pub fn set_context_for_buffer( + &mut self, + project: &Entity, + related_files: Vec, + cx: &mut Context, + ) { + self.get_or_init_project(project, cx) + .context + .update(cx, |store, _| { + store.set_related_files(related_files); + }); + } + + fn is_file_open_source( &self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &App, - ) -> bool; - fn show_completions_in_menu(&self) -> bool; - fn show_tab_accept_marker(&self) -> bool; - fn supports_jump_to_edit(&self) -> bool; - fn data_collection_state(&self, cx: &App) -> DataCollectionState; - fn usage(&self, cx: &App) -> Option; - fn toggle_data_collection(&self, cx: &mut App); - fn is_refreshing(&self, cx: &App) -> bool; - fn refresh( - &self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, - cx: &mut App, - ); - fn cycle( - &self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut App, - ); - fn accept(&self, cx: &mut App); - fn discard(&self, cx: &mut App); - fn suggest( - &self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut App, - ) -> Option; -} - -impl EditPredictionProviderHandle for Entity -where - T: EditPredictionProvider, -{ - fn name(&self) -> &'static str { - T::name() - } - - fn display_name(&self) -> &'static str { - T::display_name() - } - - fn show_completions_in_menu(&self) -> bool { - T::show_completions_in_menu() - } - - fn show_tab_accept_marker(&self) -> bool { - T::show_tab_accept_marker() - } - - fn supports_jump_to_edit(&self) -> bool { - T::supports_jump_to_edit() - } - - fn data_collection_state(&self, cx: &App) -> DataCollectionState { - self.read(cx).data_collection_state(cx) - } - - fn usage(&self, cx: &App) -> Option { - self.read(cx).usage(cx) - } - - fn toggle_data_collection(&self, cx: &mut App) { - self.update(cx, |this, cx| this.toggle_data_collection(cx)) - } - - fn is_enabled( - &self, - buffer: &Entity, - cursor_position: language::Anchor, + project: &Entity, + file: &Arc, cx: &App, ) -> bool { - self.read(cx).is_enabled(buffer, cursor_position, cx) + if !file.is_local() || file.is_private() { + return false; + } + let Some(project_state) = self.projects.get(&project.entity_id()) else { + return false; + }; + project_state + .license_detection_watchers + .get(&file.worktree_id(cx)) + .as_ref() + .is_some_and(|watcher| watcher.is_project_open_source()) } - fn is_refreshing(&self, cx: &App) -> bool { - self.read(cx).is_refreshing() + fn can_collect_file(&self, project: &Entity, file: &Arc, cx: &App) -> bool { + self.data_collection_choice.is_enabled() && self.is_file_open_source(project, file, cx) } - fn refresh( - &self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, - cx: &mut App, - ) { - self.update(cx, |this, cx| { - this.refresh(buffer, cursor_position, debounce, cx) + fn can_collect_events(&self, events: &[Arc]) -> bool { + if !self.data_collection_choice.is_enabled() { + return false; + } + events.iter().all(|event| { + matches!( + event.as_ref(), + zeta_prompt::Event::BufferChange { + in_open_source_repo: true, + .. + } + ) }) } - fn cycle( - &self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut App, + fn load_data_collection_choice() -> DataCollectionChoice { + let choice = KEY_VALUE_STORE + .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) + .log_err() + .flatten(); + + match choice.as_deref() { + Some("true") => DataCollectionChoice::Enabled, + Some("false") => DataCollectionChoice::Disabled, + Some(_) => { + log::error!("unknown value in '{ZED_PREDICT_DATA_COLLECTION_CHOICE}'"); + DataCollectionChoice::NotAnswered + } + None => DataCollectionChoice::NotAnswered, + } + } + + fn toggle_data_collection_choice(&mut self, cx: &mut Context) { + self.data_collection_choice = self.data_collection_choice.toggle(); + let new_choice = self.data_collection_choice; + db::write_and_log(cx, move || { + KEY_VALUE_STORE.write_kvp( + ZED_PREDICT_DATA_COLLECTION_CHOICE.into(), + new_choice.is_enabled().to_string(), + ) + }); + } + + pub fn shown_predictions(&self) -> impl DoubleEndedIterator { + self.shown_predictions.iter() + } + + pub fn shown_completions_len(&self) -> usize { + self.shown_predictions.len() + } + + pub fn is_prediction_rated(&self, id: &EditPredictionId) -> bool { + self.rated_predictions.contains(id) + } + + pub fn rate_prediction( + &mut self, + prediction: &EditPrediction, + rating: EditPredictionRating, + feedback: String, + cx: &mut Context, ) { - self.update(cx, |this, cx| { - this.cycle(buffer, cursor_position, direction, cx) - }) + self.rated_predictions.insert(prediction.id.clone()); + telemetry::event!( + "Edit Prediction Rated", + rating, + inputs = prediction.inputs, + output = prediction.edit_preview.as_unified_diff(&prediction.edits), + feedback + ); + self.client.telemetry().flush_events().detach(); + cx.notify(); } - fn accept(&self, cx: &mut App) { - self.update(cx, |this, cx| this.accept(cx)) - } - - fn discard(&self, cx: &mut App) { - self.update(cx, |this, cx| this.discard(cx)) - } - - fn suggest( - &self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut App, - ) -> Option { - self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx)) + fn configure_context_retrieval(&mut self, cx: &mut Context<'_, EditPredictionStore>) { + self.use_context = cx.has_flag::() + && all_language_settings(None, cx).edit_predictions.use_context; } } + +#[derive(Error, Debug)] +#[error( + "You must update to Zed version {minimum_version} or higher to continue using edit predictions." +)] +pub struct ZedUpdateRequiredError { + minimum_version: Version, +} + +#[cfg(feature = "cli-support")] +pub type EvalCacheKey = (EvalCacheEntryKind, u64); + +#[cfg(feature = "cli-support")] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum EvalCacheEntryKind { + Context, + Search, + Prediction, +} + +#[cfg(feature = "cli-support")] +impl std::fmt::Display for EvalCacheEntryKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EvalCacheEntryKind::Search => write!(f, "search"), + EvalCacheEntryKind::Context => write!(f, "context"), + EvalCacheEntryKind::Prediction => write!(f, "prediction"), + } + } +} + +#[cfg(feature = "cli-support")] +pub trait EvalCache: Send + Sync { + fn read(&self, key: EvalCacheKey) -> Option; + fn write(&self, key: EvalCacheKey, input: &str, value: &str); +} + +#[derive(Debug, Clone, Copy)] +pub enum DataCollectionChoice { + NotAnswered, + Enabled, + Disabled, +} + +impl DataCollectionChoice { + pub fn is_enabled(self) -> bool { + match self { + Self::Enabled => true, + Self::NotAnswered | Self::Disabled => false, + } + } + + pub fn is_answered(self) -> bool { + match self { + Self::Enabled | Self::Disabled => true, + Self::NotAnswered => false, + } + } + + #[must_use] + pub fn toggle(&self) -> DataCollectionChoice { + match self { + Self::Enabled => Self::Disabled, + Self::Disabled => Self::Enabled, + Self::NotAnswered => Self::Enabled, + } + } +} + +impl From for DataCollectionChoice { + fn from(value: bool) -> Self { + match value { + true => DataCollectionChoice::Enabled, + false => DataCollectionChoice::Disabled, + } + } +} + +struct ZedPredictUpsell; + +impl Dismissable for ZedPredictUpsell { + const KEY: &'static str = "dismissed-edit-predict-upsell"; + + fn dismissed() -> bool { + // To make this backwards compatible with older versions of Zed, we + // check if the user has seen the previous Edit Prediction Onboarding + // before, by checking the data collection choice which was written to + // the database once the user clicked on "Accept and Enable" + if KEY_VALUE_STORE + .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) + .log_err() + .is_some_and(|s| s.is_some()) + { + return true; + } + + KEY_VALUE_STORE + .read_kvp(Self::KEY) + .log_err() + .is_some_and(|s| s.is_some()) + } +} + +pub fn should_show_upsell_modal() -> bool { + !ZedPredictUpsell::dismissed() +} + +pub fn init(cx: &mut App) { + cx.observe_new(move |workspace: &mut Workspace, _, _cx| { + workspace.register_action( + move |workspace, _: &zed_actions::OpenZedPredictOnboarding, window, cx| { + ZedPredictModal::toggle( + workspace, + workspace.user_store().clone(), + workspace.client().clone(), + window, + cx, + ) + }, + ); + + workspace.register_action(|workspace, _: &ResetOnboarding, _window, cx| { + update_settings_file(workspace.app_state().fs.clone(), cx, move |settings, _| { + settings + .project + .all_languages + .features + .get_or_insert_default() + .edit_prediction_provider = Some(EditPredictionProvider::None) + }); + }); + }) + .detach(); +} diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs new file mode 100644 index 0000000000..eee3f1f79e --- /dev/null +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -0,0 +1,2088 @@ +use super::*; +use crate::{udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS}; +use client::{UserStore, test::FakeServer}; +use clock::{FakeSystemClock, ReplicaId}; +use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; +use cloud_llm_client::{ + EditPredictionRejectReason, EditPredictionRejection, PredictEditsBody, PredictEditsResponse, + RejectEditPredictionsBody, +}; +use futures::{ + AsyncReadExt, StreamExt, + channel::{mpsc, oneshot}, +}; +use gpui::{ + Entity, TestAppContext, + http_client::{FakeHttpClient, Response}, +}; +use indoc::indoc; +use language::{Point, ToOffset as _}; +use lsp::LanguageServerId; +use open_ai::Usage; +use parking_lot::Mutex; +use pretty_assertions::{assert_eq, assert_matches}; +use project::{FakeFs, Project}; +use serde_json::json; +use settings::SettingsStore; +use std::{path::Path, sync::Arc, time::Duration}; +use util::{path, rel_path::rel_path}; +use uuid::Uuid; +use zeta_prompt::ZetaPromptInput; + +use crate::{BufferEditPrediction, EditPredictionId, EditPredictionStore, REJECT_REQUEST_DEBOUNCE}; + +#[gpui::test] +async fn test_current_state(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "1.txt": "Hello!\nHow\nBye\n", + "2.txt": "Hola!\nComo\nAdios\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer1 = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("/root/1.txt"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot1.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_project(&project, cx); + ep_store.register_buffer(&buffer1, &project, cx); + }); + + // Prediction for current file + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx) + }); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + respond_tx + .send(model_response( + request, + indoc! {r" + --- a/root/1.txt + +++ b/root/1.txt + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) + .unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer1, None, &project, cx) + .unwrap(); + assert_matches!(prediction, BufferEditPrediction::Local { .. }); + }); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project); + }); + + // Prediction for diagnostic in another file + + let diagnostic = lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "Sentence is incomplete".to_string(), + ..Default::default() + }; + + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), + diagnostics: vec![diagnostic], + version: None, + }, + None, + language::DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx + .send(model_response( + request, + indoc! {r#" + --- a/root/2.txt + +++ b/root/2.txt + @@ ... @@ + Hola! + -Como + +Como estas? + Adios + "#}, + )) + .unwrap(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer1, None, &project, cx) + .unwrap(); + assert_matches!( + prediction, + BufferEditPrediction::Jump { prediction } if prediction.snapshot.file().unwrap().full_path(cx) == Path::new(path!("root/2.txt")) + ); + }); + + let buffer2 = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/2.txt"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer2, None, &project, cx) + .unwrap(); + assert_matches!(prediction, BufferEditPrediction::Local { .. }); + }); +} + +#[gpui::test] +async fn test_simple_request(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + // TODO Put back when we have a structured request again + // assert_eq!( + // request.excerpt_path.as_ref(), + // Path::new(path!("root/foo.md")) + // ); + // assert_eq!( + // request.cursor_point, + // Point { + // line: Line(1), + // column: 3 + // } + // ); + + respond_tx + .send(model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) + .unwrap(); + + let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); + + assert_eq!(prediction.edits.len(), 1); + assert_eq!( + prediction.edits[0].0.to_point(&snapshot).start, + language::Point::new(1, 3) + ); + assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); +} + +#[gpui::test] +async fn test_request_events(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\n\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(7..7, "How")], None, cx); + }); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + let prompt = prompt_from_request(&request); + assert!( + prompt.contains(indoc! {" + --- a/root/foo.md + +++ b/root/foo.md + @@ -1,3 +1,3 @@ + Hello! + - + +How + Bye + "}), + "{prompt}" + ); + + respond_tx + .send(model_response( + request, + indoc! {r#" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "#}, + )) + .unwrap(); + + let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); + + assert_eq!(prediction.edits.len(), 1); + assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); +} + +#[gpui::test] +async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\n\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + // First burst: insert "How" + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(7..7, "How")], None, cx); + }); + + // Simulate a pause longer than the grouping threshold (e.g. 500ms). + cx.executor().advance_clock(LAST_CHANGE_GROUPING_TIME * 2); + cx.run_until_parked(); + + // Second burst: append " are you?" immediately after "How" on the same line. + // + // Keeping both bursts on the same line ensures the existing line-span coalescing logic + // groups them into a single `LastEvent`, allowing the pause-split getter to return two diffs. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(10..10, " are you?")], None, cx); + }); + + // A second edit shortly after the first post-pause edit ensures the last edit timestamp is + // advanced after the pause boundary is recorded, making pause-splitting deterministic. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(19..19, "!")], None, cx); + }); + + // Without time-based splitting, there is one event. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!(events.len(), 1); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How are you?! + Bye + "} + ); + + // With time-based splitting, there are two distinct events. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + assert_eq!(events.len(), 2); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How + Bye + "} + ); + + let zeta_prompt::Event::BufferChange { diff, .. } = events[1].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + -How + +How are you?! + Bye + "} + ); +} + +#[gpui::test] +async fn test_empty_prediction(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let response = model_response(request, ""); + let id = response.id.clone(); + respond_tx.send(response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .is_none() + ); + }); + + // prediction is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: id, + reason: EditPredictionRejectReason::Empty, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_interpolated_empty(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + buffer.update(cx, |buffer, cx| { + buffer.set_text("Hello!\nHow are you?\nBye", cx); + }); + + let response = model_response(request, SIMPLE_DIFF); + let id = response.id.clone(); + respond_tx.send(response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .is_none() + ); + }); + + // prediction is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: id, + reason: EditPredictionRejectReason::InterpolatedEmpty, + was_shown: false + }] + ); +} + +const SIMPLE_DIFF: &str = indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye +"}; + +#[gpui::test] +async fn test_replace_current(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_tx.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // a second request is triggered + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let second_response = model_response(request, SIMPLE_DIFF); + let second_id = second_response.id.clone(); + respond_tx.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // second replaces first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + // first is reported as replaced + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Replaced, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_current_preferred(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_tx.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // a second request is triggered + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + // worse than current prediction + let second_response = model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are + Bye + "}, + ); + let second_id = second_response.id.clone(); + respond_tx.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // first is preferred over second + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // second is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: second_id, + reason: EditPredictionRejectReason::CurrentPreferred, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + // start two refresh tasks + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request1, respond_first) = requests.predict.next().await.unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_second) = requests.predict.next().await.unwrap(); + + // wait for throttle + cx.run_until_parked(); + + // second responds first + let second_response = model_response(request, SIMPLE_DIFF); + let second_id = second_response.id.clone(); + respond_second.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is second + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + let first_response = model_response(request1, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_first.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is still second, since first was cancelled + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + // first is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + cx.run_until_parked(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Canceled, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + // start two refresh tasks + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request1, respond_first) = requests.predict.next().await.unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request2, respond_second) = requests.predict.next().await.unwrap(); + + // wait for throttle, so requests are sent + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // start a third request + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + + // 2 are pending, so 2nd is cancelled + assert_eq!( + ep_store + .get_or_init_project(&project, cx) + .cancelled_predictions + .iter() + .copied() + .collect::>(), + [1] + ); + }); + + // wait for throttle + cx.run_until_parked(); + + let (request3, respond_third) = requests.predict.next().await.unwrap(); + + let first_response = model_response(request1, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_first.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + let cancelled_response = model_response(request2, SIMPLE_DIFF); + let cancelled_id = cancelled_response.id.clone(); + respond_second.send(cancelled_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is still first, since second was cancelled + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + let third_response = model_response(request3, SIMPLE_DIFF); + let third_response_id = third_response.id.clone(); + respond_third.send(third_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // third completes and replaces first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + third_response_id + ); + }); + + // second is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + cx.run_until_parked(); + + assert_eq!( + &reject_request.rejections, + &[ + EditPredictionRejection { + request_id: cancelled_id, + reason: EditPredictionRejectReason::Canceled, + was_shown: false + }, + EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Replaced, + was_shown: false + } + ] + ); +} + +#[gpui::test] +async fn test_rejections_flushing(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("test-1".into()), + EditPredictionRejectReason::Discarded, + false, + ); + ep_store.reject_prediction( + EditPredictionId("test-2".into()), + EditPredictionRejectReason::Canceled, + true, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + // batched + assert_eq!(reject_request.rejections.len(), 2); + assert_eq!( + reject_request.rejections[0], + EditPredictionRejection { + request_id: "test-1".to_string(), + reason: EditPredictionRejectReason::Discarded, + was_shown: false + } + ); + assert_eq!( + reject_request.rejections[1], + EditPredictionRejection { + request_id: "test-2".to_string(), + reason: EditPredictionRejectReason::Canceled, + was_shown: true + } + ); + + // Reaching batch size limit sends without debounce + ep_store.update(cx, |ep_store, _cx| { + for i in 0..70 { + ep_store.reject_prediction( + EditPredictionId(format!("batch-{}", i).into()), + EditPredictionRejectReason::Discarded, + false, + ); + } + }); + + // First MAX/2 items are sent immediately + cx.run_until_parked(); + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 50); + assert_eq!(reject_request.rejections[0].request_id, "batch-0"); + assert_eq!(reject_request.rejections[49].request_id, "batch-49"); + + // Remaining items are debounced with the next batch + cx.executor().advance_clock(Duration::from_secs(15)); + cx.run_until_parked(); + + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 20); + assert_eq!(reject_request.rejections[0].request_id, "batch-50"); + assert_eq!(reject_request.rejections[19].request_id, "batch-69"); + + // Request failure + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("retry-1".into()), + EditPredictionRejectReason::Discarded, + false, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + let (reject_request, _respond_tx) = requests.reject.next().await.unwrap(); + assert_eq!(reject_request.rejections.len(), 1); + assert_eq!(reject_request.rejections[0].request_id, "retry-1"); + // Simulate failure + drop(_respond_tx); + + // Add another rejection + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("retry-2".into()), + EditPredictionRejectReason::Discarded, + false, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + // Retry should include both the failed item and the new one + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 2); + assert_eq!(reject_request.rejections[0].request_id, "retry-1"); + assert_eq!(reject_request.rejections[1].request_id, "retry-2"); +} + +// Skipped until we start including diagnostics in prompt +// #[gpui::test] +// async fn test_request_diagnostics(cx: &mut TestAppContext) { +// let (ep_store, mut req_rx) = init_test_with_fake_client(cx); +// let fs = FakeFs::new(cx.executor()); +// fs.insert_tree( +// "/root", +// json!({ +// "foo.md": "Hello!\nBye" +// }), +// ) +// .await; +// let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + +// let path_to_buffer_uri = lsp::Uri::from_file_path(path!("/root/foo.md")).unwrap(); +// let diagnostic = lsp::Diagnostic { +// range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), +// severity: Some(lsp::DiagnosticSeverity::ERROR), +// message: "\"Hello\" deprecated. Use \"Hi\" instead".to_string(), +// ..Default::default() +// }; + +// project.update(cx, |project, cx| { +// project.lsp_store().update(cx, |lsp_store, cx| { +// // Create some diagnostics +// lsp_store +// .update_diagnostics( +// LanguageServerId(0), +// lsp::PublishDiagnosticsParams { +// uri: path_to_buffer_uri.clone(), +// diagnostics: vec![diagnostic], +// version: None, +// }, +// None, +// language::DiagnosticSourceKind::Pushed, +// &[], +// cx, +// ) +// .unwrap(); +// }); +// }); + +// let buffer = project +// .update(cx, |project, cx| { +// let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); +// project.open_buffer(path, cx) +// }) +// .await +// .unwrap(); + +// let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); +// let position = snapshot.anchor_before(language::Point::new(0, 0)); + +// let _prediction_task = ep_store.update(cx, |ep_store, cx| { +// ep_store.request_prediction(&project, &buffer, position, cx) +// }); + +// let (request, _respond_tx) = req_rx.next().await.unwrap(); + +// assert_eq!(request.diagnostic_groups.len(), 1); +// let value = serde_json::from_str::(request.diagnostic_groups[0].0.get()) +// .unwrap(); +// // We probably don't need all of this. TODO define a specific diagnostic type in predict_edits_v3 +// assert_eq!( +// value, +// json!({ +// "entries": [{ +// "range": { +// "start": 8, +// "end": 10 +// }, +// "diagnostic": { +// "source": null, +// "code": null, +// "code_description": null, +// "severity": 1, +// "message": "\"Hello\" deprecated. Use \"Hi\" instead", +// "markdown": null, +// "group_id": 0, +// "is_primary": true, +// "is_disk_based": false, +// "is_unnecessary": false, +// "source_kind": "Pushed", +// "data": null, +// "underline": true +// } +// }], +// "primary_ix": 0 +// }) +// ); +// } + +// Generate a model response that would apply the given diff to the active file. +fn model_response(request: open_ai::Request, diff_to_apply: &str) -> open_ai::Response { + let prompt = match &request.messages[0] { + open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(content), + } => content, + _ => panic!("unexpected request {request:?}"), + }; + + let open = "\n"; + let close = ""; + let cursor = "<|user_cursor|>"; + + let start_ix = open.len() + prompt.find(open).unwrap(); + let end_ix = start_ix + &prompt[start_ix..].find(close).unwrap(); + let excerpt = prompt[start_ix..end_ix].replace(cursor, ""); + let new_excerpt = apply_diff_to_string(diff_to_apply, &excerpt).unwrap(); + + open_ai::Response { + id: Uuid::new_v4().to_string(), + object: "response".into(), + created: 0, + model: "model".into(), + choices: vec![open_ai::Choice { + index: 0, + message: open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain(new_excerpt)), + tool_calls: vec![], + }, + finish_reason: None, + }], + usage: Usage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + } +} + +fn prompt_from_request(request: &open_ai::Request) -> &str { + assert_eq!(request.messages.len(), 1); + let open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(content), + .. + } = &request.messages[0] + else { + panic!( + "Request does not have single user message of type Plain. {:#?}", + request + ); + }; + content +} + +struct RequestChannels { + predict: mpsc::UnboundedReceiver<(open_ai::Request, oneshot::Sender)>, + reject: mpsc::UnboundedReceiver<(RejectEditPredictionsBody, oneshot::Sender<()>)>, +} + +fn init_test_with_fake_client( + cx: &mut TestAppContext, +) -> (Entity, RequestChannels) { + cx.update(move |cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + zlog::init_test(); + + let (predict_req_tx, predict_req_rx) = mpsc::unbounded(); + let (reject_req_tx, reject_req_rx) = mpsc::unbounded(); + + let http_client = FakeHttpClient::create({ + move |req| { + let uri = req.uri().path().to_string(); + let mut body = req.into_body(); + let predict_req_tx = predict_req_tx.clone(); + let reject_req_tx = reject_req_tx.clone(); + async move { + let resp = match uri.as_str() { + "/client/llm_tokens" => serde_json::to_string(&json!({ + "token": "test" + })) + .unwrap(), + "/predict_edits/raw" => { + let mut buf = Vec::new(); + body.read_to_end(&mut buf).await.ok(); + let req = serde_json::from_slice(&buf).unwrap(); + + let (res_tx, res_rx) = oneshot::channel(); + predict_req_tx.unbounded_send((req, res_tx)).unwrap(); + serde_json::to_string(&res_rx.await?).unwrap() + } + "/predict_edits/reject" => { + let mut buf = Vec::new(); + body.read_to_end(&mut buf).await.ok(); + let req = serde_json::from_slice(&buf).unwrap(); + + let (res_tx, res_rx) = oneshot::channel(); + reject_req_tx.unbounded_send((req, res_tx)).unwrap(); + serde_json::to_string(&res_rx.await?).unwrap() + } + _ => { + panic!("Unexpected path: {}", uri) + } + }; + + Ok(Response::builder().body(resp.into()).unwrap()) + } + } + }); + + let client = client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); + client.cloud_client().set_credentials(1, "test".into()); + + language_model::init(client.clone(), cx); + + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let ep_store = EditPredictionStore::global(&client, &user_store, cx); + + ( + ep_store, + RequestChannels { + predict: predict_req_rx, + reject: reject_req_rx, + }, + ) + }) +} + +const BSD_0_TXT: &str = include_str!("../license_examples/0bsd.txt"); + +#[gpui::test] +async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); + let edits: Arc<[(Range, Arc)]> = cx.update(|cx| { + to_completion_edits([(2..5, "REM".into()), (9..11, "".into())], &buffer, cx).into() + }); + + let edit_preview = cx + .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) + .await; + + let prediction = EditPrediction { + edits, + edit_preview, + buffer: buffer.clone(), + snapshot: cx.read(|cx| buffer.read(cx).snapshot()), + id: EditPredictionId("the-id".into()), + inputs: ZetaPromptInput { + events: Default::default(), + related_files: Default::default(), + cursor_path: Path::new("").into(), + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, + cursor_offset_in_excerpt: 0, + }, + buffer_snapshotted_at: Instant::now(), + response_received_at: Instant::now(), + }; + + cx.update(|cx| { + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".into()), (9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..2, "REM".into()), (6..8, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".into()), (9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(3..3, "EM".into()), (7..9, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into()), (8..10, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into()), (8..10, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); + assert_eq!(prediction.interpolate(&buffer.read(cx).snapshot()), None); + }) +} + +#[gpui::test] +async fn test_clean_up_diff(cx: &mut TestAppContext) { + init_test(cx); + + assert_eq!( + apply_edit_prediction( + indoc! {" + fn main() { + let word_1 = \"lorem\"; + let range = word.len()..word.len(); + } + "}, + indoc! {" + <|editable_region_start|> + fn main() { + let word_1 = \"lorem\"; + let range = word_1.len()..word_1.len(); + } + + <|editable_region_end|> + "}, + cx, + ) + .await, + indoc! {" + fn main() { + let word_1 = \"lorem\"; + let range = word_1.len()..word_1.len(); + } + "}, + ); + + assert_eq!( + apply_edit_prediction( + indoc! {" + fn main() { + let story = \"the quick\" + } + "}, + indoc! {" + <|editable_region_start|> + fn main() { + let story = \"the quick brown fox jumps over the lazy dog\"; + } + + <|editable_region_end|> + "}, + cx, + ) + .await, + indoc! {" + fn main() { + let story = \"the quick brown fox jumps over the lazy dog\"; + } + "}, + ); +} + +#[gpui::test] +async fn test_edit_prediction_end_of_buffer(cx: &mut TestAppContext) { + init_test(cx); + + let buffer_content = "lorem\n"; + let completion_response = indoc! {" + ```animals.js + <|start_of_file|> + <|editable_region_start|> + lorem + ipsum + <|editable_region_end|> + ```"}; + + assert_eq!( + apply_edit_prediction(buffer_content, completion_response, cx).await, + "lorem\nipsum" + ); +} + +#[gpui::test] +async fn test_can_collect_data(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({ "LICENSE": BSD_0_TXT })) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/src/main.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Disabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_remote_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + + let buffer = cx.new(|_cx| { + Buffer::remote( + language::BufferId::new(1).unwrap(), + ReplicaId::new(1), + language::Capability::ReadWrite, + "fn main() {\n println!(\"Hello\");\n}", + ) + }); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_private_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "LICENSE": BSD_0_TXT, + ".env": "SECRET_KEY=secret" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/project/.env", cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_untitled_buffer(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + let buffer = cx.new(|cx| Buffer::local("", cx)); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_when_closed_source(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({ "main.rs": "fn main() {}" })) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/project/main.rs", cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_data_collection_status_changes_on_move(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/open_source_worktree"), + json!({ "LICENSE": BSD_0_TXT, "main.rs": "" }), + ) + .await; + fs.insert_tree(path!("/closed_source_worktree"), json!({ "main.rs": "" })) + .await; + + let project = Project::test( + fs.clone(), + [ + path!("/open_source_worktree").as_ref(), + path!("/closed_source_worktree").as_ref(), + ], + cx, + ) + .await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/open_source_worktree/main.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + let closed_source_file = project + .update(cx, |project, cx| { + let worktree2 = project + .worktree_for_root_name("closed_source_worktree", cx) + .unwrap(); + worktree2.update(cx, |worktree2, cx| { + worktree2.load_file(rel_path("main.rs"), cx) + }) + }) + .await + .unwrap() + .file; + + buffer.update(cx, |buffer, cx| { + buffer.file_updated(closed_source_file, cx); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_events_in_uncollectable_buffers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/worktree1"), + json!({ "LICENSE": BSD_0_TXT, "main.rs": "", "other.rs": "" }), + ) + .await; + fs.insert_tree(path!("/worktree2"), json!({ "private.rs": "" })) + .await; + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/worktree1/main.rs"), cx) + }) + .await + .unwrap(); + let private_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/worktree2/file.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + // this has a side effect of registering the buffer to watch for edits + run_edit_prediction(&private_buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); + + private_buffer.update(cx, |private_buffer, cx| { + private_buffer.edit([(0..0, "An edit for the history!")], None, cx); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); + + // make an edit that uses too many bytes, causing private_buffer edit to not be able to be + // included + buffer.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + " ".repeat(MAX_EVENT_TOKENS * cursor_excerpt::BYTES_PER_TOKEN_GUESS), + )], + None, + cx, + ); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); +} + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); +} + +async fn apply_edit_prediction( + buffer_content: &str, + completion_response: &str, + cx: &mut TestAppContext, +) -> String { + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); + let (ep_store, _, response) = make_test_ep_store(&project, cx).await; + *response.lock() = completion_response.to_string(); + let edit_prediction = run_edit_prediction(&buffer, &project, &ep_store, cx).await; + buffer.update(cx, |buffer, cx| { + buffer.edit(edit_prediction.edits.iter().cloned(), None, cx) + }); + buffer.read_with(cx, |buffer, _| buffer.text()) +} + +async fn run_edit_prediction( + buffer: &Entity, + project: &Entity, + ep_store: &Entity, + cx: &mut TestAppContext, +) -> EditPrediction { + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, buffer, cursor, Default::default(), cx) + }); + prediction_task.await.unwrap().unwrap().prediction.unwrap() +} + +async fn make_test_ep_store( + project: &Entity, + cx: &mut TestAppContext, +) -> ( + Entity, + Arc>>, + Arc>, +) { + let default_response = indoc! {" + ```main.rs + <|start_of_file|> + <|editable_region_start|> + hello world + <|editable_region_end|> + ```" + }; + let captured_request: Arc>> = Arc::new(Mutex::new(None)); + let completion_response: Arc> = + Arc::new(Mutex::new(default_response.to_string())); + let http_client = FakeHttpClient::create({ + let captured_request = captured_request.clone(); + let completion_response = completion_response.clone(); + let mut next_request_id = 0; + move |req| { + let captured_request = captured_request.clone(); + let completion_response = completion_response.clone(); + async move { + match (req.method(), req.uri().path()) { + (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&CreateLlmTokenResponse { + token: LlmToken("the-llm-token".to_string()), + }) + .unwrap() + .into(), + ) + .unwrap()), + (&Method::POST, "/predict_edits/v2") => { + let mut request_body = String::new(); + req.into_body().read_to_string(&mut request_body).await?; + *captured_request.lock() = + Some(serde_json::from_str(&request_body).unwrap()); + next_request_id += 1; + Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&PredictEditsResponse { + request_id: format!("request-{next_request_id}"), + output_excerpt: completion_response.lock().clone(), + }) + .unwrap() + .into(), + ) + .unwrap()) + } + _ => Ok(http_client::Response::builder() + .status(404) + .body("Not Found".into()) + .unwrap()), + } + } + } + }); + + let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + RefreshLlmTokenListener::register(client.clone(), cx); + }); + let _server = FakeServer::for_client(42, &client, cx).await; + + let ep_store = cx.new(|cx| { + let mut ep_store = EditPredictionStore::new(client, project.read(cx).user_store(), cx); + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + + let worktrees = project.read(cx).worktrees(cx).collect::>(); + for worktree in worktrees { + let worktree_id = worktree.read(cx).id(); + ep_store + .get_or_init_project(project, cx) + .license_detection_watchers + .entry(worktree_id) + .or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(&worktree, cx))); + } + + ep_store + }); + + (ep_store, captured_request, completion_response) +} + +fn to_completion_edits( + iterator: impl IntoIterator, Arc)>, + buffer: &Entity, + cx: &App, +) -> Vec<(Range, Arc)> { + let buffer = buffer.read(cx); + iterator + .into_iter() + .map(|(range, text)| { + ( + buffer.anchor_after(range.start)..buffer.anchor_before(range.end), + text, + ) + }) + .collect() +} + +fn from_completion_edits( + editor_edits: &[(Range, Arc)], + buffer: &Entity, + cx: &App, +) -> Vec<(Range, Arc)> { + let buffer = buffer.read(cx); + editor_edits + .iter() + .map(|(range, text)| { + ( + range.start.to_offset(buffer)..range.end.to_offset(buffer), + text.clone(), + ) + }) + .collect() +} + +#[gpui::test] +async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let http_client = FakeHttpClient::create(|_req| async move { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let result = completion_task.await; + assert!( + result.is_err(), + "Without authentication and without custom URL, prediction should fail" + ); +} + +#[gpui::test] +async fn test_unauthenticated_with_custom_url_allows_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let predict_called = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let predict_called_clone = predict_called.clone(); + + let http_client = FakeHttpClient::create({ + move |req| { + let uri = req.uri().path().to_string(); + let predict_called = predict_called_clone.clone(); + async move { + if uri.contains("predict") { + predict_called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(gpui::http_client::Response::builder() + .body( + serde_json::to_string(&open_ai::Response { + id: "test-123".to_string(), + object: "chat.completion".to_string(), + created: 0, + model: "test".to_string(), + usage: open_ai::Usage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + choices: vec![open_ai::Choice { + index: 0, + message: open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain( + indoc! {" + ```main.rs + <|start_of_file|> + <|editable_region_start|> + fn main() { + println!(\"Hello, world!\"); + } + <|editable_region_end|> + ``` + "} + .to_string(), + )), + tool_calls: vec![], + }, + finish_reason: Some("stop".to_string()), + }], + }) + .unwrap() + .into(), + ) + .unwrap()) + } else { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + } + } + } + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_custom_predict_edits_url(Url::parse("http://test/predict").unwrap()); + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let _ = completion_task.await; + + assert!( + predict_called.load(std::sync::atomic::Ordering::SeqCst), + "With custom URL, predict endpoint should be called even without authentication" + ); +} + +#[ctor::ctor] +fn init_logger() { + zlog::init_test(); +} diff --git a/crates/edit_prediction/src/example_spec.rs b/crates/edit_prediction/src/example_spec.rs new file mode 100644 index 0000000000..bf221b576b --- /dev/null +++ b/crates/edit_prediction/src/example_spec.rs @@ -0,0 +1,212 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt::Write as _, mem, path::Path, sync::Arc}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleSpec { + #[serde(default)] + pub name: String, + pub repository_url: String, + pub revision: String, + #[serde(default)] + pub uncommitted_diff: String, + pub cursor_path: Arc, + pub cursor_position: String, + pub edit_history: String, + pub expected_patch: String, +} + +const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; +const EDIT_HISTORY_HEADING: &str = "Edit History"; +const CURSOR_POSITION_HEADING: &str = "Cursor Position"; +const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; +const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; +const REPOSITORY_URL_FIELD: &str = "repository_url"; +const REVISION_FIELD: &str = "revision"; + +impl ExampleSpec { + /// Format this example spec as markdown. + pub fn to_markdown(&self) -> String { + let mut markdown = String::new(); + + _ = writeln!(markdown, "# {}", self.name); + markdown.push('\n'); + + _ = writeln!(markdown, "repository_url = {}", self.repository_url); + _ = writeln!(markdown, "revision = {}", self.revision); + markdown.push('\n'); + + if !self.uncommitted_diff.is_empty() { + _ = writeln!(markdown, "## {}", UNCOMMITTED_DIFF_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.uncommitted_diff); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING); + _ = writeln!(markdown); + + if self.edit_history.is_empty() { + _ = writeln!(markdown, "(No edit history)"); + _ = writeln!(markdown); + } else { + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.edit_history); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", CURSOR_POSITION_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```{}", self.cursor_path.to_string_lossy()); + markdown.push_str(&self.cursor_position); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + _ = writeln!(markdown, "## {}", EXPECTED_PATCH_HEADING); + markdown.push('\n'); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.expected_patch); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + markdown + } + + /// Parse an example spec from markdown. + pub fn from_markdown(name: String, input: &str) -> anyhow::Result { + use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd}; + + let parser = Parser::new(input); + + let mut spec = ExampleSpec { + name, + repository_url: String::new(), + revision: String::new(), + uncommitted_diff: String::new(), + cursor_path: Path::new("").into(), + cursor_position: String::new(), + edit_history: String::new(), + expected_patch: String::new(), + }; + + let mut text = String::new(); + let mut block_info: CowStr = "".into(); + + #[derive(PartialEq)] + enum Section { + Start, + UncommittedDiff, + EditHistory, + CursorPosition, + ExpectedExcerpts, + ExpectedPatch, + Other, + } + + let mut current_section = Section::Start; + + for event in parser { + match event { + Event::Text(line) => { + text.push_str(&line); + + if let Section::Start = current_section + && let Some((field, value)) = line.split_once('=') + { + match field.trim() { + REPOSITORY_URL_FIELD => { + spec.repository_url = value.trim().to_string(); + } + REVISION_FIELD => { + spec.revision = value.trim().to_string(); + } + _ => {} + } + } + } + Event::End(TagEnd::Heading(HeadingLevel::H2)) => { + let title = mem::take(&mut text); + current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { + Section::UncommittedDiff + } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { + Section::EditHistory + } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { + Section::CursorPosition + } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { + Section::ExpectedPatch + } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { + Section::ExpectedExcerpts + } else { + Section::Other + }; + } + Event::End(TagEnd::Heading(HeadingLevel::H3)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(HeadingLevel::H4)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(level)) => { + anyhow::bail!("Unexpected heading level: {level}"); + } + Event::Start(Tag::CodeBlock(kind)) => { + match kind { + CodeBlockKind::Fenced(info) => { + block_info = info; + } + CodeBlockKind::Indented => { + anyhow::bail!("Unexpected indented codeblock"); + } + }; + } + Event::Start(_) => { + text.clear(); + block_info = "".into(); + } + Event::End(TagEnd::CodeBlock) => { + let block_info = block_info.trim(); + match current_section { + Section::UncommittedDiff => { + spec.uncommitted_diff = mem::take(&mut text); + } + Section::EditHistory => { + spec.edit_history.push_str(&mem::take(&mut text)); + } + Section::CursorPosition => { + spec.cursor_path = Path::new(block_info).into(); + spec.cursor_position = mem::take(&mut text); + } + Section::ExpectedExcerpts => { + mem::take(&mut text); + } + Section::ExpectedPatch => { + spec.expected_patch = mem::take(&mut text); + } + Section::Start | Section::Other => {} + } + } + _ => {} + } + } + + if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() { + anyhow::bail!("Missing cursor position codeblock"); + } + + Ok(spec) + } +} diff --git a/crates/zeta/src/license_detection.rs b/crates/edit_prediction/src/license_detection.rs similarity index 99% rename from crates/zeta/src/license_detection.rs rename to crates/edit_prediction/src/license_detection.rs index 44dae09d0a..3ad34e7e6d 100644 --- a/crates/zeta/src/license_detection.rs +++ b/crates/edit_prediction/src/license_detection.rs @@ -390,8 +390,7 @@ mod tests { use gpui::TestAppContext; use rand::Rng as _; use serde_json::json; - use settings::{Settings as _, SettingsStore}; - use worktree::WorktreeSettings; + use settings::SettingsStore; use super::*; @@ -720,7 +719,6 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - WorktreeSettings::register(cx); }); } @@ -737,6 +735,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -760,6 +759,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -818,6 +818,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs new file mode 100644 index 0000000000..b47bd2ad03 --- /dev/null +++ b/crates/edit_prediction/src/mercury.rs @@ -0,0 +1,317 @@ +use crate::{ + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, open_ai_response::text_from_response, + prediction::EditPredictionResult, +}; +use anyhow::{Context as _, Result}; +use futures::AsyncReadExt as _; +use gpui::{ + App, AppContext as _, Entity, SharedString, Task, + http_client::{self, AsyncBody, Method}, +}; +use language::{OffsetRangeExt as _, ToOffset, ToPoint as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; +use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; +use zeta_prompt::ZetaPromptInput; + +const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; + +pub struct Mercury { + pub api_token: Entity, +} + +impl Mercury { + pub fn new(cx: &mut App) -> Self { + Mercury { + api_token: mercury_api_token(cx), + } + } + + pub(crate) fn request_prediction( + &self, + EditPredictionModelInput { + buffer, + snapshot, + position, + events, + related_files, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut App, + ) -> Task>> { + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else { + return Task::ready(Ok(None)); + }; + let full_path: Arc = snapshot + .file() + .map(|file| file.full_path(cx)) + .unwrap_or_else(|| "untitled".into()) + .into(); + + let http_client = cx.http_client(); + let cursor_point = position.to_point(&snapshot); + let buffer_snapshotted_at = Instant::now(); + let active_buffer = buffer.clone(); + + let result = cx.background_spawn(async move { + let (editable_range, context_range) = + crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + &snapshot, + MAX_CONTEXT_TOKENS, + MAX_REWRITE_TOKENS, + ); + + let context_offset_range = context_range.to_offset(&snapshot); + + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = zeta_prompt::ZetaPromptInput { + events, + related_files, + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) + - context_range.start.to_offset(&snapshot), + cursor_path: full_path.clone(), + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_offset_range.start + - context_offset_range.start) + ..(editable_offset_range.end - context_offset_range.start), + }; + + let prompt = build_prompt(&inputs); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: active_buffer.downgrade(), + prompt: Some(prompt.clone()), + position, + }, + )) + .ok(); + } + + let request_body = open_ai::Request { + model: "mercury-coder".into(), + messages: vec![open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(prompt), + }], + stream: false, + max_completion_tokens: None, + stop: vec![], + temperature: None, + tool_choice: None, + parallel_tool_calls: None, + tools: vec![], + prompt_cache_key: None, + reasoning_effort: None, + }; + + let buf = serde_json::to_vec(&request_body)?; + let body: AsyncBody = buf.into(); + + let request = http_client::Request::builder() + .uri(MERCURY_API_URL) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_token)) + .header("Connection", "keep-alive") + .method(Method::POST) + .body(body) + .context("Failed to create request")?; + + let mut response = http_client + .send(request) + .await + .context("Failed to send request")?; + + let mut body: Vec = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("Failed to read response body")?; + + let response_received_at = Instant::now(); + if !response.status().is_success() { + anyhow::bail!( + "Request failed with status: {:?}\nBody: {}", + response.status(), + String::from_utf8_lossy(&body), + ); + }; + + let mut response: open_ai::Response = + serde_json::from_slice(&body).context("Failed to parse response")?; + + let id = mem::take(&mut response.id); + let response_str = text_from_response(response).unwrap_or_default(); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: active_buffer.downgrade(), + model_output: Some(response_str.clone()), + position, + }, + )) + .ok(); + } + + let response_str = response_str.strip_prefix("```\n").unwrap_or(&response_str); + let response_str = response_str.strip_suffix("\n```").unwrap_or(&response_str); + + let mut edits = Vec::new(); + const NO_PREDICTION_OUTPUT: &str = "None"; + + if response_str != NO_PREDICTION_OUTPUT { + let old_text = snapshot + .text_for_range(editable_offset_range.clone()) + .collect::(); + edits.extend( + language::text_diff(&old_text, &response_str) + .into_iter() + .map(|(range, text)| { + ( + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot + .anchor_before(editable_offset_range.start + range.end), + text, + ) + }), + ); + } + + anyhow::Ok((id, edits, snapshot, response_received_at, inputs)) + }); + + cx.spawn(async move |cx| { + let (id, edits, old_snapshot, response_received_at, inputs) = + result.await.context("Mercury edit prediction failed")?; + anyhow::Ok(Some( + EditPredictionResult::new( + EditPredictionId(id.into()), + &buffer, + &old_snapshot, + edits.into(), + buffer_snapshotted_at, + response_received_at, + inputs, + cx, + ) + .await, + )) + }) + } +} + +fn build_prompt(inputs: &ZetaPromptInput) -> String { + const RECENTLY_VIEWED_SNIPPETS_START: &str = "<|recently_viewed_code_snippets|>\n"; + const RECENTLY_VIEWED_SNIPPETS_END: &str = "<|/recently_viewed_code_snippets|>\n"; + const RECENTLY_VIEWED_SNIPPET_START: &str = "<|recently_viewed_code_snippet|>\n"; + const RECENTLY_VIEWED_SNIPPET_END: &str = "<|/recently_viewed_code_snippet|>\n"; + const CURRENT_FILE_CONTENT_START: &str = "<|current_file_content|>\n"; + const CURRENT_FILE_CONTENT_END: &str = "<|/current_file_content|>\n"; + const CODE_TO_EDIT_START: &str = "<|code_to_edit|>\n"; + const CODE_TO_EDIT_END: &str = "<|/code_to_edit|>\n"; + const EDIT_DIFF_HISTORY_START: &str = "<|edit_diff_history|>\n"; + const EDIT_DIFF_HISTORY_END: &str = "<|/edit_diff_history|>\n"; + const CURSOR_TAG: &str = "<|cursor|>"; + const CODE_SNIPPET_FILE_PATH_PREFIX: &str = "code_snippet_file_path: "; + const CURRENT_FILE_PATH_PREFIX: &str = "current_file_path: "; + + let mut prompt = String::new(); + + push_delimited( + &mut prompt, + RECENTLY_VIEWED_SNIPPETS_START..RECENTLY_VIEWED_SNIPPETS_END, + |prompt| { + for related_file in inputs.related_files.iter() { + for related_excerpt in &related_file.excerpts { + push_delimited( + prompt, + RECENTLY_VIEWED_SNIPPET_START..RECENTLY_VIEWED_SNIPPET_END, + |prompt| { + prompt.push_str(CODE_SNIPPET_FILE_PATH_PREFIX); + prompt.push_str(related_file.path.to_string_lossy().as_ref()); + prompt.push('\n'); + prompt.push_str(&related_excerpt.text.to_string()); + }, + ); + } + } + }, + ); + + push_delimited( + &mut prompt, + CURRENT_FILE_CONTENT_START..CURRENT_FILE_CONTENT_END, + |prompt| { + prompt.push_str(CURRENT_FILE_PATH_PREFIX); + prompt.push_str(inputs.cursor_path.as_os_str().to_string_lossy().as_ref()); + prompt.push('\n'); + + prompt.push_str(&inputs.cursor_excerpt[0..inputs.editable_range_in_excerpt.start]); + push_delimited(prompt, CODE_TO_EDIT_START..CODE_TO_EDIT_END, |prompt| { + prompt.push_str( + &inputs.cursor_excerpt + [inputs.editable_range_in_excerpt.start..inputs.cursor_offset_in_excerpt], + ); + prompt.push_str(CURSOR_TAG); + prompt.push_str( + &inputs.cursor_excerpt + [inputs.cursor_offset_in_excerpt..inputs.editable_range_in_excerpt.end], + ); + }); + prompt.push_str(&inputs.cursor_excerpt[inputs.editable_range_in_excerpt.end..]); + }, + ); + + push_delimited( + &mut prompt, + EDIT_DIFF_HISTORY_START..EDIT_DIFF_HISTORY_END, + |prompt| { + for event in inputs.events.iter() { + zeta_prompt::write_event(prompt, &event); + } + }, + ); + + prompt +} + +fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce(&mut String)) { + prompt.push_str(delimiters.start); + cb(prompt); + prompt.push_str(delimiters.end); +} + +pub const MERCURY_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions"); +pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token"; +pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("MERCURY_AI_TOKEN"); +pub static MERCURY_API_KEY: std::sync::OnceLock> = std::sync::OnceLock::new(); + +pub fn mercury_api_token(cx: &mut App) -> Entity { + MERCURY_API_KEY + .get_or_init(|| { + cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone())) + }) + .clone() +} + +pub fn load_mercury_api_token(cx: &mut App) -> Task> { + mercury_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx) + }) +} diff --git a/crates/zeta/src/onboarding_modal.rs b/crates/edit_prediction/src/onboarding_modal.rs similarity index 94% rename from crates/zeta/src/onboarding_modal.rs rename to crates/edit_prediction/src/onboarding_modal.rs index 94480add30..ed7adfc754 100644 --- a/crates/zeta/src/onboarding_modal.rs +++ b/crates/edit_prediction/src/onboarding_modal.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::{ZedPredictUpsell, onboarding_event}; +use crate::ZedPredictUpsell; use ai_onboarding::EditPredictionOnboarding; use client::{Client, UserStore}; use db::kvp::Dismissable; @@ -14,6 +14,16 @@ use settings::update_settings_file; use ui::{Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; +#[macro_export] +macro_rules! onboarding_event { + ($name:expr) => { + telemetry::event!($name, source = "Edit Prediction Onboarding"); + }; + ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { + telemetry::event!($name, source = "Edit Prediction Onboarding", $($key $(= $value)?),+); + }; +} + /// Introduces user to Zed's Edit Prediction feature pub struct ZedPredictModal { onboarding: Entity, diff --git a/crates/edit_prediction/src/open_ai_response.rs b/crates/edit_prediction/src/open_ai_response.rs new file mode 100644 index 0000000000..c7e3350936 --- /dev/null +++ b/crates/edit_prediction/src/open_ai_response.rs @@ -0,0 +1,31 @@ +pub fn text_from_response(mut res: open_ai::Response) -> Option { + let choice = res.choices.pop()?; + let output_text = match choice.message { + open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain(content)), + .. + } => content, + open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Multipart(mut content)), + .. + } => { + if content.is_empty() { + log::error!("No output from Baseten completion response"); + return None; + } + + match content.remove(0) { + open_ai::MessagePart::Text { text } => text, + open_ai::MessagePart::Image { .. } => { + log::error!("Expected text, got an image"); + return None; + } + } + } + _ => { + log::error!("Invalid response message: {:?}", choice.message); + return None; + } + }; + Some(output_text) +} diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs new file mode 100644 index 0000000000..c63640ccd0 --- /dev/null +++ b/crates/edit_prediction/src/prediction.rs @@ -0,0 +1,281 @@ +use std::{ + ops::Range, + sync::Arc, + time::{Duration, Instant}, +}; + +use cloud_llm_client::EditPredictionRejectReason; +use edit_prediction_types::interpolate_edits; +use gpui::{AsyncApp, Entity, SharedString}; +use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot}; +use zeta_prompt::ZetaPromptInput; + +#[derive(Clone, Default, Debug, PartialEq, Eq, Hash)] +pub struct EditPredictionId(pub SharedString); + +impl From for gpui::ElementId { + fn from(value: EditPredictionId) -> Self { + gpui::ElementId::Name(value.0) + } +} + +impl std::fmt::Display for EditPredictionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A prediction response that was returned from the provider, whether it was ultimately valid or not. +pub struct EditPredictionResult { + pub id: EditPredictionId, + pub prediction: Result, +} + +impl EditPredictionResult { + pub async fn new( + id: EditPredictionId, + edited_buffer: &Entity, + edited_buffer_snapshot: &BufferSnapshot, + edits: Arc<[(Range, Arc)]>, + buffer_snapshotted_at: Instant, + response_received_at: Instant, + inputs: ZetaPromptInput, + cx: &mut AsyncApp, + ) -> Self { + if edits.is_empty() { + return Self { + id, + prediction: Err(EditPredictionRejectReason::Empty), + }; + } + + let Some((edits, snapshot, edit_preview_task)) = edited_buffer + .read_with(cx, |buffer, cx| { + let new_snapshot = buffer.snapshot(); + let edits: Arc<[_]> = + interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits)?.into(); + + Some((edits.clone(), new_snapshot, buffer.preview_edits(edits, cx))) + }) + .ok() + .flatten() + else { + return Self { + id, + prediction: Err(EditPredictionRejectReason::InterpolatedEmpty), + }; + }; + + let edit_preview = edit_preview_task.await; + + Self { + id: id.clone(), + prediction: Ok(EditPrediction { + id, + edits, + snapshot, + edit_preview, + inputs, + buffer: edited_buffer.clone(), + buffer_snapshotted_at, + response_received_at, + }), + } + } +} + +#[derive(Clone)] +pub struct EditPrediction { + pub id: EditPredictionId, + pub edits: Arc<[(Range, Arc)]>, + pub snapshot: BufferSnapshot, + pub edit_preview: EditPreview, + pub buffer: Entity, + pub buffer_snapshotted_at: Instant, + pub response_received_at: Instant, + pub inputs: zeta_prompt::ZetaPromptInput, +} + +impl EditPrediction { + pub fn interpolate( + &self, + new_snapshot: &TextBufferSnapshot, + ) -> Option, Arc)>> { + interpolate_edits(&self.snapshot, new_snapshot, &self.edits) + } + + pub fn targets_buffer(&self, buffer: &Buffer) -> bool { + self.snapshot.remote_id() == buffer.remote_id() + } + + pub fn latency(&self) -> Duration { + self.response_received_at - self.buffer_snapshotted_at + } +} + +impl std::fmt::Debug for EditPrediction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EditPrediction") + .field("id", &self.id) + .field("edits", &self.edits) + .finish() + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + use gpui::{App, Entity, TestAppContext, prelude::*}; + use language::{Buffer, ToOffset as _}; + use zeta_prompt::ZetaPromptInput; + + #[gpui::test] + async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); + let edits: Arc<[(Range, Arc)]> = cx.update(|cx| { + to_prediction_edits([(2..5, "REM".into()), (9..11, "".into())], &buffer, cx).into() + }); + + let edit_preview = cx + .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) + .await; + + let prediction = EditPrediction { + id: EditPredictionId("prediction-1".into()), + edits, + snapshot: cx.read(|cx| buffer.read(cx).snapshot()), + buffer: buffer.clone(), + edit_preview, + inputs: ZetaPromptInput { + events: vec![], + related_files: vec![].into(), + cursor_path: Path::new("path.txt").into(), + cursor_offset_in_excerpt: 0, + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, + }, + buffer_snapshotted_at: Instant::now(), + response_received_at: Instant::now(), + }; + + cx.update(|cx| { + assert_eq!( + from_prediction_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".into()), (9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); + assert_eq!( + from_prediction_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..2, "REM".into()), (6..8, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!( + from_prediction_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".into()), (9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); + assert_eq!( + from_prediction_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(3..3, "EM".into()), (7..9, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); + assert_eq!( + from_prediction_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into()), (8..10, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); + assert_eq!( + from_prediction_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); + assert_eq!( + from_prediction_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into()), (8..10, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); + assert_eq!( + from_prediction_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); + assert_eq!(prediction.interpolate(&buffer.read(cx).snapshot()), None); + }) + } + + fn to_prediction_edits( + iterator: impl IntoIterator, Arc)>, + buffer: &Entity, + cx: &App, + ) -> Vec<(Range, Arc)> { + let buffer = buffer.read(cx); + iterator + .into_iter() + .map(|(range, text)| { + ( + buffer.anchor_after(range.start)..buffer.anchor_before(range.end), + text, + ) + }) + .collect() + } + + fn from_prediction_edits( + editor_edits: &[(Range, Arc)], + buffer: &Entity, + cx: &App, + ) -> Vec<(Range, Arc)> { + let buffer = buffer.read(cx); + editor_edits + .iter() + .map(|(range, text)| { + ( + range.start.to_offset(buffer)..range.end.to_offset(buffer), + text.clone(), + ) + }) + .collect() + } +} diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs new file mode 100644 index 0000000000..2ed24cd8ef --- /dev/null +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -0,0 +1,401 @@ +use anyhow::Result; +use futures::AsyncReadExt as _; +use gpui::{ + App, AppContext as _, Entity, SharedString, Task, + http_client::{self, AsyncBody, Method}, +}; +use language::{Point, ToOffset as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; +use lsp::DiagnosticSeverity; +use serde::{Deserialize, Serialize}; +use std::{ + fmt::{self, Write as _}, + path::Path, + sync::Arc, + time::Instant, +}; + +use crate::{EditPredictionId, EditPredictionModelInput, prediction::EditPredictionResult}; + +const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; + +pub struct SweepAi { + pub api_token: Entity, + pub debug_info: Arc, +} + +impl SweepAi { + pub fn new(cx: &mut App) -> Self { + SweepAi { + api_token: sweep_api_token(cx), + debug_info: debug_info(cx), + } + } + + pub fn request_prediction_with_sweep( + &self, + inputs: EditPredictionModelInput, + cx: &mut App, + ) -> Task>> { + let debug_info = self.debug_info.clone(); + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { + return Task::ready(Ok(None)); + }; + let full_path: Arc = inputs + .snapshot + .file() + .map(|file| file.full_path(cx)) + .unwrap_or_else(|| "untitled".into()) + .into(); + + let project_file = project::File::from_dyn(inputs.snapshot.file()); + let repo_name = project_file + .map(|file| file.worktree.read(cx).root_name_str()) + .unwrap_or("untitled") + .into(); + let offset = inputs.position.to_offset(&inputs.snapshot); + + let recent_buffers = inputs.recent_paths.iter().cloned(); + let http_client = cx.http_client(); + + let recent_buffer_snapshots = recent_buffers + .filter_map(|project_path| { + let buffer = inputs.project.read(cx).get_open_buffer(&project_path, cx)?; + if inputs.buffer == buffer { + None + } else { + Some(buffer.read(cx).snapshot()) + } + }) + .take(3) + .collect::>(); + + let buffer_snapshotted_at = Instant::now(); + + let result = cx.background_spawn(async move { + let text = inputs.snapshot.text(); + + let mut recent_changes = String::new(); + for event in &inputs.events { + write_event(event.as_ref(), &mut recent_changes).unwrap(); + } + + let mut file_chunks = recent_buffer_snapshots + .into_iter() + .map(|snapshot| { + let end_point = Point::new(30, 0).min(snapshot.max_point()); + FileChunk { + content: snapshot.text_for_range(Point::zero()..end_point).collect(), + file_path: snapshot + .file() + .map(|f| f.path().as_unix_str()) + .unwrap_or("untitled") + .to_string(), + start_line: 0, + end_line: end_point.row as usize, + timestamp: snapshot.file().and_then(|file| { + Some( + file.disk_state() + .mtime()? + .to_seconds_and_nanos_for_persistence()? + .0, + ) + }), + } + }) + .collect::>(); + + let retrieval_chunks = inputs + .related_files + .iter() + .flat_map(|related_file| { + related_file.excerpts.iter().map(|excerpt| FileChunk { + file_path: related_file.path.to_string_lossy().to_string(), + start_line: excerpt.row_range.start as usize, + end_line: excerpt.row_range.end as usize, + content: excerpt.text.to_string(), + timestamp: None, + }) + }) + .collect(); + + let diagnostic_entries = inputs + .snapshot + .diagnostics_in_range(inputs.diagnostic_search_range, false); + let mut diagnostic_content = String::new(); + let mut diagnostic_count = 0; + + for entry in diagnostic_entries { + let start_point: Point = entry.range.start; + + let severity = match entry.diagnostic.severity { + DiagnosticSeverity::ERROR => "error", + DiagnosticSeverity::WARNING => "warning", + DiagnosticSeverity::INFORMATION => "info", + DiagnosticSeverity::HINT => "hint", + _ => continue, + }; + + diagnostic_count += 1; + + writeln!( + &mut diagnostic_content, + "{} at line {}: {}", + severity, + start_point.row + 1, + entry.diagnostic.message + )?; + } + + if !diagnostic_content.is_empty() { + file_chunks.push(FileChunk { + file_path: format!("Diagnostics for {}", full_path.display()), + start_line: 0, + end_line: diagnostic_count, + content: diagnostic_content, + timestamp: None, + }); + } + + let request_body = AutocompleteRequest { + debug_info, + repo_name, + file_path: full_path.clone(), + file_contents: text.clone(), + original_file_contents: text, + cursor_position: offset, + recent_changes: recent_changes.clone(), + changes_above_cursor: true, + multiple_suggestions: false, + branch: None, + file_chunks, + retrieval_chunks, + recent_user_actions: vec![], + use_bytes: true, + // TODO + privacy_mode_enabled: false, + }; + + let mut buf: Vec = Vec::new(); + let writer = brotli::CompressorWriter::new(&mut buf, 4096, 11, 22); + serde_json::to_writer(writer, &request_body)?; + let body: AsyncBody = buf.into(); + + let ep_inputs = zeta_prompt::ZetaPromptInput { + events: inputs.events, + related_files: inputs.related_files.clone(), + cursor_path: full_path.clone(), + cursor_excerpt: request_body.file_contents.into(), + // we actually don't know + editable_range_in_excerpt: 0..inputs.snapshot.len(), + cursor_offset_in_excerpt: request_body.cursor_position, + }; + + let request = http_client::Request::builder() + .uri(SWEEP_API_URL) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_token)) + .header("Connection", "keep-alive") + .header("Content-Encoding", "br") + .method(Method::POST) + .body(body)?; + + let mut response = http_client.send(request).await?; + + let mut body: Vec = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + let response_received_at = Instant::now(); + if !response.status().is_success() { + anyhow::bail!( + "Request failed with status: {:?}\nBody: {}", + response.status(), + String::from_utf8_lossy(&body), + ); + }; + + let response: AutocompleteResponse = serde_json::from_slice(&body)?; + + let old_text = inputs + .snapshot + .text_for_range(response.start_index..response.end_index) + .collect::(); + let edits = language::text_diff(&old_text, &response.completion) + .into_iter() + .map(|(range, text)| { + ( + inputs + .snapshot + .anchor_after(response.start_index + range.start) + ..inputs + .snapshot + .anchor_before(response.start_index + range.end), + text, + ) + }) + .collect::>(); + + anyhow::Ok(( + response.autocomplete_id, + edits, + inputs.snapshot, + response_received_at, + ep_inputs, + )) + }); + + let buffer = inputs.buffer.clone(); + + cx.spawn(async move |cx| { + let (id, edits, old_snapshot, response_received_at, inputs) = result.await?; + anyhow::Ok(Some( + EditPredictionResult::new( + EditPredictionId(id.into()), + &buffer, + &old_snapshot, + edits.into(), + buffer_snapshotted_at, + response_received_at, + inputs, + cx, + ) + .await, + )) + }) + } +} + +pub const SWEEP_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://autocomplete.sweep.dev"); +pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token"; +pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("SWEEP_AI_TOKEN"); +pub static SWEEP_API_KEY: std::sync::OnceLock> = std::sync::OnceLock::new(); + +pub fn sweep_api_token(cx: &mut App) -> Entity { + SWEEP_API_KEY + .get_or_init(|| { + cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone())) + }) + .clone() +} + +pub fn load_sweep_api_token(cx: &mut App) -> Task> { + sweep_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx) + }) +} + +#[derive(Debug, Clone, Serialize)] +struct AutocompleteRequest { + pub debug_info: Arc, + pub repo_name: String, + pub branch: Option, + pub file_path: Arc, + pub file_contents: String, + pub recent_changes: String, + pub cursor_position: usize, + pub original_file_contents: String, + pub file_chunks: Vec, + pub retrieval_chunks: Vec, + pub recent_user_actions: Vec, + pub multiple_suggestions: bool, + pub privacy_mode_enabled: bool, + pub changes_above_cursor: bool, + pub use_bytes: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct FileChunk { + pub file_path: String, + pub start_line: usize, + pub end_line: usize, + pub content: String, + pub timestamp: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct UserAction { + pub action_type: ActionType, + pub line_number: usize, + pub offset: usize, + pub file_path: String, + pub timestamp: u64, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum ActionType { + CursorMovement, + InsertChar, + DeleteChar, + InsertSelection, + DeleteSelection, +} + +#[derive(Debug, Clone, Deserialize)] +struct AutocompleteResponse { + pub autocomplete_id: String, + pub start_index: usize, + pub end_index: usize, + pub completion: String, + #[allow(dead_code)] + pub confidence: f64, + #[allow(dead_code)] + pub logprobs: Option, + #[allow(dead_code)] + pub finish_reason: Option, + #[allow(dead_code)] + pub elapsed_time_ms: u64, + #[allow(dead_code)] + #[serde(default, rename = "completions")] + pub additional_completions: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct AdditionalCompletion { + pub start_index: usize, + pub end_index: usize, + pub completion: String, + pub confidence: f64, + pub autocomplete_id: String, + pub logprobs: Option, + pub finish_reason: Option, +} + +fn write_event(event: &zeta_prompt::Event, f: &mut impl fmt::Write) -> fmt::Result { + match event { + zeta_prompt::Event::BufferChange { + old_path, + path, + diff, + .. + } => { + if old_path != path { + // TODO confirm how to do this for sweep + // writeln!(f, "User renamed {:?} to {:?}\n", old_path, new_path)?; + } + + if !diff.is_empty() { + write!(f, "File: {}:\n{}\n", path.display(), diff)? + } + + fmt::Result::Ok(()) + } + } +} + +fn debug_info(cx: &gpui::App) -> Arc { + format!( + "Zed v{version} ({sha}) - OS: {os} - Zed v{version}", + version = release_channel::AppVersion::global(cx), + sha = release_channel::AppCommitSha::try_global(cx) + .map_or("unknown".to_string(), |sha| sha.full()), + os = client::telemetry::os_name(), + ) + .into() +} diff --git a/crates/edit_prediction/src/udiff.rs b/crates/edit_prediction/src/udiff.rs new file mode 100644 index 0000000000..78fec03dd7 --- /dev/null +++ b/crates/edit_prediction/src/udiff.rs @@ -0,0 +1,876 @@ +use std::borrow::Cow; +use std::fmt::Display; +use std::sync::Arc; +use std::{ + fmt::{Debug, Write}, + mem, + ops::Range, + path::Path, +}; + +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use collections::HashMap; +use gpui::AsyncApp; +use gpui::Entity; +use language::{Anchor, Buffer, OffsetRangeExt as _, TextBufferSnapshot}; +use project::{Project, ProjectPath}; +use util::paths::PathStyle; +use util::rel_path::RelPath; + +#[derive(Clone, Debug)] +pub struct OpenedBuffers(#[allow(unused)] HashMap>); + +#[must_use] +pub async fn apply_diff( + diff_str: &str, + project: &Entity, + cx: &mut AsyncApp, +) -> Result { + let mut included_files = HashMap::default(); + + let worktree_id = project.read_with(cx, |project, cx| { + anyhow::Ok( + project + .visible_worktrees(cx) + .next() + .context("no worktrees")? + .read(cx) + .id(), + ) + })??; + + for line in diff_str.lines() { + let diff_line = DiffLine::parse(line); + + if let DiffLine::OldPath { path } = diff_line { + let buffer = project + .update(cx, |project, cx| { + let project_path = ProjectPath { + worktree_id, + path: RelPath::new(Path::new(path.as_ref()), PathStyle::Posix)?.into_arc(), + }; + anyhow::Ok(project.open_buffer(project_path, cx)) + })?? + .await?; + + included_files.insert(path.to_string(), buffer); + } + } + + let ranges = [Anchor::MIN..Anchor::MAX]; + + let mut diff = DiffParser::new(diff_str); + let mut current_file = None; + let mut edits = vec![]; + + while let Some(event) = diff.next()? { + match event { + DiffEvent::Hunk { + path: file_path, + hunk, + } => { + let (buffer, ranges) = match current_file { + None => { + let buffer = included_files + .get_mut(file_path.as_ref()) + .expect("Opened all files in diff"); + + current_file = Some((buffer, ranges.as_slice())); + current_file.as_ref().unwrap() + } + Some(ref current) => current, + }; + + buffer.read_with(cx, |buffer, _| { + edits.extend( + resolve_hunk_edits_in_buffer(hunk, buffer, ranges) + .with_context(|| format!("Diff:\n{diff_str}"))?, + ); + anyhow::Ok(()) + })??; + } + DiffEvent::FileEnd { renamed_to } => { + let (buffer, _) = current_file + .take() + .context("Got a FileEnd event before an Hunk event")?; + + if let Some(renamed_to) = renamed_to { + project + .update(cx, |project, cx| { + let new_project_path = project + .find_project_path(Path::new(renamed_to.as_ref()), cx) + .with_context(|| { + format!("Failed to find worktree for new path: {}", renamed_to) + })?; + + let project_file = project::File::from_dyn(buffer.read(cx).file()) + .expect("Wrong file type"); + + anyhow::Ok(project.rename_entry( + project_file.entry_id.unwrap(), + new_project_path, + cx, + )) + })?? + .await?; + } + + let edits = mem::take(&mut edits); + buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + })?; + } + } + } + + Ok(OpenedBuffers(included_files)) +} + +pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result { + let mut diff = DiffParser::new(diff_str); + + let mut text = text.to_string(); + + while let Some(event) = diff.next()? { + match event { + DiffEvent::Hunk { hunk, .. } => { + let hunk_offset = text + .find(&hunk.context) + .ok_or_else(|| anyhow!("couldn't resolve hunk {:?}", hunk.context))?; + for edit in hunk.edits.iter().rev() { + let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end); + text.replace_range(range, &edit.text); + } + } + DiffEvent::FileEnd { .. } => {} + } + } + + Ok(text) +} + +struct PatchFile<'a> { + old_path: Cow<'a, str>, + new_path: Cow<'a, str>, +} + +struct DiffParser<'a> { + current_file: Option>, + current_line: Option<(&'a str, DiffLine<'a>)>, + hunk: Hunk, + diff: std::str::Lines<'a>, +} + +#[derive(Debug, PartialEq)] +enum DiffEvent<'a> { + Hunk { path: Cow<'a, str>, hunk: Hunk }, + FileEnd { renamed_to: Option> }, +} + +#[derive(Debug, Default, PartialEq)] +struct Hunk { + context: String, + edits: Vec, +} + +impl Hunk { + fn is_empty(&self) -> bool { + self.context.is_empty() && self.edits.is_empty() + } +} + +#[derive(Debug, PartialEq)] +struct Edit { + range: Range, + text: String, +} + +impl<'a> DiffParser<'a> { + fn new(diff: &'a str) -> Self { + let mut diff = diff.lines(); + let current_line = diff.next().map(|line| (line, DiffLine::parse(line))); + DiffParser { + current_file: None, + hunk: Hunk::default(), + current_line, + diff, + } + } + + fn next(&mut self) -> Result>> { + loop { + let (hunk_done, file_done) = match self.current_line.as_ref().map(|e| &e.1) { + Some(DiffLine::OldPath { .. }) | Some(DiffLine::Garbage(_)) | None => (true, true), + Some(DiffLine::HunkHeader(_)) => (true, false), + _ => (false, false), + }; + + if hunk_done { + if let Some(file) = &self.current_file + && !self.hunk.is_empty() + { + return Ok(Some(DiffEvent::Hunk { + path: file.old_path.clone(), + hunk: mem::take(&mut self.hunk), + })); + } + } + + if file_done { + if let Some(PatchFile { old_path, new_path }) = self.current_file.take() { + return Ok(Some(DiffEvent::FileEnd { + renamed_to: if old_path != new_path { + Some(new_path) + } else { + None + }, + })); + } + } + + let Some((line, parsed_line)) = self.current_line.take() else { + break; + }; + + util::maybe!({ + match parsed_line { + DiffLine::OldPath { path } => { + self.current_file = Some(PatchFile { + old_path: path, + new_path: "".into(), + }); + } + DiffLine::NewPath { path } => { + if let Some(current_file) = &mut self.current_file { + current_file.new_path = path + } + } + DiffLine::HunkHeader(_) => {} + DiffLine::Context(ctx) => { + if self.current_file.is_some() { + writeln!(&mut self.hunk.context, "{ctx}")?; + } + } + DiffLine::Deletion(del) => { + if self.current_file.is_some() { + let range = self.hunk.context.len() + ..self.hunk.context.len() + del.len() + '\n'.len_utf8(); + if let Some(last_edit) = self.hunk.edits.last_mut() + && last_edit.range.end == range.start + { + last_edit.range.end = range.end; + } else { + self.hunk.edits.push(Edit { + range, + text: String::new(), + }); + } + writeln!(&mut self.hunk.context, "{del}")?; + } + } + DiffLine::Addition(add) => { + if self.current_file.is_some() { + let range = self.hunk.context.len()..self.hunk.context.len(); + if let Some(last_edit) = self.hunk.edits.last_mut() + && last_edit.range.end == range.start + { + writeln!(&mut last_edit.text, "{add}").unwrap(); + } else { + self.hunk.edits.push(Edit { + range, + text: format!("{add}\n"), + }); + } + } + } + DiffLine::Garbage(_) => {} + } + + anyhow::Ok(()) + }) + .with_context(|| format!("on line:\n\n```\n{}```", line))?; + + self.current_line = self.diff.next().map(|line| (line, DiffLine::parse(line))); + } + + anyhow::Ok(None) + } +} + +fn resolve_hunk_edits_in_buffer( + hunk: Hunk, + buffer: &TextBufferSnapshot, + ranges: &[Range], +) -> Result, Arc)>, anyhow::Error> { + let context_offset = if hunk.context.is_empty() { + Ok(0) + } else { + let mut offset = None; + for range in ranges { + let range = range.to_offset(buffer); + let text = buffer.text_for_range(range.clone()).collect::(); + for (ix, _) in text.match_indices(&hunk.context) { + if offset.is_some() { + anyhow::bail!("Context is not unique enough:\n{}", hunk.context); + } + offset = Some(range.start + ix); + } + } + offset.ok_or_else(|| anyhow!("Failed to match context:\n{}", hunk.context)) + }?; + let iter = hunk.edits.into_iter().flat_map(move |edit| { + let old_text = buffer + .text_for_range(context_offset + edit.range.start..context_offset + edit.range.end) + .collect::(); + let edits_within_hunk = language::text_diff(&old_text, &edit.text); + edits_within_hunk + .into_iter() + .map(move |(inner_range, inner_text)| { + ( + buffer.anchor_after(context_offset + edit.range.start + inner_range.start) + ..buffer.anchor_before(context_offset + edit.range.start + inner_range.end), + inner_text, + ) + }) + }); + Ok(iter) +} + +#[derive(Debug, PartialEq)] +pub enum DiffLine<'a> { + OldPath { path: Cow<'a, str> }, + NewPath { path: Cow<'a, str> }, + HunkHeader(Option), + Context(&'a str), + Deletion(&'a str), + Addition(&'a str), + Garbage(&'a str), +} + +#[derive(Debug, PartialEq)] +pub struct HunkLocation { + start_line_old: u32, + count_old: u32, + start_line_new: u32, + count_new: u32, +} + +impl<'a> DiffLine<'a> { + pub fn parse(line: &'a str) -> Self { + Self::try_parse(line).unwrap_or(Self::Garbage(line)) + } + + fn try_parse(line: &'a str) -> Option { + if let Some(header) = line.strip_prefix("---").and_then(eat_required_whitespace) { + let path = parse_header_path("a/", header); + Some(Self::OldPath { path }) + } else if let Some(header) = line.strip_prefix("+++").and_then(eat_required_whitespace) { + Some(Self::NewPath { + path: parse_header_path("b/", header), + }) + } else if let Some(header) = line.strip_prefix("@@").and_then(eat_required_whitespace) { + if header.starts_with("...") { + return Some(Self::HunkHeader(None)); + } + + let mut tokens = header.split_whitespace(); + let old_range = tokens.next()?.strip_prefix('-')?; + let new_range = tokens.next()?.strip_prefix('+')?; + + let (start_line_old, count_old) = old_range.split_once(',').unwrap_or((old_range, "1")); + let (start_line_new, count_new) = new_range.split_once(',').unwrap_or((new_range, "1")); + + Some(Self::HunkHeader(Some(HunkLocation { + start_line_old: start_line_old.parse::().ok()?.saturating_sub(1), + count_old: count_old.parse().ok()?, + start_line_new: start_line_new.parse::().ok()?.saturating_sub(1), + count_new: count_new.parse().ok()?, + }))) + } else if let Some(deleted_header) = line.strip_prefix("-") { + Some(Self::Deletion(deleted_header)) + } else if line.is_empty() { + Some(Self::Context("")) + } else if let Some(context) = line.strip_prefix(" ") { + Some(Self::Context(context)) + } else { + Some(Self::Addition(line.strip_prefix("+")?)) + } + } +} + +impl<'a> Display for DiffLine<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DiffLine::OldPath { path } => write!(f, "--- {path}"), + DiffLine::NewPath { path } => write!(f, "+++ {path}"), + DiffLine::HunkHeader(Some(hunk_location)) => { + write!( + f, + "@@ -{},{} +{},{} @@", + hunk_location.start_line_old + 1, + hunk_location.count_old, + hunk_location.start_line_new + 1, + hunk_location.count_new + ) + } + DiffLine::HunkHeader(None) => write!(f, "@@ ... @@"), + DiffLine::Context(content) => write!(f, " {content}"), + DiffLine::Deletion(content) => write!(f, "-{content}"), + DiffLine::Addition(content) => write!(f, "+{content}"), + DiffLine::Garbage(line) => write!(f, "{line}"), + } + } +} + +fn parse_header_path<'a>(strip_prefix: &'static str, header: &'a str) -> Cow<'a, str> { + if !header.contains(['"', '\\']) { + let path = header.split_ascii_whitespace().next().unwrap_or(header); + return Cow::Borrowed(path.strip_prefix(strip_prefix).unwrap_or(path)); + } + + let mut path = String::with_capacity(header.len()); + let mut in_quote = false; + let mut chars = header.chars().peekable(); + let mut strip_prefix = Some(strip_prefix); + + while let Some(char) = chars.next() { + if char == '"' { + in_quote = !in_quote; + } else if char == '\\' { + let Some(&next_char) = chars.peek() else { + break; + }; + chars.next(); + path.push(next_char); + } else if char.is_ascii_whitespace() && !in_quote { + break; + } else { + path.push(char); + } + + if let Some(prefix) = strip_prefix + && path == prefix + { + strip_prefix.take(); + path.clear(); + } + } + + Cow::Owned(path) +} + +fn eat_required_whitespace(header: &str) -> Option<&str> { + let trimmed = header.trim_ascii_start(); + + if trimmed.len() == header.len() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use indoc::indoc; + use pretty_assertions::assert_eq; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + #[test] + fn parse_lines_simple() { + let input = indoc! {" + diff --git a/text.txt b/text.txt + index 86c770d..a1fd855 100644 + --- a/file.txt + +++ b/file.txt + @@ -1,2 +1,3 @@ + context + -deleted + +inserted + garbage + + --- b/file.txt + +++ a/file.txt + "}; + + let lines = input.lines().map(DiffLine::parse).collect::>(); + + pretty_assertions::assert_eq!( + lines, + &[ + DiffLine::Garbage("diff --git a/text.txt b/text.txt"), + DiffLine::Garbage("index 86c770d..a1fd855 100644"), + DiffLine::OldPath { + path: "file.txt".into() + }, + DiffLine::NewPath { + path: "file.txt".into() + }, + DiffLine::HunkHeader(Some(HunkLocation { + start_line_old: 0, + count_old: 2, + start_line_new: 0, + count_new: 3 + })), + DiffLine::Context("context"), + DiffLine::Deletion("deleted"), + DiffLine::Addition("inserted"), + DiffLine::Garbage("garbage"), + DiffLine::Context(""), + DiffLine::OldPath { + path: "b/file.txt".into() + }, + DiffLine::NewPath { + path: "a/file.txt".into() + }, + ] + ); + } + + #[test] + fn file_header_extra_space() { + let options = ["--- file", "--- file", "---\tfile"]; + + for option in options { + pretty_assertions::assert_eq!( + DiffLine::parse(option), + DiffLine::OldPath { + path: "file".into() + }, + "{option}", + ); + } + } + + #[test] + fn hunk_header_extra_space() { + let options = [ + "@@ -1,2 +1,3 @@", + "@@ -1,2 +1,3 @@", + "@@\t-1,2\t+1,3\t@@", + "@@ -1,2 +1,3 @@", + "@@ -1,2 +1,3 @@", + "@@ -1,2 +1,3 @@", + "@@ -1,2 +1,3 @@ garbage", + ]; + + for option in options { + pretty_assertions::assert_eq!( + DiffLine::parse(option), + DiffLine::HunkHeader(Some(HunkLocation { + start_line_old: 0, + count_old: 2, + start_line_new: 0, + count_new: 3 + })), + "{option}", + ); + } + } + + #[test] + fn hunk_header_without_location() { + pretty_assertions::assert_eq!(DiffLine::parse("@@ ... @@"), DiffLine::HunkHeader(None)); + } + + #[test] + fn test_parse_path() { + assert_eq!(parse_header_path("a/", "foo.txt"), "foo.txt"); + assert_eq!( + parse_header_path("a/", "foo/bar/baz.txt"), + "foo/bar/baz.txt" + ); + assert_eq!(parse_header_path("a/", "a/foo.txt"), "foo.txt"); + assert_eq!( + parse_header_path("a/", "a/foo/bar/baz.txt"), + "foo/bar/baz.txt" + ); + + // Extra + assert_eq!( + parse_header_path("a/", "a/foo/bar/baz.txt 2025"), + "foo/bar/baz.txt" + ); + assert_eq!( + parse_header_path("a/", "a/foo/bar/baz.txt\t2025"), + "foo/bar/baz.txt" + ); + assert_eq!( + parse_header_path("a/", "a/foo/bar/baz.txt \""), + "foo/bar/baz.txt" + ); + + // Quoted + assert_eq!( + parse_header_path("a/", "a/foo/bar/\"baz quox.txt\""), + "foo/bar/baz quox.txt" + ); + assert_eq!( + parse_header_path("a/", "\"a/foo/bar/baz quox.txt\""), + "foo/bar/baz quox.txt" + ); + assert_eq!( + parse_header_path("a/", "\"foo/bar/baz quox.txt\""), + "foo/bar/baz quox.txt" + ); + assert_eq!(parse_header_path("a/", "\"whatever 🤷\""), "whatever 🤷"); + assert_eq!( + parse_header_path("a/", "\"foo/bar/baz quox.txt\" 2025"), + "foo/bar/baz quox.txt" + ); + // unescaped quotes are dropped + assert_eq!(parse_header_path("a/", "foo/\"bar\""), "foo/bar"); + + // Escaped + assert_eq!( + parse_header_path("a/", "\"foo/\\\"bar\\\"/baz.txt\""), + "foo/\"bar\"/baz.txt" + ); + assert_eq!( + parse_header_path("a/", "\"C:\\\\Projects\\\\My App\\\\old file.txt\""), + "C:\\Projects\\My App\\old file.txt" + ); + } + + #[test] + fn test_parse_diff_with_leading_and_trailing_garbage() { + let diff = indoc! {" + I need to make some changes. + + I'll change the following things: + - one + - two + - three + + ``` + --- a/file.txt + +++ b/file.txt + one + +AND + two + ``` + + Summary of what I did: + - one + - two + - three + + That's about it. + "}; + + let mut events = Vec::new(); + let mut parser = DiffParser::new(diff); + while let Some(event) = parser.next().unwrap() { + events.push(event); + } + + assert_eq!( + events, + &[ + DiffEvent::Hunk { + path: "file.txt".into(), + hunk: Hunk { + context: "one\ntwo\n".into(), + edits: vec![Edit { + range: 4..4, + text: "AND\n".into() + }], + } + }, + DiffEvent::FileEnd { renamed_to: None } + ], + ) + } + + #[gpui::test] + async fn test_apply_diff_successful(cx: &mut TestAppContext) { + let fs = init_test(cx); + + let buffer_1_text = indoc! {r#" + one + two + three + four + five + "# }; + + let buffer_1_text_final = indoc! {r#" + 3 + 4 + 5 + "# }; + + let buffer_2_text = indoc! {r#" + six + seven + eight + nine + ten + "# }; + + let buffer_2_text_final = indoc! {r#" + 5 + six + seven + 7.5 + eight + nine + ten + 11 + "# }; + + fs.insert_tree( + path!("/root"), + json!({ + "file1": buffer_1_text, + "file2": buffer_2_text, + }), + ) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + + let diff = indoc! {r#" + --- a/file1 + +++ b/file1 + one + two + -three + +3 + four + five + --- a/file1 + +++ b/file1 + 3 + -four + -five + +4 + +5 + --- a/file1 + +++ b/file1 + -one + -two + 3 + 4 + --- a/file2 + +++ b/file2 + +5 + six + --- a/file2 + +++ b/file2 + seven + +7.5 + eight + --- a/file2 + +++ b/file2 + ten + +11 + "#}; + + let _buffers = apply_diff(diff, &project, &mut cx.to_async()) + .await + .unwrap(); + let buffer_1 = project + .update(cx, |project, cx| { + let project_path = project.find_project_path(path!("/root/file1"), cx).unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + + buffer_1.read_with(cx, |buffer, _cx| { + assert_eq!(buffer.text(), buffer_1_text_final); + }); + let buffer_2 = project + .update(cx, |project, cx| { + let project_path = project.find_project_path(path!("/root/file2"), cx).unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + + buffer_2.read_with(cx, |buffer, _cx| { + assert_eq!(buffer.text(), buffer_2_text_final); + }); + } + + #[gpui::test] + async fn test_apply_diff_unique_via_previous_context(cx: &mut TestAppContext) { + let fs = init_test(cx); + + let start = indoc! {r#" + one + two + three + four + five + + four + five + "# }; + + let end = indoc! {r#" + one + two + 3 + four + 5 + + four + five + "# }; + + fs.insert_tree( + path!("/root"), + json!({ + "file1": start, + }), + ) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + + let diff = indoc! {r#" + --- a/file1 + +++ b/file1 + one + two + -three + +3 + four + -five + +5 + "#}; + + let _buffers = apply_diff(diff, &project, &mut cx.to_async()) + .await + .unwrap(); + + let buffer_1 = project + .update(cx, |project, cx| { + let project_path = project.find_project_path(path!("/root/file1"), cx).unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + + buffer_1.read_with(cx, |buffer, _cx| { + assert_eq!(buffer.text(), end); + }); + } + + fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + FakeFs::new(cx.background_executor.clone()) + } +} diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs new file mode 100644 index 0000000000..0a87ca6614 --- /dev/null +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -0,0 +1,239 @@ +use std::{cmp, sync::Arc}; + +use client::{Client, UserStore}; +use cloud_llm_client::EditPredictionRejectReason; +use edit_prediction_types::{DataCollectionState, Direction, EditPredictionDelegate}; +use gpui::{App, Entity, prelude::*}; +use language::{Buffer, ToPoint as _}; +use project::Project; + +use crate::{BufferEditPrediction, EditPredictionModel, EditPredictionStore}; + +pub struct ZedEditPredictionDelegate { + store: Entity, + project: Entity, + singleton_buffer: Option>, +} + +impl ZedEditPredictionDelegate { + pub fn new( + project: Entity, + singleton_buffer: Option>, + client: &Arc, + user_store: &Entity, + cx: &mut Context, + ) -> Self { + let store = EditPredictionStore::global(client, user_store, cx); + store.update(cx, |store, cx| { + store.register_project(&project, cx); + }); + + cx.observe(&store, |_this, _ep_store, cx| { + cx.notify(); + }) + .detach(); + + Self { + project: project, + store: store, + singleton_buffer, + } + } +} + +impl EditPredictionDelegate for ZedEditPredictionDelegate { + fn name() -> &'static str { + "zed-predict" + } + + fn display_name() -> &'static str { + "Zed's Edit Predictions" + } + + fn show_predictions_in_menu() -> bool { + true + } + + fn show_tab_accept_marker() -> bool { + true + } + + fn data_collection_state(&self, cx: &App) -> DataCollectionState { + if let Some(buffer) = &self.singleton_buffer + && let Some(file) = buffer.read(cx).file() + { + let is_project_open_source = + self.store + .read(cx) + .is_file_open_source(&self.project, file, cx); + if self.store.read(cx).data_collection_choice.is_enabled() { + DataCollectionState::Enabled { + is_project_open_source, + } + } else { + DataCollectionState::Disabled { + is_project_open_source, + } + } + } else { + return DataCollectionState::Disabled { + is_project_open_source: false, + }; + } + } + + fn toggle_data_collection(&mut self, cx: &mut App) { + self.store.update(cx, |store, cx| { + store.toggle_data_collection_choice(cx); + }); + } + + fn usage(&self, cx: &App) -> Option { + self.store.read(cx).usage(cx) + } + + fn is_enabled( + &self, + _buffer: &Entity, + _cursor_position: language::Anchor, + cx: &App, + ) -> bool { + let store = self.store.read(cx); + if store.edit_prediction_model == EditPredictionModel::Sweep { + store.has_sweep_api_token(cx) + } else { + true + } + } + + fn is_refreshing(&self, cx: &App) -> bool { + self.store.read(cx).is_refreshing(&self.project) + } + + fn refresh( + &mut self, + buffer: Entity, + cursor_position: language::Anchor, + _debounce: bool, + cx: &mut Context, + ) { + let store = self.store.read(cx); + + if store.user_store.read_with(cx, |user_store, _cx| { + user_store.account_too_young() || user_store.has_overdue_invoices() + }) { + return; + } + + self.store.update(cx, |store, cx| { + if let Some(current) = + store.prediction_at(&buffer, Some(cursor_position), &self.project, cx) + && let BufferEditPrediction::Local { prediction } = current + && prediction.interpolate(buffer.read(cx)).is_some() + { + return; + } + + store.refresh_context(&self.project, &buffer, cursor_position, cx); + store.refresh_prediction_from_buffer(self.project.clone(), buffer, cursor_position, cx) + }); + } + + fn cycle( + &mut self, + _buffer: Entity, + _cursor_position: language::Anchor, + _direction: Direction, + _cx: &mut Context, + ) { + } + + fn accept(&mut self, cx: &mut Context) { + self.store.update(cx, |store, cx| { + store.accept_current_prediction(&self.project, cx); + }); + } + + fn discard(&mut self, cx: &mut Context) { + self.store.update(cx, |store, _cx| { + store.reject_current_prediction(EditPredictionRejectReason::Discarded, &self.project); + }); + } + + fn did_show(&mut self, cx: &mut Context) { + self.store.update(cx, |store, cx| { + store.did_show_current_prediction(&self.project, cx); + }); + } + + fn suggest( + &mut self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut Context, + ) -> Option { + self.store.update(cx, |store, cx| { + let prediction = + store.prediction_at(buffer, Some(cursor_position), &self.project, cx)?; + + let prediction = match prediction { + BufferEditPrediction::Local { prediction } => prediction, + BufferEditPrediction::Jump { prediction } => { + return Some(edit_prediction_types::EditPrediction::Jump { + id: Some(prediction.id.to_string().into()), + snapshot: prediction.snapshot.clone(), + target: prediction.edits.first().unwrap().0.start, + }); + } + }; + + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + + let Some(edits) = prediction.interpolate(&snapshot) else { + store.reject_current_prediction( + EditPredictionRejectReason::InterpolatedEmpty, + &self.project, + ); + return None; + }; + + let cursor_row = cursor_position.to_point(&snapshot).row; + let (closest_edit_ix, (closest_edit_range, _)) = + edits.iter().enumerate().min_by_key(|(_, (range, _))| { + let distance_from_start = + cursor_row.abs_diff(range.start.to_point(&snapshot).row); + let distance_from_end = cursor_row.abs_diff(range.end.to_point(&snapshot).row); + cmp::min(distance_from_start, distance_from_end) + })?; + + let mut edit_start_ix = closest_edit_ix; + for (range, _) in edits[..edit_start_ix].iter().rev() { + let distance_from_closest_edit = closest_edit_range.start.to_point(&snapshot).row + - range.end.to_point(&snapshot).row; + if distance_from_closest_edit <= 1 { + edit_start_ix -= 1; + } else { + break; + } + } + + let mut edit_end_ix = closest_edit_ix + 1; + for (range, _) in &edits[edit_end_ix..] { + let distance_from_closest_edit = range.start.to_point(buffer).row + - closest_edit_range.end.to_point(&snapshot).row; + if distance_from_closest_edit <= 1 { + edit_end_ix += 1; + } else { + break; + } + } + + Some(edit_prediction_types::EditPrediction::Local { + id: Some(prediction.id.to_string().into()), + edits: edits[edit_start_ix..edit_end_ix].to_vec(), + edit_preview: Some(prediction.edit_preview.clone()), + }) + }) + } +} diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs new file mode 100644 index 0000000000..01c2657330 --- /dev/null +++ b/crates/edit_prediction/src/zeta1.rs @@ -0,0 +1,671 @@ +use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant}; + +use crate::{ + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, EditPredictionStore, ZedUpdateRequiredError, + cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count}, + prediction::EditPredictionResult, +}; +use anyhow::{Context as _, Result}; +use cloud_llm_client::{ + PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse, +}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task}; +use language::{ + Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset, ToPoint as _, text_diff, +}; +use project::{Project, ProjectPath}; +use release_channel::AppVersion; +use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; +use zeta_prompt::{Event, ZetaPromptInput}; + +const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; +const START_OF_FILE_MARKER: &str = "<|start_of_file|>"; +const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>"; +const EDITABLE_REGION_END_MARKER: &str = "<|editable_region_end|>"; + +pub(crate) const MAX_CONTEXT_TOKENS: usize = 150; +pub(crate) const MAX_REWRITE_TOKENS: usize = 350; +pub(crate) const MAX_EVENT_TOKENS: usize = 500; + +pub(crate) fn request_prediction_with_zeta1( + store: &mut EditPredictionStore, + EditPredictionModelInput { + project, + buffer, + snapshot, + position, + events, + trigger, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut Context, +) -> Task>> { + let buffer_snapshotted_at = Instant::now(); + let client = store.client.clone(); + let llm_token = store.llm_token.clone(); + let app_version = AppVersion::global(cx); + + let (git_info, can_collect_file) = if let Some(file) = snapshot.file() { + let can_collect_file = store.can_collect_file(&project, file, cx); + let git_info = if can_collect_file { + git_info_for_file(&project, &ProjectPath::from_file(file.as_ref(), cx), cx) + } else { + None + }; + (git_info, can_collect_file) + } else { + (None, false) + }; + + let full_path: Arc = snapshot + .file() + .map(|f| Arc::from(f.full_path(cx).as_path())) + .unwrap_or_else(|| Arc::from(Path::new("untitled"))); + let full_path_str = full_path.to_string_lossy().into_owned(); + let cursor_point = position.to_point(&snapshot); + let prompt_for_events = { + let events = events.clone(); + move || prompt_for_events_impl(&events, MAX_EVENT_TOKENS) + }; + let gather_task = gather_context( + full_path_str, + &snapshot, + cursor_point, + prompt_for_events, + trigger, + cx, + ); + + let (uri, require_auth) = match &store.custom_predict_edits_url { + Some(custom_url) => (custom_url.clone(), false), + None => { + match client + .http_client() + .build_zed_llm_url("/predict_edits/v2", &[]) + { + Ok(url) => (url.into(), true), + Err(err) => return Task::ready(Err(err)), + } + } + }; + + cx.spawn(async move |this, cx| { + let GatherContextOutput { + mut body, + context_range, + editable_range, + included_events_count, + } = gather_task.await?; + let done_gathering_context_at = Instant::now(); + + let included_events = &events[events.len() - included_events_count..events.len()]; + body.can_collect_data = can_collect_file + && this + .read_with(cx, |this, _| this.can_collect_events(included_events)) + .unwrap_or(false); + if body.can_collect_data { + body.git_info = git_info; + } + + log::debug!( + "Events:\n{}\nExcerpt:\n{:?}", + body.input_events, + body.input_excerpt + ); + + let response = EditPredictionStore::send_api_request::( + |request| { + Ok(request + .uri(uri.as_str()) + .body(serde_json::to_string(&body)?.into())?) + }, + client, + llm_token, + app_version, + require_auth, + ) + .await; + + let context_start_offset = context_range.start.to_offset(&snapshot); + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = ZetaPromptInput { + events: included_events.into(), + related_files: vec![].into(), + cursor_path: full_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset), + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_start_offset, + }; + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(serde_json::to_string(&inputs).unwrap()), + position, + }, + )) + .ok(); + } + + let (response, usage) = match response { + Ok(response) => response, + Err(err) => { + if err.is::() { + cx.update(|cx| { + this.update(cx, |ep_store, _cx| { + ep_store.update_required = true; + }) + .ok(); + + let error_message: SharedString = err.to_string().into(); + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + cx.new(|cx| { + ErrorMessagePrompt::new(error_message.clone(), cx) + .with_link_button("Update Zed", "https://zed.dev/releases") + }) + }, + ); + }) + .ok(); + } + + return Err(err); + } + }; + + let received_response_at = Instant::now(); + log::debug!("completion response: {}", &response.output_excerpt); + + if let Some(usage) = usage { + this.update(cx, |this, cx| { + this.user_store.update(cx, |user_store, cx| { + user_store.update_edit_prediction_usage(usage, cx); + }); + }) + .ok(); + } + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + model_output: Some(response.output_excerpt.clone()), + position, + }, + )) + .ok(); + } + + let edit_prediction = process_completion_response( + response, + buffer, + &snapshot, + editable_range, + inputs, + buffer_snapshotted_at, + received_response_at, + cx, + ) + .await; + + let finished_at = Instant::now(); + + // record latency for ~1% of requests + if rand::random::() <= 2 { + telemetry::event!( + "Edit Prediction Request", + context_latency = done_gathering_context_at + .duration_since(buffer_snapshotted_at) + .as_millis(), + request_latency = received_response_at + .duration_since(done_gathering_context_at) + .as_millis(), + process_latency = finished_at.duration_since(received_response_at).as_millis() + ); + } + + edit_prediction.map(Some) + }) +} + +fn process_completion_response( + prediction_response: PredictEditsResponse, + buffer: Entity, + snapshot: &BufferSnapshot, + editable_range: Range, + inputs: ZetaPromptInput, + buffer_snapshotted_at: Instant, + received_response_at: Instant, + cx: &AsyncApp, +) -> Task> { + let snapshot = snapshot.clone(); + let request_id = prediction_response.request_id; + let output_excerpt = prediction_response.output_excerpt; + cx.spawn(async move |cx| { + let output_excerpt: Arc = output_excerpt.into(); + + let edits: Arc<[(Range, Arc)]> = cx + .background_spawn({ + let output_excerpt = output_excerpt.clone(); + let editable_range = editable_range.clone(); + let snapshot = snapshot.clone(); + async move { parse_edits(output_excerpt, editable_range, &snapshot) } + }) + .await? + .into(); + + let id = EditPredictionId(request_id.into()); + Ok(EditPredictionResult::new( + id, + &buffer, + &snapshot, + edits, + buffer_snapshotted_at, + received_response_at, + inputs, + cx, + ) + .await) + }) +} + +fn parse_edits( + output_excerpt: Arc, + editable_range: Range, + snapshot: &BufferSnapshot, +) -> Result, Arc)>> { + let content = output_excerpt.replace(CURSOR_MARKER, ""); + + let start_markers = content + .match_indices(EDITABLE_REGION_START_MARKER) + .collect::>(); + anyhow::ensure!( + start_markers.len() == 1, + "expected exactly one start marker, found {}", + start_markers.len() + ); + + let end_markers = content + .match_indices(EDITABLE_REGION_END_MARKER) + .collect::>(); + anyhow::ensure!( + end_markers.len() == 1, + "expected exactly one end marker, found {}", + end_markers.len() + ); + + let sof_markers = content + .match_indices(START_OF_FILE_MARKER) + .collect::>(); + anyhow::ensure!( + sof_markers.len() <= 1, + "expected at most one start-of-file marker, found {}", + sof_markers.len() + ); + + let codefence_start = start_markers[0].0; + let content = &content[codefence_start..]; + + let newline_ix = content.find('\n').context("could not find newline")?; + let content = &content[newline_ix + 1..]; + + let codefence_end = content + .rfind(&format!("\n{EDITABLE_REGION_END_MARKER}")) + .context("could not find end marker")?; + let new_text = &content[..codefence_end]; + + let old_text = snapshot + .text_for_range(editable_range.clone()) + .collect::(); + + Ok(compute_edits( + old_text, + new_text, + editable_range.start, + snapshot, + )) +} + +pub fn compute_edits( + old_text: String, + new_text: &str, + offset: usize, + snapshot: &BufferSnapshot, +) -> Vec<(Range, Arc)> { + text_diff(&old_text, new_text) + .into_iter() + .map(|(mut old_range, new_text)| { + old_range.start += offset; + old_range.end += offset; + + let prefix_len = common_prefix( + snapshot.chars_for_range(old_range.clone()), + new_text.chars(), + ); + old_range.start += prefix_len; + + let suffix_len = common_prefix( + snapshot.reversed_chars_for_range(old_range.clone()), + new_text[prefix_len..].chars().rev(), + ); + old_range.end = old_range.end.saturating_sub(suffix_len); + + let new_text = new_text[prefix_len..new_text.len() - suffix_len].into(); + let range = if old_range.is_empty() { + let anchor = snapshot.anchor_after(old_range.start); + anchor..anchor + } else { + snapshot.anchor_after(old_range.start)..snapshot.anchor_before(old_range.end) + }; + (range, new_text) + }) + .collect() +} + +fn common_prefix, T2: Iterator>(a: T1, b: T2) -> usize { + a.zip(b) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum() +} + +fn git_info_for_file( + project: &Entity, + project_path: &ProjectPath, + cx: &App, +) -> Option { + let git_store = project.read(cx).git_store().read(cx); + if let Some((repository, _repo_path)) = + git_store.repository_and_path_for_project_path(project_path, cx) + { + let repository = repository.read(cx); + let head_sha = repository + .head_commit + .as_ref() + .map(|head_commit| head_commit.sha.to_string()); + let remote_origin_url = repository.remote_origin_url.clone(); + let remote_upstream_url = repository.remote_upstream_url.clone(); + if head_sha.is_none() && remote_origin_url.is_none() && remote_upstream_url.is_none() { + return None; + } + Some(PredictEditsGitInfo { + head_sha, + remote_origin_url, + remote_upstream_url, + }) + } else { + None + } +} + +pub struct GatherContextOutput { + pub body: PredictEditsBody, + pub context_range: Range, + pub editable_range: Range, + pub included_events_count: usize, +} + +pub fn gather_context( + full_path_str: String, + snapshot: &BufferSnapshot, + cursor_point: language::Point, + prompt_for_events: impl FnOnce() -> (String, usize) + Send + 'static, + trigger: PredictEditsRequestTrigger, + cx: &App, +) -> Task> { + cx.background_spawn({ + let snapshot = snapshot.clone(); + async move { + let input_excerpt = excerpt_for_cursor_position( + cursor_point, + &full_path_str, + &snapshot, + MAX_REWRITE_TOKENS, + MAX_CONTEXT_TOKENS, + ); + let (input_events, included_events_count) = prompt_for_events(); + let editable_range = input_excerpt.editable_range.to_offset(&snapshot); + + let body = PredictEditsBody { + input_events, + input_excerpt: input_excerpt.prompt, + can_collect_data: false, + diagnostic_groups: None, + git_info: None, + outline: None, + speculated_output: None, + trigger, + }; + + Ok(GatherContextOutput { + body, + context_range: input_excerpt.context_range, + editable_range, + included_events_count, + }) + } + }) +} + +fn prompt_for_events_impl(events: &[Arc], mut remaining_tokens: usize) -> (String, usize) { + let mut result = String::new(); + for (ix, event) in events.iter().rev().enumerate() { + let event_string = format_event(event.as_ref()); + let event_tokens = guess_token_count(event_string.len()); + if event_tokens > remaining_tokens { + return (result, ix); + } + + if !result.is_empty() { + result.insert_str(0, "\n\n"); + } + result.insert_str(0, &event_string); + remaining_tokens -= event_tokens; + } + return (result, events.len()); +} + +pub fn format_event(event: &Event) -> String { + match event { + Event::BufferChange { + path, + old_path, + diff, + .. + } => { + let mut prompt = String::new(); + + if old_path != path { + writeln!( + prompt, + "User renamed {} to {}\n", + old_path.display(), + path.display() + ) + .unwrap(); + } + + if !diff.is_empty() { + write!( + prompt, + "User edited {}:\n```diff\n{}\n```", + path.display(), + diff + ) + .unwrap(); + } + + prompt + } + } +} + +#[derive(Debug)] +pub struct InputExcerpt { + pub context_range: Range, + pub editable_range: Range, + pub prompt: String, +} + +pub fn excerpt_for_cursor_position( + position: Point, + path: &str, + snapshot: &BufferSnapshot, + editable_region_token_limit: usize, + context_token_limit: usize, +) -> InputExcerpt { + let (editable_range, context_range) = editable_and_context_ranges_for_cursor_position( + position, + snapshot, + editable_region_token_limit, + context_token_limit, + ); + + let mut prompt = String::new(); + + writeln!(&mut prompt, "```{path}").unwrap(); + if context_range.start == Point::zero() { + writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap(); + } + + for chunk in snapshot.chunks(context_range.start..editable_range.start, false) { + prompt.push_str(chunk.text); + } + + push_editable_range(position, snapshot, editable_range.clone(), &mut prompt); + + for chunk in snapshot.chunks(editable_range.end..context_range.end, false) { + prompt.push_str(chunk.text); + } + write!(prompt, "\n```").unwrap(); + + InputExcerpt { + context_range, + editable_range, + prompt, + } +} + +fn push_editable_range( + cursor_position: Point, + snapshot: &BufferSnapshot, + editable_range: Range, + prompt: &mut String, +) { + writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap(); + for chunk in snapshot.chunks(editable_range.start..cursor_position, false) { + prompt.push_str(chunk.text); + } + prompt.push_str(CURSOR_MARKER); + for chunk in snapshot.chunks(cursor_position..editable_range.end, false) { + prompt.push_str(chunk.text); + } + write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap(); +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{App, AppContext}; + use indoc::indoc; + use language::Buffer; + + #[gpui::test] + fn test_excerpt_for_cursor_position(cx: &mut App) { + let text = indoc! {r#" + fn foo() { + let x = 42; + println!("Hello, world!"); + } + + fn bar() { + let x = 42; + let mut sum = 0; + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + return sum; + } + + fn generate_random_numbers() -> Vec { + let mut rng = rand::thread_rng(); + let mut numbers = Vec::new(); + for _ in 0..5 { + numbers.push(rng.random_range(1..101)); + } + numbers + } + "#}; + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language::rust_lang(), cx)); + let snapshot = buffer.read(cx).snapshot(); + + // Ensure we try to fit the largest possible syntax scope, resorting to line-based expansion + // when a larger scope doesn't fit the editable region. + let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32); + assert_eq!( + excerpt.prompt, + indoc! {r#" + ```main.rs + let x = 42; + println!("Hello, world!"); + <|editable_region_start|> + } + + fn bar() { + let x = 42; + let mut sum = 0; + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + r<|user_cursor_is_here|>eturn sum; + } + + fn generate_random_numbers() -> Vec { + <|editable_region_end|> + let mut rng = rand::thread_rng(); + let mut numbers = Vec::new(); + ```"#} + ); + + // The `bar` function won't fit within the editable region, so we resort to line-based expansion. + let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32); + assert_eq!( + excerpt.prompt, + indoc! {r#" + ```main.rs + fn bar() { + let x = 42; + let mut sum = 0; + <|editable_region_start|> + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + r<|user_cursor_is_here|>eturn sum; + } + + fn generate_random_numbers() -> Vec { + let mut rng = rand::thread_rng(); + <|editable_region_end|> + let mut numbers = Vec::new(); + for _ in 0..5 { + numbers.push(rng.random_range(1..101)); + ```"#} + ); + } +} diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs new file mode 100644 index 0000000000..9706e2b9ec --- /dev/null +++ b/crates/edit_prediction/src/zeta2.rs @@ -0,0 +1,243 @@ +#[cfg(feature = "cli-support")] +use crate::EvalCacheEntryKind; +use crate::open_ai_response::text_from_response; +use crate::prediction::EditPredictionResult; +use crate::{ + DebugEvent, EDIT_PREDICTIONS_MODEL_ID, EditPredictionFinishedDebugEvent, EditPredictionId, + EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, +}; +use anyhow::{Result, anyhow}; +use cloud_llm_client::EditPredictionRejectReason; +use gpui::{Task, prelude::*}; +use language::{OffsetRangeExt as _, ToOffset as _, ToPoint}; +use release_channel::AppVersion; +use std::{path::Path, sync::Arc, time::Instant}; +use zeta_prompt::CURSOR_MARKER; +use zeta_prompt::format_zeta_prompt; + +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; + +pub fn request_prediction_with_zeta2( + store: &mut EditPredictionStore, + EditPredictionModelInput { + buffer, + snapshot, + position, + related_files, + events, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut Context, +) -> Task>> { + let buffer_snapshotted_at = Instant::now(); + + let Some(excerpt_path) = snapshot + .file() + .map(|file| -> Arc { file.full_path(cx).into() }) + else { + return Task::ready(Err(anyhow!("No file path for excerpt"))); + }; + + let client = store.client.clone(); + let llm_token = store.llm_token.clone(); + let app_version = AppVersion::global(cx); + + #[cfg(feature = "cli-support")] + let eval_cache = store.eval_cache.clone(); + + let request_task = cx.background_spawn({ + async move { + let cursor_offset = position.to_offset(&snapshot); + let (editable_offset_range, prompt_input) = zeta2_prompt_input( + &snapshot, + related_files, + events, + excerpt_path, + cursor_offset, + ); + + let prompt = format_zeta_prompt(&prompt_input); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(prompt.clone()), + position, + }, + )) + .ok(); + } + + let request = open_ai::Request { + model: EDIT_PREDICTIONS_MODEL_ID.clone(), + messages: vec![open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(prompt), + }], + stream: false, + max_completion_tokens: None, + stop: Default::default(), + temperature: Default::default(), + tool_choice: None, + parallel_tool_calls: None, + tools: vec![], + prompt_cache_key: None, + reasoning_effort: None, + }; + + log::trace!("Sending edit prediction request"); + + let response = EditPredictionStore::send_raw_llm_request( + request, + client, + llm_token, + app_version, + #[cfg(feature = "cli-support")] + eval_cache, + #[cfg(feature = "cli-support")] + EvalCacheEntryKind::Prediction, + ) + .await; + let received_response_at = Instant::now(); + + log::trace!("Got edit prediction response"); + + let (res, usage) = response?; + let request_id = EditPredictionId(res.id.clone().into()); + let Some(mut output_text) = text_from_response(res) else { + return Ok((Some((request_id, None)), usage)); + }; + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + position, + model_output: Some(output_text.clone()), + }, + )) + .ok(); + } + + if output_text.contains(CURSOR_MARKER) { + log::trace!("Stripping out {CURSOR_MARKER} from response"); + output_text = output_text.replace(CURSOR_MARKER, ""); + } + + let old_text = snapshot + .text_for_range(editable_offset_range.clone()) + .collect::(); + let edits: Vec<_> = language::text_diff(&old_text, &output_text) + .into_iter() + .map(|(range, text)| { + ( + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot.anchor_before(editable_offset_range.start + range.end), + text, + ) + }) + .collect(); + + anyhow::Ok(( + Some(( + request_id, + Some(( + prompt_input, + buffer, + snapshot.clone(), + edits, + received_response_at, + )), + )), + usage, + )) + } + }); + + cx.spawn(async move |this, cx| { + let Some((id, prediction)) = + EditPredictionStore::handle_api_response(&this, request_task.await, cx)? + else { + return Ok(None); + }; + + let Some((inputs, edited_buffer, edited_buffer_snapshot, edits, received_response_at)) = + prediction + else { + return Ok(Some(EditPredictionResult { + id, + prediction: Err(EditPredictionRejectReason::Empty), + })); + }; + + Ok(Some( + EditPredictionResult::new( + id, + &edited_buffer, + &edited_buffer_snapshot, + edits.into(), + buffer_snapshotted_at, + received_response_at, + inputs, + cx, + ) + .await, + )) + }) +} + +pub fn zeta2_prompt_input( + snapshot: &language::BufferSnapshot, + related_files: Arc<[zeta_prompt::RelatedFile]>, + events: Vec>, + excerpt_path: Arc, + cursor_offset: usize, +) -> (std::ops::Range, zeta_prompt::ZetaPromptInput) { + let cursor_point = cursor_offset.to_point(snapshot); + + let (editable_range, context_range) = + crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + snapshot, + MAX_CONTEXT_TOKENS, + MAX_REWRITE_TOKENS, + ); + + let context_start_offset = context_range.start.to_offset(snapshot); + let editable_offset_range = editable_range.to_offset(snapshot); + let cursor_offset_in_excerpt = cursor_offset - context_start_offset; + let editable_range_in_excerpt = (editable_offset_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset); + + let prompt_input = zeta_prompt::ZetaPromptInput { + cursor_path: excerpt_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt, + cursor_offset_in_excerpt, + events, + related_files, + }; + (editable_offset_range, prompt_input) +} + +#[cfg(feature = "cli-support")] +pub fn zeta2_output_for_patch(input: &zeta_prompt::ZetaPromptInput, patch: &str) -> Result { + let text = &input.cursor_excerpt; + let editable_region = input.editable_range_in_excerpt.clone(); + let old_prefix = &text[..editable_region.start]; + let old_suffix = &text[editable_region.end..]; + + let new = crate::udiff::apply_diff_to_string(patch, text)?; + if !new.starts_with(old_prefix) || !new.ends_with(old_suffix) { + anyhow::bail!("Patch shouldn't affect text outside of editable region"); + } + + Ok(new[editable_region.start..new.len() - old_suffix.len()].to_string()) +} diff --git a/crates/zeta_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml similarity index 55% rename from crates/zeta_cli/Cargo.toml rename to crates/edit_prediction_cli/Cargo.toml index 660de610c1..b6bace2a2c 100644 --- a/crates/zeta_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "zeta_cli" +name = "edit_prediction_cli" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,27 +9,32 @@ license = "GPL-3.0-or-later" workspace = true [[bin]] -name = "zeta" +name = "ep" path = "src/main.rs" [dependencies] anyhow.workspace = true +anthropic.workspace = true +http_client.workspace = true +chrono.workspace = true clap.workspace = true client.workspace = true cloud_llm_client.workspace= true -cloud_zeta2_prompt.workspace= true +collections.workspace = true debug_adapter_extension.workspace = true -edit_prediction_context.workspace = true +dirs.workspace = true extension.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true gpui_tokio.workspace = true +indoc.workspace = true language.workspace = true language_extension.workspace = true language_model.workspace = true language_models.workspace = true languages = { workspace = true, features = ["load-grammars"] } +libc.workspace = true log.workspace = true node_runtime.workspace = true paths.workspace = true @@ -42,11 +47,25 @@ serde_json.workspace = true settings.workspace = true shellexpand.workspace = true smol.workspace = true +sqlez.workspace = true +sqlez_macros.workspace = true terminal_view.workspace = true util.workspace = true watch.workspace = true -workspace-hack.workspace = true -zeta.workspace = true -zeta2.workspace = true -zlog.workspace = true -ordered-float.workspace = true +edit_prediction = { workspace = true, features = ["cli-support"] } +wasmtime.workspace = true +zeta_prompt.workspace = true + +# Wasmtime is included as a dependency in order to enable the same +# features that are enabled in Zed. +# +# If we don't enable these features we get crashes when creating +# a Tree-sitter WasmStore. +[package.metadata.cargo-machete] +ignored = ["wasmtime"] + +[dev-dependencies] +indoc.workspace = true +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +pretty_assertions.workspace = true diff --git a/crates/assistant_tool/LICENSE-GPL b/crates/edit_prediction_cli/LICENSE-GPL similarity index 100% rename from crates/assistant_tool/LICENSE-GPL rename to crates/edit_prediction_cli/LICENSE-GPL diff --git a/crates/zeta_cli/build.rs b/crates/edit_prediction_cli/build.rs similarity index 100% rename from crates/zeta_cli/build.rs rename to crates/edit_prediction_cli/build.rs diff --git a/crates/edit_prediction_cli/src/anthropic_client.rs b/crates/edit_prediction_cli/src/anthropic_client.rs new file mode 100644 index 0000000000..8afc4d1c03 --- /dev/null +++ b/crates/edit_prediction_cli/src/anthropic_client.rs @@ -0,0 +1,418 @@ +use anthropic::{ + ANTHROPIC_API_URL, Message, Request as AnthropicRequest, RequestContent, + Response as AnthropicResponse, Role, non_streaming_completion, +}; +use anyhow::Result; +use http_client::HttpClient; +use indoc::indoc; +use reqwest_client::ReqwestClient; +use sqlez::bindable::Bind; +use sqlez::bindable::StaticColumnCount; +use sqlez_macros::sql; +use std::hash::Hash; +use std::hash::Hasher; +use std::path::Path; +use std::sync::Arc; + +pub struct PlainLlmClient { + http_client: Arc, + api_key: String, +} + +impl PlainLlmClient { + fn new() -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; + Ok(Self { + http_client, + api_key, + }) + } + + async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result { + let request = AnthropicRequest { + model: model.to_string(), + max_tokens, + messages, + tools: Vec::new(), + thinking: None, + tool_choice: None, + system: None, + metadata: None, + stop_sequences: Vec::new(), + temperature: None, + top_k: None, + top_p: None, + }; + + let response = non_streaming_completion( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + request, + None, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + Ok(response) + } +} + +pub struct BatchingLlmClient { + connection: sqlez::connection::Connection, + http_client: Arc, + api_key: String, +} + +struct CacheRow { + request_hash: String, + request: Option, + response: Option, + batch_id: Option, +} + +impl StaticColumnCount for CacheRow { + fn column_count() -> usize { + 4 + } +} + +impl Bind for CacheRow { + fn bind(&self, statement: &sqlez::statement::Statement, start_index: i32) -> Result { + let next_index = statement.bind(&self.request_hash, start_index)?; + let next_index = statement.bind(&self.request, next_index)?; + let next_index = statement.bind(&self.response, next_index)?; + let next_index = statement.bind(&self.batch_id, next_index)?; + Ok(next_index) + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct SerializableRequest { + model: String, + max_tokens: u64, + messages: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct SerializableMessage { + role: String, + content: String, +} + +impl BatchingLlmClient { + fn new(cache_path: &Path) -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; + + let connection = sqlez::connection::Connection::open_file(&cache_path.to_str().unwrap()); + let mut statement = sqlez::statement::Statement::prepare( + &connection, + indoc! {" + CREATE TABLE IF NOT EXISTS cache ( + request_hash TEXT PRIMARY KEY, + request TEXT, + response TEXT, + batch_id TEXT + ); + "}, + )?; + statement.exec()?; + drop(statement); + + Ok(Self { + connection, + http_client, + api_key, + }) + } + + pub fn lookup( + &self, + model: &str, + max_tokens: u64, + messages: &[Message], + ) -> Result> { + let request_hash_str = Self::request_hash(model, max_tokens, messages); + let response: Vec = self.connection.select_bound( + &sql!(SELECT response FROM cache WHERE request_hash = ?1 AND response IS NOT NULL;), + )?(request_hash_str.as_str())?; + Ok(response + .into_iter() + .next() + .and_then(|text| serde_json::from_str(&text).ok())) + } + + pub fn mark_for_batch(&self, model: &str, max_tokens: u64, messages: &[Message]) -> Result<()> { + let request_hash = Self::request_hash(model, max_tokens, messages); + + let serializable_messages: Vec = messages + .iter() + .map(|msg| SerializableMessage { + role: match msg.role { + Role::User => "user".to_string(), + Role::Assistant => "assistant".to_string(), + }, + content: message_content_to_string(&msg.content), + }) + .collect(); + + let serializable_request = SerializableRequest { + model: model.to_string(), + max_tokens, + messages: serializable_messages, + }; + + let request = Some(serde_json::to_string(&serializable_request)?); + let cache_row = CacheRow { + request_hash, + request, + response: None, + batch_id: None, + }; + self.connection.exec_bound(sql!( + INSERT OR IGNORE INTO cache(request_hash, request, response, batch_id) VALUES (?, ?, ?, ?)))?( + cache_row, + ) + } + + async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result> { + let response = self.lookup(model, max_tokens, &messages)?; + if let Some(response) = response { + return Ok(Some(response)); + } + + self.mark_for_batch(model, max_tokens, &messages)?; + + Ok(None) + } + + /// Uploads pending requests as a new batch; downloads finished batches if any. + async fn sync_batches(&self) -> Result<()> { + self.upload_pending_requests().await?; + self.download_finished_batches().await + } + + async fn download_finished_batches(&self) -> Result<()> { + let q = sql!(SELECT DISTINCT batch_id FROM cache WHERE batch_id IS NOT NULL AND response IS NULL); + let batch_ids: Vec = self.connection.select(q)?()?; + + for batch_id in batch_ids { + let batch_status = anthropic::batches::retrieve_batch( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + &batch_id, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + log::info!( + "Batch {} status: {}", + batch_id, + batch_status.processing_status + ); + + if batch_status.processing_status == "ended" { + let results = anthropic::batches::retrieve_batch_results( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + &batch_id, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + let mut success_count = 0; + for result in results { + let request_hash = result + .custom_id + .strip_prefix("req_hash_") + .unwrap_or(&result.custom_id) + .to_string(); + + match result.result { + anthropic::batches::BatchResult::Succeeded { message } => { + let response_json = serde_json::to_string(&message)?; + let q = sql!(UPDATE cache SET response = ? WHERE request_hash = ?); + self.connection.exec_bound(q)?((response_json, request_hash))?; + success_count += 1; + } + anthropic::batches::BatchResult::Errored { error } => { + log::error!("Batch request {} failed: {:?}", request_hash, error); + } + anthropic::batches::BatchResult::Canceled => { + log::warn!("Batch request {} was canceled", request_hash); + } + anthropic::batches::BatchResult::Expired => { + log::warn!("Batch request {} expired", request_hash); + } + } + } + log::info!("Downloaded {} successful requests", success_count); + } + } + + Ok(()) + } + + async fn upload_pending_requests(&self) -> Result { + let q = sql!( + SELECT request_hash, request FROM cache WHERE batch_id IS NULL AND response IS NULL + ); + + let rows: Vec<(String, String)> = self.connection.select(q)?()?; + + if rows.is_empty() { + return Ok(String::new()); + } + + let batch_requests = rows + .iter() + .map(|(hash, request_str)| { + let serializable_request: SerializableRequest = + serde_json::from_str(&request_str).unwrap(); + + let messages: Vec = serializable_request + .messages + .into_iter() + .map(|msg| Message { + role: match msg.role.as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + _ => Role::User, + }, + content: vec![RequestContent::Text { + text: msg.content, + cache_control: None, + }], + }) + .collect(); + + let params = AnthropicRequest { + model: serializable_request.model, + max_tokens: serializable_request.max_tokens, + messages, + tools: Vec::new(), + thinking: None, + tool_choice: None, + system: None, + metadata: None, + stop_sequences: Vec::new(), + temperature: None, + top_k: None, + top_p: None, + }; + + let custom_id = format!("req_hash_{}", hash); + anthropic::batches::BatchRequest { custom_id, params } + }) + .collect::>(); + + let batch_len = batch_requests.len(); + let batch = anthropic::batches::create_batch( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + anthropic::batches::CreateBatchRequest { + requests: batch_requests, + }, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + let q = sql!( + UPDATE cache SET batch_id = ? WHERE batch_id is NULL + ); + self.connection.exec_bound(q)?(batch.id.as_str())?; + + log::info!("Uploaded batch with {} requests", batch_len); + + Ok(batch.id) + } + + fn request_hash(model: &str, max_tokens: u64, messages: &[Message]) -> String { + let mut hasher = std::hash::DefaultHasher::new(); + model.hash(&mut hasher); + max_tokens.hash(&mut hasher); + for msg in messages { + message_content_to_string(&msg.content).hash(&mut hasher); + } + let request_hash = hasher.finish(); + format!("{request_hash:016x}") + } +} + +fn message_content_to_string(content: &[RequestContent]) -> String { + content + .iter() + .filter_map(|c| match c { + RequestContent::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join("\n") +} + +pub enum AnthropicClient { + // No batching + Plain(PlainLlmClient), + Batch(BatchingLlmClient), + Dummy, +} + +impl AnthropicClient { + pub fn plain() -> Result { + Ok(Self::Plain(PlainLlmClient::new()?)) + } + + pub fn batch(cache_path: &Path) -> Result { + Ok(Self::Batch(BatchingLlmClient::new(cache_path)?)) + } + + #[allow(dead_code)] + pub fn dummy() -> Self { + Self::Dummy + } + + pub async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result> { + match self { + AnthropicClient::Plain(plain_llm_client) => plain_llm_client + .generate(model, max_tokens, messages) + .await + .map(Some), + AnthropicClient::Batch(batching_llm_client) => { + batching_llm_client + .generate(model, max_tokens, messages) + .await + } + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + } + } + + pub async fn sync_batches(&self) -> Result<()> { + match self { + AnthropicClient::Plain(_) => Ok(()), + AnthropicClient::Batch(batching_llm_client) => batching_llm_client.sync_batches().await, + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + } + } +} diff --git a/crates/edit_prediction_cli/src/distill.rs b/crates/edit_prediction_cli/src/distill.rs new file mode 100644 index 0000000000..abfe178ae6 --- /dev/null +++ b/crates/edit_prediction_cli/src/distill.rs @@ -0,0 +1,22 @@ +use anyhow::{Result, anyhow}; +use std::mem; + +use crate::example::Example; + +pub async fn run_distill(example: &mut Example) -> Result<()> { + let [prediction]: [_; 1] = + mem::take(&mut example.predictions) + .try_into() + .map_err(|preds: Vec<_>| { + anyhow!( + "Example has {} predictions, but it should have exactly one", + preds.len() + ) + })?; + + example.spec.expected_patch = prediction.actual_patch; + example.prompt = None; + example.predictions = Vec::new(); + example.score = Vec::new(); + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs new file mode 100644 index 0000000000..e37619bf22 --- /dev/null +++ b/crates/edit_prediction_cli/src/example.rs @@ -0,0 +1,250 @@ +use crate::{PredictionProvider, PromptFormat, metrics::ClassificationMetrics}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use edit_prediction::example_spec::ExampleSpec; +use edit_prediction::udiff::OpenedBuffers; +use gpui::Entity; +use http_client::Url; +use language::{Anchor, Buffer}; +use project::Project; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::{ + borrow::Cow, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use zeta_prompt::RelatedFile; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Example { + #[serde(flatten)] + pub spec: ExampleSpec, + + /// The full content of the file where an edit is being predicted, and the + /// actual cursor offset. + #[serde(skip_serializing_if = "Option::is_none")] + pub buffer: Option, + + /// The context retrieved for the prediction. This requires the worktree to + /// be loaded and the language server to be started. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + + /// The input and expected output from the edit prediction model. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + /// The actual predictions from the model. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub predictions: Vec, + + /// The scores, for how well the actual predictions match the expected + /// predictions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub score: Vec, + + /// The application state used to process this example. + #[serde(skip)] + pub state: Option, +} + +#[derive(Clone, Debug)] +pub struct ExampleState { + pub project: Entity, + pub buffer: Entity, + pub cursor_position: Anchor, + pub _open_buffers: OpenedBuffers, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleContext { + pub files: Arc<[RelatedFile]>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleBuffer { + pub content: String, + pub cursor_row: u32, + pub cursor_column: u32, + pub cursor_offset: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrompt { + pub input: String, + pub expected_output: String, + pub format: PromptFormat, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrediction { + pub actual_patch: String, + pub actual_output: String, + pub provider: PredictionProvider, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleScore { + pub delta_chr_f: f32, + pub line_match: ClassificationMetrics, +} + +impl Example { + pub fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> { + // git@github.com:owner/repo.git + if self.spec.repository_url.contains('@') { + let (owner, repo) = self + .spec + .repository_url + .split_once(':') + .context("expected : in git url")? + .1 + .split_once('/') + .context("expected / in git url")?; + Ok(( + Cow::Borrowed(owner), + Cow::Borrowed(repo.trim_end_matches(".git")), + )) + // http://github.com/owner/repo.git + } else { + let url = Url::parse(&self.spec.repository_url)?; + let mut segments = url.path_segments().context("empty http url")?; + let owner = segments + .next() + .context("expected owner path segment")? + .to_string(); + let repo = segments + .next() + .context("expected repo path segment")? + .trim_end_matches(".git") + .to_string(); + assert!(segments.next().is_none()); + + Ok((owner.into(), repo.into())) + } + } +} + +pub fn read_examples(inputs: &[PathBuf]) -> Vec { + let mut examples = Vec::new(); + + let stdin_path: PathBuf = PathBuf::from("-"); + + let inputs = if inputs.is_empty() { + &[stdin_path] + } else { + inputs + }; + + for path in inputs { + let is_stdin = path.as_path() == Path::new("-"); + let content = if is_stdin { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .expect("Failed to read from stdin"); + buffer + } else { + std::fs::read_to_string(path) + .unwrap_or_else(|_| panic!("Failed to read path: {:?}", &path)) + }; + let filename = path.file_stem().unwrap().to_string_lossy().to_string(); + let ext = if !is_stdin { + path.extension() + .map(|ext| ext.to_string_lossy().to_string()) + .unwrap_or_else(|| panic!("{} should have an extension", path.display())) + } else { + "jsonl".to_string() + }; + + match ext.as_ref() { + "json" => { + let mut example = + serde_json::from_str::(&content).unwrap_or_else(|error| { + panic!("Failed to parse example file: {}\n{error}", path.display()) + }); + if example.spec.name.is_empty() { + example.spec.name = filename; + } + examples.push(example); + } + "jsonl" => examples.extend( + content + .lines() + .enumerate() + .map(|(line_ix, line)| { + let mut example = + serde_json::from_str::(line).unwrap_or_else(|error| { + panic!( + "Failed to parse example on {}:{}\n{error}", + path.display(), + line_ix + 1 + ) + }); + if example.spec.name.is_empty() { + example.spec.name = format!("{filename}-{line_ix}") + } + example + }) + .collect::>(), + ), + "md" => { + examples.push(parse_markdown_example(filename, &content).unwrap()); + } + ext => { + panic!("{} has invalid example extension `{ext}`", path.display()) + } + } + } + + sort_examples_by_repo_and_rev(&mut examples); + examples +} + +pub fn write_examples(examples: &[Example], output_path: Option<&PathBuf>) { + let mut content = String::new(); + for example in examples { + let line = serde_json::to_string(example).unwrap(); + content.push_str(&line); + content.push('\n'); + } + if let Some(output_path) = output_path { + std::fs::write(output_path, content).expect("Failed to write examples"); + } else { + std::io::stdout().write_all(&content.as_bytes()).unwrap(); + } +} + +pub fn sort_examples_by_repo_and_rev(examples: &mut [Example]) { + examples.sort_by(|a, b| { + a.spec + .repository_url + .cmp(&b.spec.repository_url) + .then(b.spec.revision.cmp(&a.spec.revision)) + }); +} + +pub fn group_examples_by_repo(examples: &mut [Example]) -> Vec> { + let mut examples_by_repo = HashMap::default(); + for example in examples.iter_mut() { + examples_by_repo + .entry(example.spec.repository_url.clone()) + .or_insert_with(Vec::new) + .push(example); + } + examples_by_repo.into_values().collect() +} + +fn parse_markdown_example(name: String, input: &str) -> Result { + let spec = ExampleSpec::from_markdown(name, input)?; + Ok(Example { + spec, + buffer: None, + context: None, + prompt: None, + predictions: Vec::new(), + score: Vec::new(), + state: None, + }) +} diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs new file mode 100644 index 0000000000..f543d0799b --- /dev/null +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -0,0 +1,288 @@ +use crate::{ + PromptFormat, + example::{Example, ExamplePrompt}, + headless::EpAppState, + load_project::run_load_project, + progress::{Progress, Step}, + retrieve_context::run_context_retrieval, +}; +use anyhow::{Context as _, Result, ensure}; +use edit_prediction::{ + EditPredictionStore, + zeta2::{zeta2_output_for_patch, zeta2_prompt_input}, +}; +use gpui::AsyncApp; +use std::sync::Arc; +use zeta_prompt::format_zeta_prompt; + +pub async fn run_format_prompt( + example: &mut Example, + prompt_format: PromptFormat, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; + + let _step_progress = Progress::global().start(Step::FormatPrompt, &example.spec.name); + + match prompt_format { + PromptFormat::Teacher => { + let prompt = TeacherPrompt::format_prompt(example); + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output: example.spec.expected_patch.clone(), // TODO + format: prompt_format, + }); + } + PromptFormat::Zeta2 => { + run_load_project(example, app_state, cx.clone()).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let state = example.state.as_ref().context("state must be set")?; + let snapshot = state.buffer.read_with(&cx, |buffer, _| buffer.snapshot())?; + let project = state.project.clone(); + let (_, input) = ep_store.update(&mut cx, |ep_store, cx| { + anyhow::Ok(zeta2_prompt_input( + &snapshot, + example + .context + .as_ref() + .context("context must be set")? + .files + .clone(), + ep_store.edit_history_for_project(&project, cx), + example.spec.cursor_path.clone(), + example + .buffer + .as_ref() + .context("buffer must be set")? + .cursor_offset, + )) + })??; + let prompt = format_zeta_prompt(&input); + let expected_output = + zeta2_output_for_patch(&input, &example.spec.expected_patch.clone())?; + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output, + format: prompt_format, + }); + } + }; + Ok(()) +} + +pub struct TeacherPrompt; + +impl TeacherPrompt { + const PROMPT: &str = include_str!("teacher.prompt.md"); + pub(crate) const EDITABLE_REGION_START: &str = "<|editable_region_start|>\n"; + pub(crate) const EDITABLE_REGION_END: &str = "<|editable_region_end|>"; + + /// Truncate edit history to this number of last lines + const MAX_HISTORY_LINES: usize = 128; + + pub fn format_prompt(example: &Example) -> String { + let edit_history = Self::format_edit_history(&example.spec.edit_history); + let context = Self::format_context(example); + let editable_region = Self::format_editable_region(example); + + let prompt = Self::PROMPT + .replace("{{context}}", &context) + .replace("{{edit_history}}", &edit_history) + .replace("{{editable_region}}", &editable_region); + + prompt + } + + pub fn parse(example: &Example, response: &str) -> Result { + // Ideally, we should always be able to find cursor position in the retrieved context. + // In reality, sometimes we don't find it for these reasons: + // 1. `example.cursor_position` contains _more_ context than included in the retrieved context + // (can be fixed by getting cursor coordinates at the load_example stage) + // 2. Context retriever just didn't include cursor line. + // + // In that case, fallback to using `cursor_position` as excerpt. + let cursor_file = &example + .buffer + .as_ref() + .context("`buffer` should be filled in in the context collection step")? + .content; + + // Extract updated (new) editable region from the model response + let new_editable_region = extract_last_codeblock(response); + + // Reconstruct old editable region we sent to the model + let old_editable_region = Self::format_editable_region(example); + let old_editable_region = Self::extract_editable_region(&old_editable_region); + ensure!( + cursor_file.contains(&old_editable_region), + "Something's wrong: editable_region is not found in the cursor file" + ); + + // Apply editable region to a larger context and compute diff. + // This is needed to get a better context lines around the editable region + let edited_file = cursor_file.replace(&old_editable_region, &new_editable_region); + let diff = language::unified_diff(&cursor_file, &edited_file); + + let diff = indoc::formatdoc! {" + --- a/{path} + +++ b/{path} + {diff}", + path = example.spec.cursor_path.to_string_lossy(), + diff = diff, + }; + + Ok(diff) + } + + fn format_edit_history(edit_history: &str) -> String { + // Strip comments ("garbage lines") from edit history + let lines = edit_history + .lines() + .filter(|&s| Self::is_udiff_content_line(s)) + .collect::>(); + + let history_lines = if lines.len() > Self::MAX_HISTORY_LINES { + &lines[lines.len() - Self::MAX_HISTORY_LINES..] + } else { + &lines + }; + + if history_lines.is_empty() { + return "(No edit history)".to_string(); + } + + history_lines.join("\n") + } + + fn format_context(example: &Example) -> String { + assert!(example.context.is_some(), "Missing context retriever step"); + + let mut prompt = String::new(); + zeta_prompt::write_related_files(&mut prompt, &example.context.as_ref().unwrap().files); + + prompt + } + + fn format_editable_region(example: &Example) -> String { + let mut result = String::new(); + + let path_str = example.spec.cursor_path.to_string_lossy(); + result.push_str(&format!("`````path=\"{path_str}\"\n")); + result.push_str(Self::EDITABLE_REGION_START); + + // TODO: control number of lines around cursor + result.push_str(&example.spec.cursor_position); + if !example.spec.cursor_position.ends_with('\n') { + result.push('\n'); + } + + result.push_str(&format!("{}\n", Self::EDITABLE_REGION_END)); + result.push_str("`````"); + + result + } + + fn extract_editable_region(text: &str) -> String { + let start = text + .find(Self::EDITABLE_REGION_START) + .map_or(0, |pos| pos + Self::EDITABLE_REGION_START.len()); + let end = text.find(Self::EDITABLE_REGION_END).unwrap_or(text.len()); + + let region = &text[start..end]; + + region.replace("<|user_cursor|>", "") + } + + fn is_udiff_content_line(s: &str) -> bool { + s.starts_with("-") + || s.starts_with("+") + || s.starts_with(" ") + || s.starts_with("---") + || s.starts_with("+++") + || s.starts_with("@@") + } +} + +fn extract_last_codeblock(text: &str) -> String { + let mut last_block = None; + let mut search_start = 0; + + while let Some(start) = text[search_start..].find("```") { + let start = start + search_start; + let bytes = text.as_bytes(); + let mut backtick_end = start; + + while backtick_end < bytes.len() && bytes[backtick_end] == b'`' { + backtick_end += 1; + } + + let backtick_count = backtick_end - start; + let closing_backticks = "`".repeat(backtick_count); + + while backtick_end < bytes.len() && bytes[backtick_end] != b'\n' { + backtick_end += 1; + } + + if let Some(end_pos) = text[backtick_end..].find(&closing_backticks) { + let code_block = &text[backtick_end + 1..backtick_end + end_pos]; + last_block = Some(code_block.to_string()); + search_start = backtick_end + end_pos + backtick_count; + } else { + break; + } + } + + last_block.unwrap_or_else(|| text.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_last_code_block() { + let text = indoc::indoc! {" + Some thinking + + ``` + first block + ``` + + `````path='something' lines=1:2 + last block + ````` + "}; + let last_block = extract_last_codeblock(text); + assert_eq!(last_block, "last block\n"); + } + + #[test] + fn test_extract_editable_region() { + let text = indoc::indoc! {" + some lines + are + here + <|editable_region_start|> + one + two three + + <|editable_region_end|> + more + lines here + "}; + let parsed = TeacherPrompt::extract_editable_region(text); + assert_eq!( + parsed, + indoc::indoc! {" + one + two three + + "} + ); + } +} diff --git a/crates/zeta_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs similarity index 79% rename from crates/zeta_cli/src/headless.rs rename to crates/edit_prediction_cli/src/headless.rs index bb4cb010cb..2deb96fdbf 100644 --- a/crates/zeta_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -1,4 +1,5 @@ use client::{Client, ProxySettings, UserStore}; +use collections::HashMap; use extension::ExtensionHostProxy; use fs::RealFs; use gpui::http_client::read_proxy_from_env; @@ -9,31 +10,49 @@ use language_extension::LspAccess; use node_runtime::{NodeBinaryOptions, NodeRuntime}; use project::Project; use project::project_settings::ProjectSettings; -use release_channel::AppVersion; +use release_channel::{AppCommitSha, AppVersion}; use reqwest_client::ReqwestClient; use settings::{Settings, SettingsStore}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use util::ResultExt as _; /// Headless subset of `workspace::AppState`. -pub struct ZetaCliAppState { +pub struct EpAppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, pub fs: Arc, pub node_runtime: NodeRuntime, + pub project_cache: ProjectCache, } -// TODO: dedupe with crates/eval/src/eval.rs -pub fn init(cx: &mut App) -> ZetaCliAppState { - let app_version = AppVersion::load(env!("ZED_PKG_VERSION")); - release_channel::init(app_version, cx); +#[derive(Default)] +pub struct ProjectCache(Mutex>>); + +impl ProjectCache { + pub fn insert(&self, repository_url: String, project: Entity) { + self.0.lock().unwrap().insert(repository_url, project); + } + + pub fn get(&self, repository_url: &String) -> Option> { + self.0.lock().unwrap().get(repository_url).cloned() + } +} + +pub fn init(cx: &mut App) -> EpAppState { + let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned())); + + let app_version = AppVersion::load( + env!("ZED_PKG_VERSION"), + option_env!("ZED_BUILD_ID"), + app_commit_sha, + ); + release_channel::init(app_version.clone(), cx); gpui_tokio::init(cx); let settings_store = SettingsStore::new(cx, &settings::default_settings()); cx.set_global(settings_store); - client::init_settings(cx); // Set User-Agent so we can download language servers from GitHub let user_agent = format!( @@ -55,8 +74,6 @@ pub fn init(cx: &mut App) -> ZetaCliAppState { }; cx.set_http_client(Arc::new(http)); - Project::init_settings(cx); - let client = Client::production(cx); cx.set_http_client(client.http_client()); @@ -102,7 +119,6 @@ pub fn init(cx: &mut App) -> ZetaCliAppState { let extension_host_proxy = ExtensionHostProxy::global(cx); - language::init(cx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); language_model::init(client.clone(), cx); @@ -111,11 +127,14 @@ pub fn init(cx: &mut App) -> ZetaCliAppState { prompt_store::init(cx); terminal_view::init(cx); - ZetaCliAppState { + let project_cache = ProjectCache::default(); + + EpAppState { languages, client, user_store, fs, node_runtime, + project_cache, } } diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs new file mode 100644 index 0000000000..38f114d726 --- /dev/null +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -0,0 +1,356 @@ +use crate::{ + example::{Example, ExampleBuffer, ExampleState}, + headless::EpAppState, + paths::{REPOS_DIR, WORKTREES_DIR}, + progress::{InfoStyle, Progress, Step, StepProgress}, +}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use edit_prediction::EditPredictionStore; +use edit_prediction::udiff::OpenedBuffers; +use futures::{ + AsyncWriteExt as _, + lock::{Mutex, OwnedMutexGuard}, +}; +use gpui::{AsyncApp, Entity}; +use language::{Anchor, Buffer, LanguageNotFound, ToOffset, ToPoint}; +use project::buffer_store::BufferStoreEvent; +use project::{Project, ProjectPath}; +use std::{ + cell::RefCell, + fs, + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{paths::PathStyle, rel_path::RelPath}; +use zeta_prompt::CURSOR_MARKER; + +pub async fn run_load_project( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { + if example.state.is_some() { + return Ok(()); + } + + let progress = Progress::global().start(Step::LoadProject, &example.spec.name); + + let project = setup_project(example, &app_state, &progress, &mut cx).await?; + + let _open_buffers = apply_edit_history(example, &project, &mut cx).await?; + + let (buffer, cursor_position) = cursor_position(example, &project, &mut cx).await?; + let (example_buffer, language_name) = buffer.read_with(&cx, |buffer, _cx| { + let cursor_point = cursor_position.to_point(&buffer); + let language_name = buffer + .language() + .map(|l| l.name().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + ( + ExampleBuffer { + content: buffer.text(), + cursor_row: cursor_point.row, + cursor_column: cursor_point.column, + cursor_offset: cursor_position.to_offset(&buffer), + }, + language_name, + ) + })?; + + progress.set_info(language_name, InfoStyle::Normal); + + example.buffer = Some(example_buffer); + example.state = Some(ExampleState { + buffer, + project, + cursor_position, + _open_buffers, + }); + Ok(()) +} + +async fn cursor_position( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result<(Entity, Anchor)> { + let language_registry = project.read_with(cx, |project, _| project.languages().clone())?; + let result = language_registry + .load_language_for_file_path(&example.spec.cursor_path) + .await; + + if let Err(error) = result + && !error.is::() + { + return Err(error); + } + + let worktree = project.read_with(cx, |project, cx| { + project + .visible_worktrees(cx) + .next() + .context("No visible worktrees") + })??; + + let cursor_path = RelPath::new(&example.spec.cursor_path, PathStyle::Posix) + .context("Failed to create RelPath")? + .into_arc(); + let cursor_buffer = project + .update(cx, |project, cx| { + project.open_buffer( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: cursor_path, + }, + cx, + ) + })? + .await?; + let cursor_offset_within_excerpt = example + .spec + .cursor_position + .find(CURSOR_MARKER) + .context("missing cursor marker")?; + let mut cursor_excerpt = example.spec.cursor_position.clone(); + cursor_excerpt.replace_range( + cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), + "", + ); + let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| { + let text = buffer.text(); + + let mut matches = text.match_indices(&cursor_excerpt); + let (excerpt_offset, _) = matches.next().with_context(|| { + format!( + "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Example: {}\nCursor excerpt did not exist in buffer.", + example.spec.name + ) + })?; + anyhow::ensure!( + matches.next().is_none(), + "More than one cursor position match found for {}", + &example.spec.name + ); + Ok(excerpt_offset) + })??; + + let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; + let cursor_anchor = + cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?; + + Ok((cursor_buffer, cursor_anchor)) +} + +async fn setup_project( + example: &mut Example, + app_state: &Arc, + step_progress: &StepProgress, + cx: &mut AsyncApp, +) -> Result> { + let ep_store = cx + .update(|cx| EditPredictionStore::try_global(cx))? + .context("Store should be initialized at init")?; + + let worktree_path = setup_worktree(example, step_progress).await?; + + if let Some(project) = app_state.project_cache.get(&example.spec.repository_url) { + ep_store.update(cx, |ep_store, _| { + ep_store.clear_history_for_project(&project); + })?; + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + let buffers = buffer_store.read_with(cx, |buffer_store, _| { + buffer_store.buffers().collect::>() + })?; + for buffer in buffers { + buffer + .update(cx, |buffer, cx| buffer.reload(cx))? + .await + .ok(); + } + return Ok(project); + } + + let project = cx.update(|cx| { + Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + cx, + ) + })?; + + project + .update(cx, |project, cx| { + project.disable_worktree_scanner(cx); + project.create_worktree(&worktree_path, true, cx) + })? + .await?; + + app_state + .project_cache + .insert(example.spec.repository_url.clone(), project.clone()); + + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + cx.subscribe(&buffer_store, { + let project = project.clone(); + move |_, event, cx| match event { + BufferStoreEvent::BufferAdded(buffer) => { + ep_store.update(cx, |store, cx| store.register_buffer(&buffer, &project, cx)); + } + _ => {} + } + })? + .detach(); + + Ok(project) +} + +async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Result { + let (repo_owner, repo_name) = example.repo_name().context("failed to get repo name")?; + let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()); + let worktree_path = WORKTREES_DIR + .join(repo_owner.as_ref()) + .join(repo_name.as_ref()); + let repo_lock = lock_repo(&repo_dir).await; + + if !repo_dir.is_dir() { + step_progress.set_substatus(format!("cloning {}", repo_name)); + fs::create_dir_all(&repo_dir)?; + run_git(&repo_dir, &["init"]).await?; + run_git( + &repo_dir, + &["remote", "add", "origin", &example.spec.repository_url], + ) + .await?; + } + + // Resolve the example to a revision, fetching it if needed. + let revision = run_git( + &repo_dir, + &[ + "rev-parse", + &format!("{}^{{commit}}", example.spec.revision), + ], + ) + .await; + let revision = if let Ok(revision) = revision { + revision + } else { + step_progress.set_substatus("fetching"); + if run_git( + &repo_dir, + &["fetch", "--depth", "1", "origin", &example.spec.revision], + ) + .await + .is_err() + { + run_git(&repo_dir, &["fetch", "origin"]).await?; + } + let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?; + revision + }; + + // Create the worktree for this example if needed. + step_progress.set_substatus("preparing worktree"); + if worktree_path.is_dir() { + run_git(&worktree_path, &["clean", "--force", "-d"]).await?; + run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; + run_git(&worktree_path, &["checkout", revision.as_str()]).await?; + } else { + let worktree_path_string = worktree_path.to_string_lossy(); + run_git( + &repo_dir, + &["branch", "-f", &example.spec.name, revision.as_str()], + ) + .await?; + run_git( + &repo_dir, + &[ + "worktree", + "add", + "-f", + &worktree_path_string, + &example.spec.name, + ], + ) + .await?; + } + drop(repo_lock); + + // Apply the uncommitted diff for this example. + if !example.spec.uncommitted_diff.is_empty() { + step_progress.set_substatus("applying diff"); + let mut apply_process = smol::process::Command::new("git") + .current_dir(&worktree_path) + .args(&["apply", "-"]) + .stdin(std::process::Stdio::piped()) + .spawn()?; + + let mut stdin = apply_process.stdin.take().context("Failed to get stdin")?; + stdin + .write_all(example.spec.uncommitted_diff.as_bytes()) + .await?; + stdin.close().await?; + drop(stdin); + + let apply_result = apply_process.output().await?; + anyhow::ensure!( + apply_result.status.success(), + "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", + apply_result.status, + String::from_utf8_lossy(&apply_result.stderr), + String::from_utf8_lossy(&apply_result.stdout), + ); + } + + step_progress.clear_substatus(); + Ok(worktree_path) +} + +async fn apply_edit_history( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result { + edit_prediction::udiff::apply_diff(&example.spec.edit_history, project, cx).await +} + +thread_local! { + static REPO_LOCKS: RefCell>>> = RefCell::new(HashMap::default()); +} + +#[must_use] +pub async fn lock_repo(path: impl AsRef) -> OwnedMutexGuard<()> { + REPO_LOCKS + .with(|cell| { + cell.borrow_mut() + .entry(path.as_ref().to_path_buf()) + .or_default() + .clone() + }) + .lock_owned() + .await +} + +async fn run_git(repo_path: &Path, args: &[&str]) -> Result { + let output = smol::process::Command::new("git") + .current_dir(repo_path) + .args(args) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", + args.join(" "), + repo_path.display(), + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs new file mode 100644 index 0000000000..dce0fbbed5 --- /dev/null +++ b/crates/edit_prediction_cli/src/main.rs @@ -0,0 +1,343 @@ +mod anthropic_client; +mod distill; +mod example; +mod format_prompt; +mod headless; +mod load_project; +mod metrics; +mod paths; +mod predict; +mod progress; +mod retrieve_context; +mod score; + +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use edit_prediction::EditPredictionStore; +use gpui::Application; +use reqwest_client::ReqwestClient; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::{path::PathBuf, sync::Arc}; + +use crate::distill::run_distill; +use crate::example::{group_examples_by_repo, read_examples, write_examples}; +use crate::format_prompt::run_format_prompt; +use crate::load_project::run_load_project; +use crate::paths::FAILED_EXAMPLES_DIR; +use crate::predict::run_prediction; +use crate::progress::Progress; +use crate::retrieve_context::run_context_retrieval; +use crate::score::run_scoring; + +#[derive(Parser, Debug)] +#[command(name = "ep")] +struct EpArgs { + #[arg(long, default_value_t = false)] + printenv: bool, + #[clap(long, default_value_t = 10, global = true)] + max_parallelism: usize, + #[command(subcommand)] + command: Option, + #[clap(global = true)] + inputs: Vec, + #[arg(long, short, global = true)] + output: Option, + #[arg(long, short, global = true)] + in_place: bool, + #[arg(long, short, global = true)] + failfast: bool, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Parse markdown examples and output a combined .jsonl file + ParseExample, + /// Create git worktrees for each example and load file contents + LoadProject, + /// Retrieve context for input examples. + Context, + /// Generate a prompt string for a specific model + FormatPrompt(FormatPromptArgs), + /// Runs edit prediction + Predict(PredictArgs), + /// Computes a score based on actual and expected patches + Score(PredictArgs), + /// Prepares a distillation dataset by copying expected outputs to + /// predicted outputs and removing actual outputs and prompts. + Distill, + /// Print aggregated scores + Eval(PredictArgs), + /// Remove git repositories and worktrees + Clean, +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Command::ParseExample => write!(f, "parse-example"), + Command::LoadProject => write!(f, "load-project"), + Command::Context => write!(f, "context"), + Command::FormatPrompt(format_prompt_args) => write!( + f, + "format-prompt --prompt-format={}", + format_prompt_args + .prompt_format + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Predict(predict_args) => { + write!( + f, + "predict --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Score(predict_args) => { + write!( + f, + "score --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Distill => write!(f, "distill"), + Command::Eval(predict_args) => write!( + f, + "eval --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Clean => write!(f, "clean"), + } + } +} + +#[derive(Debug, Args)] +struct FormatPromptArgs { + #[clap(long)] + prompt_format: PromptFormat, +} + +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] +enum PromptFormat { + Teacher, + Zeta2, +} + +#[derive(Debug, Args)] +struct PredictArgs { + #[clap(long)] + provider: PredictionProvider, + #[clap(long, default_value_t = 1)] + repetitions: usize, +} + +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] +enum PredictionProvider { + Sweep, + Mercury, + Zeta1, + Zeta2, + Teacher, + TeacherNonBatching, +} + +impl EpArgs { + fn output_path(&self) -> Option { + if self.in_place { + if self.inputs.len() == 1 { + self.inputs.first().cloned() + } else { + panic!("--in-place requires exactly one input file") + } + } else { + self.output.clone() + } + } +} + +fn main() { + let args = EpArgs::parse(); + + if args.printenv { + ::util::shell_env::print_env(); + return; + } + + let output = args.output_path(); + let command = match args.command { + Some(cmd) => cmd, + None => { + EpArgs::command().print_help().unwrap(); + return; + } + }; + + match &command { + Command::Clean => { + std::fs::remove_dir_all(&*paths::DATA_DIR).unwrap(); + return; + } + _ => {} + } + + let mut examples = read_examples(&args.inputs); + let http_client = Arc::new(ReqwestClient::new()); + let app = Application::headless().with_http_client(http_client); + + app.run(move |cx| { + let app_state = Arc::new(headless::init(cx)); + EditPredictionStore::global(&app_state.client, &app_state.user_store, cx); + + cx.spawn(async move |cx| { + let result = async { + if let Command::Predict(args) = &command { + predict::sync_batches(&args.provider).await?; + } + + let total_examples = examples.len(); + Progress::global().set_total_examples(total_examples); + + let mut grouped_examples = group_examples_by_repo(&mut examples); + let example_batches = grouped_examples.chunks_mut(args.max_parallelism); + + for example_batch in example_batches { + let futures = example_batch.into_iter().map(|repo_examples| async { + for example in repo_examples.iter_mut() { + let result = async { + match &command { + Command::ParseExample => {} + Command::LoadProject => { + run_load_project(example, app_state.clone(), cx.clone()) + .await?; + } + Command::Context => { + run_context_retrieval( + example, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::FormatPrompt(args) => { + run_format_prompt( + example, + args.prompt_format, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Predict(args) => { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Distill => { + run_distill(example).await?; + } + Command::Score(args) | Command::Eval(args) => { + run_scoring(example, &args, app_state.clone(), cx.clone()) + .await?; + } + Command::Clean => { + unreachable!() + } + } + anyhow::Ok(()) + } + .await; + + if let Err(e) = result { + Progress::global().increment_failed(); + let failed_example_path = + FAILED_EXAMPLES_DIR.join(format!("{}.json", example.spec.name)); + app_state + .fs + .write( + &failed_example_path, + &serde_json::to_vec_pretty(&example).unwrap(), + ) + .await + .unwrap(); + let err_path = FAILED_EXAMPLES_DIR + .join(format!("{}_err.txt", example.spec.name)); + app_state + .fs + .write(&err_path, e.to_string().as_bytes()) + .await + .unwrap(); + + let msg = format!( + indoc::indoc! {" + While processing {}: + + {:?} + + Written to: \x1b[36m{}\x1b[0m + + Explore this example data with: + fx \x1b[36m{}\x1b[0m + + Re-run this example with: + cargo run -p edit_prediction_cli -- {} \x1b[36m{}\x1b[0m + "}, + example.spec.name, + e, + err_path.display(), + failed_example_path.display(), + command, + failed_example_path.display(), + ); + if args.failfast || total_examples == 1 { + Progress::global().finalize(); + panic!("{}", msg); + } else { + log::error!("{}", msg); + } + } + } + }); + futures::future::join_all(futures).await; + } + Progress::global().finalize(); + + if args.output.is_some() || !matches!(command, Command::Eval(_)) { + write_examples(&examples, output.as_ref()); + } + + match &command { + Command::Predict(args) => predict::sync_batches(&args.provider).await?, + Command::Eval(_) => score::print_report(&examples), + _ => (), + }; + + anyhow::Ok(()) + } + .await; + + if let Err(e) = result { + panic!("Fatal error: {:?}", e); + } + + let _ = cx.update(|cx| cx.quit()); + }) + .detach(); + }); +} diff --git a/crates/edit_prediction_cli/src/metrics.rs b/crates/edit_prediction_cli/src/metrics.rs new file mode 100644 index 0000000000..b3e5eb8688 --- /dev/null +++ b/crates/edit_prediction_cli/src/metrics.rs @@ -0,0 +1,371 @@ +use collections::{HashMap, HashSet}; +use edit_prediction::udiff::DiffLine; +use serde::{Deserialize, Serialize}; + +type Counts = HashMap; +type CountsDelta = HashMap; + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct ClassificationMetrics { + pub true_positives: usize, + pub false_positives: usize, + pub false_negatives: usize, +} + +impl ClassificationMetrics { + pub fn from_sets( + expected: &HashSet, + actual: &HashSet, + ) -> ClassificationMetrics { + let true_positives = expected.intersection(actual).count(); + let false_positives = actual.difference(expected).count(); + let false_negatives = expected.difference(actual).count(); + + ClassificationMetrics { + true_positives, + false_positives, + false_negatives, + } + } + + pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics { + let mut true_positives = 0; + let mut false_positives = 0; + let mut false_negatives = 0; + + for (ngram, &expected_count) in expected { + let actual_count = *actual.get(ngram).unwrap_or(&0); + if actual_count > expected_count { + false_positives += actual_count - expected_count; + } else { + false_negatives += expected_count - actual_count; + } + true_positives += expected_count.min(actual_count); + } + + for (ngram, &actual_count) in actual { + if !expected.contains_key(ngram) { + false_positives += actual_count; + } + } + + ClassificationMetrics { + true_positives, + false_positives, + false_negatives, + } + } + + pub fn aggregate<'a>( + scores: impl Iterator, + ) -> ClassificationMetrics { + let mut true_positives = 0; + let mut false_positives = 0; + let mut false_negatives = 0; + + for score in scores { + true_positives += score.true_positives; + false_positives += score.false_positives; + false_negatives += score.false_negatives; + } + + ClassificationMetrics { + true_positives, + false_positives, + false_negatives, + } + } + + pub fn precision(&self) -> f64 { + if self.true_positives + self.false_positives == 0 { + 0.0 + } else { + self.true_positives as f64 / (self.true_positives + self.false_positives) as f64 + } + } + + pub fn recall(&self) -> f64 { + if self.true_positives + self.false_negatives == 0 { + 0.0 + } else { + self.true_positives as f64 / (self.true_positives + self.false_negatives) as f64 + } + } + + pub fn f1_score(&self) -> f64 { + let recall = self.recall(); + let precision = self.precision(); + if precision + recall == 0.0 { + 0.0 + } else { + 2.0 * precision * recall / (precision + recall) + } + } +} + +pub fn line_match_score( + expected_patch: &[DiffLine], + actual_patch: &[DiffLine], +) -> ClassificationMetrics { + let expected_change_lines = expected_patch + .iter() + .filter(|line| matches!(line, DiffLine::Addition(_) | DiffLine::Deletion(_))) + .map(|line| line.to_string()) + .collect(); + + let actual_change_lines = actual_patch + .iter() + .filter(|line| matches!(line, DiffLine::Addition(_) | DiffLine::Deletion(_))) + .map(|line| line.to_string()) + .collect(); + + ClassificationMetrics::from_sets(&expected_change_lines, &actual_change_lines) +} + +enum ChrfWhitespace { + #[allow(unused)] + Unchanged, + Ignore, +} + +const CHR_F_CHAR_ORDER: usize = 6; +const CHR_F_BETA: f64 = 2.0; +const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Ignore; + +/// Computes a delta-chrF score that compares two sets of edits. +/// +/// This metric works by: +/// 1. Reconstructing original, golden (expected result), and actual texts from diffs +/// 2. Computing n-gram count differences (deltas) between original→golden and original→actual +/// 3. Comparing these deltas to measure how well actual edits match expected edits +pub fn delta_chr_f(expected: &[DiffLine], actual: &[DiffLine]) -> f64 { + // Reconstruct texts from diffs + let mut original_text = String::new(); // state of the text before any edits + let mut golden_text = String::new(); // text after applying golden edits + let mut actual_text = String::new(); // text after applying actual edits + + for line in expected { + match line { + DiffLine::Context(s) => { + original_text.push_str(s); + golden_text.push_str(s); + } + DiffLine::Deletion(s) => { + original_text.push_str(s); + } + DiffLine::Addition(s) => { + golden_text.push_str(s); + } + _ => {} + } + } + + for line in actual { + match line { + DiffLine::Context(s) | DiffLine::Addition(s) => { + actual_text.push_str(s); + } + _ => {} + } + } + + // Edge case + if original_text == golden_text && golden_text == actual_text { + return 100.0; + } + + // Compute the metric + let original_ngrams = chr_f_ngram_counts(&original_text); + let golden_ngrams = chr_f_ngram_counts(&golden_text); + let actual_ngrams = chr_f_ngram_counts(&actual_text); + + let mut total_precision = 0.0; + let mut total_recall = 0.0; + + for order in 0..CHR_F_CHAR_ORDER { + let expected_delta = compute_ngram_delta(&golden_ngrams[order], &original_ngrams[order]); + let actual_delta = compute_ngram_delta(&actual_ngrams[order], &original_ngrams[order]); + + if expected_delta.is_empty() && actual_delta.is_empty() { + total_precision += 1.0; + total_recall += 1.0; + continue; + } + + let expected_counts = ngram_delta_to_counts(&expected_delta); + let actual_counts = ngram_delta_to_counts(&actual_delta); + + let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); + total_precision += score.precision(); + total_recall += score.recall(); + } + + let prec = total_precision / CHR_F_CHAR_ORDER as f64; + let recall = total_recall / CHR_F_CHAR_ORDER as f64; + let f_score = if prec + recall == 0.0 { + 0.0 + } else { + (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall) + }; + + f_score * 100.0 +} + +fn chr_f_ngram_counts(text: &str) -> Vec { + // Ignore whitespace. The original chrF implementation skips all + // whitespace. We should consider compressing multiple consecutive + // spaces into one -- this may reflect our task more closely. + let text = match CHR_F_WHITESPACE { + ChrfWhitespace::Unchanged => text.to_string(), + ChrfWhitespace::Ignore => text + .chars() + .filter(|c| !c.is_whitespace()) + .collect::(), + }; + + (1..=CHR_F_CHAR_ORDER) + .map(|order| count_ngrams(&text, order)) + .collect() +} + +fn compute_ngram_delta(after: &Counts, before: &Counts) -> CountsDelta { + let mut delta = CountsDelta::default(); + + for (ngram, &before_count) in before { + let after_count = *after.get(ngram).unwrap_or(&0); + delta.insert(ngram.clone(), after_count as isize - before_count as isize); + } + + for (ngram, &after_count) in after { + if !before.contains_key(ngram) { + delta.insert(ngram.clone(), after_count as isize); + } + } + + delta +} + +/// Convert negative counts to special deletion tokens. +/// For example, if expected delta is {"foo": -1} and actual delta is {"bar": -1}, +/// we convert it to {"¬foo": +1} and {"¬bar": +1}. This way _not_ deleting "foo" +/// will result in a false negative, and mistakenly deleting "bar" will result in a false positive. +fn ngram_delta_to_counts(delta: &CountsDelta) -> Counts { + let mut counts = Counts::default(); + + for (ngram, &delta) in delta { + if delta > 0 { + counts.insert(ngram.clone(), delta as usize); + } else { + counts.insert(format!("¬{ngram}"), delta.unsigned_abs()); + } + } + + counts +} + +fn count_ngrams(text: &str, n: usize) -> Counts { + let chars: Vec = text.chars().collect(); + let mut counts = Counts::default(); + + for window in chars.windows(n) { + let ngram: String = window.iter().collect(); + *counts.entry(ngram).or_insert(0) += 1; + } + + counts +} + +#[cfg(test)] +mod test { + use super::*; + use edit_prediction::udiff::DiffLine; + + #[test] + fn test_delta_chr_f_perfect_match() { + let diff = vec![ + DiffLine::Context("fn main() {"), + DiffLine::Deletion(" println!(\"Hello\");"), + DiffLine::Addition(" println!(\"Hello, World!\");"), + DiffLine::Context("}"), + ]; + + let score = delta_chr_f(&diff, &diff); + assert!((score - 100.0).abs() < 1e-2); + } + + #[test] + fn test_delta_chr_f_wrong_edit() { + // When the edit is wrong + let expected = vec![ + DiffLine::Context("one "), + DiffLine::Deletion("two "), + DiffLine::Context("three"), + ]; + + let actual = vec![ + DiffLine::Context("one "), + DiffLine::Context("two "), + DiffLine::Deletion("three"), + DiffLine::Addition("four"), + ]; + + // Then the score should be low + let score = delta_chr_f(&expected, &actual); + assert!(score > 20.0 && score < 40.0); + } + + #[test] + fn test_delta_chr_f_partial_match() { + let expected = vec![ + DiffLine::Deletion("let x = 42;"), + DiffLine::Addition("let x = 100;"), + ]; + + let actual = vec![ + DiffLine::Deletion("let x = 42;"), + DiffLine::Addition("let x = 99;"), + ]; + + // We got the edit location right, but the replacement text is wrong. + // Deleted ngrams will match, bringing the score somewhere in the middle. + let score = delta_chr_f(&expected, &actual); + assert!(score > 40.0 && score < 60.0); + } + + #[test] + fn test_delta_chr_f_missed_edit() { + // When predictions makes no changes + let expected = vec![ + DiffLine::Context("prefix "), + DiffLine::Deletion("old"), + DiffLine::Addition("new"), + DiffLine::Context(" suffix"), + ]; + + let actual = vec![ + DiffLine::Context("prefix "), + DiffLine::Context("old"), + DiffLine::Context(" suffix"), + ]; + + // Then the score should be low (all expected changes are false negatives) + let score = delta_chr_f(&expected, &actual); + assert!(score < 20.0); + } + + #[test] + fn test_delta_chr_f_extra_edit() { + // When adding unexpected content + let expected = vec![DiffLine::Context("hello"), DiffLine::Context("world")]; + + let actual = vec![ + DiffLine::Context("hello"), + DiffLine::Addition("extra"), + DiffLine::Context("world"), + ]; + + // Then the score should be low (all actual changes are false positives) + let score = delta_chr_f(&expected, &actual); + assert!(score < 20.0); + } +} diff --git a/crates/edit_prediction_cli/src/paths.rs b/crates/edit_prediction_cli/src/paths.rs new file mode 100644 index 0000000000..e5d420d0e3 --- /dev/null +++ b/crates/edit_prediction_cli/src/paths.rs @@ -0,0 +1,27 @@ +use std::{ + path::{Path, PathBuf}, + sync::LazyLock, +}; + +pub static DATA_DIR: LazyLock = LazyLock::new(|| { + let dir = dirs::home_dir().unwrap().join(".zed_ep"); + ensure_dir(&dir) +}); +pub static CACHE_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("cache"))); +pub static REPOS_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("repos"))); +pub static WORKTREES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&DATA_DIR.join("worktrees"))); +pub static RUN_DIR: LazyLock = LazyLock::new(|| { + DATA_DIR + .join("runs") + .join(chrono::Local::now().format("%d-%m-%y-%H_%M_%S").to_string()) +}); +pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = LazyLock::new(|| DATA_DIR.join("latest")); +pub static LLM_CACHE_DB: LazyLock = LazyLock::new(|| CACHE_DIR.join("llm_cache.sqlite")); +pub static FAILED_EXAMPLES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&RUN_DIR.join("failed"))); + +fn ensure_dir(path: &Path) -> PathBuf { + std::fs::create_dir_all(path).expect("Failed to create directory"); + path.to_path_buf() +} diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs new file mode 100644 index 0000000000..aa93c5415d --- /dev/null +++ b/crates/edit_prediction_cli/src/predict.rs @@ -0,0 +1,291 @@ +use crate::{ + PredictionProvider, PromptFormat, + anthropic_client::AnthropicClient, + example::{Example, ExamplePrediction}, + format_prompt::{TeacherPrompt, run_format_prompt}, + headless::EpAppState, + load_project::run_load_project, + paths::{LATEST_EXAMPLE_RUN_DIR, RUN_DIR}, + progress::{InfoStyle, Progress, Step}, + retrieve_context::run_context_retrieval, +}; +use anyhow::Context as _; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, future::Shared}; +use gpui::{AppContext as _, AsyncApp, Task}; +use std::{ + fs, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicUsize, Ordering::SeqCst}, + }, +}; + +pub async fn run_prediction( + example: &mut Example, + provider: Option, + repetition_count: usize, + app_state: Arc, + mut cx: AsyncApp, +) -> anyhow::Result<()> { + if !example.predictions.is_empty() { + return Ok(()); + } + + let provider = provider.context("provider is required")?; + + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; + + if matches!( + provider, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching + ) { + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); + + if example.prompt.is_none() { + run_format_prompt(example, PromptFormat::Teacher, app_state.clone(), cx).await?; + } + + let batched = matches!(provider, PredictionProvider::Teacher); + return predict_anthropic(example, repetition_count, batched).await; + } + + run_load_project(example, app_state.clone(), cx.clone()).await?; + + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); + + if matches!( + provider, + PredictionProvider::Zeta1 | PredictionProvider::Zeta2 + ) { + static AUTHENTICATED: OnceLock>> = OnceLock::new(); + AUTHENTICATED + .get_or_init(|| { + let client = app_state.client.clone(); + cx.spawn(async move |cx| { + if let Err(e) = client.sign_in_with_optional_connect(true, cx).await { + eprintln!("Authentication failed: {}", e); + } + }) + .shared() + }) + .clone() + .await; + } + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + ep_store.update(&mut cx, |store, _cx| { + let model = match provider { + PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, + PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, + PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, + PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching => { + unreachable!() + } + }; + store.set_edit_prediction_model(model); + })?; + let state = example.state.as_ref().context("state must be set")?; + let run_dir = RUN_DIR.join(&example.spec.name); + + let updated_example = Arc::new(Mutex::new(example.clone())); + let current_run_ix = Arc::new(AtomicUsize::new(0)); + + let mut debug_rx = + ep_store.update(&mut cx, |store, cx| store.debug_info(&state.project, cx))?; + let debug_task = cx.background_spawn({ + let updated_example = updated_example.clone(); + let current_run_ix = current_run_ix.clone(); + let run_dir = run_dir.clone(); + async move { + while let Some(event) = debug_rx.next().await { + let run_ix = current_run_ix.load(SeqCst); + let mut updated_example = updated_example.lock().unwrap(); + + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", run_ix)) + } else { + run_dir.clone() + }; + + match event { + DebugEvent::EditPredictionStarted(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(prompt) = request.prompt { + fs::write(run_dir.join("prediction_prompt.md"), &prompt)?; + } + } + DebugEvent::EditPredictionFinished(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(output) = request.model_output { + fs::write(run_dir.join("prediction_response.md"), &output)?; + updated_example + .predictions + .last_mut() + .unwrap() + .actual_output = output; + } + if run_ix >= repetition_count { + break; + } + } + _ => {} + } + } + anyhow::Ok(()) + } + }); + + for ix in 0..repetition_count { + current_run_ix.store(ix, SeqCst); + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", ix)) + } else { + run_dir.clone() + }; + + fs::create_dir_all(&run_dir)?; + if LATEST_EXAMPLE_RUN_DIR.is_symlink() { + fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR)?; + } + #[cfg(unix)] + std::os::unix::fs::symlink(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; + + updated_example + .lock() + .unwrap() + .predictions + .push(ExamplePrediction { + actual_patch: String::new(), + actual_output: String::new(), + provider, + }); + + let prediction = ep_store + .update(&mut cx, |store, cx| { + store.request_prediction( + &state.project, + &state.buffer, + state.cursor_position, + cloud_llm_client::PredictEditsRequestTrigger::Cli, + cx, + ) + })? + .await?; + + let actual_patch = prediction + .and_then(|prediction| { + let prediction = prediction.prediction.ok()?; + prediction.edit_preview.as_unified_diff(&prediction.edits) + }) + .unwrap_or_default(); + + let has_prediction = !actual_patch.is_empty(); + + updated_example + .lock() + .unwrap() + .predictions + .last_mut() + .unwrap() + .actual_patch = actual_patch; + + if ix == repetition_count - 1 { + let (info, style) = if has_prediction { + ("predicted", InfoStyle::Normal) + } else { + ("no prediction", InfoStyle::Warning) + }; + _step_progress.set_info(info, style); + } + } + + ep_store.update(&mut cx, |store, _| { + store.remove_project(&state.project); + })?; + debug_task.await?; + + *example = Arc::into_inner(updated_example) + .ok_or_else(|| anyhow::anyhow!("Failed to unwrap Arc"))? + .into_inner() + .map_err(|_| anyhow::anyhow!("Failed to unwrap Mutex"))?; + Ok(()) +} + +async fn predict_anthropic( + example: &mut Example, + _repetition_count: usize, + batched: bool, +) -> anyhow::Result<()> { + let llm_model_name = "claude-sonnet-4-5"; + let max_tokens = 16384; + let llm_client = if batched { + AnthropicClient::batch(&crate::paths::LLM_CACHE_DB.as_ref()) + } else { + AnthropicClient::plain() + }; + let llm_client = llm_client.context("Failed to create LLM client")?; + + let prompt = example.prompt.as_ref().context("Prompt is required")?; + + let messages = vec![anthropic::Message { + role: anthropic::Role::User, + content: vec![anthropic::RequestContent::Text { + text: prompt.input.clone(), + cache_control: None, + }], + }]; + + let Some(response) = llm_client + .generate(llm_model_name, max_tokens, messages) + .await? + else { + // Request stashed for batched processing + return Ok(()); + }; + + let actual_output = response + .content + .into_iter() + .filter_map(|content| match content { + anthropic::ResponseContent::Text { text } => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); + + let actual_patch = TeacherPrompt::parse(example, &actual_output)?; + + let prediction = ExamplePrediction { + actual_patch, + actual_output, + provider: PredictionProvider::Teacher, + }; + + example.predictions.push(prediction); + Ok(()) +} + +pub async fn sync_batches(provider: &PredictionProvider) -> anyhow::Result<()> { + match provider { + PredictionProvider::Teacher => { + let cache_path = crate::paths::LLM_CACHE_DB.as_ref(); + let llm_client = + AnthropicClient::batch(cache_path).context("Failed to create LLM client")?; + llm_client + .sync_batches() + .await + .context("Failed to sync batches")?; + } + _ => (), + }; + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/progress.rs b/crates/edit_prediction_cli/src/progress.rs new file mode 100644 index 0000000000..ddc710f202 --- /dev/null +++ b/crates/edit_prediction_cli/src/progress.rs @@ -0,0 +1,508 @@ +use std::{ + borrow::Cow, + collections::HashMap, + io::{IsTerminal, Write}, + sync::{Arc, Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use log::{Level, Log, Metadata, Record}; + +pub struct Progress { + inner: Mutex, +} + +struct ProgressInner { + completed: Vec, + in_progress: HashMap, + is_tty: bool, + terminal_width: usize, + max_example_name_len: usize, + status_lines_displayed: usize, + total_examples: usize, + failed_examples: usize, + last_line_is_logging: bool, +} + +#[derive(Clone)] +struct InProgressTask { + step: Step, + started_at: Instant, + substatus: Option, + info: Option<(String, InfoStyle)>, +} + +struct CompletedTask { + step: Step, + example_name: String, + duration: Duration, + info: Option<(String, InfoStyle)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Step { + LoadProject, + Context, + FormatPrompt, + Predict, + Score, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InfoStyle { + Normal, + Warning, +} + +impl Step { + pub fn label(&self) -> &'static str { + match self { + Step::LoadProject => "Load", + Step::Context => "Context", + Step::FormatPrompt => "Format", + Step::Predict => "Predict", + Step::Score => "Score", + } + } + + fn color_code(&self) -> &'static str { + match self { + Step::LoadProject => "\x1b[33m", + Step::Context => "\x1b[35m", + Step::FormatPrompt => "\x1b[34m", + Step::Predict => "\x1b[32m", + Step::Score => "\x1b[31m", + } + } +} + +static GLOBAL: OnceLock> = OnceLock::new(); +static LOGGER: ProgressLogger = ProgressLogger; + +const MARGIN: usize = 4; +const MAX_STATUS_LINES: usize = 10; + +impl Progress { + /// Returns the global Progress instance, initializing it if necessary. + pub fn global() -> Arc { + GLOBAL + .get_or_init(|| { + let progress = Arc::new(Self { + inner: Mutex::new(ProgressInner { + completed: Vec::new(), + in_progress: HashMap::new(), + is_tty: std::io::stderr().is_terminal(), + terminal_width: get_terminal_width(), + max_example_name_len: 0, + status_lines_displayed: 0, + total_examples: 0, + failed_examples: 0, + last_line_is_logging: false, + }), + }); + let _ = log::set_logger(&LOGGER); + log::set_max_level(log::LevelFilter::Error); + progress + }) + .clone() + } + + pub fn set_total_examples(&self, total: usize) { + let mut inner = self.inner.lock().unwrap(); + inner.total_examples = total; + } + + pub fn increment_failed(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.failed_examples += 1; + } + + /// Prints a message to stderr, clearing and redrawing status lines to avoid corruption. + /// This should be used for any output that needs to appear above the status lines. + fn log(&self, message: &str) { + let mut inner = self.inner.lock().unwrap(); + Self::clear_status_lines(&mut inner); + + if !inner.last_line_is_logging { + let reset = "\x1b[0m"; + let dim = "\x1b[2m"; + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); + eprintln!("{dim}{divider}{reset}"); + inner.last_line_is_logging = true; + } + + eprintln!("{}", message); + } + + pub fn start(self: &Arc, step: Step, example_name: &str) -> StepProgress { + let mut inner = self.inner.lock().unwrap(); + + Self::clear_status_lines(&mut inner); + + inner.max_example_name_len = inner.max_example_name_len.max(example_name.len()); + inner.in_progress.insert( + example_name.to_string(), + InProgressTask { + step, + started_at: Instant::now(), + substatus: None, + info: None, + }, + ); + + Self::print_status_lines(&mut inner); + + StepProgress { + progress: self.clone(), + step, + example_name: example_name.to_string(), + } + } + + fn finish(&self, step: Step, example_name: &str) { + let mut inner = self.inner.lock().unwrap(); + + let Some(task) = inner.in_progress.remove(example_name) else { + return; + }; + + if task.step == step { + inner.completed.push(CompletedTask { + step: task.step, + example_name: example_name.to_string(), + duration: task.started_at.elapsed(), + info: task.info, + }); + + Self::clear_status_lines(&mut inner); + Self::print_logging_closing_divider(&mut inner); + Self::print_completed(&inner, inner.completed.last().unwrap()); + Self::print_status_lines(&mut inner); + } else { + inner.in_progress.insert(example_name.to_string(), task); + } + } + + fn print_logging_closing_divider(inner: &mut ProgressInner) { + if inner.last_line_is_logging { + let reset = "\x1b[0m"; + let dim = "\x1b[2m"; + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); + eprintln!("{dim}{divider}{reset}"); + inner.last_line_is_logging = false; + } + } + + fn clear_status_lines(inner: &mut ProgressInner) { + if inner.is_tty && inner.status_lines_displayed > 0 { + // Move up and clear each line we previously displayed + for _ in 0..inner.status_lines_displayed { + eprint!("\x1b[A\x1b[K"); + } + let _ = std::io::stderr().flush(); + inner.status_lines_displayed = 0; + } + } + + fn print_completed(inner: &ProgressInner, task: &CompletedTask) { + let duration = format_duration(task.duration); + let name_width = inner.max_example_name_len; + + if inner.is_tty { + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + let dim = "\x1b[2m"; + + let yellow = "\x1b[33m"; + let info_part = task + .info + .as_ref() + .map(|(s, style)| { + if *style == InfoStyle::Warning { + format!("{yellow}{s}{reset}") + } else { + s.to_string() + } + }) + .unwrap_or_default(); + + let prefix = format!( + "{bold}{color}{label:>12}{reset} {name:12} {name: 0 { + format!(" {} failed ", failed_count) + } else { + String::new() + }; + + let range_label = format!( + " {}/{}/{} ", + done_count, in_progress_count, inner.total_examples + ); + + // Print a divider line with failed count on left, range label on right + let failed_visible_len = strip_ansi_len(&failed_label); + let range_visible_len = range_label.len(); + let middle_divider_len = inner + .terminal_width + .saturating_sub(MARGIN * 2) + .saturating_sub(failed_visible_len) + .saturating_sub(range_visible_len); + let left_divider = "─".repeat(MARGIN); + let middle_divider = "─".repeat(middle_divider_len); + let right_divider = "─".repeat(MARGIN); + eprintln!( + "{dim}{left_divider}{reset}{failed_label}{dim}{middle_divider}{reset}{range_label}{dim}{right_divider}{reset}" + ); + + let mut tasks: Vec<_> = inner.in_progress.iter().collect(); + tasks.sort_by_key(|(name, _)| *name); + + let total_tasks = tasks.len(); + let mut lines_printed = 0; + + for (name, task) in tasks.iter().take(MAX_STATUS_LINES) { + let elapsed = format_duration(task.started_at.elapsed()); + let substatus_part = task + .substatus + .as_ref() + .map(|s| truncate_with_ellipsis(s, 30)) + .unwrap_or_default(); + + let step_label = task.step.label(); + let step_color = task.step.color_code(); + let name_width = inner.max_example_name_len; + + let prefix = format!( + "{bold}{step_color}{step_label:>12}{reset} {name: MAX_STATUS_LINES { + let remaining = total_tasks - MAX_STATUS_LINES; + eprintln!("{:>12} +{remaining} more", ""); + lines_printed += 1; + } + + inner.status_lines_displayed = lines_printed + 1; // +1 for the divider line + let _ = std::io::stderr().flush(); + } + + pub fn finalize(&self) { + let mut inner = self.inner.lock().unwrap(); + Self::clear_status_lines(&mut inner); + + // Print summary if there were failures + if inner.failed_examples > 0 { + let total_processed = inner.completed.len() + inner.failed_examples; + let percentage = if total_processed > 0 { + inner.failed_examples as f64 / total_processed as f64 * 100.0 + } else { + 0.0 + }; + eprintln!( + "\n{} of {} examples failed ({:.1}%)", + inner.failed_examples, total_processed, percentage + ); + } + } +} + +pub struct StepProgress { + progress: Arc, + step: Step, + example_name: String, +} + +impl StepProgress { + pub fn set_substatus(&self, substatus: impl Into>) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.substatus = Some(substatus.into().into_owned()); + Progress::clear_status_lines(&mut inner); + Progress::print_status_lines(&mut inner); + } + } + + pub fn clear_substatus(&self) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.substatus = None; + Progress::clear_status_lines(&mut inner); + Progress::print_status_lines(&mut inner); + } + } + + pub fn set_info(&self, info: impl Into, style: InfoStyle) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.info = Some((info.into(), style)); + } + } +} + +impl Drop for StepProgress { + fn drop(&mut self) { + self.progress.finish(self.step, &self.example_name); + } +} + +struct ProgressLogger; + +impl Log for ProgressLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= Level::Info + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + + let level_color = match record.level() { + Level::Error => "\x1b[31m", + Level::Warn => "\x1b[33m", + Level::Info => "\x1b[32m", + Level::Debug => "\x1b[34m", + Level::Trace => "\x1b[35m", + }; + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + + let level_label = match record.level() { + Level::Error => "Error", + Level::Warn => "Warn", + Level::Info => "Info", + Level::Debug => "Debug", + Level::Trace => "Trace", + }; + + let message = format!( + "{bold}{level_color}{level_label:>12}{reset} {}", + record.args() + ); + + if let Some(progress) = GLOBAL.get() { + progress.log(&message); + } else { + eprintln!("{}", message); + } + } + + fn flush(&self) { + let _ = std::io::stderr().flush(); + } +} + +#[cfg(unix)] +fn get_terminal_width() -> usize { + unsafe { + let mut winsize: libc::winsize = std::mem::zeroed(); + if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut winsize) == 0 + && winsize.ws_col > 0 + { + winsize.ws_col as usize + } else { + 80 + } + } +} + +#[cfg(not(unix))] +fn get_terminal_width() -> usize { + 80 +} + +fn strip_ansi_len(s: &str) -> usize { + let mut len = 0; + let mut in_escape = false; + for c in s.chars() { + if c == '\x1b' { + in_escape = true; + } else if in_escape { + if c == 'm' { + in_escape = false; + } + } else { + len += 1; + } + } + len +} + +fn truncate_with_ellipsis(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}…", &s[..max_len.saturating_sub(1)]) + } +} + +fn format_duration(duration: Duration) -> String { + const MINUTE_IN_MILLIS: f32 = 60. * 1000.; + + let millis = duration.as_millis() as f32; + if millis < 1000.0 { + format!("{}ms", millis) + } else if millis < MINUTE_IN_MILLIS { + format!("{:.1}s", millis / 1_000.0) + } else { + format!("{:.1}m", millis / MINUTE_IN_MILLIS) + } +} diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs new file mode 100644 index 0000000000..abba4504ed --- /dev/null +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -0,0 +1,192 @@ +use crate::{ + example::{Example, ExampleContext}, + headless::EpAppState, + load_project::run_load_project, + progress::{InfoStyle, Progress, Step, StepProgress}, +}; +use anyhow::Context as _; +use collections::HashSet; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, channel::mpsc}; +use gpui::{AsyncApp, Entity}; +use language::Buffer; +use project::Project; +use std::sync::Arc; +use std::time::Duration; + +pub async fn run_context_retrieval( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> anyhow::Result<()> { + if example.context.is_some() { + return Ok(()); + } + + run_load_project(example, app_state.clone(), cx.clone()).await?; + + let step_progress: Arc = Progress::global() + .start(Step::Context, &example.spec.name) + .into(); + + let state = example.state.as_ref().unwrap(); + let project = state.project.clone(); + + let _lsp_handle = project.update(&mut cx, |project, cx| { + project.register_buffer_with_language_servers(&state.buffer, cx) + })?; + wait_for_language_servers_to_start(&project, &state.buffer, &step_progress, &mut cx).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let mut events = ep_store.update(&mut cx, |store, cx| { + store.register_buffer(&state.buffer, &project, cx); + store.set_use_context(true); + store.refresh_context(&project, &state.buffer, state.cursor_position, cx); + store.debug_info(&project, cx) + })?; + + while let Some(event) = events.next().await { + match event { + DebugEvent::ContextRetrievalFinished(_) => { + break; + } + _ => {} + } + } + + let context_files = + ep_store.update(&mut cx, |store, cx| store.context_for_project(&project, cx))?; + + let excerpt_count: usize = context_files.iter().map(|f| f.excerpts.len()).sum(); + step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal); + + example.context = Some(ExampleContext { + files: context_files, + }); + Ok(()) +} + +async fn wait_for_language_servers_to_start( + project: &Entity, + buffer: &Entity, + step_progress: &Arc, + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + let lsp_store = project.read_with(cx, |project, _| project.lsp_store())?; + + let (language_server_ids, mut starting_language_server_ids) = buffer + .update(cx, |buffer, cx| { + lsp_store.update(cx, |lsp_store, cx| { + let ids = lsp_store.language_servers_for_local_buffer(buffer, cx); + let starting_ids = ids + .iter() + .copied() + .filter(|id| !lsp_store.language_server_statuses.contains_key(&id)) + .collect::>(); + (ids, starting_ids) + }) + }) + .unwrap_or_default(); + + step_progress.set_substatus(format!("waiting for {} LSPs", language_server_ids.len())); + + let timeout = cx + .background_executor() + .timer(Duration::from_secs(60 * 5)) + .shared(); + + let (mut tx, mut rx) = mpsc::channel(language_server_ids.len()); + let added_subscription = cx.subscribe(project, { + let step_progress = step_progress.clone(); + move |_, event, _| match event { + project::Event::LanguageServerAdded(language_server_id, name, _) => { + step_progress.set_substatus(format!("LSP started: {}", name)); + tx.try_send(*language_server_id).ok(); + } + _ => {} + } + }); + + while !starting_language_server_ids.is_empty() { + futures::select! { + language_server_id = rx.next() => { + if let Some(id) = language_server_id { + starting_language_server_ids.remove(&id); + } + }, + _ = timeout.clone().fuse() => { + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); + } + } + } + + drop(added_subscription); + + if !language_server_ids.is_empty() { + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .detach(); + } + + let (mut tx, mut rx) = mpsc::channel(language_server_ids.len()); + let subscriptions = [ + cx.subscribe(&lsp_store, { + let step_progress = step_progress.clone(); + move |_, event, _| { + if let project::LspStoreEvent::LanguageServerUpdate { + message: + client::proto::update_language_server::Variant::WorkProgress( + client::proto::LspWorkProgress { + message: Some(message), + .. + }, + ), + .. + } = event + { + step_progress.set_substatus(message.clone()); + } + } + }), + cx.subscribe(project, { + let step_progress = step_progress.clone(); + move |_, event, cx| match event { + project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + let lsp_store = lsp_store.read(cx); + let name = lsp_store + .language_server_adapter_for_id(*language_server_id) + .unwrap() + .name(); + step_progress.set_substatus(format!("LSP idle: {}", name)); + tx.try_send(*language_server_id).ok(); + } + _ => {} + } + }), + ]; + + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await?; + + let mut pending_language_server_ids = HashSet::from_iter(language_server_ids.into_iter()); + while !pending_language_server_ids.is_empty() { + futures::select! { + language_server_id = rx.next() => { + if let Some(id) = language_server_id { + pending_language_server_ids.remove(&id); + } + }, + _ = timeout.clone().fuse() => { + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); + } + } + } + + drop(subscriptions); + step_progress.clear_substatus(); + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs new file mode 100644 index 0000000000..7b507e6d19 --- /dev/null +++ b/crates/edit_prediction_cli/src/score.rs @@ -0,0 +1,123 @@ +use crate::{ + PredictArgs, + example::{Example, ExampleScore}, + headless::EpAppState, + metrics::{self, ClassificationMetrics}, + predict::run_prediction, + progress::{Progress, Step}, +}; +use edit_prediction::udiff::DiffLine; +use gpui::AsyncApp; +use std::sync::Arc; + +pub async fn run_scoring( + example: &mut Example, + args: &PredictArgs, + app_state: Arc, + cx: AsyncApp, +) -> anyhow::Result<()> { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state, + cx, + ) + .await?; + + let _progress = Progress::global().start(Step::Score, &example.spec.name); + + let expected_patch = parse_patch(&example.spec.expected_patch); + + let mut scores = vec![]; + + for pred in &example.predictions { + let actual_patch = parse_patch(&pred.actual_patch); + let line_match = metrics::line_match_score(&expected_patch, &actual_patch); + let delta_chr_f = metrics::delta_chr_f(&expected_patch, &actual_patch) as f32; + + scores.push(ExampleScore { + delta_chr_f, + line_match, + }); + } + + example.score = scores; + Ok(()) +} + +fn parse_patch(patch: &str) -> Vec> { + patch.lines().map(DiffLine::parse).collect() +} + +pub fn print_report(examples: &[Example]) { + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>10} {:>8} {:>8} {:>10}", + "Example name", "TP", "FP", "FN", "Precision", "Recall", "F1", "DeltaChrF" + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + let mut all_line_match_scores = Vec::new(); + let mut all_delta_chr_f_scores = Vec::new(); + + for example in examples { + for score in example.score.iter() { + let line_match = &score.line_match; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + truncate_name(&example.spec.name, 30), + line_match.true_positives, + line_match.false_positives, + line_match.false_negatives, + line_match.precision() * 100.0, + line_match.recall() * 100.0, + line_match.f1_score() * 100.0, + score.delta_chr_f + ); + + all_line_match_scores.push(line_match.clone()); + all_delta_chr_f_scores.push(score.delta_chr_f); + } + } + + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + if !all_line_match_scores.is_empty() { + let total_line_match = ClassificationMetrics::aggregate(all_line_match_scores.iter()); + let avg_delta_chr_f: f32 = + all_delta_chr_f_scores.iter().sum::() / all_delta_chr_f_scores.len() as f32; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + "TOTAL", + total_line_match.true_positives, + total_line_match.false_positives, + total_line_match.false_negatives, + total_line_match.precision() * 100.0, + total_line_match.recall() * 100.0, + total_line_match.f1_score() * 100.0, + avg_delta_chr_f + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + } + + eprintln!("\n"); +} + +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else { + format!("{}...", &name[..max_len - 3]) + } +} diff --git a/crates/edit_prediction_cli/src/teacher.prompt.md b/crates/edit_prediction_cli/src/teacher.prompt.md new file mode 100644 index 0000000000..d629152da6 --- /dev/null +++ b/crates/edit_prediction_cli/src/teacher.prompt.md @@ -0,0 +1,53 @@ +# Instructions + +You are a code completion assistant helping a programmer finish their work. Your task is to: + +1. Analyze the edit history to understand what the programmer is trying to achieve +2. Identify any incomplete refactoring or changes that need to be finished +3. Make the remaining edits that a human programmer would logically make next (by rewriting the corresponding code sections) +4. Apply systematic changes consistently across the entire codebase - if you see a pattern starting, complete it everywhere. + +Focus on: +- Understanding the intent behind the changes (e.g., improving error handling, refactoring APIs, fixing bugs) +- Completing any partially-applied changes across the codebase +- Ensuring consistency with the programming style and patterns already established +- Making edits that maintain or improve code quality +- If the programmer started refactoring one instance of a pattern, find and update ALL similar instances +- Don't write a lot of code if you're not sure what to do + +Rules: +- Do not just mechanically apply patterns - reason about what changes make sense given the context and the programmer's apparent goals. +- Do not just fix syntax errors - look for the broader refactoring pattern and apply it systematically throughout the code. +- Keep existing formatting unless it's absolutely necessary + +Input format: +- You receive small code fragments called context (structs, field definitions, function signatures, etc.). They may or may not be relevant. +- Never modify the context code. +- You also receive a code snippet between <|editable_region_start|> and <|editable_region_end|>. This is the editable region. +- The cursor position is marked with <|user_cursor|>. + +Output format: +- Return the entire editable region, applying any edits you make. +- Remove the <|user_cursor|> marker. +- Wrap the edited code in a block of exactly five backticks. + +Output example: +````` + // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass + if let Some(socket) = &args.askpass {{ + askpass::main(socket); + return Ok(()); + }} +````` + +## User Edits History + +{{edit_history}} + +## Code Context + +{{context}} + +## Editable region + +{{editable_region}} diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index c34386b3fb..731ffc85d1 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -12,34 +12,29 @@ workspace = true path = "src/edit_prediction_context.rs" [dependencies] +parking_lot.workspace = true anyhow.workspace = true -arrayvec.workspace = true cloud_llm_client.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true -hashbrown.workspace = true -itertools.workspace = true language.workspace = true -log.workspace = true -ordered-float.workspace = true -postage.workspace = true +lsp.workspace = true project.workspace = true -regex.workspace = true +log.workspace = true serde.workspace = true -slotmap.workspace = true -strum.workspace = true -text.workspace = true +smallvec.workspace = true tree-sitter.workspace = true util.workspace = true -workspace-hack.workspace = true +zeta_prompt.workspace = true [dev-dependencies] -clap.workspace = true +env_logger.workspace = true +indoc.workspace = true futures.workspace = true gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true language = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = {workspace= true, features = ["test-support"]} serde_json.workspace = true diff --git a/crates/edit_prediction_context/src/assemble_excerpts.rs b/crates/edit_prediction_context/src/assemble_excerpts.rs new file mode 100644 index 0000000000..e337211cf9 --- /dev/null +++ b/crates/edit_prediction_context/src/assemble_excerpts.rs @@ -0,0 +1,156 @@ +use language::{BufferSnapshot, OffsetRangeExt as _, Point}; +use std::ops::Range; +use zeta_prompt::RelatedExcerpt; + +#[cfg(not(test))] +const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 512; +#[cfg(test)] +const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 24; + +pub fn assemble_excerpts( + buffer: &BufferSnapshot, + mut input_ranges: Vec>, +) -> Vec { + merge_ranges(&mut input_ranges); + + let mut outline_ranges = Vec::new(); + let outline_items = buffer.outline_items_as_points_containing(0..buffer.len(), false, None); + let mut outline_ix = 0; + for input_range in &mut input_ranges { + *input_range = clip_range_to_lines(input_range, false, buffer); + + while let Some(outline_item) = outline_items.get(outline_ix) { + let item_range = clip_range_to_lines(&outline_item.range, false, buffer); + + if item_range.start > input_range.start { + break; + } + + if item_range.end > input_range.start { + let body_range = outline_item + .body_range(buffer) + .map(|body| clip_range_to_lines(&body, true, buffer)) + .filter(|body_range| { + body_range.to_offset(buffer).len() > MAX_OUTLINE_ITEM_BODY_SIZE + }); + + add_outline_item( + item_range.clone(), + body_range.clone(), + buffer, + &mut outline_ranges, + ); + + if let Some(body_range) = body_range + && input_range.start < body_range.start + { + let mut child_outline_ix = outline_ix + 1; + while let Some(next_outline_item) = outline_items.get(child_outline_ix) { + if next_outline_item.range.end > body_range.end { + break; + } + if next_outline_item.depth == outline_item.depth + 1 { + let next_item_range = + clip_range_to_lines(&next_outline_item.range, false, buffer); + + add_outline_item( + next_item_range, + next_outline_item + .body_range(buffer) + .map(|body| clip_range_to_lines(&body, true, buffer)), + buffer, + &mut outline_ranges, + ); + } + child_outline_ix += 1; + } + } + } + + outline_ix += 1; + } + } + + input_ranges.extend_from_slice(&outline_ranges); + merge_ranges(&mut input_ranges); + + input_ranges + .into_iter() + .map(|range| RelatedExcerpt { + row_range: range.start.row..range.end.row, + text: buffer.text_for_range(range).collect(), + }) + .collect() +} + +fn clip_range_to_lines( + range: &Range, + inward: bool, + buffer: &BufferSnapshot, +) -> Range { + let mut range = range.clone(); + if inward { + if range.start.column > 0 { + range.start.column = buffer.line_len(range.start.row); + } + range.end.column = 0; + } else { + range.start.column = 0; + if range.end.column > 0 { + range.end.column = buffer.line_len(range.end.row); + } + } + range +} + +fn add_outline_item( + mut item_range: Range, + body_range: Option>, + buffer: &BufferSnapshot, + outline_ranges: &mut Vec>, +) { + if let Some(mut body_range) = body_range { + if body_range.start.column > 0 { + body_range.start.column = buffer.line_len(body_range.start.row); + } + body_range.end.column = 0; + + let head_range = item_range.start..body_range.start; + if head_range.start < head_range.end { + outline_ranges.push(head_range); + } + + let tail_range = body_range.end..item_range.end; + if tail_range.start < tail_range.end { + outline_ranges.push(tail_range); + } + } else { + item_range.start.column = 0; + item_range.end.column = buffer.line_len(item_range.end.row); + outline_ranges.push(item_range); + } +} + +pub fn merge_ranges(ranges: &mut Vec>) { + ranges.sort_unstable_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + + let mut index = 1; + while index < ranges.len() { + let mut prev_range_end = ranges[index - 1].end; + if prev_range_end.column > 0 { + prev_range_end += Point::new(1, 0); + } + + if (prev_range_end + Point::new(1, 0)) + .cmp(&ranges[index].start) + .is_ge() + { + let removed = ranges.remove(index); + if removed.end.cmp(&ranges[index - 1].end).is_gt() { + ranges[index - 1].end = removed.end; + } + } else { + index += 1; + } + } +} diff --git a/crates/edit_prediction_context/src/declaration.rs b/crates/edit_prediction_context/src/declaration.rs deleted file mode 100644 index a6efe63fc6..0000000000 --- a/crates/edit_prediction_context/src/declaration.rs +++ /dev/null @@ -1,237 +0,0 @@ -use language::LanguageId; -use project::ProjectEntryId; -use std::borrow::Cow; -use std::ops::Range; -use std::sync::Arc; -use text::{Bias, BufferId, Rope}; - -use crate::outline::OutlineDeclaration; - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct Identifier { - pub name: Arc, - pub language_id: LanguageId, -} - -slotmap::new_key_type! { - pub struct DeclarationId; -} - -#[derive(Debug, Clone)] -pub enum Declaration { - File { - project_entry_id: ProjectEntryId, - declaration: FileDeclaration, - }, - Buffer { - project_entry_id: ProjectEntryId, - buffer_id: BufferId, - rope: Rope, - declaration: BufferDeclaration, - }, -} - -const ITEM_TEXT_TRUNCATION_LENGTH: usize = 1024; - -impl Declaration { - pub fn identifier(&self) -> &Identifier { - match self { - Declaration::File { declaration, .. } => &declaration.identifier, - Declaration::Buffer { declaration, .. } => &declaration.identifier, - } - } - - pub fn parent(&self) -> Option { - match self { - Declaration::File { declaration, .. } => declaration.parent, - Declaration::Buffer { declaration, .. } => declaration.parent, - } - } - - pub fn as_buffer(&self) -> Option<&BufferDeclaration> { - match self { - Declaration::File { .. } => None, - Declaration::Buffer { declaration, .. } => Some(declaration), - } - } - - pub fn as_file(&self) -> Option<&FileDeclaration> { - match self { - Declaration::Buffer { .. } => None, - Declaration::File { declaration, .. } => Some(declaration), - } - } - - pub fn project_entry_id(&self) -> ProjectEntryId { - match self { - Declaration::File { - project_entry_id, .. - } => *project_entry_id, - Declaration::Buffer { - project_entry_id, .. - } => *project_entry_id, - } - } - - pub fn item_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.item_range.clone(), - Declaration::Buffer { declaration, .. } => declaration.item_range.clone(), - } - } - - pub fn item_text(&self) -> (Cow<'_, str>, bool) { - match self { - Declaration::File { declaration, .. } => ( - declaration.text.as_ref().into(), - declaration.text_is_truncated, - ), - Declaration::Buffer { - rope, declaration, .. - } => ( - rope.chunks_in_range(declaration.item_range.clone()) - .collect::>(), - declaration.item_range_is_truncated, - ), - } - } - - pub fn signature_text(&self) -> (Cow<'_, str>, bool) { - match self { - Declaration::File { declaration, .. } => ( - declaration.text[self.signature_range_in_item_text()].into(), - declaration.signature_is_truncated, - ), - Declaration::Buffer { - rope, declaration, .. - } => ( - rope.chunks_in_range(declaration.signature_range.clone()) - .collect::>(), - declaration.signature_range_is_truncated, - ), - } - } - - pub fn signature_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.signature_range.clone(), - Declaration::Buffer { declaration, .. } => declaration.signature_range.clone(), - } - } - - pub fn signature_range_in_item_text(&self) -> Range { - let signature_range = self.signature_range(); - let item_range = self.item_range(); - signature_range.start.saturating_sub(item_range.start) - ..(signature_range.end.saturating_sub(item_range.start)).min(item_range.len()) - } -} - -fn expand_range_to_line_boundaries_and_truncate( - range: &Range, - limit: usize, - rope: &Rope, -) -> (Range, bool) { - let mut point_range = rope.offset_to_point(range.start)..rope.offset_to_point(range.end); - point_range.start.column = 0; - point_range.end.row += 1; - point_range.end.column = 0; - - let mut item_range = - rope.point_to_offset(point_range.start)..rope.point_to_offset(point_range.end); - let is_truncated = item_range.len() > limit; - if is_truncated { - item_range.end = item_range.start + limit; - } - item_range.end = rope.clip_offset(item_range.end, Bias::Left); - (item_range, is_truncated) -} - -#[derive(Debug, Clone)] -pub struct FileDeclaration { - pub parent: Option, - pub identifier: Identifier, - /// offset range of the declaration in the file, expanded to line boundaries and truncated - pub item_range: Range, - /// text of `item_range` - pub text: Arc, - /// whether `text` was truncated - pub text_is_truncated: bool, - /// offset range of the signature in the file, expanded to line boundaries and truncated - pub signature_range: Range, - /// whether `signature` was truncated - pub signature_is_truncated: bool, -} - -impl FileDeclaration { - pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> FileDeclaration { - let (item_range_in_file, text_is_truncated) = expand_range_to_line_boundaries_and_truncate( - &declaration.item_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - - let (mut signature_range_in_file, mut signature_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.signature_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - - if signature_range_in_file.start < item_range_in_file.start { - signature_range_in_file.start = item_range_in_file.start; - signature_is_truncated = true; - } - if signature_range_in_file.end > item_range_in_file.end { - signature_range_in_file.end = item_range_in_file.end; - signature_is_truncated = true; - } - - FileDeclaration { - parent: None, - identifier: declaration.identifier, - signature_range: signature_range_in_file, - signature_is_truncated, - text: rope - .chunks_in_range(item_range_in_file.clone()) - .collect::() - .into(), - text_is_truncated, - item_range: item_range_in_file, - } - } -} - -#[derive(Debug, Clone)] -pub struct BufferDeclaration { - pub parent: Option, - pub identifier: Identifier, - pub item_range: Range, - pub item_range_is_truncated: bool, - pub signature_range: Range, - pub signature_range_is_truncated: bool, -} - -impl BufferDeclaration { - pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> Self { - let (item_range, item_range_is_truncated) = expand_range_to_line_boundaries_and_truncate( - &declaration.item_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - let (signature_range, signature_range_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.signature_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - Self { - parent: None, - identifier: declaration.identifier, - item_range, - item_range_is_truncated, - signature_range, - signature_range_is_truncated, - } - } -} diff --git a/crates/edit_prediction_context/src/declaration_scoring.rs b/crates/edit_prediction_context/src/declaration_scoring.rs deleted file mode 100644 index 6f027ed1f6..0000000000 --- a/crates/edit_prediction_context/src/declaration_scoring.rs +++ /dev/null @@ -1,289 +0,0 @@ -use cloud_llm_client::predict_edits_v3::DeclarationScoreComponents; -use collections::HashMap; -use itertools::Itertools as _; -use language::BufferSnapshot; -use ordered_float::OrderedFloat; -use serde::Serialize; -use std::{cmp::Reverse, ops::Range}; -use strum::EnumIter; -use text::{Point, ToPoint}; - -use crate::{ - Declaration, EditPredictionExcerpt, Identifier, - reference::{Reference, ReferenceRegion}, - syntax_index::SyntaxIndexState, - text_similarity::{Occurrences, jaccard_similarity, weighted_overlap_coefficient}, -}; - -const MAX_IDENTIFIER_DECLARATION_COUNT: usize = 16; - -#[derive(Clone, Debug)] -pub struct ScoredDeclaration { - pub identifier: Identifier, - pub declaration: Declaration, - pub score_components: DeclarationScoreComponents, - pub scores: DeclarationScores, -} - -#[derive(EnumIter, Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub enum DeclarationStyle { - Signature, - Declaration, -} - -impl ScoredDeclaration { - /// Returns the score for this declaration with the specified style. - pub fn score(&self, style: DeclarationStyle) -> f32 { - match style { - DeclarationStyle::Signature => self.scores.signature, - DeclarationStyle::Declaration => self.scores.declaration, - } - } - - pub fn size(&self, style: DeclarationStyle) -> usize { - match &self.declaration { - Declaration::File { declaration, .. } => match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.text.len(), - }, - Declaration::Buffer { declaration, .. } => match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.item_range.len(), - }, - } - } - - pub fn score_density(&self, style: DeclarationStyle) -> f32 { - self.score(style) / (self.size(style)) as f32 - } -} - -pub fn scored_declarations( - index: &SyntaxIndexState, - excerpt: &EditPredictionExcerpt, - excerpt_occurrences: &Occurrences, - adjacent_occurrences: &Occurrences, - identifier_to_references: HashMap>, - cursor_offset: usize, - current_buffer: &BufferSnapshot, -) -> Vec { - let cursor_point = cursor_offset.to_point(¤t_buffer); - - let mut declarations = identifier_to_references - .into_iter() - .flat_map(|(identifier, references)| { - let declarations = - index.declarations_for_identifier::(&identifier); - let declaration_count = declarations.len(); - - declarations - .into_iter() - .filter_map(|(declaration_id, declaration)| match declaration { - Declaration::Buffer { - buffer_id, - declaration: buffer_declaration, - .. - } => { - let is_same_file = buffer_id == ¤t_buffer.remote_id(); - - if is_same_file { - let overlaps_excerpt = - range_intersection(&buffer_declaration.item_range, &excerpt.range) - .is_some(); - if overlaps_excerpt - || excerpt - .parent_declarations - .iter() - .any(|(excerpt_parent, _)| excerpt_parent == &declaration_id) - { - None - } else { - let declaration_line = buffer_declaration - .item_range - .start - .to_point(current_buffer) - .row; - Some(( - true, - (cursor_point.row as i32 - declaration_line as i32) - .unsigned_abs(), - declaration, - )) - } - } else { - Some((false, u32::MAX, declaration)) - } - } - Declaration::File { .. } => { - // We can assume that a file declaration is in a different file, - // because the current one must be open - Some((false, u32::MAX, declaration)) - } - }) - .sorted_by_key(|&(_, distance, _)| distance) - .enumerate() - .map( - |( - declaration_line_distance_rank, - (is_same_file, declaration_line_distance, declaration), - )| { - let same_file_declaration_count = index.file_declaration_count(declaration); - - score_declaration( - &identifier, - &references, - declaration.clone(), - is_same_file, - declaration_line_distance, - declaration_line_distance_rank, - same_file_declaration_count, - declaration_count, - &excerpt_occurrences, - &adjacent_occurrences, - cursor_point, - current_buffer, - ) - }, - ) - .collect::>() - }) - .flatten() - .collect::>(); - - declarations.sort_unstable_by_key(|declaration| { - let score_density = declaration - .score_density(DeclarationStyle::Declaration) - .max(declaration.score_density(DeclarationStyle::Signature)); - Reverse(OrderedFloat(score_density)) - }); - - declarations -} - -fn range_intersection(a: &Range, b: &Range) -> Option> { - let start = a.start.clone().max(b.start.clone()); - let end = a.end.clone().min(b.end.clone()); - if start < end { - Some(Range { start, end }) - } else { - None - } -} - -fn score_declaration( - identifier: &Identifier, - references: &[Reference], - declaration: Declaration, - is_same_file: bool, - declaration_line_distance: u32, - declaration_line_distance_rank: usize, - same_file_declaration_count: usize, - declaration_count: usize, - excerpt_occurrences: &Occurrences, - adjacent_occurrences: &Occurrences, - cursor: Point, - current_buffer: &BufferSnapshot, -) -> Option { - let is_referenced_nearby = references - .iter() - .any(|r| r.region == ReferenceRegion::Nearby); - let is_referenced_in_breadcrumb = references - .iter() - .any(|r| r.region == ReferenceRegion::Breadcrumb); - let reference_count = references.len(); - let reference_line_distance = references - .iter() - .map(|r| { - let reference_line = r.range.start.to_point(current_buffer).row as i32; - (cursor.row as i32 - reference_line).unsigned_abs() - }) - .min() - .unwrap(); - - let item_source_occurrences = Occurrences::within_string(&declaration.item_text().0); - let item_signature_occurrences = Occurrences::within_string(&declaration.signature_text().0); - let excerpt_vs_item_jaccard = jaccard_similarity(excerpt_occurrences, &item_source_occurrences); - let excerpt_vs_signature_jaccard = - jaccard_similarity(excerpt_occurrences, &item_signature_occurrences); - let adjacent_vs_item_jaccard = - jaccard_similarity(adjacent_occurrences, &item_source_occurrences); - let adjacent_vs_signature_jaccard = - jaccard_similarity(adjacent_occurrences, &item_signature_occurrences); - - let excerpt_vs_item_weighted_overlap = - weighted_overlap_coefficient(excerpt_occurrences, &item_source_occurrences); - let excerpt_vs_signature_weighted_overlap = - weighted_overlap_coefficient(excerpt_occurrences, &item_signature_occurrences); - let adjacent_vs_item_weighted_overlap = - weighted_overlap_coefficient(adjacent_occurrences, &item_source_occurrences); - let adjacent_vs_signature_weighted_overlap = - weighted_overlap_coefficient(adjacent_occurrences, &item_signature_occurrences); - - // TODO: Consider adding declaration_file_count - let score_components = DeclarationScoreComponents { - is_same_file, - is_referenced_nearby, - is_referenced_in_breadcrumb, - reference_line_distance, - declaration_line_distance, - declaration_line_distance_rank, - reference_count, - same_file_declaration_count, - declaration_count, - excerpt_vs_item_jaccard, - excerpt_vs_signature_jaccard, - adjacent_vs_item_jaccard, - adjacent_vs_signature_jaccard, - excerpt_vs_item_weighted_overlap, - excerpt_vs_signature_weighted_overlap, - adjacent_vs_item_weighted_overlap, - adjacent_vs_signature_weighted_overlap, - }; - - Some(ScoredDeclaration { - identifier: identifier.clone(), - declaration: declaration, - scores: DeclarationScores::score(&score_components), - score_components, - }) -} - -#[derive(Clone, Debug, Serialize)] -pub struct DeclarationScores { - pub signature: f32, - pub declaration: f32, - pub retrieval: f32, -} - -impl DeclarationScores { - fn score(components: &DeclarationScoreComponents) -> DeclarationScores { - // TODO: handle truncation - - // Score related to how likely this is the correct declaration, range 0 to 1 - let retrieval = if components.is_same_file { - // TODO: use declaration_line_distance_rank - 1.0 / components.same_file_declaration_count as f32 - } else { - 1.0 / components.declaration_count as f32 - }; - - // Score related to the distance between the reference and cursor, range 0 to 1 - let distance_score = if components.is_referenced_nearby { - 1.0 / (1.0 + components.reference_line_distance as f32 / 10.0).powf(2.0) - } else { - // same score as ~14 lines away, rationale is to not overly penalize references from parent signatures - 0.5 - }; - - // For now instead of linear combination, the scores are just multiplied together. - let combined_score = 10.0 * retrieval * distance_score; - - DeclarationScores { - signature: combined_score * components.excerpt_vs_signature_weighted_overlap, - // declaration score gets boosted both by being multiplied by 2 and by there being more - // weighted overlap. - declaration: 2.0 * combined_score * components.excerpt_vs_item_weighted_overlap, - retrieval, - } - } -} diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index c994caf754..15576a835d 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -1,289 +1,474 @@ -mod declaration; -mod declaration_scoring; -mod excerpt; -mod outline; -mod reference; -mod syntax_index; -pub mod text_similarity; - -use std::sync::Arc; - +use crate::assemble_excerpts::assemble_excerpts; +use anyhow::Result; use collections::HashMap; -use gpui::{App, AppContext as _, Entity, Task}; -use language::BufferSnapshot; -use text::{Point, ToOffset as _}; +use futures::{FutureExt, StreamExt as _, channel::mpsc, future}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity}; +use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _}; +use project::{LocationLink, Project, ProjectPath}; +use smallvec::SmallVec; +use std::{ + collections::hash_map, + ops::Range, + path::Path, + sync::Arc, + time::{Duration, Instant}, +}; +use util::{RangeExt as _, ResultExt}; -pub use declaration::*; -pub use declaration_scoring::*; -pub use excerpt::*; -pub use reference::*; -pub use syntax_index::*; +mod assemble_excerpts; +#[cfg(test)] +mod edit_prediction_context_tests; +mod excerpt; +#[cfg(test)] +mod fake_definition_lsp; -#[derive(Clone, Debug)] -pub struct EditPredictionContext { - pub excerpt: EditPredictionExcerpt, - pub excerpt_text: EditPredictionExcerptText, - pub cursor_offset_in_excerpt: usize, - pub declarations: Vec, +pub use cloud_llm_client::predict_edits_v3::Line; +pub use excerpt::{EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionExcerptText}; +pub use zeta_prompt::{RelatedExcerpt, RelatedFile}; + +const IDENTIFIER_LINE_COUNT: u32 = 3; + +pub struct RelatedExcerptStore { + project: WeakEntity, + related_files: Arc<[RelatedFile]>, + related_file_buffers: Vec>, + cache: HashMap>, + update_tx: mpsc::UnboundedSender<(Entity, Anchor)>, + identifier_line_count: u32, } -impl EditPredictionContext { - pub fn gather_context_in_background( - cursor_point: Point, - buffer: BufferSnapshot, - excerpt_options: EditPredictionExcerptOptions, - syntax_index: Option>, - cx: &mut App, - ) -> Task> { - if let Some(syntax_index) = syntax_index { - let index_state = - syntax_index.read_with(cx, |index, _cx| Arc::downgrade(index.state())); - cx.background_spawn(async move { - let index_state = index_state.upgrade()?; - let index_state = index_state.lock().await; - Self::gather_context(cursor_point, &buffer, &excerpt_options, Some(&index_state)) - }) - } else { - cx.background_spawn(async move { - Self::gather_context(cursor_point, &buffer, &excerpt_options, None) - }) +pub enum RelatedExcerptStoreEvent { + StartedRefresh, + FinishedRefresh { + cache_hit_count: usize, + cache_miss_count: usize, + mean_definition_latency: Duration, + max_definition_latency: Duration, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct Identifier { + pub name: String, + pub range: Range, +} + +enum DefinitionTask { + CacheHit(Arc), + CacheMiss(Task>>>), +} + +#[derive(Debug)] +struct CacheEntry { + definitions: SmallVec<[CachedDefinition; 1]>, +} + +#[derive(Clone, Debug)] +struct CachedDefinition { + path: ProjectPath, + buffer: Entity, + anchor_range: Range, +} + +const DEBOUNCE_DURATION: Duration = Duration::from_millis(100); + +impl EventEmitter for RelatedExcerptStore {} + +impl RelatedExcerptStore { + pub fn new(project: &Entity, cx: &mut Context) -> Self { + let (update_tx, mut update_rx) = mpsc::unbounded::<(Entity, Anchor)>(); + cx.spawn(async move |this, cx| { + let executor = cx.background_executor().clone(); + while let Some((mut buffer, mut position)) = update_rx.next().await { + let mut timer = executor.timer(DEBOUNCE_DURATION).fuse(); + loop { + futures::select_biased! { + next = update_rx.next() => { + if let Some((new_buffer, new_position)) = next { + buffer = new_buffer; + position = new_position; + timer = executor.timer(DEBOUNCE_DURATION).fuse(); + } else { + return anyhow::Ok(()); + } + } + _ = timer => break, + } + } + + Self::fetch_excerpts(this.clone(), buffer, position, cx).await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + RelatedExcerptStore { + project: project.downgrade(), + update_tx, + related_files: Vec::new().into(), + related_file_buffers: Vec::new(), + cache: Default::default(), + identifier_line_count: IDENTIFIER_LINE_COUNT, } } - pub fn gather_context( - cursor_point: Point, - buffer: &BufferSnapshot, - excerpt_options: &EditPredictionExcerptOptions, - index_state: Option<&SyntaxIndexState>, - ) -> Option { - Self::gather_context_with_references_fn( - cursor_point, - buffer, - excerpt_options, - index_state, - references_in_excerpt, - ) + pub fn set_identifier_line_count(&mut self, count: u32) { + self.identifier_line_count = count; } - pub fn gather_context_with_references_fn( - cursor_point: Point, - buffer: &BufferSnapshot, - excerpt_options: &EditPredictionExcerptOptions, - index_state: Option<&SyntaxIndexState>, - get_references: impl FnOnce( - &EditPredictionExcerpt, - &EditPredictionExcerptText, - &BufferSnapshot, - ) -> HashMap>, - ) -> Option { - let excerpt = EditPredictionExcerpt::select_from_buffer( - cursor_point, - buffer, - excerpt_options, - index_state, - )?; - let excerpt_text = excerpt.text(buffer); - let excerpt_occurrences = text_similarity::Occurrences::within_string(&excerpt_text.body); + pub fn refresh(&mut self, buffer: Entity, position: Anchor, _: &mut Context) { + self.update_tx.unbounded_send((buffer, position)).ok(); + } - let adjacent_start = Point::new(cursor_point.row.saturating_sub(2), 0); - let adjacent_end = Point::new(cursor_point.row + 1, 0); - let adjacent_occurrences = text_similarity::Occurrences::within_string( - &buffer - .text_for_range(adjacent_start..adjacent_end) - .collect::(), - ); + pub fn related_files(&self) -> Arc<[RelatedFile]> { + self.related_files.clone() + } - let cursor_offset_in_file = cursor_point.to_offset(buffer); - // TODO fix this to not need saturating_sub - let cursor_offset_in_excerpt = cursor_offset_in_file.saturating_sub(excerpt.range.start); + pub fn related_files_with_buffers( + &self, + ) -> impl Iterator)> { + self.related_files + .iter() + .cloned() + .zip(self.related_file_buffers.iter().cloned()) + } - let declarations = if let Some(index_state) = index_state { - let references = get_references(&excerpt, &excerpt_text, buffer); + pub fn set_related_files(&mut self, files: Vec) { + self.related_files = files.into(); + } - scored_declarations( - &index_state, - &excerpt, - &excerpt_occurrences, - &adjacent_occurrences, - references, - cursor_offset_in_file, - buffer, + async fn fetch_excerpts( + this: WeakEntity, + buffer: Entity, + position: Anchor, + cx: &mut AsyncApp, + ) -> Result<()> { + let (project, snapshot, identifier_line_count) = this.read_with(cx, |this, cx| { + ( + this.project.upgrade(), + buffer.read(cx).snapshot(), + this.identifier_line_count, ) - } else { - vec![] + })?; + let Some(project) = project else { + return Ok(()); }; - Some(Self { - excerpt, - excerpt_text, - cursor_offset_in_excerpt, - declarations, - }) + let file = snapshot.file().cloned(); + if let Some(file) = &file { + log::debug!("retrieving_context buffer:{}", file.path().as_unix_str()); + } + + this.update(cx, |_, cx| { + cx.emit(RelatedExcerptStoreEvent::StartedRefresh); + })?; + + let identifiers = cx + .background_spawn(async move { + identifiers_for_position(&snapshot, position, identifier_line_count) + }) + .await; + + let async_cx = cx.clone(); + let start_time = Instant::now(); + let futures = this.update(cx, |this, cx| { + identifiers + .into_iter() + .filter_map(|identifier| { + let task = if let Some(entry) = this.cache.get(&identifier) { + DefinitionTask::CacheHit(entry.clone()) + } else { + DefinitionTask::CacheMiss( + this.project + .update(cx, |project, cx| { + project.definitions(&buffer, identifier.range.start, cx) + }) + .ok()?, + ) + }; + + let cx = async_cx.clone(); + let project = project.clone(); + Some(async move { + match task { + DefinitionTask::CacheHit(cache_entry) => { + Some((identifier, cache_entry, None)) + } + DefinitionTask::CacheMiss(task) => { + let locations = task.await.log_err()??; + let duration = start_time.elapsed(); + cx.update(|cx| { + ( + identifier, + Arc::new(CacheEntry { + definitions: locations + .into_iter() + .filter_map(|location| { + process_definition(location, &project, cx) + }) + .collect(), + }), + Some(duration), + ) + }) + .ok() + } + } + }) + }) + .collect::>() + })?; + + let mut cache_hit_count = 0; + let mut cache_miss_count = 0; + let mut mean_definition_latency = Duration::ZERO; + let mut max_definition_latency = Duration::ZERO; + let mut new_cache = HashMap::default(); + new_cache.reserve(futures.len()); + for (identifier, entry, duration) in future::join_all(futures).await.into_iter().flatten() { + new_cache.insert(identifier, entry); + if let Some(duration) = duration { + cache_miss_count += 1; + mean_definition_latency += duration; + max_definition_latency = max_definition_latency.max(duration); + } else { + cache_hit_count += 1; + } + } + mean_definition_latency /= cache_miss_count.max(1) as u32; + + let (new_cache, related_files, related_file_buffers) = + rebuild_related_files(&project, new_cache, cx).await?; + + if let Some(file) = &file { + log::debug!( + "finished retrieving context buffer:{}, latency:{:?}", + file.path().as_unix_str(), + start_time.elapsed() + ); + } + + this.update(cx, |this, cx| { + this.cache = new_cache; + this.related_files = related_files.into(); + this.related_file_buffers = related_file_buffers; + cx.emit(RelatedExcerptStoreEvent::FinishedRefresh { + cache_hit_count, + cache_miss_count, + mean_definition_latency, + max_definition_latency, + }); + })?; + + anyhow::Ok(()) } } -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - use gpui::{Entity, TestAppContext}; - use indoc::indoc; - use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - use crate::{EditPredictionExcerptOptions, SyntaxIndex}; - - #[gpui::test] - async fn test_call_site(cx: &mut TestAppContext) { - let (project, index, _rust_lang_id) = init_test(cx).await; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - // first process_data call site - let cursor_point = language::Point::new(8, 21); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let context = cx - .update(|cx| { - EditPredictionContext::gather_context_in_background( - cursor_point, - buffer_snapshot, - EditPredictionExcerptOptions { - max_bytes: 60, - min_bytes: 10, - target_before_cursor_over_total_bytes: 0.5, - }, - Some(index), - cx, - ) - }) - .await - .unwrap(); - - let mut snippet_identifiers = context - .declarations - .iter() - .map(|snippet| snippet.identifier.name.as_ref()) - .collect::>(); - snippet_identifiers.sort(); - assert_eq!(snippet_identifiers, vec!["main", "process_data"]); - drop(buffer); +async fn rebuild_related_files( + project: &Entity, + new_entries: HashMap>, + cx: &mut AsyncApp, +) -> Result<( + HashMap>, + Vec, + Vec>, +)> { + let mut snapshots = HashMap::default(); + let mut worktree_root_names = HashMap::default(); + for entry in new_entries.values() { + for definition in &entry.definitions { + if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) { + definition + .buffer + .read_with(cx, |buffer, _| buffer.parsing_idle())? + .await; + e.insert( + definition + .buffer + .read_with(cx, |buffer, _| buffer.snapshot())?, + ); + } + let worktree_id = definition.path.worktree_id; + if let hash_map::Entry::Vacant(e) = + worktree_root_names.entry(definition.path.worktree_id) + { + project.read_with(cx, |project, cx| { + if let Some(worktree) = project.worktree_for_id(worktree_id, cx) { + e.insert(worktree.read(cx).root_name().as_unix_str().to_string()); + } + })?; + } + } } - async fn init_test( - cx: &mut TestAppContext, - ) -> (Entity, Entity, LanguageId) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); + Ok(cx + .background_spawn(async move { + let mut files = Vec::new(); + let mut ranges_by_buffer = HashMap::<_, Vec>>::default(); + let mut paths_by_buffer = HashMap::default(); + for entry in new_entries.values() { + for definition in &entry.definitions { + let Some(snapshot) = snapshots.get(&definition.buffer.entity_id()) else { + continue; + }; + paths_by_buffer.insert(definition.buffer.entity_id(), definition.path.clone()); + ranges_by_buffer + .entry(definition.buffer.clone()) + .or_default() + .push(definition.anchor_range.to_point(snapshot)); + } + } + + for (buffer, ranges) in ranges_by_buffer { + let Some(snapshot) = snapshots.get(&buffer.entity_id()) else { + continue; + }; + let Some(project_path) = paths_by_buffer.get(&buffer.entity_id()) else { + continue; + }; + let excerpts = assemble_excerpts(snapshot, ranges); + let Some(root_name) = worktree_root_names.get(&project_path.worktree_id) else { + continue; + }; + + let path = Path::new(&format!( + "{}/{}", + root_name, + project_path.path.as_unix_str() + )) + .into(); + + files.push(( + buffer, + RelatedFile { + path, + excerpts, + max_row: snapshot.max_point().row, + }, + )); + } + + files.sort_by_key(|(_, file)| file.path.clone()); + let (related_buffers, related_files) = files.into_iter().unzip(); + + (new_entries, related_files, related_buffers) + }) + .await) +} + +const MAX_TARGET_LEN: usize = 128; + +fn process_definition( + location: LocationLink, + project: &Entity, + cx: &mut App, +) -> Option { + let buffer = location.target.buffer.read(cx); + let anchor_range = location.target.range; + let file = buffer.file()?; + let worktree = project.read(cx).worktree_for_id(file.worktree_id(cx), cx)?; + if worktree.read(cx).is_single_file() { + return None; + } + + // If the target range is large, it likely means we requested the definition of an entire module. + // For individual definitions, the target range should be small as it only covers the symbol. + let buffer = location.target.buffer.read(cx); + let target_len = anchor_range.to_offset(&buffer).len(); + if target_len > MAX_TARGET_LEN { + return None; + } + + Some(CachedDefinition { + path: ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }, + buffer: location.target.buffer, + anchor_range, + }) +} + +/// Gets all of the identifiers that are present in the given line, and its containing +/// outline items. +fn identifiers_for_position( + buffer: &BufferSnapshot, + position: Anchor, + identifier_line_count: u32, +) -> Vec { + let offset = position.to_offset(buffer); + let point = buffer.offset_to_point(offset); + + // Search for identifiers on lines adjacent to the cursor. + let start = Point::new(point.row.saturating_sub(identifier_line_count), 0); + let end = Point::new(point.row + identifier_line_count + 1, 0).min(buffer.max_point()); + let line_range = start..end; + let mut ranges = vec![line_range.to_offset(&buffer)]; + + // Search for identifiers mentioned in headers/signatures of containing outline items. + let outline_items = buffer.outline_items_as_offsets_containing(offset..offset, false, None); + for item in outline_items { + if let Some(body_range) = item.body_range(&buffer) { + ranges.push(item.range.start..body_range.start.to_offset(&buffer)); + } else { + ranges.push(item.range.clone()); + } + } + + ranges.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + ranges.dedup_by(|a, b| { + if a.start <= b.end { + b.start = b.start.min(a.start); + b.end = b.end.max(a.end); + true + } else { + false + } + }); + + let mut identifiers = Vec::new(); + let outer_range = + ranges.first().map_or(0, |r| r.start)..ranges.last().map_or(buffer.len(), |r| r.end); + + let mut captures = buffer + .syntax + .captures(outer_range.clone(), &buffer.text, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) }); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "a.rs": indoc! {r#" - fn main() { - let x = 1; - let y = 2; - let z = add(x, y); - println!("Result: {}", z); - } + for range in ranges { + captures.set_byte_range(range.start..outer_range.end); - fn add(a: i32, b: i32) -> i32 { - a + b - } - "#}, - "b.rs": indoc! {" - pub struct Config { - pub name: String, - pub value: i32, - } + let mut last_range = None; + while let Some(capture) = captures.peek() { + let node_range = capture.node.byte_range(); + if node_range.start > range.end { + break; + } + let config = captures.grammars()[capture.grammar_index] + .highlights_config + .as_ref(); - impl Config { - pub fn new(name: String, value: i32) -> Self { - Config { name, value } - } - } - "}, - "c.rs": indoc! {r#" - use std::collections::HashMap; + if let Some(config) = config + && config.identifier_capture_indices.contains(&capture.index) + && range.contains_inclusive(&node_range) + && Some(&node_range) != last_range.as_ref() + { + let name = buffer.text_for_range(node_range.clone()).collect(); + identifiers.push(Identifier { + range: buffer.anchor_after(node_range.start) + ..buffer.anchor_before(node_range.end), + name, + }); + last_range = Some(node_range); + } - fn main() { - let args: Vec = std::env::args().collect(); - let data: Vec = args[1..] - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - let result = process_data(data); - println!("{:?}", result); - } - - fn process_data(data: Vec) -> HashMap { - let mut counts = HashMap::new(); - for value in data { - *counts.entry(value).or_insert(0) += 1; - } - counts - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn test_process_data() { - let data = vec![1, 2, 2, 3]; - let result = process_data(data); - assert_eq!(result.get(&2), Some(&2)); - } - } - "#} - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let lang = rust_lang(); - let lang_id = lang.id(); - language_registry.add(Arc::new(lang)); - - let file_indexing_parallelism = 2; - let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx)); - cx.run_until_parked(); - - (project, index, lang_id) + captures.advance(); + } } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_highlights_query(include_str!("../../languages/src/rust/highlights.scm")) - .unwrap() - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } + identifiers } diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs new file mode 100644 index 0000000000..d93a660811 --- /dev/null +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -0,0 +1,510 @@ +use super::*; +use futures::channel::mpsc::UnboundedReceiver; +use gpui::TestAppContext; +use indoc::indoc; +use language::{Point, ToPoint as _, rust_lang}; +use lsp::FakeLanguageServer; +use project::{FakeFs, LocationLink, Project}; +use serde_json::json; +use settings::SettingsStore; +use std::fmt::Write as _; +use util::{path, test::marked_text_ranges}; + +#[gpui::test] +async fn test_edit_prediction_context(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), test_project_1()).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let mut servers = setup_fake_lsp(&project, cx); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + let _server = servers.next().await.unwrap(); + cx.run_until_parked(); + + let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(&project, cx)); + related_excerpt_store.update(cx, |store, cx| { + let position = { + let buffer = buffer.read(cx); + let offset = buffer.text().find("todo").unwrap(); + buffer.anchor_before(offset) + }; + + store.set_identifier_line_count(0); + store.refresh(buffer.clone(), position, cx); + }); + + cx.executor().advance_clock(DEBOUNCE_DURATION); + related_excerpt_store.update(cx, |store, _| { + let excerpts = store.related_files(); + assert_related_files( + &excerpts, + &[ + ( + "root/src/company.rs", + &[indoc! {" + pub struct Company { + owner: Arc, + address: Address, + }"}], + ), + ( + "root/src/main.rs", + &[ + indoc! {" + pub struct Session { + company: Arc, + } + + impl Session { + pub fn set_company(&mut self, company: Arc) {"}, + indoc! {" + } + }"}, + ], + ), + ( + "root/src/person.rs", + &[ + indoc! {" + impl Person { + pub fn get_first_name(&self) -> &str { + &self.first_name + }"}, + "}", + ], + ), + ], + ); + }); +} + +#[gpui::test] +fn test_assemble_excerpts(cx: &mut TestAppContext) { + let table = [ + ( + indoc! {r#" + struct User { + first_name: String, + «last_name»: String, + age: u32, + email: String, + create_at: Instant, + } + + impl User { + pub fn first_name(&self) -> String { + self.first_name.clone() + } + + pub fn full_name(&self) -> String { + « format!("{} {}", self.first_name, self.last_name) + » } + } + "#}, + indoc! {r#" + struct User { + first_name: String, + last_name: String, + … + } + + impl User { + … + pub fn full_name(&self) -> String { + format!("{} {}", self.first_name, self.last_name) + } + } + "#}, + ), + ( + indoc! {r#" + struct «User» { + first_name: String, + last_name: String, + age: u32, + } + + impl User { + // methods + } + "#}, + indoc! {r#" + struct User { + first_name: String, + last_name: String, + age: u32, + } + … + "#}, + ), + ( + indoc! {r#" + trait «FooProvider» { + const NAME: &'static str; + + fn provide_foo(&self, id: usize) -> Foo; + + fn provide_foo_batched(&self, ids: &[usize]) -> Vec { + ids.iter() + .map(|id| self.provide_foo(*id)) + .collect() + } + + fn sync(&self); + } + "# + }, + indoc! {r#" + trait FooProvider { + const NAME: &'static str; + + fn provide_foo(&self, id: usize) -> Foo; + + fn provide_foo_batched(&self, ids: &[usize]) -> Vec { + … + } + + fn sync(&self); + } + "#}, + ), + ( + indoc! {r#" + trait «Something» { + fn method1(&self, id: usize) -> Foo; + + fn method2(&self, ids: &[usize]) -> Vec { + struct Helper1 { + field1: usize, + } + + struct Helper2 { + field2: usize, + } + + struct Helper3 { + filed2: usize, + } + } + + fn sync(&self); + } + "# + }, + indoc! {r#" + trait Something { + fn method1(&self, id: usize) -> Foo; + + fn method2(&self, ids: &[usize]) -> Vec { + … + } + + fn sync(&self); + } + "#}, + ), + ]; + + for (input, expected_output) in table { + let (input, ranges) = marked_text_ranges(&input, false); + let buffer = cx.new(|cx| Buffer::local(input, cx).with_language(rust_lang(), cx)); + buffer.read_with(cx, |buffer, _cx| { + let ranges: Vec> = ranges + .into_iter() + .map(|range| range.to_point(&buffer)) + .collect(); + + let excerpts = assemble_excerpts(&buffer.snapshot(), ranges); + + let output = format_excerpts(buffer, &excerpts); + assert_eq!(output, expected_output); + }); + } +} + +#[gpui::test] +async fn test_fake_definition_lsp(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), test_project_1()).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let mut servers = setup_fake_lsp(&project, cx); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + let _server = servers.next().await.unwrap(); + cx.run_until_parked(); + + let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("Address {").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub struct Address {"], cx); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("State::CA").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub enum State {"], cx); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("to_string()").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub fn to_string(&self) -> String {"], cx); +} + +fn init_test(cx: &mut TestAppContext) { + let settings_store = cx.update(|cx| SettingsStore::test(cx)); + cx.set_global(settings_store); + env_logger::try_init().ok(); +} + +fn setup_fake_lsp( + project: &Entity, + cx: &mut TestAppContext, +) -> UnboundedReceiver { + let (language_registry, fs) = project.read_with(cx, |project, _| { + (project.languages().clone(), project.fs().clone()) + }); + let language = rust_lang(); + language_registry.add(language.clone()); + fake_definition_lsp::register_fake_definition_server(&language_registry, language, fs) +} + +fn test_project_1() -> serde_json::Value { + let person_rs = indoc! {r#" + pub struct Person { + first_name: String, + last_name: String, + email: String, + age: u32, + } + + impl Person { + pub fn get_first_name(&self) -> &str { + &self.first_name + } + + pub fn get_last_name(&self) -> &str { + &self.last_name + } + + pub fn get_email(&self) -> &str { + &self.email + } + + pub fn get_age(&self) -> u32 { + self.age + } + } + "#}; + + let address_rs = indoc! {r#" + pub struct Address { + street: String, + city: String, + state: State, + zip: u32, + } + + pub enum State { + CA, + OR, + WA, + TX, + // ... + } + + impl Address { + pub fn get_street(&self) -> &str { + &self.street + } + + pub fn get_city(&self) -> &str { + &self.city + } + + pub fn get_state(&self) -> State { + self.state + } + + pub fn get_zip(&self) -> u32 { + self.zip + } + } + "#}; + + let company_rs = indoc! {r#" + use super::person::Person; + use super::address::Address; + + pub struct Company { + owner: Arc, + address: Address, + } + + impl Company { + pub fn get_owner(&self) -> &Person { + &self.owner + } + + pub fn get_address(&self) -> &Address { + &self.address + } + + pub fn to_string(&self) -> String { + format!("{} ({})", self.owner.first_name, self.address.city) + } + } + "#}; + + let main_rs = indoc! {r#" + use std::sync::Arc; + use super::person::Person; + use super::address::Address; + use super::company::Company; + + pub struct Session { + company: Arc, + } + + impl Session { + pub fn set_company(&mut self, company: Arc) { + self.company = company; + if company.owner != self.company.owner { + log("new owner", company.owner.get_first_name()); todo(); + } + } + } + + fn main() { + let company = Company { + owner: Arc::new(Person { + first_name: "John".to_string(), + last_name: "Doe".to_string(), + email: "john@example.com".to_string(), + age: 30, + }), + address: Address { + street: "123 Main St".to_string(), + city: "Anytown".to_string(), + state: State::CA, + zip: 12345, + }, + }; + + println!("Company: {}", company.to_string()); + } + "#}; + + json!({ + "src": { + "person.rs": person_rs, + "address.rs": address_rs, + "company.rs": company_rs, + "main.rs": main_rs, + }, + }) +} + +fn assert_related_files(actual_files: &[RelatedFile], expected_files: &[(&str, &[&str])]) { + let actual_files = actual_files + .iter() + .map(|file| { + let excerpts = file + .excerpts + .iter() + .map(|excerpt| excerpt.text.to_string()) + .collect::>(); + (file.path.to_str().unwrap(), excerpts) + }) + .collect::>(); + let expected_excerpts = expected_files + .iter() + .map(|(path, texts)| { + ( + *path, + texts + .iter() + .map(|line| line.to_string()) + .collect::>(), + ) + }) + .collect::>(); + pretty_assertions::assert_eq!(actual_files, expected_excerpts) +} + +fn assert_definitions(definitions: &[LocationLink], first_lines: &[&str], cx: &mut TestAppContext) { + let actual_first_lines = definitions + .iter() + .map(|definition| { + definition.target.buffer.read_with(cx, |buffer, _| { + let mut start = definition.target.range.start.to_point(&buffer); + start.column = 0; + let end = Point::new(start.row, buffer.line_len(start.row)); + buffer + .text_for_range(start..end) + .collect::() + .trim() + .to_string() + }) + }) + .collect::>(); + + assert_eq!(actual_first_lines, first_lines); +} + +fn format_excerpts(buffer: &Buffer, excerpts: &[RelatedExcerpt]) -> String { + let mut output = String::new(); + let file_line_count = buffer.max_point().row; + let mut current_row = 0; + for excerpt in excerpts { + if excerpt.text.is_empty() { + continue; + } + if current_row < excerpt.row_range.start { + writeln!(&mut output, "…").unwrap(); + } + current_row = excerpt.row_range.start; + + for line in excerpt.text.to_string().lines() { + output.push_str(line); + output.push('\n'); + current_row += 1; + } + } + if current_row < file_line_count { + writeln!(&mut output, "…").unwrap(); + } + output +} diff --git a/crates/edit_prediction_context/src/excerpt.rs b/crates/edit_prediction_context/src/excerpt.rs index 58549d579d..3fc7eed4ac 100644 --- a/crates/edit_prediction_context/src/excerpt.rs +++ b/crates/edit_prediction_context/src/excerpt.rs @@ -1,11 +1,9 @@ -use language::{BufferSnapshot, LanguageId}; +use cloud_llm_client::predict_edits_v3::Line; +use language::{BufferSnapshot, LanguageId, Point, ToOffset as _, ToPoint as _}; use std::ops::Range; -use text::{Point, ToOffset as _, ToPoint as _}; use tree_sitter::{Node, TreeCursor}; use util::RangeExt; -use crate::{BufferDeclaration, declaration::DeclarationId, syntax_index::SyntaxIndexState}; - // TODO: // // - Test parent signatures @@ -31,18 +29,16 @@ pub struct EditPredictionExcerptOptions { pub target_before_cursor_over_total_bytes: f32, } -// TODO: consider merging these #[derive(Debug, Clone)] pub struct EditPredictionExcerpt { pub range: Range, - pub parent_declarations: Vec<(DeclarationId, Range)>, + pub line_range: Range, pub size: usize, } #[derive(Debug, Clone)] pub struct EditPredictionExcerptText { pub body: String, - pub parent_signatures: Vec, pub language_id: Option, } @@ -51,17 +47,8 @@ impl EditPredictionExcerpt { let body = buffer .text_for_range(self.range.clone()) .collect::(); - let parent_signatures = self - .parent_declarations - .iter() - .map(|(_, range)| buffer.text_for_range(range.clone()).collect::()) - .collect(); let language_id = buffer.language().map(|l| l.id()); - EditPredictionExcerptText { - body, - parent_signatures, - language_id, - } + EditPredictionExcerptText { body, language_id } } /// Selects an excerpt around a buffer position, attempting to choose logical boundaries based @@ -78,7 +65,6 @@ impl EditPredictionExcerpt { query_point: Point, buffer: &BufferSnapshot, options: &EditPredictionExcerptOptions, - syntax_index: Option<&SyntaxIndexState>, ) -> Option { if buffer.len() <= options.max_bytes { log::debug!( @@ -86,28 +72,23 @@ impl EditPredictionExcerpt { buffer.len(), options.max_bytes ); - return Some(EditPredictionExcerpt::new(0..buffer.len(), Vec::new())); + let offset_range = 0..buffer.len(); + let line_range = Line(0)..Line(buffer.max_point().row); + return Some(EditPredictionExcerpt::new(offset_range, line_range)); } let query_offset = query_point.to_offset(buffer); - let query_range = Point::new(query_point.row, 0).to_offset(buffer) - ..Point::new(query_point.row + 1, 0).to_offset(buffer); + let query_line_range = query_point.row..query_point.row + 1; + let query_range = Point::new(query_line_range.start, 0).to_offset(buffer) + ..Point::new(query_line_range.end, 0).to_offset(buffer); if query_range.len() >= options.max_bytes { return None; } - let parent_declarations = if let Some(syntax_index) = syntax_index { - syntax_index - .buffer_declarations_containing_range(buffer.remote_id(), query_range.clone()) - .collect() - } else { - Vec::new() - }; - let excerpt_selector = ExcerptSelector { query_offset, query_range, - parent_declarations: &parent_declarations, + query_line_range: Line(query_line_range.start)..Line(query_line_range.end), buffer, options, }; @@ -130,32 +111,20 @@ impl EditPredictionExcerpt { excerpt_selector.select_lines() } - fn new(range: Range, parent_declarations: Vec<(DeclarationId, Range)>) -> Self { - let size = range.len() - + parent_declarations - .iter() - .map(|(_, range)| range.len()) - .sum::(); + fn new(range: Range, line_range: Range) -> Self { Self { + size: range.len(), range, - parent_declarations, - size, + line_range, } } - fn with_expanded_range(&self, new_range: Range) -> Self { + fn with_expanded_range(&self, new_range: Range, new_line_range: Range) -> Self { if !new_range.contains_inclusive(&self.range) { // this is an issue because parent_signature_ranges may be incorrect log::error!("bug: with_expanded_range called with disjoint range"); } - let mut parent_declarations = Vec::with_capacity(self.parent_declarations.len()); - for (declaration_id, range) in &self.parent_declarations { - if !range.contains_inclusive(&new_range) { - break; - } - parent_declarations.push((*declaration_id, range.clone())); - } - Self::new(new_range, parent_declarations) + Self::new(new_range, new_line_range) } fn parent_signatures_size(&self) -> usize { @@ -166,7 +135,7 @@ impl EditPredictionExcerpt { struct ExcerptSelector<'a> { query_offset: usize, query_range: Range, - parent_declarations: &'a [(DeclarationId, &'a BufferDeclaration)], + query_line_range: Range, buffer: &'a BufferSnapshot, options: &'a EditPredictionExcerptOptions, } @@ -178,10 +147,13 @@ impl<'a> ExcerptSelector<'a> { let mut cursor = selected_layer_root.walk(); loop { - let excerpt_range = node_line_start(cursor.node()).to_offset(&self.buffer) - ..node_line_end(cursor.node()).to_offset(&self.buffer); + let line_start = node_line_start(cursor.node()); + let line_end = node_line_end(cursor.node()); + let line_range = Line(line_start.row)..Line(line_end.row); + let excerpt_range = + line_start.to_offset(&self.buffer)..line_end.to_offset(&self.buffer); if excerpt_range.contains_inclusive(&self.query_range) { - let excerpt = self.make_excerpt(excerpt_range); + let excerpt = self.make_excerpt(excerpt_range, line_range); if excerpt.size <= self.options.max_bytes { return Some(self.expand_to_siblings(&mut cursor, excerpt)); } @@ -272,9 +244,13 @@ impl<'a> ExcerptSelector<'a> { let mut forward = None; while !forward_done { - let new_end = node_line_end(forward_cursor.node()).to_offset(&self.buffer); + let new_end_point = node_line_end(forward_cursor.node()); + let new_end = new_end_point.to_offset(&self.buffer); if new_end > excerpt.range.end { - let new_excerpt = excerpt.with_expanded_range(excerpt.range.start..new_end); + let new_excerpt = excerpt.with_expanded_range( + excerpt.range.start..new_end, + excerpt.line_range.start..Line(new_end_point.row), + ); if new_excerpt.size <= self.options.max_bytes { forward = Some(new_excerpt); break; @@ -289,9 +265,13 @@ impl<'a> ExcerptSelector<'a> { let mut backward = None; while !backward_done { - let new_start = node_line_start(backward_cursor.node()).to_offset(&self.buffer); + let new_start_point = node_line_start(backward_cursor.node()); + let new_start = new_start_point.to_offset(&self.buffer); if new_start < excerpt.range.start { - let new_excerpt = excerpt.with_expanded_range(new_start..excerpt.range.end); + let new_excerpt = excerpt.with_expanded_range( + new_start..excerpt.range.end, + Line(new_start_point.row)..excerpt.line_range.end, + ); if new_excerpt.size <= self.options.max_bytes { backward = Some(new_excerpt); break; @@ -339,7 +319,7 @@ impl<'a> ExcerptSelector<'a> { fn select_lines(&self) -> Option { // early return if line containing query_offset is already too large - let excerpt = self.make_excerpt(self.query_range.clone()); + let excerpt = self.make_excerpt(self.query_range.clone(), self.query_line_range.clone()); if excerpt.size > self.options.max_bytes { log::debug!( "excerpt for cursor line is {} bytes, which exceeds the window", @@ -353,24 +333,24 @@ impl<'a> ExcerptSelector<'a> { let before_bytes = (self.options.target_before_cursor_over_total_bytes * bytes_remaining as f32) as usize; - let start_point = { + let start_line = { let offset = self.query_offset.saturating_sub(before_bytes); let point = offset.to_point(self.buffer); - Point::new(point.row + 1, 0) + Line(point.row + 1) }; - let start_offset = start_point.to_offset(&self.buffer); - let end_point = { + let start_offset = Point::new(start_line.0, 0).to_offset(&self.buffer); + let end_line = { let offset = start_offset + bytes_remaining; let point = offset.to_point(self.buffer); - Point::new(point.row, 0) + Line(point.row) }; - let end_offset = end_point.to_offset(&self.buffer); + let end_offset = Point::new(end_line.0, 0).to_offset(&self.buffer); // this could be expanded further since recalculated `signature_size` may be smaller, but // skipping that for now for simplicity // // TODO: could also consider checking if lines immediately before / after fit. - let excerpt = self.make_excerpt(start_offset..end_offset); + let excerpt = self.make_excerpt(start_offset..end_offset, start_line..end_line); if excerpt.size > self.options.max_bytes { log::error!( "bug: line-based excerpt selection has size {}, \ @@ -382,14 +362,8 @@ impl<'a> ExcerptSelector<'a> { return Some(excerpt); } - fn make_excerpt(&self, range: Range) -> EditPredictionExcerpt { - let parent_declarations = self - .parent_declarations - .iter() - .filter(|(_, declaration)| declaration.item_range.contains_inclusive(&range)) - .map(|(id, declaration)| (*id, declaration.signature_range.clone())) - .collect(); - EditPredictionExcerpt::new(range, parent_declarations) + fn make_excerpt(&self, range: Range, line_range: Range) -> EditPredictionExcerpt { + EditPredictionExcerpt::new(range, line_range) } /// Returns `true` if the `forward` excerpt is a better choice than the `backward` excerpt. @@ -445,30 +419,14 @@ fn node_line_end(node: Node) -> Point { mod tests { use super::*; use gpui::{AppContext, TestAppContext}; - use language::{Buffer, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; + use language::Buffer; use util::test::{generate_marked_text, marked_text_offsets_by}; fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot { - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang().into(), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language::rust_lang(), cx)); buffer.read_with(cx, |buffer, _| buffer.snapshot()) } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } - fn cursor_and_excerpt_range(text: &str) -> (String, usize, Range) { let (text, offsets) = marked_text_offsets_by(text, vec!['ˇ', '«', '»']); (text, offsets[&'ˇ'][0], offsets[&'«'][0]..offsets[&'»'][0]) @@ -480,9 +438,8 @@ mod tests { let buffer = create_buffer(&text, cx); let cursor_point = cursor.to_point(&buffer); - let excerpt = - EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &options, None) - .expect("Should select an excerpt"); + let excerpt = EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &options) + .expect("Should select an excerpt"); pretty_assertions::assert_eq!( generate_marked_text(&text, std::slice::from_ref(&excerpt.range), false), generate_marked_text(&text, &[expected_excerpt], false) diff --git a/crates/edit_prediction_context/src/fake_definition_lsp.rs b/crates/edit_prediction_context/src/fake_definition_lsp.rs new file mode 100644 index 0000000000..31fb681309 --- /dev/null +++ b/crates/edit_prediction_context/src/fake_definition_lsp.rs @@ -0,0 +1,329 @@ +use collections::HashMap; +use futures::channel::mpsc::UnboundedReceiver; +use language::{Language, LanguageRegistry}; +use lsp::{ + FakeLanguageServer, LanguageServerBinary, TextDocumentSyncCapability, TextDocumentSyncKind, Uri, +}; +use parking_lot::Mutex; +use project::Fs; +use std::{ops::Range, path::PathBuf, sync::Arc}; +use tree_sitter::{Parser, QueryCursor, StreamingIterator, Tree}; + +/// Registers a fake language server that implements go-to-definition using tree-sitter, +/// making the assumption that all names are unique, and all variables' types are +/// explicitly declared. +pub fn register_fake_definition_server( + language_registry: &Arc, + language: Arc, + fs: Arc, +) -> UnboundedReceiver { + let index = Arc::new(Mutex::new(DefinitionIndex::new(language.clone()))); + + language_registry.register_fake_lsp( + language.name(), + language::FakeLspAdapter { + name: "fake-definition-lsp", + initialization_options: None, + prettier_plugins: Vec::new(), + disk_based_diagnostics_progress_token: None, + disk_based_diagnostics_sources: Vec::new(), + language_server_binary: LanguageServerBinary { + path: PathBuf::from("fake-definition-lsp"), + arguments: Vec::new(), + env: None, + }, + capabilities: lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + ..Default::default() + }, + label_for_completion: None, + initializer: Some(Box::new({ + move |server| { + server.handle_notification::({ + let index = index.clone(); + move |params, _cx| { + index + .lock() + .open_buffer(params.text_document.uri, ¶ms.text_document.text); + } + }); + + server.handle_notification::({ + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let uri = params.text_document.uri; + let path = uri.to_file_path().ok(); + index.lock().mark_buffer_closed(&uri); + + if let Some(path) = path { + let index = index.clone(); + let fs = fs.clone(); + cx.spawn(async move |_cx| { + if let Ok(content) = fs.load(&path).await { + index.lock().index_file(uri, &content); + } + }) + .detach(); + } + } + }); + + server.handle_notification::({ + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let index = index.clone(); + let fs = fs.clone(); + cx.spawn(async move |_cx| { + for event in params.changes { + if index.lock().is_buffer_open(&event.uri) { + continue; + } + + match event.typ { + lsp::FileChangeType::DELETED => { + index.lock().remove_definitions_for_file(&event.uri); + } + lsp::FileChangeType::CREATED + | lsp::FileChangeType::CHANGED => { + if let Some(path) = event.uri.to_file_path().ok() { + if let Ok(content) = fs.load(&path).await { + index.lock().index_file(event.uri, &content); + } + } + } + _ => {} + } + } + }) + .detach(); + } + }); + + server.handle_notification::({ + let index = index.clone(); + move |params, _cx| { + if let Some(change) = params.content_changes.into_iter().last() { + index + .lock() + .index_file(params.text_document.uri, &change.text); + } + } + }); + + server.handle_notification::( + { + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let index = index.clone(); + let fs = fs.clone(); + let files = fs.as_fake().files(); + cx.spawn(async move |_cx| { + for folder in params.event.added { + let Ok(path) = folder.uri.to_file_path() else { + continue; + }; + for file in &files { + if let Some(uri) = Uri::from_file_path(&file).ok() + && file.starts_with(&path) + && let Ok(content) = fs.load(&file).await + { + index.lock().index_file(uri, &content); + } + } + } + }) + .detach(); + } + }, + ); + + server.set_request_handler::({ + let index = index.clone(); + move |params, _cx| { + let result = index.lock().get_definitions( + params.text_document_position_params.text_document.uri, + params.text_document_position_params.position, + ); + async move { Ok(result) } + } + }); + } + })), + }, + ) +} + +struct DefinitionIndex { + language: Arc, + definitions: HashMap>, + files: HashMap, +} + +#[derive(Debug)] +struct FileEntry { + contents: String, + is_open_in_buffer: bool, +} + +impl DefinitionIndex { + fn new(language: Arc) -> Self { + Self { + language, + definitions: HashMap::default(), + files: HashMap::default(), + } + } + + fn remove_definitions_for_file(&mut self, uri: &Uri) { + self.definitions.retain(|_, locations| { + locations.retain(|loc| &loc.uri != uri); + !locations.is_empty() + }); + self.files.remove(uri); + } + + fn open_buffer(&mut self, uri: Uri, content: &str) { + self.index_file_inner(uri, content, true); + } + + fn mark_buffer_closed(&mut self, uri: &Uri) { + if let Some(entry) = self.files.get_mut(uri) { + entry.is_open_in_buffer = false; + } + } + + fn is_buffer_open(&self, uri: &Uri) -> bool { + self.files + .get(uri) + .map(|entry| entry.is_open_in_buffer) + .unwrap_or(false) + } + + fn index_file(&mut self, uri: Uri, content: &str) { + self.index_file_inner(uri, content, false); + } + + fn index_file_inner(&mut self, uri: Uri, content: &str, is_open_in_buffer: bool) -> Option<()> { + self.remove_definitions_for_file(&uri); + let grammar = self.language.grammar()?; + let outline_config = grammar.outline_config.as_ref()?; + let mut parser = Parser::new(); + parser.set_language(&grammar.ts_language).ok()?; + let tree = parser.parse(content, None)?; + let declarations = extract_declarations_from_tree(&tree, content, outline_config); + for (name, byte_range) in declarations { + let range = byte_range_to_lsp_range(content, byte_range); + let location = lsp::Location { + uri: uri.clone(), + range, + }; + self.definitions + .entry(name) + .or_insert_with(Vec::new) + .push(location); + } + self.files.insert( + uri, + FileEntry { + contents: content.to_string(), + is_open_in_buffer, + }, + ); + + Some(()) + } + + fn get_definitions( + &mut self, + uri: Uri, + position: lsp::Position, + ) -> Option { + let entry = self.files.get(&uri)?; + let name = word_at_position(&entry.contents, position)?; + let locations = self.definitions.get(name).cloned()?; + Some(lsp::GotoDefinitionResponse::Array(locations)) + } +} + +fn extract_declarations_from_tree( + tree: &Tree, + content: &str, + outline_config: &language::OutlineConfig, +) -> Vec<(String, Range)> { + let mut cursor = QueryCursor::new(); + let mut declarations = Vec::new(); + let mut matches = cursor.matches(&outline_config.query, tree.root_node(), content.as_bytes()); + while let Some(query_match) = matches.next() { + let mut name_range: Option> = None; + let mut has_item_range = false; + + for capture in query_match.captures { + let range = capture.node.byte_range(); + if capture.index == outline_config.name_capture_ix { + name_range = Some(range); + } else if capture.index == outline_config.item_capture_ix { + has_item_range = true; + } + } + + if let Some(name_range) = name_range + && has_item_range + { + let name = content[name_range.clone()].to_string(); + if declarations.iter().any(|(n, _)| n == &name) { + continue; + } + declarations.push((name, name_range)); + } + } + declarations +} + +fn byte_range_to_lsp_range(content: &str, byte_range: Range) -> lsp::Range { + let start = byte_offset_to_position(content, byte_range.start); + let end = byte_offset_to_position(content, byte_range.end); + lsp::Range { start, end } +} + +fn byte_offset_to_position(content: &str, offset: usize) -> lsp::Position { + let mut line = 0; + let mut character = 0; + let mut current_offset = 0; + for ch in content.chars() { + if current_offset >= offset { + break; + } + if ch == '\n' { + line += 1; + character = 0; + } else { + character += 1; + } + current_offset += ch.len_utf8(); + } + lsp::Position { line, character } +} + +fn word_at_position(content: &str, position: lsp::Position) -> Option<&str> { + let mut lines = content.lines(); + let line = lines.nth(position.line as usize)?; + let column = position.character as usize; + if column > line.len() { + return None; + } + let start = line[..column] + .rfind(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + 1) + .unwrap_or(0); + let end = line[column..] + .find(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + column) + .unwrap_or(line.len()); + Some(&line[start..end]).filter(|word| !word.is_empty()) +} diff --git a/crates/edit_prediction_context/src/outline.rs b/crates/edit_prediction_context/src/outline.rs deleted file mode 100644 index ec02c869df..0000000000 --- a/crates/edit_prediction_context/src/outline.rs +++ /dev/null @@ -1,126 +0,0 @@ -use language::{BufferSnapshot, SyntaxMapMatches}; -use std::{cmp::Reverse, ops::Range}; - -use crate::declaration::Identifier; - -// TODO: -// -// * how to handle multiple name captures? for now last one wins -// -// * annotation ranges -// -// * new "signature" capture for outline queries -// -// * Check parent behavior of "int x, y = 0" declarations in a test - -pub struct OutlineDeclaration { - pub parent_index: Option, - pub identifier: Identifier, - pub item_range: Range, - pub signature_range: Range, -} - -pub fn declarations_in_buffer(buffer: &BufferSnapshot) -> Vec { - declarations_overlapping_range(0..buffer.len(), buffer) -} - -pub fn declarations_overlapping_range( - range: Range, - buffer: &BufferSnapshot, -) -> Vec { - let mut declarations = OutlineIterator::new(range, buffer).collect::>(); - declarations.sort_unstable_by_key(|item| (item.item_range.start, Reverse(item.item_range.end))); - - let mut parent_stack: Vec<(usize, Range)> = Vec::new(); - for (index, declaration) in declarations.iter_mut().enumerate() { - while let Some((top_parent_index, top_parent_range)) = parent_stack.last() { - if declaration.item_range.start >= top_parent_range.end { - parent_stack.pop(); - } else { - declaration.parent_index = Some(*top_parent_index); - break; - } - } - parent_stack.push((index, declaration.item_range.clone())); - } - declarations -} - -/// Iterates outline items without being ordered w.r.t. nested items and without populating -/// `parent`. -pub struct OutlineIterator<'a> { - buffer: &'a BufferSnapshot, - matches: SyntaxMapMatches<'a>, -} - -impl<'a> OutlineIterator<'a> { - pub fn new(range: Range, buffer: &'a BufferSnapshot) -> Self { - let matches = buffer.syntax.matches(range, &buffer.text, |grammar| { - grammar.outline_config.as_ref().map(|c| &c.query) - }); - - Self { buffer, matches } - } -} - -impl<'a> Iterator for OutlineIterator<'a> { - type Item = OutlineDeclaration; - - fn next(&mut self) -> Option { - while let Some(mat) = self.matches.peek() { - let config = self.matches.grammars()[mat.grammar_index] - .outline_config - .as_ref() - .unwrap(); - - let mut name_range = None; - let mut item_range = None; - let mut signature_start = None; - let mut signature_end = None; - - let mut add_to_signature = |range: Range| { - if signature_start.is_none() { - signature_start = Some(range.start); - } - signature_end = Some(range.end); - }; - - for capture in mat.captures { - let range = capture.node.byte_range(); - if capture.index == config.name_capture_ix { - name_range = Some(range.clone()); - add_to_signature(range); - } else if Some(capture.index) == config.context_capture_ix - || Some(capture.index) == config.extra_context_capture_ix - { - add_to_signature(range); - } else if capture.index == config.item_capture_ix { - item_range = Some(range.clone()); - } - } - - let language_id = mat.language.id(); - self.matches.advance(); - - if let Some(name_range) = name_range - && let Some(item_range) = item_range - && let Some(signature_start) = signature_start - && let Some(signature_end) = signature_end - { - let name = self - .buffer - .text_for_range(name_range) - .collect::() - .into(); - - return Some(OutlineDeclaration { - identifier: Identifier { name, language_id }, - item_range: item_range, - signature_range: signature_start..signature_end, - parent_index: None, - }); - } - } - None - } -} diff --git a/crates/edit_prediction_context/src/reference.rs b/crates/edit_prediction_context/src/reference.rs deleted file mode 100644 index 699adf1d80..0000000000 --- a/crates/edit_prediction_context/src/reference.rs +++ /dev/null @@ -1,173 +0,0 @@ -use collections::HashMap; -use language::BufferSnapshot; -use std::ops::Range; -use util::RangeExt; - -use crate::{ - declaration::Identifier, - excerpt::{EditPredictionExcerpt, EditPredictionExcerptText}, -}; - -#[derive(Debug, Clone)] -pub struct Reference { - pub identifier: Identifier, - pub range: Range, - pub region: ReferenceRegion, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum ReferenceRegion { - Breadcrumb, - Nearby, -} - -pub fn references_in_excerpt( - excerpt: &EditPredictionExcerpt, - excerpt_text: &EditPredictionExcerptText, - snapshot: &BufferSnapshot, -) -> HashMap> { - let mut references = references_in_range( - excerpt.range.clone(), - excerpt_text.body.as_str(), - ReferenceRegion::Nearby, - snapshot, - ); - - for ((_, range), text) in excerpt - .parent_declarations - .iter() - .zip(excerpt_text.parent_signatures.iter()) - { - references.extend(references_in_range( - range.clone(), - text.as_str(), - ReferenceRegion::Breadcrumb, - snapshot, - )); - } - - let mut identifier_to_references: HashMap> = HashMap::default(); - for reference in references { - identifier_to_references - .entry(reference.identifier.clone()) - .or_insert_with(Vec::new) - .push(reference); - } - identifier_to_references -} - -/// Finds all nodes which have a "variable" match from the highlights query within the offset range. -pub fn references_in_range( - range: Range, - range_text: &str, - reference_region: ReferenceRegion, - buffer: &BufferSnapshot, -) -> Vec { - let mut matches = buffer - .syntax - .matches(range.clone(), &buffer.text, |grammar| { - grammar - .highlights_config - .as_ref() - .map(|config| &config.query) - }); - - let mut references = Vec::new(); - let mut last_added_range = None; - while let Some(mat) = matches.peek() { - let config = matches.grammars()[mat.grammar_index] - .highlights_config - .as_ref(); - - if let Some(config) = config { - for capture in mat.captures { - if config.identifier_capture_indices.contains(&capture.index) { - let node_range = capture.node.byte_range(); - - // sometimes multiple highlight queries match - this deduplicates them - if Some(node_range.clone()) == last_added_range { - continue; - } - - if !range.contains_inclusive(&node_range) { - continue; - } - - let identifier_text = - &range_text[node_range.start - range.start..node_range.end - range.start]; - - references.push(Reference { - identifier: Identifier { - name: identifier_text.into(), - language_id: mat.language.id(), - }, - range: node_range.clone(), - region: reference_region, - }); - last_added_range = Some(node_range); - } - } - } - - matches.advance(); - } - references -} - -#[cfg(test)] -mod test { - use gpui::{TestAppContext, prelude::*}; - use indoc::indoc; - use language::{BufferSnapshot, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; - - use crate::reference::{ReferenceRegion, references_in_range}; - - #[gpui::test] - fn test_identifier_node_truncated(cx: &mut TestAppContext) { - let code = indoc! { r#" - fn main() { - add(1, 2); - } - - fn add(a: i32, b: i32) -> i32 { - a + b - } - "# }; - let buffer = create_buffer(code, cx); - - let range = 0..35; - let references = references_in_range( - range.clone(), - &code[range], - ReferenceRegion::Breadcrumb, - &buffer, - ); - assert_eq!(references.len(), 2); - assert_eq!(references[0].identifier.name.as_ref(), "main"); - assert_eq!(references[1].identifier.name.as_ref(), "add"); - } - - fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot { - let buffer = - cx.new(|cx| language::Buffer::local(text, cx).with_language(rust_lang().into(), cx)); - buffer.read_with(cx, |buffer, _| buffer.snapshot()) - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_highlights_query(include_str!("../../languages/src/rust/highlights.scm")) - .unwrap() - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } -} diff --git a/crates/edit_prediction_context/src/syntax_index.rs b/crates/edit_prediction_context/src/syntax_index.rs deleted file mode 100644 index d2763a6cfd..0000000000 --- a/crates/edit_prediction_context/src/syntax_index.rs +++ /dev/null @@ -1,1043 +0,0 @@ -use anyhow::{Result, anyhow}; -use collections::{HashMap, HashSet}; -use futures::channel::mpsc; -use futures::lock::Mutex; -use futures::{FutureExt as _, StreamExt, future}; -use gpui::{App, AppContext as _, AsyncApp, Context, Entity, Task, WeakEntity}; -use itertools::Itertools; -use language::{Buffer, BufferEvent}; -use postage::stream::Stream as _; -use project::buffer_store::{BufferStore, BufferStoreEvent}; -use project::worktree_store::{WorktreeStore, WorktreeStoreEvent}; -use project::{PathChange, Project, ProjectEntryId, ProjectPath}; -use slotmap::SlotMap; -use std::iter; -use std::ops::{DerefMut, Range}; -use std::sync::Arc; -use text::BufferId; -use util::{RangeExt as _, debug_panic, some_or_debug_panic}; - -use crate::declaration::{ - BufferDeclaration, Declaration, DeclarationId, FileDeclaration, Identifier, -}; -use crate::outline::declarations_in_buffer; - -// TODO -// -// * Also queue / debounce buffer changes. A challenge for this is that use of -// `buffer_declarations_containing_range` assumes that the index is always immediately up to date. -// -// * Add a per language configuration for skipping indexing. - -// Potential future improvements: -// -// * Prevent indexing of a large file from blocking the queue. -// -// * Send multiple selected excerpt ranges. Challenge is that excerpt ranges influence which -// references are present and their scores. -// -// * Include single-file worktrees / non visible worktrees? E.g. go to definition that resolves to a -// file in a build dependency. Should not be editable in that case - but how to distinguish the case -// where it should be editable? - -// Potential future optimizations: -// -// * Index files on multiple threads in Zed (currently only parallel for the CLI). Adding some kind -// of priority system to the background executor could help - it's single threaded for now to avoid -// interfering with other work. -// -// * Parse files directly instead of loading into a Rope. -// -// - This would allow the task handling dirty_files to be done entirely on the background executor. -// -// - Make SyntaxMap generic to handle embedded languages? Will also need to find line boundaries, -// but that can be done by scanning characters in the flat representation. -// -// * Use something similar to slotmap without key versions. -// -// * Concurrent slotmap - -pub struct SyntaxIndex { - state: Arc>, - project: WeakEntity, - initial_file_indexing_done_rx: postage::watch::Receiver, -} - -pub struct SyntaxIndexState { - declarations: SlotMap, - identifiers: HashMap>, - files: HashMap, - buffers: HashMap, - dirty_files: HashMap, - dirty_files_tx: mpsc::Sender<()>, - _file_indexing_task: Option>, -} - -#[derive(Debug, Default)] -struct FileState { - declarations: Vec, -} - -#[derive(Default)] -struct BufferState { - declarations: Vec, - task: Option>, -} - -impl SyntaxIndex { - pub fn new( - project: &Entity, - file_indexing_parallelism: usize, - cx: &mut Context, - ) -> Self { - assert!(file_indexing_parallelism > 0); - let (dirty_files_tx, mut dirty_files_rx) = mpsc::channel::<()>(1); - let (mut initial_file_indexing_done_tx, initial_file_indexing_done_rx) = - postage::watch::channel(); - - let initial_state = SyntaxIndexState { - declarations: SlotMap::default(), - identifiers: HashMap::default(), - files: HashMap::default(), - buffers: HashMap::default(), - dirty_files: HashMap::default(), - dirty_files_tx, - _file_indexing_task: None, - }; - let this = Self { - project: project.downgrade(), - state: Arc::new(Mutex::new(initial_state)), - initial_file_indexing_done_rx, - }; - - let worktree_store = project.read(cx).worktree_store(); - let initial_worktree_snapshots = worktree_store - .read(cx) - .worktrees() - .map(|w| w.read(cx).snapshot()) - .collect::>(); - if !initial_worktree_snapshots.is_empty() { - this.state.try_lock().unwrap()._file_indexing_task = - Some(cx.spawn(async move |this, cx| { - let snapshots_file_count = initial_worktree_snapshots - .iter() - .map(|worktree| worktree.file_count()) - .sum::(); - let chunk_size = snapshots_file_count.div_ceil(file_indexing_parallelism); - let chunk_count = snapshots_file_count.div_ceil(chunk_size); - let file_chunks = initial_worktree_snapshots - .iter() - .flat_map(|worktree| { - let worktree_id = worktree.id(); - worktree.files(false, 0).map(move |entry| { - ( - entry.id, - ProjectPath { - worktree_id, - path: entry.path.clone(), - }, - ) - }) - }) - .chunks(chunk_size); - - let mut tasks = Vec::with_capacity(chunk_count); - for chunk in file_chunks.into_iter() { - tasks.push(Self::update_dirty_files( - &this, - chunk.into_iter().collect(), - cx.clone(), - )); - } - futures::future::join_all(tasks).await; - - log::info!("Finished initial file indexing"); - *initial_file_indexing_done_tx.borrow_mut() = true; - - let Ok(state) = this.read_with(cx, |this, _cx| this.state.clone()) else { - return; - }; - while dirty_files_rx.next().await.is_some() { - let mut state = state.lock().await; - let was_underused = state.dirty_files.capacity() > 255 - && state.dirty_files.len() * 8 < state.dirty_files.capacity(); - let dirty_files = state.dirty_files.drain().collect::>(); - if was_underused { - state.dirty_files.shrink_to_fit(); - } - drop(state); - if dirty_files.is_empty() { - continue; - } - - let chunk_size = dirty_files.len().div_ceil(file_indexing_parallelism); - let chunk_count = dirty_files.len().div_ceil(chunk_size); - let mut tasks = Vec::with_capacity(chunk_count); - let chunks = dirty_files.into_iter().chunks(chunk_size); - for chunk in chunks.into_iter() { - tasks.push(Self::update_dirty_files( - &this, - chunk.into_iter().collect(), - cx.clone(), - )); - } - futures::future::join_all(tasks).await; - } - })); - } - - cx.subscribe(&worktree_store, Self::handle_worktree_store_event) - .detach(); - - let buffer_store = project.read(cx).buffer_store().clone(); - for buffer in buffer_store.read(cx).buffers().collect::>() { - this.register_buffer(&buffer, cx); - } - cx.subscribe(&buffer_store, Self::handle_buffer_store_event) - .detach(); - - this - } - - async fn update_dirty_files( - this: &WeakEntity, - dirty_files: Vec<(ProjectEntryId, ProjectPath)>, - mut cx: AsyncApp, - ) { - for (entry_id, project_path) in dirty_files { - let Ok(task) = this.update(&mut cx, |this, cx| { - this.update_file(entry_id, project_path, cx) - }) else { - return; - }; - task.await; - } - } - - pub fn wait_for_initial_file_indexing(&self, cx: &App) -> Task> { - if *self.initial_file_indexing_done_rx.borrow() { - Task::ready(Ok(())) - } else { - let mut rx = self.initial_file_indexing_done_rx.clone(); - cx.background_spawn(async move { - loop { - match rx.recv().await { - Some(true) => return Ok(()), - Some(false) => {} - None => { - return Err(anyhow!( - "SyntaxIndex dropped while waiting for initial file indexing" - )); - } - } - } - }) - } - } - - pub fn indexed_file_paths(&self, cx: &App) -> Task> { - let state = self.state.clone(); - let project = self.project.clone(); - - cx.spawn(async move |cx| { - let state = state.lock().await; - let Some(project) = project.upgrade() else { - return vec![]; - }; - project - .read_with(cx, |project, cx| { - state - .files - .keys() - .filter_map(|entry_id| project.path_for_entry(*entry_id, cx)) - .collect() - }) - .unwrap_or_default() - }) - } - - fn handle_worktree_store_event( - &mut self, - _worktree_store: Entity, - event: &WorktreeStoreEvent, - cx: &mut Context, - ) { - use WorktreeStoreEvent::*; - match event { - WorktreeUpdatedEntries(worktree_id, updated_entries_set) => { - let state = Arc::downgrade(&self.state); - let worktree_id = *worktree_id; - let updated_entries_set = updated_entries_set.clone(); - cx.background_spawn(async move { - let Some(state) = state.upgrade() else { return }; - let mut state = state.lock().await; - for (path, entry_id, path_change) in updated_entries_set.iter() { - if let PathChange::Removed = path_change { - state.files.remove(entry_id); - state.dirty_files.remove(entry_id); - } else { - let project_path = ProjectPath { - worktree_id, - path: path.clone(), - }; - state.dirty_files.insert(*entry_id, project_path); - } - } - match state.dirty_files_tx.try_send(()) { - Err(err) if err.is_disconnected() => { - log::error!("bug: syntax indexing queue is disconnected"); - } - _ => {} - } - }) - .detach(); - } - WorktreeDeletedEntry(_worktree_id, project_entry_id) => { - let project_entry_id = *project_entry_id; - self.with_state(cx, move |state| { - state.files.remove(&project_entry_id); - }) - } - _ => {} - } - } - - fn handle_buffer_store_event( - &mut self, - _buffer_store: Entity, - event: &BufferStoreEvent, - cx: &mut Context, - ) { - use BufferStoreEvent::*; - match event { - BufferAdded(buffer) => self.register_buffer(buffer, cx), - BufferOpened { .. } - | BufferChangedFilePath { .. } - | BufferDropped { .. } - | SharedBufferClosed { .. } => {} - } - } - - pub fn state(&self) -> &Arc> { - &self.state - } - - fn with_state(&self, cx: &mut App, f: impl FnOnce(&mut SyntaxIndexState) + Send + 'static) { - if let Some(mut state) = self.state.try_lock() { - f(&mut state); - return; - } - let state = Arc::downgrade(&self.state); - cx.background_spawn(async move { - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - f(&mut state) - }) - .detach(); - } - - fn register_buffer(&self, buffer: &Entity, cx: &mut Context) { - let buffer_id = buffer.read(cx).remote_id(); - cx.observe_release(buffer, move |this, _buffer, cx| { - this.with_state(cx, move |state| { - if let Some(buffer_state) = state.buffers.remove(&buffer_id) { - SyntaxIndexState::remove_buffer_declarations( - &buffer_state.declarations, - &mut state.declarations, - &mut state.identifiers, - ); - } - }) - }) - .detach(); - cx.subscribe(buffer, Self::handle_buffer_event).detach(); - - self.update_buffer(buffer.clone(), cx); - } - - fn handle_buffer_event( - &mut self, - buffer: Entity, - event: &BufferEvent, - cx: &mut Context, - ) { - match event { - BufferEvent::Edited => self.update_buffer(buffer, cx), - _ => {} - } - } - - fn update_buffer(&self, buffer_entity: Entity, cx: &mut Context) { - let buffer = buffer_entity.read(cx); - if buffer.language().is_none() { - return; - } - - let Some(project_entry_id) = - project::File::from_dyn(buffer.file()).and_then(|f| f.project_entry_id(cx)) - else { - return; - }; - let buffer_id = buffer.remote_id(); - - let mut parse_status = buffer.parse_status(); - let snapshot_task = cx.spawn({ - let weak_buffer = buffer_entity.downgrade(); - async move |_, cx| { - while *parse_status.borrow() != language::ParseStatus::Idle { - parse_status.changed().await?; - } - weak_buffer.read_with(cx, |buffer, _cx| buffer.snapshot()) - } - }); - - let state = Arc::downgrade(&self.state); - let task = cx.background_spawn(async move { - // TODO: How to handle errors? - let Ok(snapshot) = snapshot_task.await else { - return; - }; - let rope = snapshot.text.as_rope(); - - let declarations = declarations_in_buffer(&snapshot) - .into_iter() - .map(|item| { - ( - item.parent_index, - BufferDeclaration::from_outline(item, &rope), - ) - }) - .collect::>(); - - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - let state = state.deref_mut(); - - let buffer_state = state - .buffers - .entry(buffer_id) - .or_insert_with(Default::default); - - SyntaxIndexState::remove_buffer_declarations( - &buffer_state.declarations, - &mut state.declarations, - &mut state.identifiers, - ); - - let mut new_ids = Vec::with_capacity(declarations.len()); - state.declarations.reserve(declarations.len()); - for (parent_index, mut declaration) in declarations { - declaration.parent = - parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied())); - - let identifier = declaration.identifier.clone(); - let declaration_id = state.declarations.insert(Declaration::Buffer { - rope: rope.clone(), - buffer_id, - declaration, - project_entry_id, - }); - new_ids.push(declaration_id); - - state - .identifiers - .entry(identifier) - .or_default() - .insert(declaration_id); - } - - buffer_state.declarations = new_ids; - }); - - self.with_state(cx, move |state| { - state - .buffers - .entry(buffer_id) - .or_insert_with(Default::default) - .task = Some(task) - }); - } - - fn update_file( - &mut self, - entry_id: ProjectEntryId, - project_path: ProjectPath, - cx: &mut Context, - ) -> Task<()> { - let Some(project) = self.project.upgrade() else { - return Task::ready(()); - }; - let project = project.read(cx); - - let language_registry = project.languages(); - let Some(available_language) = - language_registry.language_for_file_path(project_path.path.as_std_path()) - else { - return Task::ready(()); - }; - let language = if let Some(Ok(Ok(language))) = language_registry - .load_language(&available_language) - .now_or_never() - { - if language - .grammar() - .is_none_or(|grammar| grammar.outline_config.is_none()) - { - return Task::ready(()); - } - future::Either::Left(async { Ok(language) }) - } else { - let language_registry = language_registry.clone(); - future::Either::Right(async move { - anyhow::Ok( - language_registry - .load_language(&available_language) - .await??, - ) - }) - }; - - let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) else { - return Task::ready(()); - }; - - let snapshot_task = worktree.update(cx, |worktree, cx| { - let load_task = worktree.load_file(&project_path.path, cx); - cx.spawn(async move |_this, cx| { - let loaded_file = load_task.await?; - let language = language.await?; - - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(loaded_file.text, cx); - buffer.set_language(Some(language), cx); - buffer - })?; - - let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; - while *parse_status.borrow() != language::ParseStatus::Idle { - parse_status.changed().await?; - } - - buffer.read_with(cx, |buffer, _cx| buffer.snapshot()) - }) - }); - - let state = Arc::downgrade(&self.state); - cx.background_spawn(async move { - // TODO: How to handle errors? - let Ok(snapshot) = snapshot_task.await else { - return; - }; - let rope = snapshot.as_rope(); - let declarations = declarations_in_buffer(&snapshot) - .into_iter() - .map(|item| (item.parent_index, FileDeclaration::from_outline(item, rope))) - .collect::>(); - - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - let state = state.deref_mut(); - - let file_state = state.files.entry(entry_id).or_insert_with(Default::default); - for old_declaration_id in &file_state.declarations { - let Some(declaration) = state.declarations.remove(*old_declaration_id) else { - debug_panic!("declaration not found"); - continue; - }; - if let Some(identifier_declarations) = - state.identifiers.get_mut(declaration.identifier()) - { - identifier_declarations.remove(old_declaration_id); - } - } - - let mut new_ids = Vec::with_capacity(declarations.len()); - state.declarations.reserve(declarations.len()); - for (parent_index, mut declaration) in declarations { - declaration.parent = - parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied())); - - let identifier = declaration.identifier.clone(); - let declaration_id = state.declarations.insert(Declaration::File { - project_entry_id: entry_id, - declaration, - }); - new_ids.push(declaration_id); - - state - .identifiers - .entry(identifier) - .or_default() - .insert(declaration_id); - } - file_state.declarations = new_ids; - }) - } -} - -impl SyntaxIndexState { - pub fn declaration(&self, id: DeclarationId) -> Option<&Declaration> { - self.declarations.get(id) - } - - /// Returns declarations for the identifier. If the limit is exceeded, returns an empty vector. - /// - /// TODO: Consider doing some pre-ranking and instead truncating when N is exceeded. - pub fn declarations_for_identifier( - &self, - identifier: &Identifier, - ) -> Vec<(DeclarationId, &Declaration)> { - // make sure to not have a large stack allocation - assert!(N < 32); - - let Some(declaration_ids) = self.identifiers.get(&identifier) else { - return vec![]; - }; - - let mut result = Vec::with_capacity(N); - let mut included_buffer_entry_ids = arrayvec::ArrayVec::<_, N>::new(); - let mut file_declarations = Vec::new(); - - for declaration_id in declaration_ids { - let declaration = self.declarations.get(*declaration_id); - let Some(declaration) = some_or_debug_panic(declaration) else { - continue; - }; - match declaration { - Declaration::Buffer { - project_entry_id, .. - } => { - included_buffer_entry_ids.push(*project_entry_id); - result.push((*declaration_id, declaration)); - if result.len() == N { - return Vec::new(); - } - } - Declaration::File { - project_entry_id, .. - } => { - if !included_buffer_entry_ids.contains(&project_entry_id) { - file_declarations.push((*declaration_id, declaration)); - } - } - } - } - - for (declaration_id, declaration) in file_declarations { - match declaration { - Declaration::File { - project_entry_id, .. - } => { - if !included_buffer_entry_ids.contains(&project_entry_id) { - result.push((declaration_id, declaration)); - - if result.len() == N { - return Vec::new(); - } - } - } - Declaration::Buffer { .. } => {} - } - } - - result - } - - pub fn buffer_declarations_containing_range( - &self, - buffer_id: BufferId, - range: Range, - ) -> impl Iterator { - let Some(buffer_state) = self.buffers.get(&buffer_id) else { - return itertools::Either::Left(iter::empty()); - }; - - let iter = buffer_state - .declarations - .iter() - .filter_map(move |declaration_id| { - let Some(declaration) = self - .declarations - .get(*declaration_id) - .and_then(|d| d.as_buffer()) - else { - log::error!("bug: missing buffer outline declaration"); - return None; - }; - if declaration.item_range.contains_inclusive(&range) { - return Some((*declaration_id, declaration)); - } - return None; - }); - itertools::Either::Right(iter) - } - - pub fn file_declaration_count(&self, declaration: &Declaration) -> usize { - match declaration { - Declaration::File { - project_entry_id, .. - } => self - .files - .get(project_entry_id) - .map(|file_state| file_state.declarations.len()) - .unwrap_or_default(), - Declaration::Buffer { buffer_id, .. } => self - .buffers - .get(buffer_id) - .map(|buffer_state| buffer_state.declarations.len()) - .unwrap_or_default(), - } - } - - fn remove_buffer_declarations( - old_declaration_ids: &[DeclarationId], - declarations: &mut SlotMap, - identifiers: &mut HashMap>, - ) { - for old_declaration_id in old_declaration_ids { - let Some(declaration) = declarations.remove(*old_declaration_id) else { - debug_panic!("declaration not found"); - continue; - }; - if let Some(identifier_declarations) = identifiers.get_mut(declaration.identifier()) { - identifier_declarations.remove(old_declaration_id); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - use gpui::TestAppContext; - use indoc::indoc; - use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use text::OffsetRangeExt as _; - use util::{path, rel_path::rel_path}; - - use crate::syntax_index::SyntaxIndex; - - #[gpui::test] - async fn test_unopen_indexed_files(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let main = Identifier { - name: "main".into(), - language_id: rust_lang_id, - }; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - - let decl = expect_file_decl("a.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, main); - assert_eq!(decl.item_range, 0..98); - - let decl = expect_file_decl("c.rs", &decls[1].1, &project, cx); - assert_eq!(decl.identifier, main.clone()); - assert_eq!(decl.item_range, 32..280); - }); - } - - #[gpui::test] - async fn test_parents_in_file(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let test_process_data = Identifier { - name: "test_process_data".into(), - language_id: rust_lang_id, - }; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&test_process_data); - assert_eq!(decls.len(), 1); - - let decl = expect_file_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, test_process_data); - - let parent_id = decl.parent.unwrap(); - let parent = index_state.declaration(parent_id).unwrap(); - let parent_decl = expect_file_decl("c.rs", &parent, &project, cx); - assert_eq!( - parent_decl.identifier, - Identifier { - name: "tests".into(), - language_id: rust_lang_id - } - ); - assert_eq!(parent_decl.parent, None); - }); - } - - #[gpui::test] - async fn test_parents_in_buffer(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let test_process_data = Identifier { - name: "test_process_data".into(), - language_id: rust_lang_id, - }; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&test_process_data); - assert_eq!(decls.len(), 1); - - let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, test_process_data); - - let parent_id = decl.parent.unwrap(); - let parent = index_state.declaration(parent_id).unwrap(); - let parent_decl = expect_buffer_decl("c.rs", &parent, &project, cx); - assert_eq!( - parent_decl.identifier, - Identifier { - name: "tests".into(), - language_id: rust_lang_id - } - ); - assert_eq!(parent_decl.parent, None); - }); - - drop(buffer); - } - - #[gpui::test] - async fn test_declarations_limt(cx: &mut TestAppContext) { - let (_, index, rust_lang_id) = init_test(cx).await; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - let decls = index_state.declarations_for_identifier::<1>(&Identifier { - name: "main".into(), - language_id: rust_lang_id, - }); - assert_eq!(decls.len(), 0); - } - - #[gpui::test] - async fn test_buffer_shadow(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - - let main = Identifier { - name: "main".into(), - language_id: rust_lang_id, - }; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - let index_state_arc = index.read_with(cx, |index, _cx| index.state().clone()); - { - let index_state = index_state_arc.lock().await; - - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, main); - assert_eq!(decl.item_range.to_offset(&buffer.read(cx)), 32..280); - - expect_file_decl("a.rs", &decls[1].1, &project, cx); - }); - } - - // Drop the buffer and wait for release - cx.update(|_| { - drop(buffer); - }); - cx.run_until_parked(); - - let index_state = index_state_arc.lock().await; - - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - expect_file_decl("a.rs", &decls[0].1, &project, cx); - expect_file_decl("c.rs", &decls[1].1, &project, cx); - }); - } - - fn expect_buffer_decl<'a>( - path: &str, - declaration: &'a Declaration, - project: &Entity, - cx: &App, - ) -> &'a BufferDeclaration { - if let Declaration::Buffer { - declaration, - project_entry_id, - .. - } = declaration - { - let project_path = project - .read(cx) - .path_for_entry(*project_entry_id, cx) - .unwrap(); - assert_eq!(project_path.path.as_ref(), rel_path(path),); - declaration - } else { - panic!("Expected a buffer declaration, found {:?}", declaration); - } - } - - fn expect_file_decl<'a>( - path: &str, - declaration: &'a Declaration, - project: &Entity, - cx: &App, - ) -> &'a FileDeclaration { - if let Declaration::File { - declaration, - project_entry_id: file, - } = declaration - { - assert_eq!( - project - .read(cx) - .path_for_entry(*file, cx) - .unwrap() - .path - .as_ref(), - rel_path(path), - ); - declaration - } else { - panic!("Expected a file declaration, found {:?}", declaration); - } - } - - async fn init_test( - cx: &mut TestAppContext, - ) -> (Entity, Entity, LanguageId) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "a.rs": indoc! {r#" - fn main() { - let x = 1; - let y = 2; - let z = add(x, y); - println!("Result: {}", z); - } - - fn add(a: i32, b: i32) -> i32 { - a + b - } - "#}, - "b.rs": indoc! {" - pub struct Config { - pub name: String, - pub value: i32, - } - - impl Config { - pub fn new(name: String, value: i32) -> Self { - Config { name, value } - } - } - "}, - "c.rs": indoc! {r#" - use std::collections::HashMap; - - fn main() { - let args: Vec = std::env::args().collect(); - let data: Vec = args[1..] - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - let result = process_data(data); - println!("{:?}", result); - } - - fn process_data(data: Vec) -> HashMap { - let mut counts = HashMap::new(); - for value in data { - *counts.entry(value).or_insert(0) += 1; - } - counts - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn test_process_data() { - let data = vec![1, 2, 2, 3]; - let result = process_data(data); - assert_eq!(result.get(&2), Some(&2)); - } - } - "#} - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let lang = rust_lang(); - let lang_id = lang.id(); - language_registry.add(Arc::new(lang)); - - let file_indexing_parallelism = 2; - let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx)); - cx.run_until_parked(); - - (project, index, lang_id) - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } -} diff --git a/crates/edit_prediction_context/src/text_similarity.rs b/crates/edit_prediction_context/src/text_similarity.rs deleted file mode 100644 index 99d8fb4dd1..0000000000 --- a/crates/edit_prediction_context/src/text_similarity.rs +++ /dev/null @@ -1,272 +0,0 @@ -use hashbrown::HashTable; -use regex::Regex; -use std::{ - hash::{Hash, Hasher as _}, - sync::LazyLock, -}; - -use crate::reference::Reference; - -// TODO: Consider implementing sliding window similarity matching like -// https://github.com/sourcegraph/cody-public-snapshot/blob/8e20ac6c1460c08b0db581c0204658112a246eda/vscode/src/completions/context/retrievers/jaccard-similarity/bestJaccardMatch.ts -// -// That implementation could actually be more efficient - no need to track words in the window that -// are not in the query. - -// TODO: Consider a flat sorted Vec<(String, usize)> representation. Intersection can just walk the -// two in parallel. - -static IDENTIFIER_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\b\w+\b").unwrap()); - -/// Multiset of text occurrences for text similarity that only stores hashes and counts. -#[derive(Debug, Default)] -pub struct Occurrences { - table: HashTable, - total_count: usize, -} - -#[derive(Debug)] -struct OccurrenceEntry { - hash: u64, - count: usize, -} - -impl Occurrences { - pub fn within_string(text: &str) -> Self { - Self::from_identifiers(IDENTIFIER_REGEX.find_iter(text).map(|mat| mat.as_str())) - } - - #[allow(dead_code)] - pub fn within_references(references: &[Reference]) -> Self { - Self::from_identifiers( - references - .iter() - .map(|reference| reference.identifier.name.as_ref()), - ) - } - - pub fn from_identifiers<'a>(identifiers: impl IntoIterator) -> Self { - let mut this = Self::default(); - // TODO: Score matches that match case higher? - // - // TODO: Also include unsplit identifier? - for identifier in identifiers { - for identifier_part in split_identifier(identifier) { - this.add_hash(fx_hash(&identifier_part.to_lowercase())); - } - } - this - } - - fn add_hash(&mut self, hash: u64) { - self.table - .entry( - hash, - |entry: &OccurrenceEntry| entry.hash == hash, - |entry| entry.hash, - ) - .and_modify(|entry| entry.count += 1) - .or_insert(OccurrenceEntry { hash, count: 1 }); - self.total_count += 1; - } - - fn contains_hash(&self, hash: u64) -> bool { - self.get_count(hash) != 0 - } - - fn get_count(&self, hash: u64) -> usize { - self.table - .find(hash, |entry| entry.hash == hash) - .map(|entry| entry.count) - .unwrap_or(0) - } -} - -pub fn fx_hash(data: &T) -> u64 { - let mut hasher = collections::FxHasher::default(); - data.hash(&mut hasher); - hasher.finish() -} - -// Splits camelcase / snakecase / kebabcase / pascalcase -// -// TODO: Make this more efficient / elegant. -fn split_identifier(identifier: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0; - let chars: Vec = identifier.chars().collect(); - - if chars.is_empty() { - return parts; - } - - let mut i = 0; - while i < chars.len() { - let ch = chars[i]; - - // Handle explicit delimiters (underscore and hyphen) - if ch == '_' || ch == '-' { - if i > start { - parts.push(&identifier[start..i]); - } - start = i + 1; - i += 1; - continue; - } - - // Handle camelCase and PascalCase transitions - if i > 0 && i < chars.len() { - let prev_char = chars[i - 1]; - - // Transition from lowercase/digit to uppercase - if (prev_char.is_lowercase() || prev_char.is_ascii_digit()) && ch.is_uppercase() { - parts.push(&identifier[start..i]); - start = i; - } - // Handle sequences like "XMLParser" -> ["XML", "Parser"] - else if i + 1 < chars.len() - && ch.is_uppercase() - && chars[i + 1].is_lowercase() - && prev_char.is_uppercase() - { - parts.push(&identifier[start..i]); - start = i; - } - } - - i += 1; - } - - // Add the last part if there's any remaining - if start < identifier.len() { - parts.push(&identifier[start..]); - } - - // Filter out empty strings - parts.into_iter().filter(|s| !s.is_empty()).collect() -} - -pub fn jaccard_similarity<'a>(mut set_a: &'a Occurrences, mut set_b: &'a Occurrences) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - let intersection = set_a - .table - .iter() - .filter(|entry| set_b.contains_hash(entry.hash)) - .count(); - let union = set_a.table.len() + set_b.table.len() - intersection; - intersection as f32 / union as f32 -} - -// TODO -#[allow(dead_code)] -pub fn overlap_coefficient<'a>(mut set_a: &'a Occurrences, mut set_b: &'a Occurrences) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - let intersection = set_a - .table - .iter() - .filter(|entry| set_b.contains_hash(entry.hash)) - .count(); - intersection as f32 / set_a.table.len() as f32 -} - -// TODO -#[allow(dead_code)] -pub fn weighted_jaccard_similarity<'a>( - mut set_a: &'a Occurrences, - mut set_b: &'a Occurrences, -) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - - let mut numerator = 0; - let mut denominator_a = 0; - let mut used_count_b = 0; - for entry_a in set_a.table.iter() { - let count_a = entry_a.count; - let count_b = set_b.get_count(entry_a.hash); - numerator += count_a.min(count_b); - denominator_a += count_a.max(count_b); - used_count_b += count_b; - } - - let denominator = denominator_a + (set_b.total_count - used_count_b); - if denominator == 0 { - 0.0 - } else { - numerator as f32 / denominator as f32 - } -} - -pub fn weighted_overlap_coefficient<'a>( - mut set_a: &'a Occurrences, - mut set_b: &'a Occurrences, -) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - - let mut numerator = 0; - for entry_a in set_a.table.iter() { - let count_a = entry_a.count; - let count_b = set_b.get_count(entry_a.hash); - numerator += count_a.min(count_b); - } - - let denominator = set_a.total_count.min(set_b.total_count); - if denominator == 0 { - 0.0 - } else { - numerator as f32 / denominator as f32 - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_split_identifier() { - assert_eq!(split_identifier("snake_case"), vec!["snake", "case"]); - assert_eq!(split_identifier("kebab-case"), vec!["kebab", "case"]); - assert_eq!(split_identifier("PascalCase"), vec!["Pascal", "Case"]); - assert_eq!(split_identifier("camelCase"), vec!["camel", "Case"]); - assert_eq!(split_identifier("XMLParser"), vec!["XML", "Parser"]); - } - - #[test] - fn test_similarity_functions() { - // 10 identifier parts, 8 unique - // Repeats: 2 "outline", 2 "items" - let set_a = Occurrences::within_string( - "let mut outline_items = query_outline_items(&language, &tree, &source);", - ); - // 14 identifier parts, 11 unique - // Repeats: 2 "outline", 2 "language", 2 "tree" - let set_b = Occurrences::within_string( - "pub fn query_outline_items(language: &Language, tree: &Tree, source: &str) -> Vec {", - ); - - // 6 overlaps: "outline", "items", "query", "language", "tree", "source" - // 7 non-overlaps: "let", "mut", "pub", "fn", "vec", "item", "str" - assert_eq!(jaccard_similarity(&set_a, &set_b), 6.0 / (6.0 + 7.0)); - - // Numerator is one more than before due to both having 2 "outline". - // Denominator is the same except for 3 more due to the non-overlapping duplicates - assert_eq!( - weighted_jaccard_similarity(&set_a, &set_b), - 7.0 / (7.0 + 7.0 + 3.0) - ); - - // Numerator is the same as jaccard_similarity. Denominator is the size of the smaller set, 8. - assert_eq!(overlap_coefficient(&set_a, &set_b), 6.0 / 8.0); - - // Numerator is the same as weighted_jaccard_similarity. Denominator is the total weight of - // the smaller set, 10. - assert_eq!(weighted_overlap_coefficient(&set_a, &set_b), 7.0 / 10.0); - } -} diff --git a/crates/edit_prediction_types/Cargo.toml b/crates/edit_prediction_types/Cargo.toml new file mode 100644 index 0000000000..00a8577911 --- /dev/null +++ b/crates/edit_prediction_types/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "edit_prediction_types" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/edit_prediction_types.rs" + +[dependencies] +client.workspace = true +gpui.workspace = true +language.workspace = true +text.workspace = true diff --git a/crates/assistant_tools/LICENSE-GPL b/crates/edit_prediction_types/LICENSE-GPL similarity index 100% rename from crates/assistant_tools/LICENSE-GPL rename to crates/edit_prediction_types/LICENSE-GPL diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs new file mode 100644 index 0000000000..945cfea4a1 --- /dev/null +++ b/crates/edit_prediction_types/src/edit_prediction_types.rs @@ -0,0 +1,304 @@ +use std::{ops::Range, sync::Arc}; + +use client::EditPredictionUsage; +use gpui::{App, Context, Entity, SharedString}; +use language::{Anchor, Buffer, OffsetRangeExt}; + +// TODO: Find a better home for `Direction`. +// +// This should live in an ancestor crate of `editor` and `edit_prediction`, +// but at time of writing there isn't an obvious spot. +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum Direction { + Prev, + Next, +} + +#[derive(Clone)] +pub enum EditPrediction { + /// Edits within the buffer that requested the prediction + Local { + id: Option, + edits: Vec<(Range, Arc)>, + edit_preview: Option, + }, + /// Jump to a different file from the one that requested the prediction + Jump { + id: Option, + snapshot: language::BufferSnapshot, + target: language::Anchor, + }, +} + +pub enum DataCollectionState { + /// The provider doesn't support data collection. + Unsupported, + /// Data collection is enabled. + Enabled { is_project_open_source: bool }, + /// Data collection is disabled or unanswered. + Disabled { is_project_open_source: bool }, +} + +impl DataCollectionState { + pub fn is_supported(&self) -> bool { + !matches!(self, DataCollectionState::Unsupported) + } + + pub fn is_enabled(&self) -> bool { + matches!(self, DataCollectionState::Enabled { .. }) + } + + pub fn is_project_open_source(&self) -> bool { + match self { + Self::Enabled { + is_project_open_source, + } + | Self::Disabled { + is_project_open_source, + } => *is_project_open_source, + _ => false, + } + } +} + +pub trait EditPredictionDelegate: 'static + Sized { + fn name() -> &'static str; + fn display_name() -> &'static str; + fn show_predictions_in_menu() -> bool; + fn show_tab_accept_marker() -> bool { + false + } + fn supports_jump_to_edit() -> bool { + true + } + + fn data_collection_state(&self, _cx: &App) -> DataCollectionState { + DataCollectionState::Unsupported + } + + fn usage(&self, _cx: &App) -> Option { + None + } + + fn toggle_data_collection(&mut self, _cx: &mut App) {} + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool; + fn is_refreshing(&self, cx: &App) -> bool; + fn refresh( + &mut self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut Context, + ); + fn cycle( + &mut self, + buffer: Entity, + cursor_position: language::Anchor, + direction: Direction, + cx: &mut Context, + ); + fn accept(&mut self, cx: &mut Context); + fn discard(&mut self, cx: &mut Context); + fn did_show(&mut self, _cx: &mut Context) {} + fn suggest( + &mut self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut Context, + ) -> Option; +} + +pub trait EditPredictionDelegateHandle { + fn name(&self) -> &'static str; + fn display_name(&self) -> &'static str; + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool; + fn show_predictions_in_menu(&self) -> bool; + fn show_tab_accept_marker(&self) -> bool; + fn supports_jump_to_edit(&self) -> bool; + fn data_collection_state(&self, cx: &App) -> DataCollectionState; + fn usage(&self, cx: &App) -> Option; + fn toggle_data_collection(&self, cx: &mut App); + fn is_refreshing(&self, cx: &App) -> bool; + fn refresh( + &self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut App, + ); + fn cycle( + &self, + buffer: Entity, + cursor_position: language::Anchor, + direction: Direction, + cx: &mut App, + ); + fn did_show(&self, cx: &mut App); + fn accept(&self, cx: &mut App); + fn discard(&self, cx: &mut App); + fn suggest( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut App, + ) -> Option; +} + +impl EditPredictionDelegateHandle for Entity +where + T: EditPredictionDelegate, +{ + fn name(&self) -> &'static str { + T::name() + } + + fn display_name(&self) -> &'static str { + T::display_name() + } + + fn show_predictions_in_menu(&self) -> bool { + T::show_predictions_in_menu() + } + + fn show_tab_accept_marker(&self) -> bool { + T::show_tab_accept_marker() + } + + fn supports_jump_to_edit(&self) -> bool { + T::supports_jump_to_edit() + } + + fn data_collection_state(&self, cx: &App) -> DataCollectionState { + self.read(cx).data_collection_state(cx) + } + + fn usage(&self, cx: &App) -> Option { + self.read(cx).usage(cx) + } + + fn toggle_data_collection(&self, cx: &mut App) { + self.update(cx, |this, cx| this.toggle_data_collection(cx)) + } + + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool { + self.read(cx).is_enabled(buffer, cursor_position, cx) + } + + fn is_refreshing(&self, cx: &App) -> bool { + self.read(cx).is_refreshing(cx) + } + + fn refresh( + &self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut App, + ) { + self.update(cx, |this, cx| { + this.refresh(buffer, cursor_position, debounce, cx) + }) + } + + fn cycle( + &self, + buffer: Entity, + cursor_position: language::Anchor, + direction: Direction, + cx: &mut App, + ) { + self.update(cx, |this, cx| { + this.cycle(buffer, cursor_position, direction, cx) + }) + } + + fn accept(&self, cx: &mut App) { + self.update(cx, |this, cx| this.accept(cx)) + } + + fn discard(&self, cx: &mut App) { + self.update(cx, |this, cx| this.discard(cx)) + } + + fn did_show(&self, cx: &mut App) { + self.update(cx, |this, cx| this.did_show(cx)) + } + + fn suggest( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut App, + ) -> Option { + self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditPredictionGranularity { + Word, + Line, + Full, +} +/// Returns edits updated based on user edits since the old snapshot. None is returned if any user +/// edit is not a prefix of a predicted insertion. +pub fn interpolate_edits( + old_snapshot: &text::BufferSnapshot, + new_snapshot: &text::BufferSnapshot, + current_edits: &[(Range, Arc)], +) -> Option, Arc)>> { + let mut edits = Vec::new(); + + let mut model_edits = current_edits.iter().peekable(); + for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { + while let Some((model_old_range, _)) = model_edits.peek() { + let model_old_range = model_old_range.to_offset(old_snapshot); + if model_old_range.end < user_edit.old.start { + let (model_old_range, model_new_text) = model_edits.next().unwrap(); + edits.push((model_old_range.clone(), model_new_text.clone())); + } else { + break; + } + } + + if let Some((model_old_range, model_new_text)) = model_edits.peek() { + let model_old_offset_range = model_old_range.to_offset(old_snapshot); + if user_edit.old == model_old_offset_range { + let user_new_text = new_snapshot + .text_for_range(user_edit.new.clone()) + .collect::(); + + if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { + if !model_suffix.is_empty() { + let anchor = old_snapshot.anchor_after(user_edit.old.end); + edits.push((anchor..anchor, model_suffix.into())); + } + + model_edits.next(); + continue; + } + } + } + + return None; + } + + edits.extend(model_edits.cloned()); + + if edits.is_empty() { None } else { Some(edits) } +} diff --git a/crates/edit_prediction_button/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml similarity index 70% rename from crates/edit_prediction_button/Cargo.toml rename to crates/edit_prediction_ui/Cargo.toml index 07447280fa..b406a45060 100644 --- a/crates/edit_prediction_button/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "edit_prediction_button" +name = "edit_prediction_ui" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,32 +9,45 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/edit_prediction_button.rs" +path = "src/edit_prediction_ui.rs" doctest = false [dependencies] anyhow.workspace = true +buffer_diff.workspace = true +git.workspace = true +log.workspace = true +time.workspace = true client.workspace = true cloud_llm_client.workspace = true +codestral.workspace = true +command_palette_hooks.workspace = true copilot.workspace = true +edit_prediction_types.workspace = true +edit_prediction.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true +futures.workspace = true gpui.workspace = true indoc.workspace = true -edit_prediction.workspace = true language.workspace = true +markdown.workspace = true +menu.workspace = true +multi_buffer.workspace = true paths.workspace = true project.workspace = true regex.workspace = true settings.workspace = true supermaven.workspace = true telemetry.workspace = true +text.workspace = true +theme.workspace = true ui.workspace = true -workspace-hack.workspace = true +util.workspace = true workspace.workspace = true zed_actions.workspace = true -zeta.workspace = true +zeta_prompt.workspace = true [dev-dependencies] copilot = { workspace = true, features = ["test-support"] } diff --git a/crates/cloud_zeta2_prompt/LICENSE-GPL b/crates/edit_prediction_ui/LICENSE-GPL similarity index 100% rename from crates/cloud_zeta2_prompt/LICENSE-GPL rename to crates/edit_prediction_ui/LICENSE-GPL diff --git a/crates/edit_prediction_button/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs similarity index 53% rename from crates/edit_prediction_button/src/edit_prediction_button.rs rename to crates/edit_prediction_ui/src/edit_prediction_button.rs index b2186c6aae..0dcea47720 100644 --- a/crates/edit_prediction_button/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -1,14 +1,21 @@ use anyhow::Result; -use client::{UserStore, zed_urls}; +use client::{Client, UserStore, zed_urls}; use cloud_llm_client::UsageLimit; +use codestral::CodestralEditPredictionDelegate; use copilot::{Copilot, Status}; -use editor::{Editor, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll}; -use feature_flags::{FeatureFlagAppExt, PredictEditsRateCompletionsFeatureFlag}; +use edit_prediction::{ + EditPredictionStore, MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag, +}; +use edit_prediction_types::EditPredictionDelegateHandle; +use editor::{ + Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll, +}; +use feature_flags::FeatureFlagAppExt; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div, - pulsating_between, + ease_in_out, pulsating_between, }; use indoc::indoc; use language::{ @@ -17,7 +24,12 @@ use language::{ }; use project::DisableAiSettings; use regex::Regex; -use settings::{Settings, SettingsStore, update_settings_file}; +use settings::{ + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, + update_settings_file, +}; use std::{ sync::{Arc, LazyLock}, time::Duration, @@ -27,12 +39,16 @@ use ui::{ Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton, IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*, }; +use util::ResultExt as _; use workspace::{ StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, notifications::NotificationId, }; -use zed_actions::OpenBrowser; -use zeta::RateCompletions; +use zed_actions::{OpenBrowser, OpenSettingsAt}; + +use crate::{ + CaptureExample, RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag, +}; actions!( edit_prediction, @@ -42,7 +58,8 @@ actions!( ] ); -const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot"; +const COPILOT_SETTINGS_PATH: &str = "/settings/copilot"; +const COPILOT_SETTINGS_URL: &str = concat!("https://github.com", "/settings/copilot"); const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security"; struct CopilotErrorToast; @@ -54,7 +71,7 @@ pub struct EditPredictionButton { editor_focus_handle: Option, language: Option>, file: Option>, - edit_prediction_provider: Option>, + edit_prediction_provider: Option>, fs: Arc, user_store: Entity, popover_menu_handle: PopoverMenuHandle, @@ -71,17 +88,15 @@ impl Render for EditPredictionButton { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { // Return empty div if AI is disabled if DisableAiSettings::get_global(cx).disable_ai { - return div(); + return div().hidden(); } let all_language_settings = all_language_settings(None, cx); match all_language_settings.edit_predictions.provider { - EditPredictionProvider::None => div(), - EditPredictionProvider::Copilot => { let Some(copilot) = Copilot::global(cx) else { - return div(); + return div().hidden(); }; let status = copilot.read(cx).status(); @@ -122,32 +137,31 @@ impl Render for EditPredictionButton { }); } })) - .tooltip(|window, cx| { - Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx) + .tooltip(|_window, cx| { + Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx) }), ); } - let this = cx.entity(); + let this = cx.weak_entity(); div().child( PopoverMenu::new("copilot") .menu(move |window, cx| { let current_status = Copilot::global(cx)?.read(cx).status(); - Some(match current_status { + match current_status { Status::Authorized => this.update(cx, |this, cx| { this.build_copilot_context_menu(window, cx) }), _ => this.update(cx, |this, cx| { this.build_copilot_start_menu(window, cx) }), - }) + } + .ok() }) .anchor(Corner::BottomRight) .trigger_with_tooltip( IconButton::new("copilot-icon", icon), - |window, cx| { - Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx) - }, + |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx), ) .with_handle(self.popover_menu_handle.clone()), ) @@ -183,7 +197,7 @@ impl Render for EditPredictionButton { let icon = status.to_icon(); let tooltip_text = status.to_tooltip(); let has_menu = status.has_menu(); - let this = cx.entity(); + let this = cx.weak_entity(); let fs = self.fs.clone(); div().child( @@ -193,6 +207,7 @@ impl Render for EditPredictionButton { Some(ContextMenu::build(window, cx, |menu, _, _| { let fs = fs.clone(); let activate_url = activate_url.clone(); + menu.entry("Sign In", None, move |_, cx| { cx.open_url(activate_url.as_str()) }) @@ -209,9 +224,11 @@ impl Render for EditPredictionButton { ) })) } - SupermavenButtonStatus::Ready => Some(this.update(cx, |this, cx| { - this.build_supermaven_context_menu(window, cx) - })), + SupermavenButtonStatus::Ready => this + .update(cx, |this, cx| { + this.build_supermaven_context_menu(window, cx) + }) + .ok(), _ => None, }) .anchor(Corner::BottomRight) @@ -219,12 +236,7 @@ impl Render for EditPredictionButton { IconButton::new("supermaven-icon", icon), move |window, cx| { if has_menu { - Tooltip::for_action( - tooltip_text.clone(), - &ToggleMenu, - window, - cx, - ) + Tooltip::for_action(tooltip_text.clone(), &ToggleMenu, cx) } else { Tooltip::text(tooltip_text.clone())(window, cx) } @@ -234,35 +246,109 @@ impl Render for EditPredictionButton { ) } - EditPredictionProvider::Zed => { + EditPredictionProvider::Codestral => { let enabled = self.editor_enabled.unwrap_or(true); + let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx); + let this = cx.weak_entity(); - let zeta_icon = if enabled { - IconName::ZedPredict + let tooltip_meta = if has_api_key { + "Powered by Codestral" } else { - IconName::ZedPredictDisabled + "Missing API key for Codestral" }; - if zeta::should_show_upsell_modal() { + div().child( + PopoverMenu::new("codestral") + .menu(move |window, cx| { + this.update(cx, |this, cx| { + this.build_codestral_context_menu(window, cx) + }) + .ok() + }) + .anchor(Corner::BottomRight) + .trigger_with_tooltip( + IconButton::new("codestral-icon", IconName::AiMistral) + .shape(IconButtonShape::Square) + .when(!has_api_key, |this| { + this.indicator(Indicator::dot().color(Color::Error)) + .indicator_border_color(Some( + cx.theme().colors().status_bar_background, + )) + }) + .when(has_api_key && !enabled, |this| { + this.indicator(Indicator::dot().color(Color::Ignored)) + .indicator_border_color(Some( + cx.theme().colors().status_bar_background, + )) + }), + move |_window, cx| { + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + tooltip_meta, + cx, + ) + }, + ) + .with_handle(self.popover_menu_handle.clone()), + ) + } + provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => { + let enabled = self.editor_enabled.unwrap_or(true); + + let ep_icon; + let tooltip_meta; + let mut missing_token = false; + + match provider { + EditPredictionProvider::Experimental( + EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, + ) => { + ep_icon = IconName::SweepAi; + tooltip_meta = if missing_token { + "Missing API key for Sweep" + } else { + "Powered by Sweep" + }; + missing_token = edit_prediction::EditPredictionStore::try_global(cx) + .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx)); + } + EditPredictionProvider::Experimental( + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + ) => { + ep_icon = IconName::Inception; + missing_token = edit_prediction::EditPredictionStore::try_global(cx) + .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx)); + tooltip_meta = if missing_token { + "Missing API key for Mercury" + } else { + "Powered by Mercury" + }; + } + _ => { + ep_icon = if enabled { + IconName::ZedPredict + } else { + IconName::ZedPredictDisabled + }; + tooltip_meta = "Powered by Zeta" + } + }; + + if edit_prediction::should_show_upsell_modal() { let tooltip_meta = if self.user_store.read(cx).current_user().is_some() { "Choose a Plan" } else { - "Sign In" + "Sign In To Use" }; return div().child( - IconButton::new("zed-predict-pending-button", zeta_icon) + IconButton::new("zed-predict-pending-button", ep_icon) .shape(IconButtonShape::Square) .indicator(Indicator::dot().color(Color::Muted)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) - .tooltip(move |window, cx| { - Tooltip::with_meta( - "Edit Predictions", - None, - tooltip_meta, - window, - cx, - ) + .tooltip(move |_window, cx| { + Tooltip::with_meta("Edit Predictions", None, tooltip_meta, cx) }) .on_click(cx.listener(move |_, _, window, cx| { telemetry::event!( @@ -288,51 +374,73 @@ impl Render for EditPredictionButton { } let show_editor_predictions = self.editor_show_predictions; + let user = self.user_store.read(cx).current_user(); - let icon_button = IconButton::new("zed-predict-pending-button", zeta_icon) + let indicator_color = if missing_token { + Some(Color::Error) + } else if enabled && (!show_editor_predictions || over_limit) { + Some(if over_limit { + Color::Error + } else { + Color::Muted + }) + } else { + None + }; + + let icon_button = IconButton::new("zed-predict-pending-button", ep_icon) .shape(IconButtonShape::Square) - .when( - enabled && (!show_editor_predictions || over_limit), - |this| { - this.indicator(Indicator::dot().when_else( - over_limit, - |dot| dot.color(Color::Error), - |dot| dot.color(Color::Muted), - )) + .when_some(indicator_color, |this, color| { + this.indicator(Indicator::dot().color(color)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) - }, - ) + }) .when(!self.popover_menu_handle.is_deployed(), |element| { - element.tooltip(move |window, cx| { - if enabled { + let user = user.clone(); + + element.tooltip(move |_window, cx| { + let description = if enabled { if show_editor_predictions { - Tooltip::for_action("Edit Prediction", &ToggleMenu, window, cx) + tooltip_meta + } else if user.is_none() { + "Sign In To Use" } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Hidden For This File", - window, - cx, - ) + "Hidden For This File" } } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Disabled For This File", - window, - cx, - ) - } + "Disabled For This File" + }; + + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + description, + cx, + ) }) }); - let this = cx.entity(); + let this = cx.weak_entity(); - let mut popover_menu = PopoverMenu::new("zeta") - .menu(move |window, cx| { - Some(this.update(cx, |this, cx| this.build_zeta_context_menu(window, cx))) + let mut popover_menu = PopoverMenu::new("edit-prediction") + .when(user.is_some(), |popover_menu| { + let this = this.clone(); + + popover_menu.menu(move |window, cx| { + this.update(cx, |this, cx| { + this.build_edit_prediction_context_menu(provider, window, cx) + }) + .ok() + }) + }) + .when(user.is_none(), |popover_menu| { + let this = this.clone(); + + popover_menu.menu(move |window, cx| { + this.update(cx, |this, cx| { + this.build_zeta_upsell_context_menu(window, cx) + }) + .ok() + }) }) .anchor(Corner::BottomRight) .with_handle(self.popover_menu_handle.clone()); @@ -358,6 +466,8 @@ impl Render for EditPredictionButton { div().child(popover_menu.into_any_element()) } + + EditPredictionProvider::None => div().hidden(), } } } @@ -367,6 +477,7 @@ impl EditPredictionButton { fs: Arc, user_store: Entity, popover_menu_handle: PopoverMenuHandle, + client: Arc, cx: &mut Context, ) -> Self { if let Some(copilot) = Copilot::global(cx) { @@ -376,6 +487,23 @@ impl EditPredictionButton { cx.observe_global::(move |_, cx| cx.notify()) .detach(); + cx.observe_global::(move |_, cx| cx.notify()) + .detach(); + + let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx); + let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx); + + cx.spawn(async move |this, cx| { + _ = futures::join!(sweep_api_token_task, mercury_api_token_task); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }) + .detach(); + + CodestralEditPredictionDelegate::ensure_api_key_loaded(client.http_client(), cx); + Self { editor_subscription: None, editor_enabled: None, @@ -390,6 +518,110 @@ impl EditPredictionButton { } } + fn get_available_providers(&self, cx: &mut App) -> Vec { + let mut providers = Vec::new(); + + providers.push(EditPredictionProvider::Zed); + + if cx.has_flag::() { + providers.push(EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + )); + } + + if let Some(copilot) = Copilot::global(cx) { + if matches!(copilot.read(cx).status(), Status::Authorized) { + providers.push(EditPredictionProvider::Copilot); + } + } + + if let Some(supermaven) = Supermaven::global(cx) { + if let Supermaven::Spawned(agent) = supermaven.read(cx) { + if matches!(agent.account_status, AccountStatus::Ready) { + providers.push(EditPredictionProvider::Supermaven); + } + } + } + + if CodestralEditPredictionDelegate::has_api_key(cx) { + providers.push(EditPredictionProvider::Codestral); + } + + if cx.has_flag::() + && edit_prediction::sweep_ai::sweep_api_token(cx) + .read(cx) + .has_key() + { + providers.push(EditPredictionProvider::Experimental( + EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, + )); + } + + if cx.has_flag::() + && edit_prediction::mercury::mercury_api_token(cx) + .read(cx) + .has_key() + { + providers.push(EditPredictionProvider::Experimental( + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + )); + } + + providers + } + + fn add_provider_switching_section( + &self, + mut menu: ContextMenu, + current_provider: EditPredictionProvider, + cx: &mut App, + ) -> ContextMenu { + let available_providers = self.get_available_providers(cx); + + let providers: Vec<_> = available_providers + .into_iter() + .filter(|p| *p != EditPredictionProvider::None) + .collect(); + + if !providers.is_empty() { + menu = menu.separator().header("Providers"); + + for provider in providers { + let is_current = provider == current_provider; + let fs = self.fs.clone(); + + let name = match provider { + EditPredictionProvider::Zed => "Zed AI", + EditPredictionProvider::Copilot => "GitHub Copilot", + EditPredictionProvider::Supermaven => "Supermaven", + EditPredictionProvider::Codestral => "Codestral", + EditPredictionProvider::Experimental( + EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, + ) => "Sweep", + EditPredictionProvider::Experimental( + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + ) => "Mercury", + EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + ) => "Zeta2", + EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => { + continue; + } + }; + + menu = menu.item( + ContextMenuEntry::new(name) + .toggleable(IconPosition::Start, is_current) + .handler(move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }), + ) + } + } + + menu + } + pub fn build_copilot_start_menu( &mut self, window: &mut Window, @@ -488,13 +720,7 @@ impl EditPredictionButton { let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle); let eager_mode = matches!(current_mode, EditPredictionsMode::Eager); - if matches!( - provider, - EditPredictionProvider::Zed - | EditPredictionProvider::Copilot - | EditPredictionProvider::Supermaven - ) { - menu = menu + menu = menu .separator() .header("Display Modes") .item( @@ -523,102 +749,111 @@ impl EditPredictionButton { } }), ); - } menu = menu.separator().header("Privacy"); - if let Some(provider) = &self.edit_prediction_provider { - let data_collection = provider.data_collection_state(cx); - if data_collection.is_supported() { - let provider = provider.clone(); - let enabled = data_collection.is_enabled(); - let is_open_source = data_collection.is_project_open_source(); - let is_collecting = data_collection.is_enabled(); - let (icon_name, icon_color) = if is_open_source && is_collecting { - (IconName::Check, Color::Success) - } else { - (IconName::Check, Color::Accent) - }; - menu = menu.item( - ContextMenuEntry::new("Training Data Collection") - .toggleable(IconPosition::Start, data_collection.is_enabled()) - .icon(icon_name) - .icon_color(icon_color) - .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { - let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { - (true, true) => ( - "Project identified as open source, and you're sharing data.", - Color::Default, - IconName::Check, - Color::Success, - ), - (true, false) => ( - "Project identified as open source, but you're not sharing data.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, true) => ( - "Project not identified as open source. No data captured.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, false) => ( - "Project not identified as open source, and setting turned off.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - }; - v_flex() - .gap_2() - .child( - Label::new(indoc!{ - "Help us improve our open dataset model by sharing data from open source repositories. \ - Zed must detect a license file in your repo for this setting to take effect. \ - Files with sensitive data and secrets are excluded by default." - }) - ) - .child( - h_flex() - .items_start() - .pt_2() - .pr_1() - .flex_1() - .gap_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) - .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) - ) - .into_any_element() - }) - .handler(move |_, cx| { - provider.toggle_data_collection(cx); + if matches!( + provider, + EditPredictionProvider::Zed + | EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + ) + ) { + if let Some(provider) = &self.edit_prediction_provider { + let data_collection = provider.data_collection_state(cx); - if !enabled { - telemetry::event!( - "Data Collection Enabled", - source = "Edit Prediction Status Menu" - ); - } else { - telemetry::event!( - "Data Collection Disabled", - source = "Edit Prediction Status Menu" - ); - } - }) - ); + if data_collection.is_supported() { + let provider = provider.clone(); + let enabled = data_collection.is_enabled(); + let is_open_source = data_collection.is_project_open_source(); + let is_collecting = data_collection.is_enabled(); + let (icon_name, icon_color) = if is_open_source && is_collecting { + (IconName::Check, Color::Success) + } else { + (IconName::Check, Color::Accent) + }; - if is_collecting && !is_open_source { menu = menu.item( - ContextMenuEntry::new("No data captured.") - .disabled(true) - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::Small), + ContextMenuEntry::new("Training Data Collection") + .toggleable(IconPosition::Start, data_collection.is_enabled()) + .icon(icon_name) + .icon_color(icon_color) + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { + let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { + (true, true) => ( + "Project identified as open source, and you're sharing data.", + Color::Default, + IconName::Check, + Color::Success, + ), + (true, false) => ( + "Project identified as open source, but you're not sharing data.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, true) => ( + "Project not identified as open source. No data captured.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, false) => ( + "Project not identified as open source, and setting turned off.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + }; + v_flex() + .gap_2() + .child( + Label::new(indoc!{ + "Help us improve our open dataset model by sharing data from open source repositories. \ + Zed must detect a license file in your repo for this setting to take effect. \ + Files with sensitive data and secrets are excluded by default." + }) + ) + .child( + h_flex() + .items_start() + .pt_2() + .pr_1() + .flex_1() + .gap_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) + .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) + ) + .into_any_element() + }) + .handler(move |_, cx| { + provider.toggle_data_collection(cx); + + if !enabled { + telemetry::event!( + "Data Collection Enabled", + source = "Edit Prediction Status Menu" + ); + } else { + telemetry::event!( + "Data Collection Disabled", + source = "Edit Prediction Status Menu" + ); + } + }) ); + + if is_collecting && !is_open_source { + menu = menu.item( + ContextMenuEntry::new("No data captured.") + .disabled(true) + .icon(IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::Small), + ); + } } } } @@ -645,7 +880,7 @@ impl EditPredictionButton { } }), ).item( - ContextMenuEntry::new("View Documentation") + ContextMenuEntry::new("View Docs") .icon(IconName::FileGeneric) .icon_color(Color::Muted) .handler(move |_, cx| { @@ -665,6 +900,7 @@ impl EditPredictionButton { if let Some(editor_focus_handle) = self.editor_focus_handle.clone() { menu = menu .separator() + .header("Actions") .entry( "Predict Edit at Cursor", Some(Box::new(ShowEditPrediction)), @@ -675,7 +911,17 @@ impl EditPredictionButton { } }, ) - .context(editor_focus_handle); + .context(editor_focus_handle) + .when( + cx.has_flag::(), + |this| { + this.action( + "Capture Edit Prediction Example", + CaptureExample.boxed_clone(), + ) + .action("Rate Predictions", RatePredictions.boxed_clone()) + }, + ); } menu @@ -686,22 +932,25 @@ impl EditPredictionButton { window: &mut Window, cx: &mut Context, ) -> Entity { + let all_language_settings = all_language_settings(None, cx); + let copilot_config = copilot::copilot_chat::CopilotChatConfiguration { + enterprise_uri: all_language_settings + .edit_predictions + .copilot + .enterprise_uri + .clone(), + }; + let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref()); + ContextMenu::build(window, cx, |menu, window, cx| { - self.build_language_settings_menu(menu, window, cx) - .separator() - .entry("Use Zed AI instead", None, { - let fs = self.fs.clone(); - move |_window, cx| { - set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed) - } - }) - .separator() + let menu = self.build_language_settings_menu(menu, window, cx); + let menu = + self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx); + + menu.separator() .link( "Go to Copilot Settings", - OpenBrowser { - url: COPILOT_SETTINGS_URL.to_string(), - } - .boxed_clone(), + OpenBrowser { url: settings_url }.boxed_clone(), ) .action("Sign Out", copilot::SignOut.boxed_clone()) }) @@ -713,16 +962,34 @@ impl EditPredictionButton { cx: &mut Context, ) -> Entity { ContextMenu::build(window, cx, |menu, window, cx| { - self.build_language_settings_menu(menu, window, cx) - .separator() + let menu = self.build_language_settings_menu(menu, window, cx); + let menu = + self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx); + + menu.separator() .action("Sign Out", supermaven::SignOut.boxed_clone()) }) } - fn build_zeta_context_menu( + fn build_codestral_context_menu( &self, window: &mut Window, cx: &mut Context, + ) -> Entity { + ContextMenu::build(window, cx, |menu, window, cx| { + let menu = self.build_language_settings_menu(menu, window, cx); + let menu = + self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx); + + menu + }) + } + + fn build_edit_prediction_context_menu( + &self, + provider: EditPredictionProvider, + window: &mut Window, + cx: &mut Context, ) -> Entity { ContextMenu::build(window, cx, |mut menu, window, cx| { if let Some(usage) = self @@ -807,10 +1074,99 @@ impl EditPredictionButton { .separator(); } - self.build_language_settings_menu(menu, window, cx).when( - cx.has_flag::(), - |this| this.action("Rate Completions", RateCompletions.boxed_clone()), - ) + menu = self.build_language_settings_menu(menu, window, cx); + + if cx.has_flag::() { + let settings = all_language_settings(None, cx); + let context_retrieval = settings.edit_predictions.use_context; + menu = menu.separator().header("Context Retrieval").item( + ContextMenuEntry::new("Enable Context Retrieval") + .toggleable(IconPosition::Start, context_retrieval) + .action(workspace::ToggleEditPrediction.boxed_clone()) + .handler({ + let fs = self.fs.clone(); + move |_, cx| { + update_settings_file(fs.clone(), cx, move |settings, _| { + settings + .project + .all_languages + .features + .get_or_insert_default() + .experimental_edit_prediction_context_retrieval = + Some(!context_retrieval) + }); + } + }), + ); + } + + menu = self.add_provider_switching_section(menu, provider, cx); + menu = menu.separator().item( + ContextMenuEntry::new("Configure Providers") + .icon(IconName::Settings) + .icon_position(IconPosition::Start) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + OpenSettingsAt { + path: "edit_predictions.providers".to_string(), + } + .boxed_clone(), + cx, + ); + }), + ); + + menu + }) + } + + fn build_zeta_upsell_context_menu( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + ContextMenu::build(window, cx, |mut menu, _window, cx| { + menu = menu + .custom_row(move |_window, cx| { + let description = indoc! { + "You get 2,000 accepted suggestions at every keystroke for free, \ + powered by Zeta, our open-source, open-data model" + }; + + v_flex() + .max_w_64() + .h(rems_from_px(148.)) + .child(render_zeta_tab_animation(cx)) + .child(Label::new("Edit Prediction")) + .child( + Label::new(description) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + }) + .separator() + .entry("Sign In & Start Using", None, |window, cx| { + let client = Client::global(cx); + window + .spawn(cx, async move |cx| { + client + .sign_in_with_optional_connect(true, &cx) + .await + .log_err(); + }) + .detach(); + }) + .link( + "Learn More", + OpenBrowser { + url: zed_urls::edit_prediction_docs(cx), + } + .boxed_clone(), + ); + + menu }) } @@ -921,7 +1277,12 @@ async fn open_disabled_globs_setting_in_editor( }); if !edits.is_empty() { - item.edit(edits, cx); + item.edit( + edits + .into_iter() + .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)), + cx, + ); } let text = item.buffer().read(cx).snapshot(cx).text(); @@ -936,6 +1297,7 @@ async fn open_disabled_globs_setting_in_editor( .map(|inner_match| inner_match.start()..inner_match.end()) }); if let Some(range) = range { + let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end); item.change_selections( SelectionEffects::scroll(Autoscroll::newest()), window, @@ -1010,3 +1372,166 @@ fn toggle_edit_prediction_mode(fs: Arc, mode: EditPredictionsMode, cx: & }); } } + +fn render_zeta_tab_animation(cx: &App) -> impl IntoElement { + let tab = |n: u64, inverted: bool| { + let text_color = cx.theme().colors().text; + + h_flex().child( + h_flex() + .text_size(TextSize::XSmall.rems(cx)) + .text_color(text_color) + .child("tab") + .with_animation( + ElementId::Integer(n), + Animation::new(Duration::from_secs(3)).repeat(), + move |tab, delta| { + let n_f32 = n as f32; + + let offset = if inverted { + 0.2 * (4.0 - n_f32) + } else { + 0.2 * n_f32 + }; + + let phase = (delta - offset + 1.0) % 1.0; + let pulse = if phase < 0.6 { + let t = phase / 0.6; + 1.0 - (0.5 - t).abs() * 2.0 + } else { + 0.0 + }; + + let eased = ease_in_out(pulse); + let opacity = 0.1 + 0.5 * eased; + + tab.text_color(text_color.opacity(opacity)) + }, + ), + ) + }; + + let tab_sequence = |inverted: bool| { + h_flex() + .gap_1() + .child(tab(0, inverted)) + .child(tab(1, inverted)) + .child(tab(2, inverted)) + .child(tab(3, inverted)) + .child(tab(4, inverted)) + }; + + h_flex() + .my_1p5() + .p_4() + .justify_center() + .gap_2() + .rounded_xs() + .border_1() + .border_dashed() + .border_color(cx.theme().colors().border) + .bg(gpui::pattern_slash( + cx.theme().colors().border.opacity(0.5), + 1., + 8., + )) + .child(tab_sequence(true)) + .child(Icon::new(IconName::ZedPredict)) + .child(tab_sequence(false)) +} + +fn copilot_settings_url(enterprise_uri: Option<&str>) -> String { + match enterprise_uri { + Some(uri) => { + format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH) + } + None => COPILOT_SETTINGS_URL.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + cx.update_global(|settings_store: &mut SettingsStore, cx| { + settings_store + .set_user_settings( + r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#, + cx, + ) + .unwrap(); + }); + + let url = cx.update(|cx| { + let all_language_settings = all_language_settings(None, cx); + copilot_settings_url( + all_language_settings + .edit_predictions + .copilot + .enterprise_uri + .as_deref(), + ) + }); + + assert_eq!(url, "https://my-company.ghe.com/settings/copilot"); + } + + #[gpui::test] + async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + cx.update_global(|settings_store: &mut SettingsStore, cx| { + settings_store + .set_user_settings( + r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#, + cx, + ) + .unwrap(); + }); + + let url = cx.update(|cx| { + let all_language_settings = all_language_settings(None, cx); + copilot_settings_url( + all_language_settings + .edit_predictions + .copilot + .enterprise_uri + .as_deref(), + ) + }); + + assert_eq!(url, "https://my-company.ghe.com/settings/copilot"); + } + + #[gpui::test] + async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + let url = cx.update(|cx| { + let all_language_settings = all_language_settings(None, cx); + copilot_settings_url( + all_language_settings + .edit_predictions + .copilot + .enterprise_uri + .as_deref(), + ) + }); + + assert_eq!(url, "https://github.com/settings/copilot"); + } +} diff --git a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs new file mode 100644 index 0000000000..92d66d2bec --- /dev/null +++ b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs @@ -0,0 +1,370 @@ +use std::{ + any::TypeId, + collections::VecDeque, + ops::Add, + sync::Arc, + time::{Duration, Instant}, +}; + +use anyhow::Result; +use client::{Client, UserStore}; +use editor::{Editor, PathKey}; +use futures::StreamExt as _; +use gpui::{ + Animation, AnimationExt, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement as _, IntoElement as _, ParentElement as _, SharedString, + Styled as _, Task, TextAlign, Window, actions, div, pulsating_between, +}; +use multi_buffer::MultiBuffer; +use project::Project; +use text::Point; +use ui::{ + ButtonCommon, Clickable, Disableable, FluentBuilder as _, IconButton, IconName, + StyledTypography as _, h_flex, v_flex, +}; + +use edit_prediction::{ + ContextRetrievalFinishedDebugEvent, ContextRetrievalStartedDebugEvent, DebugEvent, + EditPredictionStore, +}; +use workspace::Item; + +pub struct EditPredictionContextView { + empty_focus_handle: FocusHandle, + project: Entity, + store: Entity, + runs: VecDeque, + current_ix: usize, + _update_task: Task>, +} + +#[derive(Debug)] +struct RetrievalRun { + editor: Entity, + started_at: Instant, + metadata: Vec<(&'static str, SharedString)>, + finished_at: Option, +} + +actions!( + dev, + [ + /// Go to the previous context retrieval run + EditPredictionContextGoBack, + /// Go to the next context retrieval run + EditPredictionContextGoForward + ] +); + +impl EditPredictionContextView { + pub fn new( + project: Entity, + client: &Arc, + user_store: &Entity, + window: &mut gpui::Window, + cx: &mut Context, + ) -> Self { + let store = EditPredictionStore::global(client, user_store, cx); + + let mut debug_rx = store.update(cx, |store, cx| store.debug_info(&project, cx)); + let _update_task = cx.spawn_in(window, async move |this, cx| { + while let Some(event) = debug_rx.next().await { + this.update_in(cx, |this, window, cx| { + this.handle_store_event(event, window, cx) + })?; + } + Ok(()) + }); + + Self { + empty_focus_handle: cx.focus_handle(), + project, + runs: VecDeque::new(), + current_ix: 0, + store, + _update_task, + } + } + + fn handle_store_event( + &mut self, + event: DebugEvent, + window: &mut gpui::Window, + cx: &mut Context, + ) { + match event { + DebugEvent::ContextRetrievalStarted(info) => { + if info.project_entity_id == self.project.entity_id() { + self.handle_context_retrieval_started(info, window, cx); + } + } + DebugEvent::ContextRetrievalFinished(info) => { + if info.project_entity_id == self.project.entity_id() { + self.handle_context_retrieval_finished(info, window, cx); + } + } + DebugEvent::EditPredictionStarted(_) => {} + DebugEvent::EditPredictionFinished(_) => {} + } + } + + fn handle_context_retrieval_started( + &mut self, + info: ContextRetrievalStartedDebugEvent, + window: &mut Window, + cx: &mut Context, + ) { + if self + .runs + .back() + .is_some_and(|run| run.finished_at.is_none()) + { + self.runs.pop_back(); + } + + let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly)); + let editor = cx + .new(|cx| Editor::for_multibuffer(multibuffer, Some(self.project.clone()), window, cx)); + + if self.runs.len() == 32 { + self.runs.pop_front(); + } + + self.runs.push_back(RetrievalRun { + editor, + started_at: info.timestamp, + finished_at: None, + metadata: Vec::new(), + }); + + cx.notify(); + } + + fn handle_context_retrieval_finished( + &mut self, + info: ContextRetrievalFinishedDebugEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(run) = self.runs.back_mut() else { + return; + }; + + run.finished_at = Some(info.timestamp); + run.metadata = info.metadata; + + let related_files = self + .store + .read(cx) + .context_for_project_with_buffers(&self.project, cx) + .map_or(Vec::new(), |files| files.collect()); + + let editor = run.editor.clone(); + let multibuffer = run.editor.read(cx).buffer().clone(); + + if self.current_ix + 2 == self.runs.len() { + self.current_ix += 1; + } + + cx.spawn_in(window, async move |this, cx| { + let mut paths = Vec::new(); + for (related_file, buffer) in related_files { + let point_ranges = related_file + .excerpts + .iter() + .map(|excerpt| { + Point::new(excerpt.row_range.start, 0)..Point::new(excerpt.row_range.end, 0) + }) + .collect::>(); + cx.update(|_, cx| { + let path = PathKey::for_buffer(&buffer, cx); + paths.push((path, buffer, point_ranges)); + })?; + } + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.clear(cx); + + for (path, buffer, ranges) in paths { + multibuffer.set_excerpts_for_path(path, buffer, ranges, 0, cx); + } + })?; + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + })?; + + this.update(cx, |_, cx| cx.notify()) + }) + .detach(); + } + + fn handle_go_back( + &mut self, + _: &EditPredictionContextGoBack, + window: &mut Window, + cx: &mut Context, + ) { + self.current_ix = self.current_ix.saturating_sub(1); + cx.focus_self(window); + cx.notify(); + } + + fn handle_go_forward( + &mut self, + _: &EditPredictionContextGoForward, + window: &mut Window, + cx: &mut Context, + ) { + self.current_ix = self + .current_ix + .add(1) + .min(self.runs.len().saturating_sub(1)); + cx.focus_self(window); + cx.notify(); + } + + fn render_informational_footer( + &self, + cx: &mut Context<'_, EditPredictionContextView>, + ) -> ui::Div { + let run = &self.runs[self.current_ix]; + let new_run_started = self + .runs + .back() + .map_or(false, |latest_run| latest_run.finished_at.is_none()); + + h_flex() + .p_2() + .w_full() + .font_buffer(cx) + .text_xs() + .border_t_1() + .gap_2() + .child(v_flex().h_full().flex_1().child({ + let t0 = run.started_at; + let mut table = ui::Table::<2>::new().width(ui::px(300.)).no_ui_font(); + for (key, value) in &run.metadata { + table = table.row([key.into_any_element(), value.clone().into_any_element()]) + } + table = table.row([ + "Total Time".into_any_element(), + format!("{} ms", (run.finished_at.unwrap_or(t0) - t0).as_millis()) + .into_any_element(), + ]); + table + })) + .child( + v_flex().h_full().text_align(TextAlign::Right).child( + h_flex() + .justify_end() + .child( + IconButton::new("go-back", IconName::ChevronLeft) + .disabled(self.current_ix == 0 || self.runs.len() < 2) + .tooltip(ui::Tooltip::for_action_title( + "Go to previous run", + &EditPredictionContextGoBack, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_go_back(&EditPredictionContextGoBack, window, cx); + })), + ) + .child( + div() + .child(format!("{}/{}", self.current_ix + 1, self.runs.len())) + .map(|this| { + if new_run_started { + this.with_animation( + "pulsating-count", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any_element() + } else { + this.into_any_element() + } + }), + ) + .child( + IconButton::new("go-forward", IconName::ChevronRight) + .disabled(self.current_ix + 1 == self.runs.len()) + .tooltip(ui::Tooltip::for_action_title( + "Go to next run", + &EditPredictionContextGoBack, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_go_forward( + &EditPredictionContextGoForward, + window, + cx, + ); + })), + ), + ), + ) + } +} + +impl Focusable for EditPredictionContextView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.runs + .get(self.current_ix) + .map(|run| run.editor.read(cx).focus_handle(cx)) + .unwrap_or_else(|| self.empty_focus_handle.clone()) + } +} + +impl EventEmitter<()> for EditPredictionContextView {} + +impl Item for EditPredictionContextView { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Edit Prediction Context".into() + } + + fn buffer_kind(&self, _cx: &App) -> workspace::item::ItemBufferKind { + workspace::item::ItemBufferKind::Multibuffer + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.runs.get(self.current_ix)?.editor.clone().into()) + } else { + None + } + } +} + +impl gpui::Render for EditPredictionContextView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { + v_flex() + .key_context("EditPredictionContext") + .on_action(cx.listener(Self::handle_go_back)) + .on_action(cx.listener(Self::handle_go_forward)) + .size_full() + .map(|this| { + if self.runs.is_empty() { + this.child( + v_flex() + .size_full() + .justify_center() + .items_center() + .child("No retrieval runs yet"), + ) + } else { + this.child(self.runs[self.current_ix].editor.clone()) + .child(self.render_informational_footer(cx)) + } + }) + } +} diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs new file mode 100644 index 0000000000..a762fd22aa --- /dev/null +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -0,0 +1,330 @@ +mod edit_prediction_button; +mod edit_prediction_context_view; +mod rate_prediction_modal; + +use std::any::{Any as _, TypeId}; +use std::path::Path; +use std::sync::Arc; + +use command_palette_hooks::CommandPaletteFilter; +use edit_prediction::{ + EditPredictionStore, ResetOnboarding, Zeta2FeatureFlag, example_spec::ExampleSpec, +}; +use edit_prediction_context_view::EditPredictionContextView; +use editor::Editor; +use feature_flags::FeatureFlagAppExt as _; +use git::repository::DiffType; +use gpui::{Window, actions}; +use language::ToPoint as _; +use log; +use project::DisableAiSettings; +use rate_prediction_modal::RatePredictionsModal; +use settings::{Settings as _, SettingsStore}; +use text::ToOffset as _; +use ui::{App, prelude::*}; +use workspace::{SplitDirection, Workspace}; + +pub use edit_prediction_button::{EditPredictionButton, ToggleMenu}; + +use crate::rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag; + +actions!( + dev, + [ + /// Opens the edit prediction context view. + OpenEditPredictionContextView, + ] +); + +actions!( + edit_prediction, + [ + /// Opens the rate completions modal. + RatePredictions, + /// Captures an ExampleSpec from the current editing session and opens it as Markdown. + CaptureExample, + ] +); + +pub fn init(cx: &mut App) { + feature_gate_predict_edits_actions(cx); + + cx.observe_new(move |workspace: &mut Workspace, _, _cx| { + workspace.register_action(|workspace, _: &RatePredictions, window, cx| { + if cx.has_flag::() { + RatePredictionsModal::toggle(workspace, window, cx); + } + }); + + workspace.register_action(capture_edit_prediction_example); + workspace.register_action_renderer(|div, _, _, cx| { + let has_flag = cx.has_flag::(); + div.when(has_flag, |div| { + div.on_action(cx.listener( + move |workspace, _: &OpenEditPredictionContextView, window, cx| { + let project = workspace.project(); + workspace.split_item( + SplitDirection::Right, + Box::new(cx.new(|cx| { + EditPredictionContextView::new( + project.clone(), + workspace.client(), + workspace.user_store(), + window, + cx, + ) + })), + window, + cx, + ); + }, + )) + }) + }); + }) + .detach(); +} + +fn feature_gate_predict_edits_actions(cx: &mut App) { + let rate_completion_action_types = [TypeId::of::()]; + let reset_onboarding_action_types = [TypeId::of::()]; + let all_action_types = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + zed_actions::OpenZedPredictOnboarding.type_id(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&rate_completion_action_types); + filter.hide_action_types(&reset_onboarding_action_types); + filter.hide_action_types(&[zed_actions::OpenZedPredictOnboarding.type_id()]); + }); + + cx.observe_global::(move |cx| { + let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; + let has_feature_flag = cx.has_flag::(); + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + if is_ai_disabled { + filter.hide_action_types(&all_action_types); + } else if has_feature_flag { + filter.show_action_types(&rate_completion_action_types); + } else { + filter.hide_action_types(&rate_completion_action_types); + } + }); + }) + .detach(); + + cx.observe_flag::(move |is_enabled, cx| { + if !DisableAiSettings::get_global(cx).disable_ai { + if is_enabled { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.show_action_types(&rate_completion_action_types); + }); + } else { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&rate_completion_action_types); + }); + } + } + }) + .detach(); +} + +fn capture_edit_prediction_example( + workspace: &mut Workspace, + _: &CaptureExample, + window: &mut Window, + cx: &mut Context, +) { + let Some(ep_store) = EditPredictionStore::try_global(cx) else { + return; + }; + + let project = workspace.project().clone(); + + let (worktree_root, repository) = { + let project_ref = project.read(cx); + let worktree_root = project_ref + .visible_worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path()); + let repository = project_ref.active_repository(cx); + (worktree_root, repository) + }; + + let (Some(worktree_root), Some(repository)) = (worktree_root, repository) else { + log::error!("CaptureExampleSpec: missing worktree or active repository"); + return; + }; + + let repository_snapshot = repository.read(cx).snapshot(); + if worktree_root.as_ref() != repository_snapshot.work_directory_abs_path.as_ref() { + log::error!( + "repository is not at worktree root (repo={:?}, worktree={:?})", + repository_snapshot.work_directory_abs_path, + worktree_root + ); + return; + } + + let Some(repository_url) = repository_snapshot + .remote_origin_url + .clone() + .or_else(|| repository_snapshot.remote_upstream_url.clone()) + else { + log::error!("active repository has no origin/upstream remote url"); + return; + }; + + let Some(revision) = repository_snapshot + .head_commit + .as_ref() + .map(|commit| commit.sha.to_string()) + else { + log::error!("active repository has no head commit"); + return; + }; + + let mut events = ep_store.update(cx, |store, cx| { + store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + + let Some(editor) = workspace.active_item_as::(cx) else { + log::error!("no active editor"); + return; + }; + + let Some(project_path) = editor.read(cx).project_path(cx) else { + log::error!("active editor has no project path"); + return; + }; + + let Some((buffer, cursor_anchor)) = editor + .read(cx) + .buffer() + .read(cx) + .text_anchor_for_position(editor.read(cx).selections.newest_anchor().head(), cx) + else { + log::error!("failed to resolve cursor buffer/anchor"); + return; + }; + + let snapshot = buffer.read(cx).snapshot(); + let cursor_point = cursor_anchor.to_point(&snapshot); + let (_editable_range, context_range) = + edit_prediction::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + &snapshot, + 100, + 50, + ); + + let cursor_path: Arc = repository + .read(cx) + .project_path_to_repo_path(&project_path, cx) + .map(|repo_path| Path::new(repo_path.as_unix_str()).into()) + .unwrap_or_else(|| Path::new(project_path.path.as_unix_str()).into()); + + let cursor_position = { + let context_start_offset = context_range.start.to_offset(&snapshot); + let cursor_offset = cursor_anchor.to_offset(&snapshot); + let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset); + let mut excerpt = snapshot.text_for_range(context_range).collect::(); + if cursor_offset_in_excerpt <= excerpt.len() { + excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER); + } + excerpt + }; + + let markdown_language = workspace + .app_state() + .languages + .language_for_name("Markdown"); + + cx.spawn_in(window, async move |workspace_entity, cx| { + let markdown_language = markdown_language.await?; + + let uncommitted_diff_rx = repository.update(cx, |repository, cx| { + repository.diff(DiffType::HeadToWorktree, cx) + })?; + + let uncommitted_diff = match uncommitted_diff_rx.await { + Ok(Ok(diff)) => diff, + Ok(Err(error)) => { + log::error!("failed to compute uncommitted diff: {error:#}"); + return Ok(()); + } + Err(error) => { + log::error!("uncommitted diff channel dropped: {error:#}"); + return Ok(()); + } + }; + + let mut edit_history = String::new(); + let mut expected_patch = String::new(); + if let Some(last_event) = events.pop() { + for event in &events { + zeta_prompt::write_event(&mut edit_history, event); + if !edit_history.ends_with('\n') { + edit_history.push('\n'); + } + edit_history.push('\n'); + } + + zeta_prompt::write_event(&mut expected_patch, &last_event); + } + + let format = + time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]"); + let name = match format { + Ok(format) => { + let now = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + now.format(&format) + .unwrap_or_else(|_| "unknown-time".to_string()) + } + Err(_) => "unknown-time".to_string(), + }; + + let markdown = ExampleSpec { + name, + repository_url, + revision, + uncommitted_diff, + cursor_path, + cursor_position, + edit_history, + expected_patch, + } + .to_markdown(); + + let buffer = project + .update(cx, |project, cx| project.create_buffer(false, cx))? + .await?; + buffer.update(cx, |buffer, cx| { + buffer.set_text(markdown, cx); + buffer.set_language(Some(markdown_language), cx); + })?; + + workspace_entity.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new( + cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)), + ), + None, + true, + window, + cx, + ); + }) + }) + .detach_and_log_err(cx); +} diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs new file mode 100644 index 0000000000..22e82bc445 --- /dev/null +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -0,0 +1,905 @@ +use buffer_diff::{BufferDiff, BufferDiffSnapshot}; +use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore}; +use editor::{Editor, ExcerptRange, MultiBuffer}; +use feature_flags::FeatureFlag; +use gpui::{ + App, BorderStyle, DismissEvent, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, + Length, StyleRefinement, TextStyleRefinement, Window, actions, prelude::*, +}; +use language::{LanguageRegistry, Point, language_settings}; +use markdown::{Markdown, MarkdownStyle}; +use settings::Settings as _; +use std::{fmt::Write, sync::Arc, time::Duration}; +use theme::ThemeSettings; +use ui::{KeyBinding, List, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use workspace::{ModalView, Workspace}; + +actions!( + zeta, + [ + /// Rates the active completion with a thumbs up. + ThumbsUpActivePrediction, + /// Rates the active completion with a thumbs down. + ThumbsDownActivePrediction, + /// Navigates to the next edit in the completion history. + NextEdit, + /// Navigates to the previous edit in the completion history. + PreviousEdit, + /// Focuses on the completions list. + FocusPredictions, + /// Previews the selected completion. + PreviewPrediction, + ] +); + +pub struct PredictEditsRatePredictionsFeatureFlag; + +impl FeatureFlag for PredictEditsRatePredictionsFeatureFlag { + const NAME: &'static str = "predict-edits-rate-completions"; +} + +pub struct RatePredictionsModal { + ep_store: Entity, + language_registry: Arc, + active_prediction: Option, + selected_index: usize, + diff_editor: Entity, + focus_handle: FocusHandle, + _subscription: gpui::Subscription, + current_view: RatePredictionView, +} + +struct ActivePrediction { + prediction: EditPrediction, + feedback_editor: Entity, + formatted_inputs: Entity, +} + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +enum RatePredictionView { + SuggestedEdits, + RawInput, +} + +impl RatePredictionView { + pub fn name(&self) -> &'static str { + match self { + Self::SuggestedEdits => "Suggested Edits", + Self::RawInput => "Recorded Events & Input", + } + } +} + +impl RatePredictionsModal { + pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + if let Some(ep_store) = EditPredictionStore::try_global(cx) { + let language_registry = workspace.app_state().languages.clone(); + workspace.toggle_modal(window, cx, |window, cx| { + RatePredictionsModal::new(ep_store, language_registry, window, cx) + }); + + telemetry::event!("Rate Prediction Modal Open", source = "Edit Prediction"); + } + } + + pub fn new( + ep_store: Entity, + language_registry: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let subscription = cx.observe(&ep_store, |_, _, cx| cx.notify()); + + Self { + ep_store, + language_registry, + selected_index: 0, + focus_handle: cx.focus_handle(), + active_prediction: None, + _subscription: subscription, + diff_editor: cx.new(|cx| { + let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly)); + let mut editor = Editor::for_multibuffer(multibuffer, None, window, cx); + editor.disable_inline_diagnostics(); + editor.set_expand_all_diff_hunks(cx); + editor.set_show_git_diff_gutter(false, cx); + editor + }), + current_view: RatePredictionView::SuggestedEdits, + } + } + + fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context) { + self.selected_index += 1; + self.selected_index = usize::min( + self.selected_index, + self.ep_store.read(cx).shown_predictions().count(), + ); + cx.notify(); + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _: &mut Window, + cx: &mut Context, + ) { + self.selected_index = self.selected_index.saturating_sub(1); + cx.notify(); + } + + fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) { + let next_index = self + .ep_store + .read(cx) + .shown_predictions() + .skip(self.selected_index) + .enumerate() + .skip(1) // Skip straight to the next item + .find(|(_, completion)| !completion.edits.is_empty()) + .map(|(ix, _)| ix + self.selected_index); + + if let Some(next_index) = next_index { + self.selected_index = next_index; + cx.notify(); + } + } + + fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context) { + let ep_store = self.ep_store.read(cx); + let completions_len = ep_store.shown_completions_len(); + + let prev_index = self + .ep_store + .read(cx) + .shown_predictions() + .rev() + .skip((completions_len - 1) - self.selected_index) + .enumerate() + .skip(1) // Skip straight to the previous item + .find(|(_, completion)| !completion.edits.is_empty()) + .map(|(ix, _)| self.selected_index - ix); + + if let Some(prev_index) = prev_index { + self.selected_index = prev_index; + cx.notify(); + } + cx.notify(); + } + + fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context) { + self.selected_index = 0; + cx.notify(); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.selected_index = self.ep_store.read(cx).shown_completions_len() - 1; + cx.notify(); + } + + pub fn thumbs_up_active( + &mut self, + _: &ThumbsUpActivePrediction, + window: &mut Window, + cx: &mut Context, + ) { + self.ep_store.update(cx, |ep_store, cx| { + if let Some(active) = &self.active_prediction { + ep_store.rate_prediction( + &active.prediction, + EditPredictionRating::Positive, + active.feedback_editor.read(cx).text(cx), + cx, + ); + } + }); + + let current_completion = self + .active_prediction + .as_ref() + .map(|completion| completion.prediction.clone()); + self.select_completion(current_completion, false, window, cx); + self.select_next_edit(&Default::default(), window, cx); + self.confirm(&Default::default(), window, cx); + + cx.notify(); + } + + pub fn thumbs_down_active( + &mut self, + _: &ThumbsDownActivePrediction, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(active) = &self.active_prediction { + if active.feedback_editor.read(cx).text(cx).is_empty() { + return; + } + + self.ep_store.update(cx, |ep_store, cx| { + ep_store.rate_prediction( + &active.prediction, + EditPredictionRating::Negative, + active.feedback_editor.read(cx).text(cx), + cx, + ); + }); + } + + let current_completion = self + .active_prediction + .as_ref() + .map(|completion| completion.prediction.clone()); + self.select_completion(current_completion, false, window, cx); + self.select_next_edit(&Default::default(), window, cx); + self.confirm(&Default::default(), window, cx); + + cx.notify(); + } + + fn focus_completions( + &mut self, + _: &FocusPredictions, + window: &mut Window, + cx: &mut Context, + ) { + cx.focus_self(window); + cx.notify(); + } + + fn preview_completion( + &mut self, + _: &PreviewPrediction, + window: &mut Window, + cx: &mut Context, + ) { + let completion = self + .ep_store + .read(cx) + .shown_predictions() + .skip(self.selected_index) + .take(1) + .next() + .cloned(); + + self.select_completion(completion, false, window, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + let completion = self + .ep_store + .read(cx) + .shown_predictions() + .skip(self.selected_index) + .take(1) + .next() + .cloned(); + + self.select_completion(completion, true, window, cx); + } + + pub fn select_completion( + &mut self, + prediction: Option, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + // Avoid resetting completion rating if it's already selected. + if let Some(prediction) = prediction { + self.selected_index = self + .ep_store + .read(cx) + .shown_predictions() + .enumerate() + .find(|(_, completion_b)| prediction.id == completion_b.id) + .map(|(ix, _)| ix) + .unwrap_or(self.selected_index); + cx.notify(); + + if let Some(prev_prediction) = self.active_prediction.as_ref() + && prediction.id == prev_prediction.prediction.id + { + if focus { + window.focus(&prev_prediction.feedback_editor.focus_handle(cx)); + } + return; + } + + self.diff_editor.update(cx, |editor, cx| { + let new_buffer = prediction.edit_preview.build_result_buffer(cx); + let new_buffer_snapshot = new_buffer.read(cx).snapshot(); + let old_buffer_snapshot = prediction.snapshot.clone(); + let new_buffer_id = new_buffer_snapshot.remote_id(); + + let range = prediction + .edit_preview + .compute_visible_range(&prediction.edits) + .unwrap_or(Point::zero()..Point::zero()); + let start = Point::new(range.start.row.saturating_sub(5), 0); + let end = Point::new(range.end.row + 5, 0).min(new_buffer_snapshot.max_point()); + + let diff = cx.new::(|cx| { + let diff_snapshot = BufferDiffSnapshot::new_with_base_buffer( + new_buffer_snapshot.text.clone(), + Some(old_buffer_snapshot.text().into()), + old_buffer_snapshot.clone(), + cx, + ); + let diff = BufferDiff::new(&new_buffer_snapshot, cx); + cx.spawn(async move |diff, cx| { + let diff_snapshot = diff_snapshot.await; + diff.update(cx, |diff, cx| { + diff.set_snapshot(diff_snapshot, &new_buffer_snapshot.text, cx); + }) + }) + .detach(); + diff + }); + + editor.disable_header_for_buffer(new_buffer_id, cx); + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.clear(cx); + multibuffer.push_excerpts( + new_buffer, + vec![ExcerptRange { + context: start..end, + primary: start..end, + }], + cx, + ); + multibuffer.add_diff(diff, cx); + }); + }); + + let mut formatted_inputs = String::new(); + + write!(&mut formatted_inputs, "## Events\n\n").unwrap(); + + for event in &prediction.inputs.events { + formatted_inputs.push_str("```diff\n"); + zeta_prompt::write_event(&mut formatted_inputs, event.as_ref()); + formatted_inputs.push_str("```\n\n"); + } + + write!(&mut formatted_inputs, "## Related files\n\n").unwrap(); + + for included_file in prediction.inputs.related_files.as_ref() { + write!( + &mut formatted_inputs, + "### {}\n\n", + included_file.path.display() + ) + .unwrap(); + + for excerpt in included_file.excerpts.iter() { + write!( + &mut formatted_inputs, + "```{}\n{}\n```\n", + included_file.path.display(), + excerpt.text + ) + .unwrap(); + } + } + + write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap(); + + writeln!( + &mut formatted_inputs, + "```{}\n{}{}\n```\n", + prediction.inputs.cursor_path.display(), + &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt], + &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..], + ) + .unwrap(); + + self.active_prediction = Some(ActivePrediction { + prediction, + feedback_editor: cx.new(|cx| { + let mut editor = Editor::multi_line(window, cx); + editor.disable_scrollbars_and_minimap(window, cx); + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor.set_show_line_numbers(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_runnables(false, cx); + editor.set_show_breakpoints(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_show_edit_predictions(Some(false), window, cx); + editor.set_placeholder_text("Add your feedback…", window, cx); + if focus { + cx.focus_self(window); + } + editor + }), + formatted_inputs: cx.new(|cx| { + Markdown::new( + formatted_inputs.into(), + Some(self.language_registry.clone()), + None, + cx, + ) + }), + }); + } else { + self.active_prediction = None; + } + + cx.notify(); + } + + fn render_view_nav(&self, cx: &Context) -> impl IntoElement { + h_flex() + .h_8() + .px_1() + .border_b_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .gap_1() + .child( + Button::new( + ElementId::Name("suggested-edits".into()), + RatePredictionView::SuggestedEdits.name(), + ) + .label_size(LabelSize::Small) + .on_click(cx.listener(move |this, _, _window, cx| { + this.current_view = RatePredictionView::SuggestedEdits; + cx.notify(); + })) + .toggle_state(self.current_view == RatePredictionView::SuggestedEdits), + ) + .child( + Button::new( + ElementId::Name("raw-input".into()), + RatePredictionView::RawInput.name(), + ) + .label_size(LabelSize::Small) + .on_click(cx.listener(move |this, _, _window, cx| { + this.current_view = RatePredictionView::RawInput; + cx.notify(); + })) + .toggle_state(self.current_view == RatePredictionView::RawInput), + ) + } + + fn render_suggested_edits(&self, cx: &mut Context) -> Option> { + let bg_color = cx.theme().colors().editor_background; + Some( + div() + .id("diff") + .p_4() + .size_full() + .bg(bg_color) + .overflow_scroll() + .whitespace_nowrap() + .child(self.diff_editor.clone()), + ) + } + + fn render_raw_input( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let theme_settings = ThemeSettings::get_global(cx); + let buffer_font_size = theme_settings.buffer_font_size(cx); + + Some( + v_flex() + .size_full() + .overflow_hidden() + .relative() + .child( + div() + .id("raw-input") + .py_4() + .px_6() + .size_full() + .bg(cx.theme().colors().editor_background) + .overflow_scroll() + .child(if let Some(active_prediction) = &self.active_prediction { + markdown::MarkdownElement::new( + active_prediction.formatted_inputs.clone(), + MarkdownStyle { + base_text_style: window.text_style(), + syntax: cx.theme().syntax().clone(), + code_block: StyleRefinement { + text: TextStyleRefinement { + font_family: Some( + theme_settings.buffer_font.family.clone(), + ), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }, + padding: EdgesRefinement { + top: Some(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(8.)), + )), + left: Some(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(8.)), + )), + right: Some(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(8.)), + )), + bottom: Some(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(8.)), + )), + }, + margin: EdgesRefinement { + top: Some(Length::Definite(px(8.).into())), + left: Some(Length::Definite(px(0.).into())), + right: Some(Length::Definite(px(0.).into())), + bottom: Some(Length::Definite(px(12.).into())), + }, + border_style: Some(BorderStyle::Solid), + border_widths: EdgesRefinement { + top: Some(AbsoluteLength::Pixels(px(1.))), + left: Some(AbsoluteLength::Pixels(px(1.))), + right: Some(AbsoluteLength::Pixels(px(1.))), + bottom: Some(AbsoluteLength::Pixels(px(1.))), + }, + border_color: Some(cx.theme().colors().border_variant), + background: Some( + cx.theme().colors().editor_background.into(), + ), + ..Default::default() + }, + ..Default::default() + }, + ) + .into_any_element() + } else { + div() + .child("No active completion".to_string()) + .into_any_element() + }), + ) + .id("raw-input-view"), + ) + } + + fn render_active_completion( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let active_prediction = self.active_prediction.as_ref()?; + let completion_id = active_prediction.prediction.id.clone(); + let focus_handle = &self.focus_handle(cx); + + let border_color = cx.theme().colors().border; + let bg_color = cx.theme().colors().editor_background; + + let rated = self.ep_store.read(cx).is_prediction_rated(&completion_id); + let feedback_empty = active_prediction + .feedback_editor + .read(cx) + .text(cx) + .is_empty(); + + let label_container = h_flex().pl_1().gap_1p5(); + + Some( + v_flex() + .size_full() + .overflow_hidden() + .relative() + .child( + v_flex() + .size_full() + .overflow_hidden() + .relative() + .child(self.render_view_nav(cx)) + .when_some( + match self.current_view { + RatePredictionView::SuggestedEdits => { + self.render_suggested_edits(cx) + } + RatePredictionView::RawInput => self.render_raw_input(window, cx), + }, + |this, element| this.child(element), + ), + ) + .when(!rated, |this| { + this.child( + h_flex() + .p_2() + .gap_2() + .border_y_1() + .border_color(border_color) + .child( + Icon::new(IconName::Info) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + div().w_full().pr_2().flex_wrap().child( + Label::new(concat!( + "Explain why this completion is good or bad. ", + "If it's negative, describe what you expected instead." + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ) + }) + .when(!rated, |this| { + this.child( + div() + .h_40() + .pt_1() + .bg(bg_color) + .child(active_prediction.feedback_editor.clone()), + ) + }) + .child( + h_flex() + .p_1() + .h_8() + .max_h_8() + .border_t_1() + .border_color(border_color) + .max_w_full() + .justify_between() + .children(if rated { + Some( + label_container + .child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .child(Label::new("Rated completion.").color(Color::Muted)), + ) + } else if active_prediction.prediction.edits.is_empty() { + Some( + label_container + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child(Label::new("No edits produced.").color(Color::Muted)), + ) + } else { + Some(label_container) + }) + .child( + h_flex() + .gap_1() + .child( + Button::new("bad", "Bad Prediction") + .icon(IconName::ThumbsDown) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .disabled(rated || feedback_empty) + .when(feedback_empty, |this| { + this.tooltip(Tooltip::text( + "Explain what's bad about it before reporting it", + )) + }) + .key_binding(KeyBinding::for_action_in( + &ThumbsDownActivePrediction, + focus_handle, + cx, + )) + .on_click(cx.listener(move |this, _, window, cx| { + if this.active_prediction.is_some() { + this.thumbs_down_active( + &ThumbsDownActivePrediction, + window, + cx, + ); + } + })), + ) + .child( + Button::new("good", "Good Prediction") + .icon(IconName::ThumbsUp) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .disabled(rated) + .key_binding(KeyBinding::for_action_in( + &ThumbsUpActivePrediction, + focus_handle, + cx, + )) + .on_click(cx.listener(move |this, _, window, cx| { + if this.active_prediction.is_some() { + this.thumbs_up_active( + &ThumbsUpActivePrediction, + window, + cx, + ); + } + })), + ), + ), + ), + ) + } + + fn render_shown_completions(&self, cx: &Context) -> impl Iterator { + self.ep_store + .read(cx) + .shown_predictions() + .cloned() + .enumerate() + .map(|(index, completion)| { + let selected = self + .active_prediction + .as_ref() + .is_some_and(|selected| selected.prediction.id == completion.id); + let rated = self.ep_store.read(cx).is_prediction_rated(&completion.id); + + let (icon_name, icon_color, tooltip_text) = + match (rated, completion.edits.is_empty()) { + (true, _) => (IconName::Check, Color::Success, "Rated Prediction"), + (false, true) => (IconName::File, Color::Muted, "No Edits Produced"), + (false, false) => (IconName::FileDiff, Color::Accent, "Edits Available"), + }; + + let file = completion.buffer.read(cx).file(); + let file_name = file + .as_ref() + .map_or(SharedString::new_static("untitled"), |file| { + file.file_name(cx).to_string().into() + }); + let file_path = file.map(|file| file.path().as_unix_str().to_string()); + + ListItem::new(completion.id.clone()) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .focused(index == self.selected_index) + .toggle_state(selected) + .child( + h_flex() + .id("completion-content") + .gap_3() + .child(Icon::new(icon_name).color(icon_color).size(IconSize::Small)) + .child( + v_flex() + .child( + h_flex() + .gap_1() + .child(Label::new(file_name).size(LabelSize::Small)) + .when_some(file_path, |this, p| { + this.child( + Label::new(p) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .child( + Label::new(format!( + "{} ago, {:.2?}", + format_time_ago( + completion.response_received_at.elapsed() + ), + completion.latency() + )) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ), + ) + .tooltip(Tooltip::text(tooltip_text)) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_completion(Some(completion.clone()), true, window, cx); + })) + }) + } +} + +impl Render for RatePredictionsModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let border_color = cx.theme().colors().border; + + h_flex() + .key_context("RatePredictionModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::dismiss)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_prev_edit)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_next_edit)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::thumbs_up_active)) + .on_action(cx.listener(Self::thumbs_down_active)) + .on_action(cx.listener(Self::focus_completions)) + .on_action(cx.listener(Self::preview_completion)) + .bg(cx.theme().colors().elevated_surface_background) + .border_1() + .border_color(border_color) + .w(window.viewport_size().width - px(320.)) + .h(window.viewport_size().height - px(300.)) + .rounded_lg() + .shadow_lg() + .child( + v_flex() + .w_72() + .h_full() + .border_r_1() + .border_color(border_color) + .flex_shrink_0() + .overflow_hidden() + .child( + h_flex() + .h_8() + .px_2() + .justify_between() + .border_b_1() + .border_color(border_color) + .child(Icon::new(IconName::ZedPredict).size(IconSize::Small)) + .child( + Label::new("From most recent to oldest") + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child( + div() + .id("completion_list") + .p_0p5() + .h_full() + .overflow_y_scroll() + .child( + List::new() + .empty_message( + div() + .p_2() + .child( + Label::new(concat!( + "No completions yet. ", + "Use the editor to generate some, ", + "and make sure to rate them!" + )) + .color(Color::Muted), + ) + .into_any_element(), + ) + .children(self.render_shown_completions(cx)), + ), + ), + ) + .children(self.render_active_completion(window, cx)) + .on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))) + } +} + +impl EventEmitter for RatePredictionsModal {} + +impl Focusable for RatePredictionsModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for RatePredictionsModal {} + +fn format_time_ago(elapsed: Duration) -> String { + let seconds = elapsed.as_secs(); + if seconds < 120 { + "1 minute".to_string() + } else if seconds < 3600 { + format!("{} minutes", seconds / 60) + } else if seconds < 7200 { + "1 hour".to_string() + } else if seconds < 86400 { + format!("{} hours", seconds / 3600) + } else if seconds < 172800 { + "1 day".to_string() + } else { + format!("{} days", seconds / 86400) + } +} diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 52b3fa2aff..f3ed28ab05 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -41,6 +41,7 @@ dap.workspace = true db.workspace = true buffer_diff.workspace = true emojis.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true @@ -48,7 +49,7 @@ fs.workspace = true git.workspace = true gpui.workspace = true indoc.workspace = true -edit_prediction.workspace = true +edit_prediction_types.workspace = true itertools.workspace = true language.workspace = true linkify.workspace = true @@ -64,6 +65,7 @@ project.workspace = true rand.workspace = true regex.workspace = true rpc.workspace = true +rope.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true @@ -82,6 +84,8 @@ tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } tree-sitter-python = { workspace = true, optional = true } +ztracing.workspace = true +tracing.workspace = true unicode-segmentation.workspace = true unicode-script.workspace = true unindent = { workspace = true, optional = true } @@ -92,7 +96,7 @@ uuid.workspace = true vim_mode_setting.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true +zlog.workspace = true [dev-dependencies] criterion.workspace = true @@ -106,6 +110,7 @@ multi_buffer = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } release_channel.workspace = true rand.workspace = true +semver.workspace = true settings = { workspace = true, features = ["test-support"] } tempfile.workspace = true text = { workspace = true, features = ["test-support"] } @@ -116,6 +121,7 @@ tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true tree-sitter-yaml.workspace = true tree-sitter-bash.workspace = true +tree-sitter-md.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/editor/benches/display_map.rs b/crates/editor/benches/display_map.rs index 919249ad01..c443bdba1c 100644 --- a/crates/editor/benches/display_map.rs +++ b/crates/editor/benches/display_map.rs @@ -2,6 +2,7 @@ use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use editor::MultiBuffer; use gpui::TestDispatcher; use itertools::Itertools; +use multi_buffer::MultiBufferOffset; use rand::{Rng, SeedableRng, rngs::StdRng}; use std::num::NonZeroU32; use text::Bias; @@ -24,7 +25,9 @@ fn to_tab_point_benchmark(c: &mut Criterion) { let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot.clone()); let fold_point = fold_snapshot.to_fold_point( - inlay_snapshot.to_point(InlayOffset(rng.random_range(0..length))), + inlay_snapshot.to_point(InlayOffset( + rng.random_range(MultiBufferOffset(0)..MultiBufferOffset(length)), + )), Bias::Left, ); let (_, snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(4).unwrap()); @@ -42,7 +45,7 @@ fn to_tab_point_benchmark(c: &mut Criterion) { &snapshot, |bench, snapshot| { bench.iter(|| { - snapshot.to_tab_point(fold_point); + snapshot.fold_point_to_tab_point(fold_point); }); }, ); @@ -69,12 +72,14 @@ fn to_fold_point_benchmark(c: &mut Criterion) { let (_, fold_snapshot) = FoldMap::new(inlay_snapshot.clone()); let fold_point = fold_snapshot.to_fold_point( - inlay_snapshot.to_point(InlayOffset(rng.random_range(0..length))), + inlay_snapshot.to_point(InlayOffset( + rng.random_range(MultiBufferOffset(0)..MultiBufferOffset(length)), + )), Bias::Left, ); let (_, snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(4).unwrap()); - let tab_point = snapshot.to_tab_point(fold_point); + let tab_point = snapshot.fold_point_to_tab_point(fold_point); (length, snapshot, tab_point) }; @@ -89,7 +94,7 @@ fn to_fold_point_benchmark(c: &mut Criterion) { &snapshot, |bench, snapshot| { bench.iter(|| { - snapshot.to_fold_point(tab_point, Bias::Left); + snapshot.tab_point_to_fold_point(tab_point, Bias::Left); }); }, ); diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs index 0ae1af5537..4323c6c973 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/editor/benches/editor_render.rs @@ -4,7 +4,6 @@ use editor::{ actions::{DeleteToPreviousWordStart, SelectAll, SplitSelectionIntoLines}, }; use gpui::{AppContext, Focusable as _, TestAppContext, TestDispatcher}; -use project::Project; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; use settings::SettingsStore; use ui::IntoElement; @@ -124,11 +123,7 @@ pub fn benches() { cx.set_global(store); assets::Assets.load_test_fonts(cx); theme::init(theme::LoadThemes::JustBase, cx); - // release_channel::init(SemanticVersion::default(), cx); - client::init_settings(cx); - language::init(cx); - workspace::init_settings(cx); - Project::init_settings(cx); + // release_channel::init(semver::Version::new(0,0,0), cx); editor::init(cx); }); diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 8e68288803..ba36f88f63 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -213,15 +213,6 @@ 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)] -pub struct ShowCompletions { - #[serde(default)] - pub(super) trigger: Option, -} - /// Handles text input in the editor. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] @@ -318,6 +309,41 @@ pub struct GoToPreviousDiagnostic { pub severity: GoToDiagnosticSeverityFilter, } +/// Adds a cursor above the current selection. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct AddSelectionAbove { + #[serde(default = "default_true")] + pub skip_soft_wrap: bool, +} + +/// Adds a cursor below the current selection. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct AddSelectionBelow { + #[serde(default = "default_true")] + pub skip_soft_wrap: bool, +} + +/// Inserts a snippet at the cursor. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct InsertSnippet { + /// Language name if using a named snippet, or `None` for a global snippet + /// + /// This is typically lowercase and matches the filename containing the snippet, without the `.json` extension. + pub language: Option, + /// Name if using a named snippet + pub name: Option, + + /// Snippet body, if not using a named snippet + // todo(andrew): use `ListOrDirect` or similar for multiline snippet body + pub snippet: Option, +} + actions!( debugger, [ @@ -344,11 +370,8 @@ actions!( AcceptEditPrediction, /// Accepts a partial edit prediction. #[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])] - AcceptPartialEditPrediction, - /// Adds a cursor above the current selection. - AddSelectionAbove, - /// Adds a cursor below the current selection. - AddSelectionBelow, + AcceptNextWordEditPrediction, + AcceptNextLineEditPrediction, /// Applies all diff hunks in the editor. ApplyAllDiffHunks, /// Applies the diff hunk at the current position. @@ -444,10 +467,10 @@ actions!( /// Expands all diff hunks in the editor. #[action(deprecated_aliases = ["editor::ExpandAllHunkDiffs"])] ExpandAllDiffHunks, + /// Collapses all diff hunks in the editor. + CollapseAllDiffHunks, /// Expands macros recursively at cursor position. ExpandMacroRecursively, - /// Finds all references to the symbol at cursor. - FindAllReferences, /// Finds the next match in the search. FindNextMatch, /// Finds the previous match in the search. @@ -456,6 +479,33 @@ actions!( Fold, /// Folds all foldable regions in the editor. FoldAll, + /// Folds all code blocks at indentation level 1. + #[action(name = "FoldAtLevel_1")] + FoldAtLevel1, + /// Folds all code blocks at indentation level 2. + #[action(name = "FoldAtLevel_2")] + FoldAtLevel2, + /// Folds all code blocks at indentation level 3. + #[action(name = "FoldAtLevel_3")] + FoldAtLevel3, + /// Folds all code blocks at indentation level 4. + #[action(name = "FoldAtLevel_4")] + FoldAtLevel4, + /// Folds all code blocks at indentation level 5. + #[action(name = "FoldAtLevel_5")] + FoldAtLevel5, + /// Folds all code blocks at indentation level 6. + #[action(name = "FoldAtLevel_6")] + FoldAtLevel6, + /// Folds all code blocks at indentation level 7. + #[action(name = "FoldAtLevel_7")] + FoldAtLevel7, + /// Folds all code blocks at indentation level 8. + #[action(name = "FoldAtLevel_8")] + FoldAtLevel8, + /// Folds all code blocks at indentation level 9. + #[action(name = "FoldAtLevel_9")] + FoldAtLevel9, /// Folds all function bodies in the editor. FoldFunctionBodies, /// Folds the current code block and all its children. @@ -496,6 +546,10 @@ actions!( GoToParentModule, /// Goes to the previous change in the file. GoToPreviousChange, + /// Goes to the next reference to the symbol under the cursor. + GoToNextReference, + /// Goes to the previous reference to the symbol under the cursor. + GoToPreviousReference, /// Goes to the type definition of the symbol at cursor. GoToTypeDefinition, /// Goes to type definition in a split pane. @@ -574,6 +628,8 @@ actions!( NextEditPrediction, /// Scrolls to the next screen. NextScreen, + /// Goes to the next snippet tabstop if one exists. + NextSnippetTabstop, /// Opens the context menu at cursor position. OpenContextMenu, /// Opens excerpts from the current file. @@ -607,6 +663,8 @@ actions!( Paste, /// Navigates to the previous edit prediction. PreviousEditPrediction, + /// Goes to the previous snippet tabstop if one exists. + PreviousSnippetTabstop, /// Redoes the last undone edit. Redo, /// Redoes the last selection change. @@ -623,6 +681,10 @@ actions!( ReloadFile, /// Rewraps text to fit within the preferred line length. Rewrap, + /// Rotates selections or lines backward. + RotateSelectionsBackward, + /// Rotates selections or lines forward. + RotateSelectionsForward, /// Runs flycheck diagnostics. RunFlycheck, /// Scrolls the cursor to the bottom of the viewport. @@ -685,6 +747,8 @@ actions!( SelectToStartOfParagraph, /// Extends selection up. SelectUp, + /// Shows code completion suggestions at the cursor position. + ShowCompletions, /// Shows the system character palette. ShowCharacterPalette, /// Shows edit prediction at cursor. @@ -783,3 +847,20 @@ actions!( WrapSelectionsInTag ] ); + +/// Finds all references to the symbol at cursor. +#[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct FindAllReferences { + #[serde(default = "default_true")] + pub always_open_multibuffer: bool, +} + +impl Default for FindAllReferences { + fn default() -> Self { + Self { + always_open_multibuffer: true, + } + } +} diff --git a/crates/editor/src/blink_manager.rs b/crates/editor/src/blink_manager.rs index 9c2b911f1b..d99cf6a7d5 100644 --- a/crates/editor/src/blink_manager.rs +++ b/crates/editor/src/blink_manager.rs @@ -1,20 +1,28 @@ -use crate::EditorSettings; use gpui::Context; -use settings::Settings; use settings::SettingsStore; use smol::Timer; use std::time::Duration; +use ui::App; pub struct BlinkManager { blink_interval: Duration, blink_epoch: usize, + /// Whether the blinking is paused. blinking_paused: bool, + /// Whether the cursor should be visibly rendered or not. visible: bool, + /// Whether the blinking currently enabled. enabled: bool, + /// Whether the blinking is enabled in the settings. + blink_enabled_in_settings: fn(&App) -> bool, } impl BlinkManager { - pub fn new(blink_interval: Duration, cx: &mut Context) -> Self { + pub fn new( + blink_interval: Duration, + blink_enabled_in_settings: fn(&App) -> bool, + cx: &mut Context, + ) -> Self { // Make sure we blink the cursors if the setting is re-enabled cx.observe_global::(move |this, cx| { this.blink_cursors(this.blink_epoch, cx) @@ -27,6 +35,7 @@ impl BlinkManager { blinking_paused: false, visible: true, enabled: false, + blink_enabled_in_settings, } } @@ -55,7 +64,7 @@ impl BlinkManager { } fn blink_cursors(&mut self, epoch: usize, cx: &mut Context) { - if EditorSettings::get_global(cx).cursor_blink { + if (self.blink_enabled_in_settings)(cx) { if epoch == self.blink_epoch && self.enabled && !self.blinking_paused { self.visible = !self.visible; cx.notify(); @@ -83,6 +92,7 @@ impl BlinkManager { } } + /// Enable the blinking of the cursor. pub fn enable(&mut self, cx: &mut Context) { if self.enabled { return; @@ -95,6 +105,7 @@ impl BlinkManager { self.blink_cursors(self.blink_epoch, cx); } + /// Disable the blinking of the cursor. pub fn disable(&mut self, _cx: &mut Context) { self.visible = false; self.enabled = false; diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs new file mode 100644 index 0000000000..4879c5e9ce --- /dev/null +++ b/crates/editor/src/bracket_colorization.rs @@ -0,0 +1,1374 @@ +//! Bracket highlights, also known as "rainbow brackets". +//! Uses tree-sitter queries from brackets.scm to capture bracket pairs, +//! and theme accents to colorize those. + +use std::ops::Range; + +use crate::Editor; +use collections::HashMap; +use gpui::{Context, HighlightStyle}; +use itertools::Itertools; +use language::language_settings; +use multi_buffer::{Anchor, ExcerptId}; +use ui::{ActiveTheme, utils::ensure_minimum_contrast}; + +struct ColorizedBracketsHighlight; + +impl Editor { + pub(crate) fn colorize_brackets(&mut self, invalidate: bool, cx: &mut Context) { + if !self.mode.is_full() { + return; + } + + if invalidate { + self.fetched_tree_sitter_chunks.clear(); + } + + let accents_count = cx.theme().accents().0.len(); + let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); + let all_excerpts = self.buffer().read(cx).excerpt_ids(); + let anchors_in_multi_buffer = |current_excerpt: ExcerptId, + text_anchors: [text::Anchor; 4]| + -> Option<[Option<_>; 4]> { + multi_buffer_snapshot + .anchors_in_excerpt(current_excerpt, text_anchors) + .or_else(|| { + all_excerpts + .iter() + .filter(|&&excerpt_id| excerpt_id != current_excerpt) + .find_map(|&excerpt_id| { + multi_buffer_snapshot.anchors_in_excerpt(excerpt_id, text_anchors) + }) + })? + .collect_array() + }; + + let bracket_matches_by_accent = self.visible_excerpts(false, cx).into_iter().fold( + HashMap::default(), + |mut acc, (excerpt_id, (buffer, _, buffer_range))| { + let buffer_snapshot = buffer.read(cx).snapshot(); + if language_settings::language_settings( + buffer_snapshot.language().map(|language| language.name()), + buffer_snapshot.file(), + cx, + ) + .colorize_brackets + { + let fetched_chunks = self + .fetched_tree_sitter_chunks + .entry(excerpt_id) + .or_default(); + + let brackets_by_accent = buffer_snapshot + .fetch_bracket_ranges( + buffer_range.start..buffer_range.end, + Some(fetched_chunks), + ) + .into_iter() + .flat_map(|(chunk_range, pairs)| { + if fetched_chunks.insert(chunk_range) { + pairs + } else { + Vec::new() + } + }) + .filter_map(|pair| { + let color_index = pair.color_index?; + + let buffer_open_range = buffer_snapshot + .anchor_before(pair.open_range.start) + ..buffer_snapshot.anchor_after(pair.open_range.end); + let buffer_close_range = buffer_snapshot + .anchor_before(pair.close_range.start) + ..buffer_snapshot.anchor_after(pair.close_range.end); + let [ + buffer_open_range_start, + buffer_open_range_end, + buffer_close_range_start, + buffer_close_range_end, + ] = anchors_in_multi_buffer( + excerpt_id, + [ + buffer_open_range.start, + buffer_open_range.end, + buffer_close_range.start, + buffer_close_range.end, + ], + )?; + let multi_buffer_open_range = + buffer_open_range_start.zip(buffer_open_range_end); + let multi_buffer_close_range = + buffer_close_range_start.zip(buffer_close_range_end); + + let mut ranges = Vec::with_capacity(2); + if let Some((open_start, open_end)) = multi_buffer_open_range { + ranges.push(open_start..open_end); + } + if let Some((close_start, close_end)) = multi_buffer_close_range { + ranges.push(close_start..close_end); + } + if ranges.is_empty() { + None + } else { + Some((color_index % accents_count, ranges)) + } + }); + + for (accent_number, new_ranges) in brackets_by_accent { + let ranges = acc + .entry(accent_number) + .or_insert_with(Vec::>::new); + + for new_range in new_ranges { + let i = ranges + .binary_search_by(|probe| { + probe.start.cmp(&new_range.start, &multi_buffer_snapshot) + }) + .unwrap_or_else(|i| i); + ranges.insert(i, new_range); + } + } + } + + acc + }, + ); + + if invalidate { + self.clear_highlights::(cx); + } + + let editor_background = cx.theme().colors().editor_background; + for (accent_number, bracket_highlights) in bracket_matches_by_accent { + let bracket_color = cx.theme().accents().color_for_index(accent_number as u32); + let adjusted_color = ensure_minimum_contrast(bracket_color, editor_background, 55.0); + let style = HighlightStyle { + color: Some(adjusted_color), + ..HighlightStyle::default() + }; + + self.highlight_text_key::( + accent_number, + bracket_highlights, + style, + true, + cx, + ); + } + } +} + +#[cfg(test)] +mod tests { + use std::{cmp, sync::Arc, time::Duration}; + + use super::*; + use crate::{ + DisplayPoint, EditorMode, EditorSnapshot, MoveToBeginning, MoveToEnd, MoveUp, + display_map::{DisplayRow, ToDisplayPoint}, + editor_tests::init_test, + test::{ + editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext, + }, + }; + use collections::HashSet; + use fs::FakeFs; + use gpui::{AppContext as _, UpdateGlobal as _}; + use indoc::indoc; + use itertools::Itertools; + use language::{Capability, markdown_lang}; + use languages::rust_lang; + use multi_buffer::{ExcerptRange, MultiBuffer}; + use pretty_assertions::assert_eq; + use project::Project; + use rope::Point; + use serde_json::json; + use settings::{AccentContent, SettingsStore}; + use text::{Bias, OffsetRangeExt, ToOffset}; + use theme::ThemeStyleContent; + use ui::SharedString; + use util::{path, post_inc}; + + #[gpui::test] + async fn test_basic_bracket_colorization(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(rust_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + cx.set_state(indoc! {r#"ˇuse std::{collections::HashMap, future::Future}; + +fn main() { + let a = one((), { () }, ()); + println!("{a}"); + println!("{a}"); + for i in 0..a { + println!("{i}"); + } + + let b = { + { + { + [([([([([([([([([([((), ())])])])])])])])])])] + } + } + }; +} + +#[rustfmt::skip] +fn one(a: (), (): (), c: ()) -> usize { 1 } + +fn two(a: HashMap>>) -> usize +where + T: Future>>>>, +{ + 2 +} +"#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"use std::«1{collections::HashMap, future::Future}1»; + +fn main«1()1» «1{ + let a = one«2(«3()3», «3{ «4()4» }3», «3()3»)2»; + println!«2("{a}")2»; + println!«2("{a}")2»; + for i in 0..a «2{ + println!«3("{i}")3»; + }2» + + let b = «2{ + «3{ + «4{ + «5[«6(«7[«1(«2[«3(«4[«5(«6[«7(«1[«2(«3[«4(«5[«6(«7[«1(«2[«3(«4()4», «4()4»)3»]2»)1»]7»)6»]5»)4»]3»)2»]1»)7»]6»)5»]4»)3»]2»)1»]7»)6»]5» + }4» + }3» + }2»; +}1» + +#«1[rustfmt::skip]1» +fn one«1(a: «2()2», «2()2»: «2()2», c: «2()2»)1» -> usize «1{ 1 }1» + +fn two«11»«1(a: HashMap«24»>3»>2»)1» -> usize +where + T: Future«15»>4»>3»>2»>1», +«1{ + 2 +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +6 hsla(95.00, 38.00%, 62.00%, 1.00) +7 hsla(39.00, 67.00%, 69.00%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "All brackets should be colored based on their depth" + ); + } + + #[gpui::test] + async fn test_file_less_file_colorization(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let editor = cx.add_window(|window, cx| { + let multi_buffer = MultiBuffer::build_simple("fn main() {}", cx); + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }); + }); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + "fn main«1()1» «1{}1» +1 hsla(207.80, 16.20%, 69.19%, 1.00) +", + editor + .update(cx, |editor, window, cx| { + editor_bracket_colors_markup(&editor.snapshot(window, cx)) + }) + .unwrap(), + "File-less buffer should still have its brackets colorized" + ); + } + + #[gpui::test] + async fn test_markdown_bracket_colorization(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(markdown_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + cx.set_state(indoc! {r#"ˇ[LLM-powered features](./ai/overview.md), [bring and configure your own API keys](./ai/llm-providers.md#use-your-own-keys)"#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"«1[LLM-powered features]1»«1(./ai/overview.md)1», «1[bring and configure your own API keys]1»«1(./ai/llm-providers.md#use-your-own-keys)1» +1 hsla(207.80, 16.20%, 69.19%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "All markdown brackets should be colored based on their depth" + ); + + cx.set_state(indoc! {r#"ˇ{{}}"#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"«1{«2{}2»}1» +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "All markdown brackets should be colored based on their depth, again" + ); + } + + #[gpui::test] + async fn test_bracket_colorization_when_editing(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(rust_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + cx.set_state(indoc! {r#" +struct Foo<'a, T> { + data: Vec>, +} + +fn process_data() { + let map:ˇ +} +"#}); + + cx.update_editor(|editor, window, cx| { + editor.handle_input(" Result<", window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + indoc! {r#" +struct Foo«1<'a, T>1» «1{ + data: Vec«23»>2», +}1» + +fn process_data«1()1» «1{ + let map: Result< +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +"#}, + &bracket_colors_markup(&mut cx), + "Brackets without pairs should be ignored and not colored" + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("Option1» «1{ + data: Vec«23»>2», +}1» + +fn process_data«1()1» «1{ + let map: Result", window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + indoc! {r#" +struct Foo«1<'a, T>1» «1{ + data: Vec«23»>2», +}1» + +fn process_data«1()1» «1{ + let map: Result2» +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +"#}, + &bracket_colors_markup(&mut cx), + "When brackets start to get closed, inner brackets are re-colored based on their depth" + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input(">", window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + indoc! {r#" +struct Foo«1<'a, T>1» «1{ + data: Vec«23»>2», +}1» + +fn process_data«1()1» «1{ + let map: Result3»>2» +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +"#}, + &bracket_colors_markup(&mut cx), + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input(", ()> = unimplemented!();", window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + indoc! {r#" +struct Foo«1<'a, T>1» «1{ + data: Vec«23»>2», +}1» + +fn process_data«1()1» «1{ + let map: Result«24»>3», «3()3»>2» = unimplemented!«2()2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +"#}, + &bracket_colors_markup(&mut cx), + ); + } + + #[gpui::test] + async fn test_bracket_colorization_chunks(cx: &mut gpui::TestAppContext) { + let comment_lines = 100; + + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(rust_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + cx.set_state(&separate_with_comment_lines( + indoc! {r#" +mod foo { + ˇfn process_data_1() { + let map: Option> = None; + } +"#}, + indoc! {r#" + fn process_data_2() { + let map: Option> = None; + } +} +"#}, + comment_lines, + )); + + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + &separate_with_comment_lines( + indoc! {r#" +mod foo «1{ + fn process_data_1«2()2» «2{ + let map: Option«34»>3» = None; + }2» +"#}, + indoc! {r#" + fn process_data_2() { + let map: Option> = None; + } +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +"#}, + comment_lines, + ), + &bracket_colors_markup(&mut cx), + "First, the only visible chunk is getting the bracket highlights" + ); + + cx.update_editor(|editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + editor.move_up(&MoveUp, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + &separate_with_comment_lines( + indoc! {r#" +mod foo «1{ + fn process_data_1«2()2» «2{ + let map: Option«34»>3» = None; + }2» +"#}, + indoc! {r#" + fn process_data_2«2()2» «2{ + let map: Option«34»>3» = None; + }2» +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +"#}, + comment_lines, + ), + &bracket_colors_markup(&mut cx), + "After scrolling to the bottom, both chunks should have the highlights" + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("{{}}}", window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + &separate_with_comment_lines( + indoc! {r#" +mod foo «1{ + fn process_data_1() { + let map: Option> = None; + } +"#}, + indoc! {r#" + fn process_data_2«2()2» «2{ + let map: Option«34»>3» = None; + } + «3{«4{}4»}3»}2»}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +"#}, + comment_lines, + ), + &bracket_colors_markup(&mut cx), + "First chunk's brackets are invalidated after an edit, and only 2nd (visible) chunk is re-colorized" + ); + + cx.update_editor(|editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + &separate_with_comment_lines( + indoc! {r#" +mod foo «1{ + fn process_data_1«2()2» «2{ + let map: Option«34»>3» = None; + }2» +"#}, + indoc! {r#" + fn process_data_2«2()2» «2{ + let map: Option«34»>3» = None; + } + «3{«4{}4»}3»}2»}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +"#}, + comment_lines, + ), + &bracket_colors_markup(&mut cx), + "Scrolling back to top should re-colorize all chunks' brackets" + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.colorize_brackets = Some(false); + }); + }); + }); + assert_eq!( + &separate_with_comment_lines( + indoc! {r#" +mod foo { + fn process_data_1() { + let map: Option> = None; + } +"#}, + r#" fn process_data_2() { + let map: Option> = None; + } + {{}}}} + +"#, + comment_lines, + ), + &bracket_colors_markup(&mut cx), + "Turning bracket colorization off should remove all bracket colors" + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.colorize_brackets = Some(true); + }); + }); + }); + assert_eq!( + &separate_with_comment_lines( + indoc! {r#" +mod foo «1{ + fn process_data_1«2()2» «2{ + let map: Option«34»>3» = None; + }2» +"#}, + r#" fn process_data_2() { + let map: Option> = None; + } + {{}}}}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +"#, + comment_lines, + ), + &bracket_colors_markup(&mut cx), + "Turning bracket colorization back on refreshes the visible excerpts' bracket colors" + ); + } + + #[gpui::test] + async fn test_rainbow_bracket_highlights(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(rust_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + // taken from r-a https://github.com/rust-lang/rust-analyzer/blob/d733c07552a2dc0ec0cc8f4df3f0ca969a93fd90/crates/ide/src/inlay_hints.rs#L81-L297 + cx.set_state(indoc! {r#"ˇ + pub(crate) fn inlay_hints( + db: &RootDatabase, + file_id: FileId, + range_limit: Option, + config: &InlayHintsConfig, + ) -> Vec { + let _p = tracing::info_span!("inlay_hints").entered(); + let sema = Semantics::new(db); + let file_id = sema + .attach_first_edition(file_id) + .unwrap_or_else(|| EditionedFileId::current_edition(db, file_id)); + let file = sema.parse(file_id); + let file = file.syntax(); + + let mut acc = Vec::new(); + + let Some(scope) = sema.scope(file) else { + return acc; + }; + let famous_defs = FamousDefs(&sema, scope.krate()); + let display_target = famous_defs.1.to_display_target(sema.db); + + let ctx = &mut InlayHintCtx::default(); + let mut hints = |event| { + if let Some(node) = handle_event(ctx, event) { + hints(&mut acc, ctx, &famous_defs, config, file_id, display_target, node); + } + }; + let mut preorder = file.preorder(); + salsa::attach(sema.db, || { + while let Some(event) = preorder.next() { + if matches!((&event, range_limit), (WalkEvent::Enter(node), Some(range)) if range.intersect(node.text_range()).is_none()) + { + preorder.skip_subtree(); + continue; + } + hints(event); + } + }); + if let Some(range_limit) = range_limit { + acc.retain(|hint| range_limit.contains_range(hint.range)); + } + acc + } + + #[derive(Default)] + struct InlayHintCtx { + lifetime_stacks: Vec>, + extern_block_parent: Option, + } + + pub(crate) fn inlay_hints_resolve( + db: &RootDatabase, + file_id: FileId, + resolve_range: TextRange, + hash: u64, + config: &InlayHintsConfig, + hasher: impl Fn(&InlayHint) -> u64, + ) -> Option { + let _p = tracing::info_span!("inlay_hints_resolve").entered(); + let sema = Semantics::new(db); + let file_id = sema + .attach_first_edition(file_id) + .unwrap_or_else(|| EditionedFileId::current_edition(db, file_id)); + let file = sema.parse(file_id); + let file = file.syntax(); + + let scope = sema.scope(file)?; + let famous_defs = FamousDefs(&sema, scope.krate()); + let mut acc = Vec::new(); + + let display_target = famous_defs.1.to_display_target(sema.db); + + let ctx = &mut InlayHintCtx::default(); + let mut hints = |event| { + if let Some(node) = handle_event(ctx, event) { + hints(&mut acc, ctx, &famous_defs, config, file_id, display_target, node); + } + }; + + let mut preorder = file.preorder(); + while let Some(event) = preorder.next() { + // This can miss some hints that require the parent of the range to calculate + if matches!(&event, WalkEvent::Enter(node) if resolve_range.intersect(node.text_range()).is_none()) + { + preorder.skip_subtree(); + continue; + } + hints(event); + } + acc.into_iter().find(|hint| hasher(hint) == hash) + } + + fn handle_event(ctx: &mut InlayHintCtx, node: WalkEvent) -> Option { + match node { + WalkEvent::Enter(node) => { + if let Some(node) = ast::AnyHasGenericParams::cast(node.clone()) { + let params = node + .generic_param_list() + .map(|it| { + it.lifetime_params() + .filter_map(|it| { + it.lifetime().map(|it| format_smolstr!("{}", &it.text()[1..])) + }) + .collect() + }) + .unwrap_or_default(); + ctx.lifetime_stacks.push(params); + } + if let Some(node) = ast::ExternBlock::cast(node.clone()) { + ctx.extern_block_parent = Some(node); + } + Some(node) + } + WalkEvent::Leave(n) => { + if ast::AnyHasGenericParams::can_cast(n.kind()) { + ctx.lifetime_stacks.pop(); + } + if ast::ExternBlock::can_cast(n.kind()) { + ctx.extern_block_parent = None; + } + None + } + } + } + + // At some point when our hir infra is fleshed out enough we should flip this and traverse the + // HIR instead of the syntax tree. + fn hints( + hints: &mut Vec, + ctx: &mut InlayHintCtx, + famous_defs @ FamousDefs(sema, _krate): &FamousDefs<'_, '_>, + config: &InlayHintsConfig, + file_id: EditionedFileId, + display_target: DisplayTarget, + node: SyntaxNode, + ) { + closing_brace::hints( + hints, + sema, + config, + display_target, + InRealFile { file_id, value: node.clone() }, + ); + if let Some(any_has_generic_args) = ast::AnyHasGenericArgs::cast(node.clone()) { + generic_param::hints(hints, famous_defs, config, any_has_generic_args); + } + + match_ast! { + match node { + ast::Expr(expr) => { + chaining::hints(hints, famous_defs, config, display_target, &expr); + adjustment::hints(hints, famous_defs, config, display_target, &expr); + match expr { + ast::Expr::CallExpr(it) => param_name::hints(hints, famous_defs, config, file_id, ast::Expr::from(it)), + ast::Expr::MethodCallExpr(it) => { + param_name::hints(hints, famous_defs, config, file_id, ast::Expr::from(it)) + } + ast::Expr::ClosureExpr(it) => { + closure_captures::hints(hints, famous_defs, config, it.clone()); + closure_ret::hints(hints, famous_defs, config, display_target, it) + }, + ast::Expr::RangeExpr(it) => range_exclusive::hints(hints, famous_defs, config, it), + _ => Some(()), + } + }, + ast::Pat(it) => { + binding_mode::hints(hints, famous_defs, config, &it); + match it { + ast::Pat::IdentPat(it) => { + bind_pat::hints(hints, famous_defs, config, display_target, &it); + } + ast::Pat::RangePat(it) => { + range_exclusive::hints(hints, famous_defs, config, it); + } + _ => {} + } + Some(()) + }, + ast::Item(it) => match it { + ast::Item::Fn(it) => { + implicit_drop::hints(hints, famous_defs, config, display_target, &it); + if let Some(extern_block) = &ctx.extern_block_parent { + extern_block::fn_hints(hints, famous_defs, config, &it, extern_block); + } + lifetime::fn_hints(hints, ctx, famous_defs, config, it) + }, + ast::Item::Static(it) => { + if let Some(extern_block) = &ctx.extern_block_parent { + extern_block::static_hints(hints, famous_defs, config, &it, extern_block); + } + implicit_static::hints(hints, famous_defs, config, Either::Left(it)) + }, + ast::Item::Const(it) => implicit_static::hints(hints, famous_defs, config, Either::Right(it)), + ast::Item::Enum(it) => discriminant::enum_hints(hints, famous_defs, config, it), + ast::Item::ExternBlock(it) => extern_block::extern_block_hints(hints, famous_defs, config, it), + _ => None, + }, + // trait object type elisions + ast::Type(ty) => match ty { + ast::Type::FnPtrType(ptr) => lifetime::fn_ptr_hints(hints, ctx, famous_defs, config, ptr), + ast::Type::PathType(path) => { + lifetime::fn_path_hints(hints, ctx, famous_defs, config, &path); + implied_dyn_trait::hints(hints, famous_defs, config, Either::Left(path)); + Some(()) + }, + ast::Type::DynTraitType(dyn_) => { + implied_dyn_trait::hints(hints, famous_defs, config, Either::Right(dyn_)); + Some(()) + }, + _ => Some(()), + }, + ast::GenericParamList(it) => bounds::hints(hints, famous_defs, config, it), + _ => Some(()), + } + }; + } + "#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + let actual_ranges = cx.update_editor(|editor, window, cx| { + editor + .snapshot(window, cx) + .all_text_highlight_ranges::() + }); + + let mut highlighted_brackets = HashMap::default(); + for (color, range) in actual_ranges.iter().cloned() { + highlighted_brackets.insert(range, color); + } + + let last_bracket = actual_ranges + .iter() + .max_by_key(|(_, p)| p.end.row) + .unwrap() + .clone(); + + cx.update_editor(|editor, window, cx| { + let was_scrolled = editor.set_scroll_position( + gpui::Point::new(0.0, last_bracket.1.end.row as f64 * 2.0), + window, + cx, + ); + assert!(was_scrolled.0); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + let ranges_after_scrolling = cx.update_editor(|editor, window, cx| { + editor + .snapshot(window, cx) + .all_text_highlight_ranges::() + }); + let new_last_bracket = ranges_after_scrolling + .iter() + .max_by_key(|(_, p)| p.end.row) + .unwrap() + .clone(); + + assert_ne!( + last_bracket, new_last_bracket, + "After scrolling down, we should have highlighted more brackets" + ); + + cx.update_editor(|editor, window, cx| { + let was_scrolled = editor.set_scroll_position(gpui::Point::default(), window, cx); + assert!(was_scrolled.0); + }); + + for _ in 0..200 { + cx.update_editor(|editor, window, cx| { + editor.apply_scroll_delta(gpui::Point::new(0.0, 0.25), window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + let colored_brackets = cx.update_editor(|editor, window, cx| { + editor + .snapshot(window, cx) + .all_text_highlight_ranges::() + }); + for (color, range) in colored_brackets.clone() { + assert!( + highlighted_brackets.entry(range).or_insert(color) == &color, + "Colors should stay consistent while scrolling!" + ); + } + + let snapshot = cx.update_editor(|editor, window, cx| editor.snapshot(window, cx)); + let scroll_position = snapshot.scroll_position(); + let visible_lines = + cx.update_editor(|editor, _, _| editor.visible_line_count().unwrap()); + let visible_range = DisplayRow(scroll_position.y as u32) + ..DisplayRow((scroll_position.y + visible_lines) as u32); + + let current_highlighted_bracket_set: HashSet = HashSet::from_iter( + colored_brackets + .iter() + .flat_map(|(_, range)| [range.start, range.end]), + ); + + for highlight_range in highlighted_brackets.keys().filter(|bracket_range| { + visible_range.contains(&bracket_range.start.to_display_point(&snapshot).row()) + || visible_range.contains(&bracket_range.end.to_display_point(&snapshot).row()) + }) { + assert!( + current_highlighted_bracket_set.contains(&highlight_range.start) + || current_highlighted_bracket_set.contains(&highlight_range.end), + "Should not lose highlights while scrolling in the visible range!" + ); + } + + let buffer_snapshot = snapshot.buffer().as_singleton().unwrap().2; + for bracket_match in buffer_snapshot + .fetch_bracket_ranges( + snapshot + .display_point_to_point( + DisplayPoint::new(visible_range.start, 0), + Bias::Left, + ) + .to_offset(&buffer_snapshot) + ..snapshot + .display_point_to_point( + DisplayPoint::new( + visible_range.end, + snapshot.line_len(visible_range.end), + ), + Bias::Right, + ) + .to_offset(&buffer_snapshot), + None, + ) + .iter() + .flat_map(|entry| entry.1) + .filter(|bracket_match| bracket_match.color_index.is_some()) + { + let start = bracket_match.open_range.to_point(buffer_snapshot); + let end = bracket_match.close_range.to_point(buffer_snapshot); + let start_bracket = colored_brackets.iter().find(|(_, range)| *range == start); + assert!( + start_bracket.is_some(), + "Existing bracket start in the visible range should be highlighted. Missing color for match: \"{}\" at position {:?}", + buffer_snapshot + .text_for_range(start.start..end.end) + .collect::(), + start + ); + + let end_bracket = colored_brackets.iter().find(|(_, range)| *range == end); + assert!( + end_bracket.is_some(), + "Existing bracket end in the visible range should be highlighted. Missing color for match: \"{}\" at position {:?}", + buffer_snapshot + .text_for_range(start.start..end.end) + .collect::(), + start + ); + + assert_eq!( + start_bracket.unwrap().0, + end_bracket.unwrap().0, + "Bracket pair should be highlighted the same color!" + ) + } + } + } + + #[gpui::test] + async fn test_multi_buffer(cx: &mut gpui::TestAppContext) { + let comment_lines = 100; + + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "fn main() {{()}}", + "lib.rs": separate_with_comment_lines( + indoc! {r#" + mod foo { + fn process_data_1() { + let map: Option> = None; + // a + // b + // c + } + "#}, + indoc! {r#" + fn process_data_2() { + let other_map: Option> = None; + } + } + "#}, + comment_lines, + ) + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/lib.rs"), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(1, 0))], + cx, + ); + + let excerpt_rows = 5; + let rest_of_first_except_rows = 3; + multi_buffer.push_excerpts( + buffer_1.clone(), + [ + ExcerptRange::new(Point::new(0, 0)..Point::new(excerpt_rows, 0)), + ExcerptRange::new( + Point::new( + comment_lines as u32 + excerpt_rows + rest_of_first_except_rows, + 0, + ) + ..Point::new( + comment_lines as u32 + + excerpt_rows + + rest_of_first_except_rows + + excerpt_rows, + 0, + ), + ), + ], + cx, + ); + multi_buffer + }); + + let editor = cx.add_window(|window, cx| { + Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx) + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + let editor_snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + assert_eq!( + indoc! {r#" + + +fn main«1()1» «1{«2{«3()3»}2»}1» + + +mod foo «1{ + fn process_data_1«2()2» «2{ + let map: Option«34»>3» = None; + // a + // b + + + fn process_data_2«2()2» «2{ + let other_map: Option«34»>3» = None; + }2» +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +"#,}, + &editor_bracket_colors_markup(&editor_snapshot), + "Multi buffers should have their brackets colored even if no excerpts contain the bracket counterpart (after fn `process_data_2()`) \ +or if the buffer pair spans across multiple excerpts (the one after `mod foo`)" + ); + + editor + .update(cx, |editor, window, cx| { + editor.handle_input("{[]", window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + let editor_snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + assert_eq!( + indoc! {r#" + + +{«1[]1»fn main«1()1» «1{«2{«3()3»}2»}1» + + +mod foo «1{ + fn process_data_1«2()2» «2{ + let map: Option«34»>3» = None; + // a + // b + + + fn process_data_2«2()2» «2{ + let other_map: Option«34»>3» = None; + }2» +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +4 hsla(187.00, 47.00%, 59.22%, 1.00) +5 hsla(355.00, 65.00%, 75.94%, 1.00) +"#,}, + &editor_bracket_colors_markup(&editor_snapshot), + ); + + cx.update(|cx| { + let theme = cx.theme().name.clone(); + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.theme.theme_overrides = HashMap::from_iter([( + theme.to_string(), + ThemeStyleContent { + accents: vec![ + AccentContent(Some(SharedString::new("#ff0000"))), + AccentContent(Some(SharedString::new("#0000ff"))), + ], + ..ThemeStyleContent::default() + }, + )]); + }); + }); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + let editor_snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + assert_eq!( + indoc! {r#" + + +{«1[]1»fn main«1()1» «1{«2{«1()1»}2»}1» + + +mod foo «1{ + fn process_data_1«2()2» «2{ + let map: Option«12»>1» = None; + // a + // b + + + fn process_data_2«2()2» «2{ + let other_map: Option«12»>1» = None; + }2» +}1» + +1 hsla(0.00, 100.00%, 78.12%, 1.00) +2 hsla(240.00, 100.00%, 82.81%, 1.00) +"#,}, + &editor_bracket_colors_markup(&editor_snapshot), + "After updating theme accents, the editor should update the bracket coloring" + ); + } + + fn separate_with_comment_lines(head: &str, tail: &str, comment_lines: usize) -> String { + let mut result = head.to_string(); + result.push_str("\n"); + result.push_str(&"//\n".repeat(comment_lines)); + result.push_str(tail); + result + } + + fn bracket_colors_markup(cx: &mut EditorTestContext) -> String { + cx.update_editor(|editor, window, cx| { + editor_bracket_colors_markup(&editor.snapshot(window, cx)) + }) + } + + fn editor_bracket_colors_markup(snapshot: &EditorSnapshot) -> String { + fn display_point_to_offset(text: &str, point: DisplayPoint) -> usize { + let mut offset = 0; + for (row_idx, line) in text.lines().enumerate() { + if row_idx < point.row().0 as usize { + offset += line.len() + 1; // +1 for newline + } else { + offset += point.column() as usize; + break; + } + } + offset + } + + let actual_ranges = snapshot.all_text_highlight_ranges::(); + let editor_text = snapshot.text(); + + let mut next_index = 1; + let mut color_to_index = HashMap::default(); + let mut annotations = Vec::new(); + for (color, range) in &actual_ranges { + let color_index = *color_to_index + .entry(*color) + .or_insert_with(|| post_inc(&mut next_index)); + let start = snapshot.point_to_display_point(range.start, Bias::Left); + let end = snapshot.point_to_display_point(range.end, Bias::Right); + let start_offset = display_point_to_offset(&editor_text, start); + let end_offset = display_point_to_offset(&editor_text, end); + let bracket_text = &editor_text[start_offset..end_offset]; + let bracket_char = bracket_text.chars().next().unwrap(); + + if matches!(bracket_char, '{' | '[' | '(' | '<') { + annotations.push((start_offset, format!("«{color_index}"))); + } else { + annotations.push((end_offset, format!("{color_index}»"))); + } + } + + annotations.sort_by(|(pos_a, text_a), (pos_b, text_b)| { + pos_a.cmp(pos_b).reverse().then_with(|| { + let a_is_opening = text_a.starts_with('«'); + let b_is_opening = text_b.starts_with('«'); + match (a_is_opening, b_is_opening) { + (true, false) => cmp::Ordering::Less, + (false, true) => cmp::Ordering::Greater, + _ => cmp::Ordering::Equal, + } + }) + }); + annotations.dedup(); + + let mut markup = editor_text; + for (offset, text) in annotations { + markup.insert_str(offset, &text); + } + + markup.push_str("\n"); + for (index, color) in color_to_index + .iter() + .map(|(color, index)| (*index, *color)) + .sorted_by_key(|(index, _)| *index) + { + markup.push_str(&format!("{index} {color}\n")); + } + + markup + } +} diff --git a/crates/editor/src/clangd_ext.rs b/crates/editor/src/clangd_ext.rs index 17ed522211..4993658098 100644 --- a/crates/editor/src/clangd_ext.rs +++ b/crates/editor/src/clangd_ext.rs @@ -88,10 +88,14 @@ pub fn switch_source_header( ) })?; - let path = PathBuf::from(goto); - workspace .update_in(cx, |workspace, window, cx| { + let goto = if workspace.path_style(cx).is_windows() { + goto.strip_prefix('/').unwrap_or(goto) + } else { + goto + }; + let path = PathBuf::from(goto); workspace.open_abs_path( path, OpenOptions { diff --git a/crates/editor/src/code_completion_tests.rs b/crates/editor/src/code_completion_tests.rs index ec97c0ebb3..4602824486 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/crates/editor/src/code_completion_tests.rs @@ -239,6 +239,89 @@ async fn test_fuzzy_over_sort_positions(cx: &mut TestAppContext) { assert_eq!(matches[2].string, "fetch_code_lens"); } +#[gpui::test] +async fn test_semver_label_sort_by_latest_version(cx: &mut TestAppContext) { + let mut versions = [ + "10.4.112", + "10.4.22", + "10.4.2", + "10.4.20", + "10.4.21", + "10.4.12", + // Pre-release versions + "10.4.22-alpha", + "10.4.22-beta.1", + "10.4.22-rc.1", + // Build metadata versions + "10.4.21+build.123", + "10.4.20+20210327", + ]; + versions.sort_by(|a, b| { + match ( + semver::Version::parse(a).ok(), + semver::Version::parse(b).ok(), + ) { + (Some(a_ver), Some(b_ver)) => b_ver.cmp(&a_ver), + _ => std::cmp::Ordering::Equal, + } + }); + let completions: Vec<_> = versions + .iter() + .enumerate() + .map(|(i, version)| { + // This sort text would come from the LSP + let sort_text = format!("{:08}", i); + CompletionBuilder::new(version, None, &sort_text, None) + }) + .collect(); + + // Case 1: User types just the major and minor version + let matches = + filter_and_sort_matches("10.4.", &completions, SnippetSortOrder::default(), cx).await; + // Versions are ordered by recency (latest first) + let expected_versions = [ + "10.4.112", + "10.4.22", + "10.4.22-rc.1", + "10.4.22-beta.1", + "10.4.22-alpha", + "10.4.21+build.123", + "10.4.21", + "10.4.20+20210327", + "10.4.20", + "10.4.12", + "10.4.2", + ]; + for (match_item, expected) in matches.iter().zip(expected_versions.iter()) { + assert_eq!(match_item.string.as_ref() as &str, *expected); + } + + // Case 2: User types the major, minor, and patch version + let matches = + filter_and_sort_matches("10.4.2", &completions, SnippetSortOrder::default(), cx).await; + let expected_versions = [ + // Exact match comes first + "10.4.2", + // Ordered by recency with exact major, minor, and patch versions + "10.4.22", + "10.4.22-rc.1", + "10.4.22-beta.1", + "10.4.22-alpha", + "10.4.21+build.123", + "10.4.21", + "10.4.20+20210327", + "10.4.20", + // Versions with non-exact patch versions are ordered by fuzzy score + // Higher fuzzy score than 112 patch version since "2" appears before "1" + // in "12", making it rank higher than "112" + "10.4.12", + "10.4.112", + ]; + for (match_item, expected) in matches.iter().zip(expected_versions.iter()) { + assert_eq!(match_item.string.as_ref() as &str, *expected); + } +} + async fn test_for_each_prefix( target: &str, completions: &Vec, @@ -259,30 +342,55 @@ struct CompletionBuilder; impl CompletionBuilder { fn constant(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::CONSTANT) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::CONSTANT), + ) } fn function(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::FUNCTION) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::FUNCTION), + ) } fn method(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::METHOD) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::METHOD), + ) } fn variable(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::VARIABLE) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::VARIABLE), + ) } fn snippet(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::SNIPPET) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::SNIPPET), + ) } fn new( label: &str, filter_text: Option<&str>, sort_text: &str, - kind: CompletionItemKind, + kind: Option, ) -> Completion { Completion { replace_range: Anchor::MIN..Anchor::MAX, @@ -294,7 +402,7 @@ impl CompletionBuilder { server_id: LanguageServerId(0), lsp_completion: Box::new(CompletionItem { label: label.to_string(), - kind: Some(kind), + kind: kind, sort_text: Some(sort_text.to_string()), filter_text: filter_text.map(|text| text.to_string()), ..Default::default() @@ -305,6 +413,8 @@ impl CompletionBuilder { icon_path: None, insert_text_mode: None, confirm: None, + match_start: None, + snippet_deduplication_key: None, } } } diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index a89125a3aa..d255effdb7 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -8,6 +8,7 @@ use gpui::{ use itertools::Itertools; use language::CodeLabel; use language::{Buffer, LanguageName, LanguageRegistry}; +use lsp::CompletionItemTag; use markdown::{Markdown, MarkdownElement}; use multi_buffer::{Anchor, ExcerptId}; use ordered_float::OrderedFloat; @@ -17,7 +18,6 @@ use project::{CompletionDisplayOptions, CompletionSource}; use task::DebugScenario; use task::TaskContext; -use std::collections::VecDeque; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::{ @@ -28,23 +28,29 @@ use std::{ rc::Rc, }; use task::ResolvedTask; -use ui::{Color, IntoElement, ListItem, Pixels, Popover, Styled, prelude::*}; +use ui::{ + Color, IntoElement, ListItem, Pixels, Popover, ScrollAxes, Scrollbars, Styled, WithScrollbar, + prelude::*, +}; use util::ResultExt; -use crate::CodeActionSource; use crate::hover_popover::{hover_markdown_style, open_markdown_url}; use crate::{ - CodeActionProvider, CompletionId, CompletionItemKind, CompletionProvider, DisplayRow, Editor, - EditorStyle, ResolvedTasks, + CodeActionProvider, CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, + ResolvedTasks, actions::{ConfirmCodeAction, ConfirmCompletion}, split_words, styled_runs_for_code_label, }; -use settings::SnippetSortOrder; +use crate::{CodeActionSource, EditorSettings}; +use collections::{HashSet, VecDeque}; +use settings::{Settings, SnippetSortOrder}; pub const MENU_GAP: Pixels = px(4.); pub const MENU_ASIDE_X_PADDING: Pixels = px(16.); pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.); pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.); +pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.); +pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.); // Constants for the markdown cache. The purpose of this cache is to reduce flickering due to // documentation not yet being parsed. @@ -200,6 +206,13 @@ impl CodeContextMenu { CodeContextMenu::CodeActions(_) => (), } } + + pub fn primary_scroll_handle(&self) -> UniformListScrollHandle { + match self { + CodeContextMenu::Completions(menu) => menu.scroll_handle.clone(), + CodeContextMenu::CodeActions(menu) => menu.scroll_handle.clone(), + } + } } pub enum ContextMenuOrigin { @@ -217,7 +230,9 @@ pub struct CompletionsMenu { pub is_incomplete: bool, pub buffer: Entity, pub completions: Rc>>, - match_candidates: Arc<[StringMatchCandidate]>, + /// String match candidate for each completion, grouped by `match_start`. + match_candidates: Arc<[(Option, Vec)]>, + /// Entries displayed in the menu, which is a filtered and sorted subset of `match_candidates`. pub entries: Rc>>, pub selected_item: usize, filter_task: Task<()>, @@ -249,8 +264,17 @@ enum MarkdownCacheKey { #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum CompletionsMenuSource { + /// Show all completions (words, snippets, LSP) Normal, + /// Show only snippets (not words or LSP) + /// + /// Used after typing a non-word character + SnippetsOnly, + /// Tab stops within a snippet that have a predefined finite set of choices SnippetChoices, + /// Show only words (not snippets or LSP) + /// + /// Used when word completions are explicitly triggered Words { ignore_threshold: bool }, } @@ -261,6 +285,20 @@ impl Drop for CompletionsMenu { } } +struct CompletionMenuScrollBarSetting; + +impl ui::scrollbars::GlobalSetting for CompletionMenuScrollBarSetting { + fn get_value(_cx: &App) -> &Self { + &Self + } +} + +impl ui::scrollbars::ScrollbarVisibility for CompletionMenuScrollBarSetting { + fn visibility(&self, cx: &App) -> ui::scrollbars::ShowScrollbar { + EditorSettings::get_global(cx).completion_menu_scrollbar + } +} + impl CompletionsMenu { pub fn new( id: CompletionId, @@ -272,6 +310,7 @@ impl CompletionsMenu { is_incomplete: bool, buffer: Entity, completions: Box<[Completion]>, + scroll_handle: Option, display_options: CompletionDisplayOptions, snippet_sort_order: SnippetSortOrder, language_registry: Option>, @@ -282,6 +321,8 @@ impl CompletionsMenu { .iter() .enumerate() .map(|(id, completion)| StringMatchCandidate::new(id, completion.label.filter_text())) + .into_group_map_by(|candidate| completions[candidate.id].match_start) + .into_iter() .collect(); let completions_menu = Self { @@ -299,7 +340,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: true, last_rendered_range: RefCell::new(None).into(), @@ -321,6 +362,7 @@ impl CompletionsMenu { choices: &Vec, selection: Range, buffer: Entity, + scroll_handle: Option, snippet_sort_order: SnippetSortOrder, ) -> Self { let completions = choices @@ -328,11 +370,9 @@ impl CompletionsMenu { .map(|choice| Completion { replace_range: selection.start.text_anchor..selection.end.text_anchor, new_text: choice.to_string(), - label: CodeLabel { - text: choice.to_string(), - runs: Default::default(), - filter_range: Default::default(), - }, + label: CodeLabel::plain(choice.to_string(), None), + match_start: None, + snippet_deduplication_key: None, icon_path: None, documentation: None, confirm: None, @@ -341,11 +381,14 @@ impl CompletionsMenu { }) .collect(); - let match_candidates = choices - .iter() - .enumerate() - .map(|(id, completion)| StringMatchCandidate::new(id, completion)) - .collect(); + let match_candidates = Arc::new([( + None, + choices + .iter() + .enumerate() + .map(|(id, completion)| StringMatchCandidate::new(id, completion)) + .collect(), + )]); let entries = choices .iter() .enumerate() @@ -370,7 +413,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: false, show_completion_documentation: false, @@ -475,7 +518,7 @@ impl CompletionsMenu { cx: &mut Context, ) { self.scroll_handle - .scroll_to_item(self.selected_item, ScrollStrategy::Top); + .scroll_to_item(self.selected_item, ScrollStrategy::Nearest); if let Some(provider) = provider { let entries = self.entries.borrow(); let entry = if self.selected_item < entries.len() { @@ -801,27 +844,38 @@ impl CompletionsMenu { FontWeight::BOLD.into(), ) }), - styled_runs_for_code_label(&completion.label, &style.syntax).map( - |(range, mut highlight)| { - // Ignore font weight for syntax highlighting, as we'll use it - // for fuzzy matches. - highlight.font_weight = None; - if completion - .source - .lsp_completion(false) - .and_then(|lsp_completion| lsp_completion.deprecated) - .unwrap_or(false) - { - highlight.strikethrough = Some(StrikethroughStyle { - thickness: 1.0.into(), - ..Default::default() - }); - highlight.color = Some(cx.theme().colors().text_muted); - } + styled_runs_for_code_label( + &completion.label, + &style.syntax, + &style.local_player, + ) + .map(|(range, mut highlight)| { + // Ignore font weight for syntax highlighting, as we'll use it + // for fuzzy matches. + highlight.font_weight = None; + if completion + .source + .lsp_completion(false) + .and_then(|lsp_completion| { + match (lsp_completion.deprecated, &lsp_completion.tags) { + (Some(true), _) => Some(true), + (_, Some(tags)) => { + Some(tags.contains(&CompletionItemTag::DEPRECATED)) + } + _ => None, + } + }) + .unwrap_or(false) + { + highlight.strikethrough = Some(StrikethroughStyle { + thickness: 1.0.into(), + ..Default::default() + }); + highlight.color = Some(cx.theme().colors().text_muted); + } - (range, highlight) - }, - ), + (range, highlight) + }), ); let completion_label = StyledText::new(completion.label.text.clone()) @@ -866,33 +920,36 @@ impl CompletionsMenu { }) }); - div().min_w(px(280.)).max_w(px(540.)).child( - ListItem::new(mat.candidate_id) - .inset(true) - .toggle_state(item_ix == selected_item) - .on_click(cx.listener(move |editor, _event, window, cx| { - cx.stop_propagation(); - if let Some(task) = editor.confirm_completion( - &ConfirmCompletion { - item_ix: Some(item_ix), - }, - window, - cx, - ) { - task.detach_and_log_err(cx) - } - })) - .start_slot::(start_slot) - .child(h_flex().overflow_hidden().child(completion_label)) - .end_slot::
{ let padding_right = if icon.is_some() { px(4.) } else { px(8.) }; @@ -9322,6 +9641,67 @@ impl Editor { }) } + fn render_edit_prediction_jump_outside_popover( + &self, + snapshot: &BufferSnapshot, + window: &mut Window, + cx: &mut App, + ) -> Stateful
{ + let keybind = self.render_edit_prediction_accept_keybind(window, cx); + let has_keybind = keybind.is_some(); + + let file_name = snapshot + .file() + .map(|file| SharedString::new(file.file_name(cx))) + .unwrap_or(SharedString::new_static("untitled")); + + h_flex() + .id("ep-jump-outside-popover") + .py_1() + .px_2() + .gap_1() + .rounded_md() + .border_1() + .bg(Self::edit_prediction_line_popover_bg_color(cx)) + .border_color(Self::edit_prediction_callout_popover_border_color(cx)) + .shadow_xs() + .when(!has_keybind, |el| { + let status_colors = cx.theme().status(); + + el.bg(status_colors.error_background) + .border_color(status_colors.error.opacity(0.6)) + .pl_2() + .child(Icon::new(IconName::ZedPredictError).color(Color::Error)) + .cursor_default() + .hoverable_tooltip(move |_window, cx| { + cx.new(|_| MissingEditPredictionKeybindingTooltip).into() + }) + }) + .children(keybind) + .child( + Label::new(file_name) + .size(LabelSize::Small) + .buffer_font(cx) + .when(!has_keybind, |el| { + el.color(cx.theme().status().error.into()).strikethrough() + }), + ) + .when(!has_keybind, |el| { + el.child( + h_flex().ml_1().child( + Icon::new(IconName::Info) + .size(IconSize::Small) + .color(cx.theme().status().error.into()), + ), + ) + }) + .child( + div() + .mt(px(1.5)) + .child(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)), + ) + } + fn edit_prediction_line_popover_bg_color(cx: &App) -> Hsla { let accent_color = cx.theme().colors().text_accent; let editor_bg_color = cx.theme().colors().editor_background; @@ -9334,7 +9714,7 @@ impl Editor { editor_bg_color.blend(accent_color.opacity(0.6)) } fn get_prediction_provider_icon_name( - provider: &Option, + provider: &Option, ) -> IconName { match provider { Some(provider) => match provider.provider.name() { @@ -9664,8 +10044,7 @@ impl Editor { } pub fn render_context_menu( - &self, - style: &EditorStyle, + &mut self, max_height_in_lines: u32, window: &mut Window, cx: &mut Context, @@ -9675,7 +10054,9 @@ impl Editor { if !menu.visible() { return None; }; - Some(menu.render(style, max_height_in_lines, window, cx)) + self.style + .as_ref() + .map(|style| menu.render(style, max_height_in_lines, window, cx)) } fn render_context_menu_aside( @@ -9735,13 +10116,16 @@ impl Editor { let id = post_inc(&mut self.next_completion_id); let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; - *self.context_menu.borrow_mut() = Some(CodeContextMenu::Completions( + let mut context_menu = self.context_menu.borrow_mut(); + let old_menu = context_menu.take(); + *context_menu = Some(CodeContextMenu::Completions( CompletionsMenu::new_snippet_choices( id, true, choices, selection, buffer, + old_menu.map(|menu| menu.primary_scroll_handle()), snippet_sort_order, ), )); @@ -9749,7 +10133,7 @@ impl Editor { pub fn insert_snippet( &mut self, - insertion_ranges: &[Range], + insertion_ranges: &[Range], snippet: Snippet, window: &mut Window, cx: &mut Context, @@ -9786,14 +10170,13 @@ impl Editor { .flat_map(|tabstop_range| { let mut delta = 0_isize; insertion_ranges.iter().map(move |insertion_range| { - let insertion_start = insertion_range.start as isize + delta; - delta += - snippet.text.len() as isize - insertion_range.len() as isize; + let insertion_start = insertion_range.start + delta; + delta += snippet.text.len() as isize + - (insertion_range.end - insertion_range.start) as isize; - let start = ((insertion_start + tabstop_range.start) as usize) - .min(snapshot.len()); - let end = ((insertion_start + tabstop_range.end) as usize) - .min(snapshot.len()); + let start = + (insertion_start + tabstop_range.start).min(snapshot.len()); + let end = (insertion_start + tabstop_range.end).min(snapshot.len()); snapshot.anchor_before(start)..snapshot.anchor_after(end) }) }) @@ -9844,8 +10227,7 @@ impl Editor { // Check whether the just-entered snippet ends with an auto-closable bracket. if self.autoclose_regions.is_empty() { let snapshot = self.buffer.read(cx).snapshot(cx); - let mut all_selections = self.selections.all::(cx); - for selection in &mut all_selections { + for selection in &mut self.selections.all::(&self.display_snapshot(cx)) { let selection_head = selection.head(); let Some(scope) = snapshot.language_scope_at(selection_head) else { continue; @@ -9983,9 +10365,12 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); self.transact(window, cx, |this, window, cx| { this.select_autoclose_pair(window, cx); + + let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut linked_ranges = HashMap::<_, Vec<_>>::default(); if !this.linked_edit_ranges.is_empty() { - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&display_map); let snapshot = this.buffer.read(cx).snapshot(cx); for selection in selections.iter() { @@ -10004,8 +10389,7 @@ impl Editor { } } - let mut selections = this.selections.all::(cx); - let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut selections = this.selections.all::(&display_map); for selection in &mut selections { if selection.is_empty() { let old_head = selection.head(); @@ -10068,7 +10452,7 @@ impl Editor { }) } this.refresh_edit_prediction(true, false, window, cx); - linked_editing_ranges::refresh_linked_ranges(this, window, cx); + refresh_linked_ranges(this, window, cx); }); } @@ -10106,6 +10490,42 @@ impl Editor { self.outdent(&Outdent, window, cx); } + pub fn next_snippet_tabstop( + &mut self, + _: &NextSnippetTabstop, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_single_line() || self.snippet_stack.is_empty() { + cx.propagate(); + return; + } + + if self.move_to_next_snippet_tabstop(window, cx) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + return; + } + cx.propagate(); + } + + pub fn previous_snippet_tabstop( + &mut self, + _: &PreviousSnippetTabstop, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_single_line() || self.snippet_stack.is_empty() { + cx.propagate(); + return; + } + + if self.move_to_prev_snippet_tabstop(window, cx) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + return; + } + cx.propagate(); + } + pub fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { if self.mode.is_single_line() { cx.propagate(); @@ -10120,7 +10540,7 @@ impl Editor { return; } self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); - let mut selections = self.selections.all_adjusted(cx); + let mut selections = self.selections.all_adjusted(&self.display_snapshot(cx)); let buffer = self.buffer.read(cx); let snapshot = buffer.snapshot(cx); let rows_iter = selections.iter().map(|s| s.head().row); @@ -10236,7 +10656,7 @@ impl Editor { } self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); - let mut selections = self.selections.all::(cx); + let mut selections = self.selections.all::(&self.display_snapshot(cx)); let mut prev_edited_row = 0; let mut row_delta = 0; let mut edits = Vec::new(); @@ -10345,7 +10765,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut deletion_ranges = Vec::new(); let mut last_outdent = None; { @@ -10406,7 +10826,9 @@ impl Editor { cx, ); }); - let selections = this.selections.all::(cx); + let selections = this + .selections + .all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); }); } @@ -10423,7 +10845,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let selections = self .selections - .all::(cx) + .all::(&self.display_snapshot(cx)) .into_iter() .map(|s| s.range()); @@ -10431,7 +10853,9 @@ impl Editor { this.buffer.update(cx, |buffer, cx| { buffer.autoindent_ranges(selections, cx); }); - let selections = this.selections.all::(cx); + let selections = this + .selections + .all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); }); } @@ -10439,7 +10863,7 @@ impl Editor { pub fn delete_line(&mut self, _: &DeleteLine, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut new_cursors = Vec::new(); let mut edit_ranges = Vec::new(); @@ -10460,29 +10884,33 @@ impl Editor { let buffer = display_map.buffer_snapshot(); let mut edit_start = ToOffset::to_offset(&Point::new(rows.start.0, 0), buffer); - let edit_end = if buffer.max_point().row >= rows.end.0 { + let (edit_end, target_row) = if buffer.max_point().row >= rows.end.0 { // If there's a line after the range, delete the \n from the end of the row range - ToOffset::to_offset(&Point::new(rows.end.0, 0), buffer) + ( + ToOffset::to_offset(&Point::new(rows.end.0, 0), buffer), + rows.end, + ) } else { // If there isn't a line after the range, delete the \n from the line before the // start of the row range - edit_start = edit_start.saturating_sub(1); - buffer.len() + edit_start = edit_start.saturating_sub_usize(1); + (buffer.len(), rows.start.previous_row()) }; - let (cursor, goal) = movement::down_by_rows( - &display_map, + let text_layout_details = self.text_layout_details(window); + let x = display_map.x_for_display_point( selection.head().to_display_point(&display_map), - rows.len() as u32, - selection.goal, - false, - &self.text_layout_details(window), + &text_layout_details, ); + let row = Point::new(target_row.0, 0) + .to_display_point(&display_map) + .row(); + let column = display_map.display_column_for_x(row, x, &text_layout_details); new_cursors.push(( selection.id, - buffer.anchor_after(cursor.to_point(&display_map)), - goal, + buffer.anchor_after(DisplayPoint::new(row, column).to_point(&display_map)), + SelectionGoal::None, )); edit_ranges.push(edit_start..edit_end); } @@ -10529,7 +10957,7 @@ impl Editor { return; } let mut row_ranges = Vec::>::new(); - for selection in self.selections.all::(cx) { + for selection in self.selections.all::(&self.display_snapshot(cx)) { let start = MultiBufferRow(selection.start.row); // Treat single line selections as if they include the next line. Otherwise this action // would do nothing for single line selections individual cursors. @@ -10672,7 +11100,11 @@ impl Editor { let mut edits = Vec::new(); let mut boundaries = Vec::new(); - for selection in self.selections.all::(cx).iter() { + for selection in self + .selections + .all_adjusted(&self.display_snapshot(cx)) + .iter() + { let Some(wrap_config) = snapshot .language_at(selection.start) .and_then(|lang| lang.config().wrap_characters.clone()) @@ -10712,7 +11144,9 @@ impl Editor { boundaries.into_iter() { let open_offset = start_before.to_offset(&buffer) + start_prefix_len; - let close_offset = end_after.to_offset(&buffer).saturating_sub(end_suffix_len); + let close_offset = end_after + .to_offset(&buffer) + .saturating_sub_usize(end_suffix_len); new_selections.push(open_offset..open_offset); new_selections.push(close_offset..close_offset); } @@ -10742,7 +11176,10 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let mut buffer_ids = HashSet::default(); let snapshot = self.buffer().read(cx).snapshot(cx); - for selection in self.selections.all::(cx) { + for selection in self + .selections + .all::(&self.display_snapshot(cx)) + { buffer_ids.extend(snapshot.buffer_ids_for_range(selection.range())) } @@ -10759,7 +11196,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let selections = self .selections - .all(cx) + .all(&self.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect(); @@ -10793,6 +11230,20 @@ impl Editor { } } + pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { + if let Some(status) = self + .addons + .iter() + .find_map(|(_, addon)| addon.override_status_for_buffer_id(buffer_id, cx)) + { + return Some(status); + } + self.project + .as_ref()? + .read(cx) + .status_for_buffer_id(buffer_id, cx) + } + pub fn open_active_item_in_terminal( &mut self, _: &OpenInTerminal, @@ -10955,6 +11406,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + if self.breakpoint_store.is_none() { + return; + } + for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { let breakpoint = breakpoint.unwrap_or_else(|| Breakpoint { message: None, @@ -11014,6 +11469,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + if self.breakpoint_store.is_none() { + return; + } + for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { let Some(breakpoint) = breakpoint.filter(|breakpoint| breakpoint.is_disabled()) else { continue; @@ -11033,6 +11492,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + if self.breakpoint_store.is_none() { + return; + } + for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { let Some(breakpoint) = breakpoint.filter(|breakpoint| breakpoint.is_enabled()) else { continue; @@ -11052,6 +11515,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + if self.breakpoint_store.is_none() { + return; + } + for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { if let Some(breakpoint) = breakpoint { self.edit_breakpoint_at_anchor( @@ -11127,7 +11594,7 @@ impl Editor { .read(cx) .base_text() .as_rope() - .slice(hunk.diff_base_byte_range.clone()); + .slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0); let buffer_snapshot = buffer.snapshot(); let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { @@ -11152,6 +11619,168 @@ impl Editor { self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut rand::rng())) } + pub fn rotate_selections_forward( + &mut self, + _: &RotateSelectionsForward, + window: &mut Window, + cx: &mut Context, + ) { + self.rotate_selections(window, cx, false) + } + + pub fn rotate_selections_backward( + &mut self, + _: &RotateSelectionsBackward, + window: &mut Window, + cx: &mut Context, + ) { + self.rotate_selections(window, cx, true) + } + + fn rotate_selections(&mut self, window: &mut Window, cx: &mut Context, reverse: bool) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + let display_snapshot = self.display_snapshot(cx); + let selections = self.selections.all::(&display_snapshot); + + if selections.len() < 2 { + return; + } + + let (edits, new_selections) = { + let buffer = self.buffer.read(cx).read(cx); + let has_selections = selections.iter().any(|s| !s.is_empty()); + if has_selections { + let mut selected_texts: Vec = selections + .iter() + .map(|selection| { + buffer + .text_for_range(selection.start..selection.end) + .collect() + }) + .collect(); + + if reverse { + selected_texts.rotate_left(1); + } else { + selected_texts.rotate_right(1); + } + + let mut offset_delta: i64 = 0; + let mut new_selections = Vec::new(); + let edits: Vec<_> = selections + .iter() + .zip(selected_texts.iter()) + .map(|(selection, new_text)| { + let old_len = (selection.end.0 - selection.start.0) as i64; + let new_len = new_text.len() as i64; + let adjusted_start = + MultiBufferOffset((selection.start.0 as i64 + offset_delta) as usize); + let adjusted_end = + MultiBufferOffset((adjusted_start.0 as i64 + new_len) as usize); + + new_selections.push(Selection { + id: selection.id, + start: adjusted_start, + end: adjusted_end, + reversed: selection.reversed, + goal: selection.goal, + }); + + offset_delta += new_len - old_len; + (selection.start..selection.end, new_text.clone()) + }) + .collect(); + (edits, new_selections) + } else { + let mut all_rows: Vec = selections + .iter() + .map(|selection| buffer.offset_to_point(selection.start).row) + .collect(); + all_rows.sort_unstable(); + all_rows.dedup(); + + if all_rows.len() < 2 { + return; + } + + let line_ranges: Vec> = all_rows + .iter() + .map(|&row| { + let start = Point::new(row, 0); + let end = Point::new(row, buffer.line_len(MultiBufferRow(row))); + buffer.point_to_offset(start)..buffer.point_to_offset(end) + }) + .collect(); + + let mut line_texts: Vec = line_ranges + .iter() + .map(|range| buffer.text_for_range(range.clone()).collect()) + .collect(); + + if reverse { + line_texts.rotate_left(1); + } else { + line_texts.rotate_right(1); + } + + let edits = line_ranges + .iter() + .zip(line_texts.iter()) + .map(|(range, new_text)| (range.clone(), new_text.clone())) + .collect(); + + let num_rows = all_rows.len(); + let row_to_index: std::collections::HashMap = all_rows + .iter() + .enumerate() + .map(|(i, &row)| (row, i)) + .collect(); + + // Compute new line start offsets after rotation (handles CRLF) + let newline_len = line_ranges[1].start.0 - line_ranges[0].end.0; + let first_line_start = line_ranges[0].start.0; + let mut new_line_starts: Vec = vec![first_line_start]; + for text in line_texts.iter().take(num_rows - 1) { + let prev_start = *new_line_starts.last().unwrap(); + new_line_starts.push(prev_start + text.len() + newline_len); + } + + let new_selections = selections + .iter() + .map(|selection| { + let point = buffer.offset_to_point(selection.start); + let old_index = row_to_index[&point.row]; + let new_index = if reverse { + (old_index + num_rows - 1) % num_rows + } else { + (old_index + 1) % num_rows + }; + let new_offset = + MultiBufferOffset(new_line_starts[new_index] + point.column as usize); + Selection { + id: selection.id, + start: new_offset, + end: new_offset, + reversed: selection.reversed, + goal: selection.goal, + } + }) + .collect(); + + (edits, new_selections) + } + }; + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + this.change_selections(Default::default(), window, cx, |s| { + s.select(new_selections); + }); + }); + } + fn manipulate_lines( &mut self, window: &mut Window, @@ -11167,7 +11796,7 @@ impl Editor { let mut edits = Vec::new(); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut selections = selections.iter().peekable(); let mut contiguous_row_selections = Vec::new(); let mut new_selections = Vec::new(); @@ -11567,9 +12196,9 @@ impl Editor { let mut new_selections = Vec::new(); let mut edits = Vec::new(); - let mut selection_adjustment = 0i32; + let mut selection_adjustment = 0isize; - for selection in self.selections.all_adjusted(cx) { + for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { let selection_is_empty = selection.is_empty(); let (start, end) = if selection_is_empty { @@ -11583,18 +12212,20 @@ impl Editor { }; let text = buffer.text_for_range(start..end).collect::(); - let old_length = text.len() as i32; + let old_length = text.len() as isize; let text = callback(&text); new_selections.push(Selection { - start: (start as i32 - selection_adjustment) as usize, - end: ((start + text.len()) as i32 - selection_adjustment) as usize, + start: MultiBufferOffset((start.0 as isize - selection_adjustment) as usize), + end: MultiBufferOffset( + ((start.0 + text.len()) as isize - selection_adjustment) as usize, + ), goal: SelectionGoal::None, id: selection.id, reversed: selection.reversed, }); - selection_adjustment += old_length - text.len() as i32; + selection_adjustment += old_length - text.len() as isize; edits.push((start..end, text)); } @@ -11661,7 +12292,7 @@ impl Editor { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let buffer = display_map.buffer_snapshot(); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut edits = Vec::new(); let mut selections_iter = selections.iter().peekable(); @@ -11687,13 +12318,26 @@ impl Editor { rows.end.previous_row().0, buffer.line_len(rows.end.previous_row()), ); - let text = buffer - .text_for_range(start..end) - .chain(Some("\n")) - .collect::(); + + let mut text = buffer.text_for_range(start..end).collect::(); + let insert_location = if upwards { - Point::new(rows.end.0, 0) + // When duplicating upward, we need to insert before the current line. + // If we're on the last line and it doesn't end with a newline, + // we need to add a newline before the duplicated content. + let needs_leading_newline = rows.end.0 >= buffer.max_point().row + && buffer.max_point().column > 0 + && !text.ends_with('\n'); + + if needs_leading_newline { + text.insert(0, '\n'); + end + } else { + text.push('\n'); + Point::new(rows.start.0, 0) + } } else { + text.push('\n'); start }; edits.push((insert_location..insert_location, text)); @@ -11706,11 +12350,57 @@ impl Editor { } } - self.transact(window, cx, |this, _, cx| { + self.transact(window, cx, |this, window, cx| { this.buffer.update(cx, |buffer, cx| { buffer.edit(edits, None, cx); }); + // When duplicating upward with whole lines, move the cursor to the duplicated line + if upwards && whole_lines { + let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + + this.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + let mut new_ranges = Vec::new(); + let selections = s.all::(&display_map); + let mut selections_iter = selections.iter().peekable(); + + while let Some(first_selection) = selections_iter.next() { + // Group contiguous selections together to find the total row span + let mut group_selections = vec![first_selection]; + let mut rows = first_selection.spanned_rows(false, &display_map); + + while let Some(next_selection) = selections_iter.peek() { + let next_rows = next_selection.spanned_rows(false, &display_map); + if next_rows.start < rows.end { + rows.end = next_rows.end; + group_selections.push(selections_iter.next().unwrap()); + } else { + break; + } + } + + let row_count = rows.end.0 - rows.start.0; + + // Move all selections in this group up by the total number of duplicated rows + for selection in group_selections { + let new_start = Point::new( + selection.start.row.saturating_sub(row_count), + selection.start.column, + ); + + let new_end = Point::new( + selection.end.row.saturating_sub(row_count), + selection.end.column, + ); + + new_ranges.push(new_start..new_end); + } + } + + s.select_ranges(new_ranges); + }); + } + this.request_autoscroll(Autoscroll::fit(), cx); }); } @@ -11756,7 +12446,7 @@ impl Editor { let mut unfold_ranges = Vec::new(); let mut refold_creases = Vec::new(); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut selections = selections.iter().peekable(); let mut contiguous_row_selections = Vec::new(); let mut new_selections = Vec::new(); @@ -11867,7 +12557,7 @@ impl Editor { let mut unfold_ranges = Vec::new(); let mut refold_creases = Vec::new(); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut selections = selections.iter().peekable(); let mut contiguous_row_selections = Vec::new(); let mut new_selections = Vec::new(); @@ -11952,7 +12642,7 @@ impl Editor { let text_layout_details = &self.text_layout_details(window); self.transact(window, cx, |this, window, cx| { let edits = this.change_selections(Default::default(), window, cx, |s| { - let mut edits: Vec<(Range, String)> = Default::default(); + let mut edits: Vec<(Range, String)> = Default::default(); s.move_with(|display_map, selection| { if !selection.is_empty() { return; @@ -11963,10 +12653,10 @@ impl Editor { if head.column() == display_map.line_len(head.row()) { transpose_offset = display_map .buffer_snapshot() - .clip_offset(transpose_offset.saturating_sub(1), Bias::Left); + .clip_offset(transpose_offset.saturating_sub_usize(1), Bias::Left); } - if transpose_offset == 0 { + if transpose_offset == MultiBufferOffset(0) { return; } @@ -11981,11 +12671,11 @@ impl Editor { let transpose_start = display_map .buffer_snapshot() - .clip_offset(transpose_offset.saturating_sub(1), Bias::Left); + .clip_offset(transpose_offset.saturating_sub_usize(1), Bias::Left); if edits.last().is_none_or(|e| e.0.end <= transpose_start) { let transpose_end = display_map .buffer_snapshot() - .clip_offset(transpose_offset + 1, Bias::Right); + .clip_offset(transpose_offset + 1usize, Bias::Right); if let Some(ch) = display_map .buffer_snapshot() .chars_at(transpose_start) @@ -12000,7 +12690,9 @@ impl Editor { }); this.buffer .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); - let selections = this.selections.all::(cx); + let selections = this + .selections + .all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| { s.select(selections); }); @@ -12019,7 +12711,7 @@ impl Editor { pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context) { let buffer = self.buffer.read(cx).snapshot(cx); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); #[derive(Clone, Debug, PartialEq)] enum CommentFormat { @@ -12395,11 +13087,12 @@ impl Editor { ) -> ClipboardItem { let mut text = String::new(); let buffer = self.buffer.read(cx).snapshot(cx); - let mut selections = self.selections.all::(cx); + let mut selections = self.selections.all::(&self.display_snapshot(cx)); let mut clipboard_selections = Vec::with_capacity(selections.len()); { let max_point = buffer.max_point(); let mut is_first = true; + let mut prev_selection_was_entire_line = false; for selection in &mut selections { let is_entire_line = (selection.is_empty() && cut_no_selection_line) || self.selections.line_mode(); @@ -12414,21 +13107,24 @@ impl Editor { } if is_first { is_first = false; - } else { + } else if !prev_selection_was_entire_line { text += "\n"; } + prev_selection_was_entire_line = is_entire_line; let mut len = 0; for chunk in buffer.text_for_range(selection.start..selection.end) { text.push_str(chunk); len += chunk.len(); } - clipboard_selections.push(ClipboardSelection { + + clipboard_selections.push(ClipboardSelection::for_buffer( len, is_entire_line, - first_line_indent: buffer - .indent_size_for_line(MultiBufferRow(selection.start.row)) - .len, - }); + selection.range(), + &buffer, + self.project.as_ref(), + cx, + )); } } @@ -12491,7 +13187,7 @@ impl Editor { } fn do_copy(&self, strip_leading_indents: bool, cx: &mut Context) { - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); let buffer = self.buffer.read(cx).read(cx); let mut text = String::new(); @@ -12499,13 +13195,23 @@ impl Editor { { let max_point = buffer.max_point(); let mut is_first = true; + let mut prev_selection_was_entire_line = false; for selection in &selections { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty() || self.selections.line_mode(); + let mut add_trailing_newline = false; if is_entire_line { start = Point::new(start.row, 0); - end = cmp::min(max_point, Point::new(end.row + 1, 0)); + let next_line_start = Point::new(end.row + 1, 0); + if next_line_start <= max_point { + end = next_line_start; + } else { + // We're on the last line without a trailing newline. + // Copy to the end of the line and add a newline afterwards. + end = Point::new(end.row, buffer.line_len(MultiBufferRow(end.row))); + add_trailing_newline = true; + } } let mut trimmed_selections = Vec::new(); @@ -12548,21 +13254,27 @@ impl Editor { for trimmed_range in trimmed_selections { if is_first { is_first = false; - } else { + } else if !prev_selection_was_entire_line { text += "\n"; } + prev_selection_was_entire_line = is_entire_line; let mut len = 0; for chunk in buffer.text_for_range(trimmed_range.start..trimmed_range.end) { text.push_str(chunk); len += chunk.len(); } - clipboard_selections.push(ClipboardSelection { + if add_trailing_newline { + text.push('\n'); + len += 1; + } + clipboard_selections.push(ClipboardSelection::for_buffer( len, is_entire_line, - first_line_indent: buffer - .indent_size_for_line(MultiBufferRow(trimmed_range.start.row)) - .len, - }); + trimmed_range, + &buffer, + self.project.as_ref(), + cx, + )); } } } @@ -12589,8 +13301,12 @@ impl Editor { self.transact(window, cx, |this, window, cx| { let had_active_edit_prediction = this.has_active_edit_prediction(); - let old_selections = this.selections.all::(cx); - let cursor_offset = this.selections.last::(cx).head(); + let display_map = this.display_snapshot(cx); + let old_selections = this.selections.all::(&display_map); + let cursor_offset = this + .selections + .last::(&display_map) + .head(); if let Some(mut clipboard_selections) = clipboard_selections { let all_selections_were_entire_line = @@ -12619,7 +13335,11 @@ impl Editor { let end_offset = start_offset + clipboard_selection.len; to_insert = &clipboard_text[start_offset..end_offset]; entire_line = clipboard_selection.is_entire_line; - start_offset = end_offset + 1; + start_offset = if entire_line { + end_offset + } else { + end_offset + 1 + }; original_indent_column = Some(clipboard_selection.first_line_indent); } else { to_insert = &*clipboard_text; @@ -12671,7 +13391,9 @@ impl Editor { ); }); - let selections = this.selections.all::(cx); + let selections = this + .selections + .all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); } else { let url = url::Url::parse(&clipboard_text).ok(); @@ -12723,6 +13445,10 @@ impl Editor { }); } + // 🤔 | .. | show_in_menu | + // | .. | true true + // | had_edit_prediction | false true + let trigger_in_words = this.show_edit_predictions_in_menu() || !had_active_edit_prediction; @@ -12736,7 +13462,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let selections = self.selections.all::(cx); + let selections = self + .selections + .all::(&self.display_snapshot(cx)); if selections.is_empty() { log::warn!("There should always be at least one selection in Zed. This is a bug."); @@ -13989,7 +14717,7 @@ impl Editor { } self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); self.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(vec![0..0]); + s.select_ranges(vec![Anchor::min()..Anchor::min()]); }); } @@ -13999,7 +14727,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let mut selection = self.selections.last::(cx); + let mut selection = self.selections.last::(&self.display_snapshot(cx)); selection.set_head(Point::zero(), SelectionGoal::None); self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); self.change_selections(Default::default(), window, cx, |s| { @@ -14078,7 +14806,9 @@ impl Editor { pub fn select_to_end(&mut self, _: &SelectToEnd, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let buffer = self.buffer.read(cx).snapshot(cx); - let mut selection = self.selections.first::(cx); + let mut selection = self + .selections + .first::(&self.display_snapshot(cx)); selection.set_head(buffer.len(), SelectionGoal::None); self.change_selections(Default::default(), window, cx, |s| { s.select(vec![selection]); @@ -14087,16 +14817,15 @@ impl Editor { pub fn select_all(&mut self, _: &SelectAll, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); - let end = self.buffer.read(cx).read(cx).len(); self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(vec![0..end]); + s.select_ranges(vec![Anchor::min()..Anchor::max()]); }); } pub fn select_line(&mut self, _: &SelectLine, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let mut selections = self.selections.all::(cx); + let mut selections = self.selections.all::(&display_map); let max_point = display_map.buffer_snapshot().max_point(); for selection in &mut selections { let rows = selection.spanned_rows(true, &display_map); @@ -14117,7 +14846,7 @@ impl Editor { ) { let selections = self .selections - .all::(cx) + .all::(&self.display_snapshot(cx)) .into_iter() .map(|selection| selection.start..selection.end) .collect::>(); @@ -14170,27 +14899,33 @@ impl Editor { pub fn add_selection_above( &mut self, - _: &AddSelectionAbove, + action: &AddSelectionAbove, window: &mut Window, cx: &mut Context, ) { - self.add_selection(true, window, cx); + self.add_selection(true, action.skip_soft_wrap, window, cx); } pub fn add_selection_below( &mut self, - _: &AddSelectionBelow, + action: &AddSelectionBelow, window: &mut Window, cx: &mut Context, ) { - self.add_selection(false, window, cx); + self.add_selection(false, action.skip_soft_wrap, window, cx); } - fn add_selection(&mut self, above: bool, window: &mut Window, cx: &mut Context) { + fn add_selection( + &mut self, + above: bool, + skip_soft_wrap: bool, + window: &mut Window, + cx: &mut Context, + ) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let all_selections = self.selections.all::(cx); + let all_selections = self.selections.all::(&display_map); let text_layout_details = self.text_layout_details(window); let (mut columnar_selections, new_selections_to_columnarize) = { @@ -14273,12 +15008,19 @@ impl Editor { }; let mut maybe_new_selection = None; + let direction = if above { -1 } else { 1 }; + while row != end_row { - if above { + if skip_soft_wrap { + row = display_map + .start_of_relative_buffer_row(DisplayPoint::new(row, 0), direction) + .row(); + } else if above { row.0 -= 1; } else { row.0 += 1; } + if let Some(new_selection) = self.selections.build_columnar_selection( &display_map, row, @@ -14317,7 +15059,7 @@ impl Editor { let final_selection_ids: HashSet<_> = self .selections - .all::(cx) + .all::(&display_map) .iter() .map(|s| s.id) .collect(); @@ -14334,9 +15076,55 @@ impl Editor { } } + pub fn insert_snippet_at_selections( + &mut self, + action: &InsertSnippet, + window: &mut Window, + cx: &mut Context, + ) { + self.try_insert_snippet_at_selections(action, window, cx) + .log_err(); + } + + fn try_insert_snippet_at_selections( + &mut self, + action: &InsertSnippet, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let insertion_ranges = self + .selections + .all::(&self.display_snapshot(cx)) + .into_iter() + .map(|selection| selection.range()) + .collect_vec(); + + let snippet = if let Some(snippet_body) = &action.snippet { + if action.language.is_none() && action.name.is_none() { + Snippet::parse(snippet_body)? + } else { + bail!("`snippet` is mutually exclusive with `language` and `name`") + } + } else if let Some(name) = &action.name { + let project = self.project().context("no project")?; + let snippet_store = project.read(cx).snippets().read(cx); + let snippet = snippet_store + .snippets_for(action.language.clone(), cx) + .into_iter() + .find(|snippet| snippet.name == *name) + .context("snippet not found")?; + Snippet::parse(&snippet.body)? + } else { + // todo(andrew): open modal to select snippet + bail!("`name` or `snippet` is required") + }; + + self.insert_snippet(&insertion_ranges, snippet, window, cx) + } + fn select_match_ranges( &mut self, - range: Range, + range: Range, reversed: bool, replace_newest: bool, auto_scroll: Option, @@ -14375,7 +15163,7 @@ impl Editor { cx: &mut Context, ) -> Result<()> { let buffer = display_map.buffer_snapshot(); - let mut selections = self.selections.all::(cx); + let mut selections = self.selections.all::(&display_map); if let Some(mut select_next_state) = self.select_next_state.take() { let query = &select_next_state.query; if !select_next_state.done { @@ -14385,14 +15173,15 @@ impl Editor { let bytes_after_last_selection = buffer.bytes_in_range(last_selection.end..buffer.len()); - let bytes_before_first_selection = buffer.bytes_in_range(0..first_selection.start); + let bytes_before_first_selection = + buffer.bytes_in_range(MultiBufferOffset(0)..first_selection.start); let query_matches = query .stream_find_iter(bytes_after_last_selection) .map(|result| (last_selection.end, result)) .chain( query .stream_find_iter(bytes_before_first_selection) - .map(|result| (0, result)), + .map(|result| (MultiBufferOffset(0), result)), ); for (start_offset, query_match) in query_matches { @@ -14404,11 +15193,13 @@ impl Editor { || (!buffer.is_inside_word(offset_range.start, None) && !buffer.is_inside_word(offset_range.end, None)) { - // TODO: This is n^2, because we might check all the selections - if !selections - .iter() - .any(|selection| selection.range().overlaps(&offset_range)) - { + let idx = selections + .partition_point(|selection| selection.end <= offset_range.start); + let overlaps = selections + .get(idx) + .map_or(false, |selection| selection.start < offset_range.end); + + if !overlaps { next_selected_range = Some(offset_range); break; } @@ -14448,7 +15239,7 @@ impl Editor { } if let Some(next_selection) = selections_iter.peek() { - if next_selection.range().len() == selection.range().len() { + if next_selection.len() == selection.len() { let next_selected_text = buffer .text_for_range(next_selection.range()) .collect::(); @@ -14490,7 +15281,7 @@ impl Editor { .collect::(); let is_empty = query.is_empty(); let select_state = SelectNextState { - query: AhoCorasick::new(&[query])?, + query: self.build_query(&[query], cx)?, wordwise: true, done: is_empty, }; @@ -14500,7 +15291,7 @@ impl Editor { } } else if let Some(selected_text) = selected_text { self.select_next_state = Some(SelectNextState { - query: AhoCorasick::new(&[selected_text])?, + query: self.build_query(&[selected_text], cx)?, wordwise: false, done: false, }); @@ -14536,18 +15327,21 @@ impl Editor { let mut new_selections = Vec::new(); - let reversed = self.selections.oldest::(cx).reversed; + let reversed = self + .selections + .oldest::(&display_map) + .reversed; let buffer = display_map.buffer_snapshot(); let query_matches = select_next_state .query - .stream_find_iter(buffer.bytes_in_range(0..buffer.len())); + .stream_find_iter(buffer.bytes_in_range(MultiBufferOffset(0)..buffer.len())); for query_match in query_matches.into_iter() { let query_match = query_match.context("query match for select all action")?; // can only fail due to I/O let offset_range = if reversed { - query_match.end()..query_match.start() + MultiBufferOffset(query_match.end())..MultiBufferOffset(query_match.start()) } else { - query_match.start()..query_match.end() + MultiBufferOffset(query_match.start())..MultiBufferOffset(query_match.end()) }; if !select_next_state.wordwise @@ -14600,7 +15394,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let buffer = display_map.buffer_snapshot(); - let mut selections = self.selections.all::(cx); + let mut selections = self.selections.all::(&display_map); if let Some(mut select_prev_state) = self.select_prev_state.take() { let query = &select_prev_state.query; if !select_prev_state.done { @@ -14609,7 +15403,7 @@ impl Editor { let mut next_selected_range = None; // When we're iterating matches backwards, the oldest match will actually be the furthest one in the buffer. let bytes_before_last_selection = - buffer.reversed_bytes_in_range(0..last_selection.start); + buffer.reversed_bytes_in_range(MultiBufferOffset(0)..last_selection.start); let bytes_after_first_selection = buffer.reversed_bytes_in_range(first_selection.end..buffer.len()); let query_matches = query @@ -14667,7 +15461,7 @@ impl Editor { } if let Some(next_selection) = selections_iter.peek() { - if next_selection.range().len() == selection.range().len() { + if next_selection.len() == selection.len() { let next_selected_text = buffer .text_for_range(next_selection.range()) .collect::(); @@ -14708,7 +15502,7 @@ impl Editor { .collect::(); let is_empty = query.is_empty(); let select_state = SelectNextState { - query: AhoCorasick::new(&[query.chars().rev().collect::()])?, + query: self.build_query(&[query.chars().rev().collect::()], cx)?, wordwise: true, done: is_empty, }; @@ -14718,7 +15512,8 @@ impl Editor { } } else if let Some(selected_text) = selected_text { self.select_prev_state = Some(SelectNextState { - query: AhoCorasick::new(&[selected_text.chars().rev().collect::()])?, + query: self + .build_query(&[selected_text.chars().rev().collect::()], cx)?, wordwise: false, done: false, }); @@ -14728,6 +15523,25 @@ impl Editor { Ok(()) } + /// Builds an `AhoCorasick` automaton from the provided patterns, while + /// setting the case sensitivity based on the global + /// `SelectNextCaseSensitive` setting, if set, otherwise based on the + /// editor's settings. + fn build_query(&self, patterns: I, cx: &Context) -> Result + where + I: IntoIterator, + P: AsRef<[u8]>, + { + let case_sensitive = self.select_next_is_case_sensitive.map_or_else( + || EditorSettings::get_global(cx).search.case_sensitive, + |value| value, + ); + + let mut builder = AhoCorasickBuilder::new(); + builder.ascii_case_insensitive(!case_sensitive); + builder.build(patterns) + } + pub fn find_next_match( &mut self, _: &FindNextMatch, @@ -14788,7 +15602,9 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let text_layout_details = &self.text_layout_details(window); self.transact(window, cx, |this, window, cx| { - let mut selections = this.selections.all::(cx); + let mut selections = this + .selections + .all::(&this.display_snapshot(cx)); let mut edits = Vec::new(); let mut selection_edit_ranges = Vec::new(); let mut last_toggled_row = None; @@ -15019,7 +15835,7 @@ impl Editor { // Adjust selections so that they end before any comment suffixes that // were inserted. let mut suffixes_inserted = suffixes_inserted.into_iter().peekable(); - let mut selections = this.selections.all::(cx); + let mut selections = this.selections.all::(&this.display_snapshot(cx)); let snapshot = this.buffer.read(cx).read(cx); for selection in &mut selections { while let Some((row, suffix_len)) = suffixes_inserted.peek().copied() { @@ -15045,7 +15861,7 @@ impl Editor { drop(snapshot); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&this.display_snapshot(cx)); let selections_on_single_row = selections.windows(2).all(|selections| { selections[0].start.row == selections[1].start.row && selections[0].end.row == selections[1].end.row @@ -15089,12 +15905,15 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let buffer = self.buffer.read(cx).snapshot(cx); - let old_selections = self.selections.all::(cx).into_boxed_slice(); + let old_selections = self + .selections + .all::(&self.display_snapshot(cx)) + .into_boxed_slice(); fn update_selection( - selection: &Selection, + selection: &Selection, buffer_snap: &MultiBufferSnapshot, - ) -> Option> { + ) -> Option> { let cursor = selection.head(); let (_buffer_id, symbols) = buffer_snap.symbols_containing(cursor, None)?; for symbol in symbols.iter().rev() { @@ -15144,7 +15963,10 @@ impl Editor { let Some(visible_row_count) = self.visible_row_count() else { return; }; - let old_selections: Box<[_]> = self.selections.all::(cx).into(); + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); if old_selections.is_empty() { return; } @@ -15302,7 +16124,7 @@ impl Editor { let buffer = self.buffer.read(cx).snapshot(cx); let selections = self .selections - .all::(cx) + .all::(&self.display_snapshot(cx)) .into_iter() // subtracting the offset requires sorting .sorted_by_key(|i| i.start); @@ -15354,7 +16176,7 @@ impl Editor { let mut selections = vec![]; for (id, parent, text) in full_edits { let start = parent.start - offset; - offset += parent.len() - text.len(); + offset += (parent.end - parent.start) - text.len(); selections.push(Selection { id, start, @@ -15374,7 +16196,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let old_selections: Box<[_]> = self.selections.all::(cx).into(); + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); if old_selections.is_empty() { return; } @@ -15389,8 +16214,18 @@ impl Editor { .map(|selection| { let old_range = selection.start..selection.end; - if let Some(node) = buffer.syntax_next_sibling(old_range) { - let new_range = node.byte_range(); + let old_range = + old_range.start.to_offset(&buffer)..old_range.end.to_offset(&buffer); + let excerpt = buffer.excerpt_containing(old_range.clone()); + + if let Some(mut excerpt) = excerpt + && let Some(node) = excerpt + .buffer() + .syntax_next_sibling(excerpt.map_range_to_buffer(old_range)) + { + let new_range = excerpt.map_range_from_buffer( + BufferOffset(node.byte_range().start)..BufferOffset(node.byte_range().end), + ); selected_sibling = true; Selection { id: selection.id, @@ -15423,7 +16258,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let old_selections: Box<[_]> = self.selections.all::(cx).into(); + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); if old_selections.is_empty() { return; } @@ -15437,9 +16275,18 @@ impl Editor { .iter() .map(|selection| { let old_range = selection.start..selection.end; + let old_range = + old_range.start.to_offset(&buffer)..old_range.end.to_offset(&buffer); + let excerpt = buffer.excerpt_containing(old_range.clone()); - if let Some(node) = buffer.syntax_prev_sibling(old_range) { - let new_range = node.byte_range(); + if let Some(mut excerpt) = excerpt + && let Some(node) = excerpt + .buffer() + .syntax_prev_sibling(excerpt.map_range_to_buffer(old_range)) + { + let new_range = excerpt.map_range_from_buffer( + BufferOffset(node.byte_range().start)..BufferOffset(node.byte_range().end), + ); selected_sibling = true; Selection { id: selection.id, @@ -15588,7 +16435,7 @@ impl Editor { fn fetch_runnable_ranges( snapshot: &DisplaySnapshot, range: Range, - ) -> Vec { + ) -> Vec<(Range, language::RunnableRange)> { snapshot.buffer_snapshot().runnable_ranges(range).collect() } @@ -15596,12 +16443,12 @@ impl Editor { project: Entity, snapshot: DisplaySnapshot, prefer_lsp: bool, - runnable_ranges: Vec, + runnable_ranges: Vec<(Range, language::RunnableRange)>, cx: AsyncWindowContext, ) -> Task> { cx.spawn(async move |cx| { let mut runnable_rows = Vec::with_capacity(runnable_ranges.len()); - for mut runnable in runnable_ranges { + for (run_range, mut runnable) in runnable_ranges { let Some(tasks) = cx .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) .ok() @@ -15619,10 +16466,7 @@ impl Editor { continue; } - let point = runnable - .run_range - .start - .to_point(&snapshot.buffer_snapshot()); + let point = run_range.start.to_point(&snapshot.buffer_snapshot()); let Some(row) = snapshot .buffer_snapshot() .buffer_line_for_row(MultiBufferRow(point.row)) @@ -15637,9 +16481,7 @@ impl Editor { (runnable.buffer_id, row), RunnableTasks { templates: tasks, - offset: snapshot - .buffer_snapshot() - .anchor_before(runnable.run_range.start), + offset: snapshot.buffer_snapshot().anchor_before(run_range.start), context_range, column: point.column, extra_variables: runnable.extra_captures, @@ -15726,7 +16568,7 @@ impl Editor { let mut best_destination = None; for (open, close) in enclosing_bracket_ranges { let close = close.to_inclusive(); - let length = close.end() - open.start; + let length = *close.end() - open.start; let inside = selection.start >= open.end && selection.end <= *close.start(); let in_bracket_range = open.to_inclusive().contains(&selection.head()) || close.contains(&selection.head()); @@ -15877,7 +16719,7 @@ impl Editor { ) { let current_scroll_position = self.scroll_position(cx); let lines_to_expand = EditorSettings::get_global(cx).expand_excerpt_lines; - let mut should_scroll_up = false; + let mut scroll = None; if direction == ExpandExcerptDirection::Down { let multi_buffer = self.buffer.read(cx); @@ -15890,17 +16732,30 @@ impl Editor { let excerpt_end_row = Point::from_anchor(&excerpt_range.end, &buffer_snapshot).row; let last_row = buffer_snapshot.max_point().row; let lines_below = last_row.saturating_sub(excerpt_end_row); - should_scroll_up = lines_below >= lines_to_expand; + if lines_below >= lines_to_expand { + scroll = Some( + current_scroll_position + + gpui::Point::new(0.0, lines_to_expand as ScrollOffset), + ); + } } } + if direction == ExpandExcerptDirection::Up + && self + .buffer + .read(cx) + .snapshot(cx) + .excerpt_before(excerpt) + .is_none() + { + scroll = Some(current_scroll_position); + } self.buffer.update(cx, |buffer, cx| { buffer.expand_excerpts([excerpt], lines_to_expand, direction, cx) }); - if should_scroll_up { - let new_scroll_position = - current_scroll_position + gpui::Point::new(0.0, lines_to_expand as ScrollOffset); + if let Some(new_scroll_position) = scroll { self.set_scroll_position(new_scroll_position, window, cx); } } @@ -15972,7 +16827,9 @@ impl Editor { cx: &mut Context, ) { let buffer = self.buffer.read(cx).snapshot(cx); - let selection = self.selections.newest::(cx); + let selection = self + .selections + .newest::(&self.display_snapshot(cx)); let mut active_group_id = None; if let ActiveDiagnostic::Group(active_group) = &self.active_diagnostics @@ -15982,34 +16839,29 @@ impl Editor { } fn filtered<'a>( - snapshot: EditorSnapshot, severity: GoToDiagnosticSeverityFilter, - diagnostics: impl Iterator>, - ) -> impl Iterator> { + diagnostics: impl Iterator>, + ) -> impl Iterator> { diagnostics .filter(move |entry| severity.matches(entry.diagnostic.severity)) .filter(|entry| entry.range.start != entry.range.end) .filter(|entry| !entry.diagnostic.is_unnecessary) - .filter(move |entry| !snapshot.intersects_fold(entry.range.start)) } - let snapshot = self.snapshot(window, cx); let before = filtered( - snapshot.clone(), severity, buffer - .diagnostics_in_range(0..selection.start) + .diagnostics_in_range(MultiBufferOffset(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), ); - let mut found: Option> = None; + let mut found: Option> = None; if direction == Direction::Prev { 'outer: for prev_diagnostics in [before.collect::>(), after.collect::>()] { @@ -16041,6 +16893,15 @@ impl Editor { let Some(buffer_id) = buffer.buffer_id_for_anchor(next_diagnostic_start) else { return; }; + let snapshot = self.snapshot(window, cx); + if snapshot.intersects_fold(next_diagnostic.range.start) { + self.unfold_ranges( + std::slice::from_ref(&next_diagnostic.range), + true, + false, + cx, + ); + } self.change_selections(Default::default(), window, cx, |s| { s.select_ranges(vec![ next_diagnostic.range.start..next_diagnostic.range.start, @@ -16053,7 +16914,7 @@ impl Editor { pub fn go_to_next_hunk(&mut self, _: &GoToHunk, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let snapshot = self.snapshot(window, cx); - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); self.go_to_hunk_before_or_after_position( &snapshot, selection.head(), @@ -16114,7 +16975,7 @@ impl Editor { ) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let snapshot = self.snapshot(window, cx); - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&snapshot.display_snapshot); self.go_to_hunk_before_or_after_position( &snapshot, selection.head(), @@ -16147,7 +17008,7 @@ impl Editor { .map(|s| s.to_vec()) { self.change_selections(Default::default(), window, cx, |s| { - let map = s.display_map(); + let map = s.display_snapshot(); s.select_display_ranges(selections.iter().map(|a| { let point = a.to_display_point(&map); point..point @@ -16168,7 +17029,7 @@ impl Editor { .map(|s| s.to_vec()) { self.change_selections(Default::default(), window, cx, |s| { - let map = s.display_map(); + let map = s.display_snapshot(); s.select_display_ranges(selections.iter().map(|a| { let point = a.to_display_point(&map); point..point @@ -16204,7 +17065,10 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let snapshot = self.snapshot(window, cx); let buffer = &snapshot.buffer_snapshot(); - let position = self.selections.newest::(cx).head(); + let position = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); let anchor_position = buffer.anchor_after(position); // Get all document highlights (both read and write) @@ -16304,7 +17168,7 @@ impl Editor { GoToDefinitionFallback::None => Ok(Navigated::No), GoToDefinitionFallback::FindAllReferences => { match editor.update_in(cx, |editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) })? { Some(references) => references.await, None => Ok(Navigated::No), @@ -16387,7 +17251,10 @@ impl Editor { let Some(provider) = self.semantics_provider.clone() else { return Task::ready(Ok(Navigated::No)); }; - let head = self.selections.newest::(cx).head(); + let head = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); let buffer = self.buffer.read(cx); let Some((buffer, head)) = buffer.text_anchor_for_position(head, cx) else { return Task::ready(Ok(Navigated::No)); @@ -16555,10 +17422,6 @@ impl Editor { } if num_locations > 1 { - let Some(workspace) = workspace else { - return Ok(Navigated::No); - }; - let tab_kind = match kind { Some(GotoDefinitionKind::Implementation) => "Implementations", Some(GotoDefinitionKind::Symbol) | None => "Definitions", @@ -16588,13 +17451,20 @@ impl Editor { }) .context("buffer title")?; + let Some(workspace) = workspace else { + return Ok(Navigated::No); + }; + let opened = workspace .update_in(cx, |workspace, window, cx| { + let allow_preview = PreviewTabsSettings::get_global(cx) + .enable_preview_multibuffer_from_code_navigation; Self::open_locations_in_multibuffer( workspace, locations, title, split, + allow_preview, MultibufferSelectionMode::First, window, cx, @@ -16607,14 +17477,23 @@ impl Editor { // If there is one url or file, open it directly match first_url_or_file { Some(Either::Left(url)) => { - cx.update(|_, cx| cx.open_url(&url))?; + cx.update(|window, cx| { + if parse_zed_link(&url, cx).is_some() { + window + .dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + } else { + cx.open_url(&url); + } + })?; Ok(Navigated::Yes) } Some(Either::Right(path)) => { + // TODO(andrew): respect preview tab settings + // `enable_keep_preview_on_code_navigation` and + // `enable_preview_file_from_code_navigation` let Some(workspace) = workspace else { return Ok(Navigated::No); }; - workspace .update_in(cx, |workspace, window, cx| { workspace.open_resolved_path(path, window, cx) @@ -16625,10 +17504,6 @@ impl Editor { None => Ok(Navigated::No), } } else { - let Some(workspace) = workspace else { - return Ok(Navigated::No); - }; - let (target_buffer, target_ranges) = locations.into_iter().next().unwrap(); let target_range = target_ranges.first().unwrap().clone(); @@ -16642,6 +17517,9 @@ impl Editor { { editor.go_to_singleton_buffer_range(range, window, cx); } else { + let Some(workspace) = workspace else { + return Navigated::No; + }; let pane = workspace.read(cx).active_pane().clone(); window.defer(cx, move |window, cx| { let target_editor: Entity = @@ -16652,11 +17530,19 @@ impl Editor { workspace.active_pane().clone() }; + let preview_tabs_settings = PreviewTabsSettings::get_global(cx); + let keep_old_preview = preview_tabs_settings + .enable_keep_preview_on_code_navigation; + let allow_new_preview = preview_tabs_settings + .enable_preview_file_from_code_navigation; + workspace.open_project_item( pane, target_buffer.clone(), true, true, + keep_old_preview, + allow_new_preview, window, cx, ) @@ -16712,20 +17598,154 @@ impl Editor { }) } + fn go_to_next_reference( + &mut self, + _: &GoToNextReference, + window: &mut Window, + cx: &mut Context, + ) { + let task = self.go_to_reference_before_or_after_position(Direction::Next, 1, window, cx); + if let Some(task) = task { + task.detach(); + }; + } + + fn go_to_prev_reference( + &mut self, + _: &GoToPreviousReference, + window: &mut Window, + cx: &mut Context, + ) { + let task = self.go_to_reference_before_or_after_position(Direction::Prev, 1, window, cx); + if let Some(task) = task { + task.detach(); + }; + } + + pub fn go_to_reference_before_or_after_position( + &mut self, + direction: Direction, + count: usize, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let selection = self.selections.newest_anchor(); + let head = selection.head(); + + let multi_buffer = self.buffer.read(cx); + + let (buffer, text_head) = multi_buffer.text_anchor_for_position(head, cx)?; + let workspace = self.workspace()?; + let project = workspace.read(cx).project().clone(); + let references = + project.update(cx, |project, cx| project.references(&buffer, text_head, cx)); + Some(cx.spawn_in(window, async move |editor, cx| -> Result<()> { + let Some(locations) = references.await? else { + return Ok(()); + }; + + if locations.is_empty() { + // totally normal - the cursor may be on something which is not + // a symbol (e.g. a keyword) + log::info!("no references found under cursor"); + return Ok(()); + } + + let multi_buffer = editor.read_with(cx, |editor, _| editor.buffer().clone())?; + + let (locations, current_location_index) = + multi_buffer.update(cx, |multi_buffer, cx| { + let mut locations = locations + .into_iter() + .filter_map(|loc| { + let start = multi_buffer.buffer_anchor_to_anchor( + &loc.buffer, + loc.range.start, + cx, + )?; + let end = multi_buffer.buffer_anchor_to_anchor( + &loc.buffer, + loc.range.end, + cx, + )?; + Some(start..end) + }) + .collect::>(); + + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + // There is an O(n) implementation, but given this list will be + // small (usually <100 items), the extra O(log(n)) factor isn't + // worth the (surprisingly large amount of) extra complexity. + locations + .sort_unstable_by(|l, r| l.start.cmp(&r.start, &multi_buffer_snapshot)); + + let head_offset = head.to_offset(&multi_buffer_snapshot); + + let current_location_index = locations.iter().position(|loc| { + loc.start.to_offset(&multi_buffer_snapshot) <= head_offset + && loc.end.to_offset(&multi_buffer_snapshot) >= head_offset + }); + + (locations, current_location_index) + })?; + + let Some(current_location_index) = current_location_index else { + // This indicates something has gone wrong, because we already + // handle the "no references" case above + log::error!( + "failed to find current reference under cursor. Total references: {}", + locations.len() + ); + return Ok(()); + }; + + let destination_location_index = match direction { + Direction::Next => (current_location_index + count) % locations.len(), + Direction::Prev => { + (current_location_index + locations.len() - count % locations.len()) + % locations.len() + } + }; + + // TODO(cameron): is this needed? + // the thinking is to avoid "jumping to the current location" (avoid + // polluting "jumplist" in vim terms) + if current_location_index == destination_location_index { + return Ok(()); + } + + let Range { start, end } = locations[destination_location_index]; + + editor.update_in(cx, |editor, window, cx| { + let effects = SelectionEffects::default(); + + editor.unfold_ranges(&[start..end], false, false, cx); + editor.change_selections(effects, window, cx, |s| { + s.select_ranges([start..start]); + }); + })?; + + Ok(()) + })) + } + pub fn find_all_references( &mut self, - _: &FindAllReferences, + action: &FindAllReferences, window: &mut Window, cx: &mut Context, ) -> Option>> { - let selection = self.selections.newest::(cx); + let always_open_multibuffer = action.always_open_multibuffer; + let selection = self.selections.newest_anchor(); let multi_buffer = self.buffer.read(cx); - let head = selection.head(); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let selection_offset = selection.map(|anchor| anchor.to_offset(&multi_buffer_snapshot)); + let selection_point = selection.map(|anchor| anchor.to_point(&multi_buffer_snapshot)); + let head = selection_offset.head(); + let head_anchor = multi_buffer_snapshot.anchor_at( head, - if head < selection.tail() { + if head < selection_offset.tail() { Bias::Right } else { Bias::Left @@ -16771,6 +17791,15 @@ impl Editor { let buffer = location.buffer.read(cx); (location.buffer, location.range.to_point(buffer)) }) + // if special-casing the single-match case, remove ranges + // that intersect current selection + .filter(|(location_buffer, location)| { + if always_open_multibuffer || &buffer != location_buffer { + return true; + } + + !location.contains_inclusive(&selection_point.range()) + }) .into_group_map() })?; if locations.is_empty() { @@ -16780,6 +17809,60 @@ impl Editor { ranges.sort_by_key(|range| (range.start, Reverse(range.end))); ranges.dedup(); } + let mut num_locations = 0; + for ranges in locations.values_mut() { + ranges.sort_by_key(|range| (range.start, Reverse(range.end))); + ranges.dedup(); + num_locations += ranges.len(); + } + + if num_locations == 1 && !always_open_multibuffer { + let (target_buffer, target_ranges) = locations.into_iter().next().unwrap(); + let target_range = target_ranges.first().unwrap().clone(); + + return editor.update_in(cx, |editor, window, cx| { + let range = target_range.to_point(target_buffer.read(cx)); + let range = editor.range_for_match(&range); + let range = range.start..range.start; + + if Some(&target_buffer) == editor.buffer.read(cx).as_singleton().as_ref() { + editor.go_to_singleton_buffer_range(range, window, cx); + } else { + let pane = workspace.read(cx).active_pane().clone(); + window.defer(cx, move |window, cx| { + let target_editor: Entity = + workspace.update(cx, |workspace, cx| { + let pane = workspace.active_pane().clone(); + + let preview_tabs_settings = PreviewTabsSettings::get_global(cx); + let keep_old_preview = preview_tabs_settings + .enable_keep_preview_on_code_navigation; + let allow_new_preview = preview_tabs_settings + .enable_preview_file_from_code_navigation; + + workspace.open_project_item( + pane, + target_buffer.clone(), + true, + true, + keep_old_preview, + allow_new_preview, + window, + cx, + ) + }); + target_editor.update(cx, |target_editor, cx| { + // When selecting a definition in a different buffer, disable the nav history + // to avoid creating a history entry at the previous cursor location. + pane.update(cx, |pane, _| pane.disable_history()); + target_editor.go_to_singleton_buffer_range(range, window, cx); + pane.update(cx, |pane, _| pane.enable_history()); + }); + }); + } + Navigated::No + }); + } workspace.update_in(cx, |workspace, window, cx| { let target = locations @@ -16800,11 +17883,14 @@ impl Editor { } else { format!("References to {target}") }; + let allow_preview = PreviewTabsSettings::get_global(cx) + .enable_preview_multibuffer_from_code_navigation; Self::open_locations_in_multibuffer( workspace, locations, title, false, + allow_preview, MultibufferSelectionMode::First, window, cx, @@ -16814,12 +17900,13 @@ impl Editor { })) } - /// Opens a multibuffer with the given project locations in it + /// Opens a multibuffer with the given project locations in it. pub fn open_locations_in_multibuffer( workspace: &mut Workspace, locations: std::collections::HashMap, Vec>>, title: String, split: bool, + allow_preview: bool, multibuffer_selection_mode: MultibufferSelectionMode, window: &mut Window, cx: &mut Context, @@ -16867,6 +17954,7 @@ impl Editor { .is_some_and(|it| *it == key) }) }); + let was_existing = existing.is_some(); let editor = existing.unwrap_or_else(|| { cx.new(|cx| { let mut editor = Editor::for_multibuffer( @@ -16879,66 +17967,51 @@ impl Editor { editor }) }); - editor.update(cx, |editor, cx| { - match multibuffer_selection_mode { - MultibufferSelectionMode::First => { - if let Some(first_range) = ranges.first() { - editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - selections.clear_disjoint(); - selections - .select_anchor_ranges(std::iter::once(first_range.clone())); - }, - ); - } - editor.highlight_background::( - &ranges, - |theme| theme.colors().editor_highlighted_line_background, - cx, - ); - } - MultibufferSelectionMode::All => { + editor.update(cx, |editor, cx| match multibuffer_selection_mode { + MultibufferSelectionMode::First => { + if let Some(first_range) = ranges.first() { editor.change_selections( SelectionEffects::no_scroll(), window, cx, |selections| { selections.clear_disjoint(); - selections.select_anchor_ranges(ranges); + selections.select_anchor_ranges(std::iter::once(first_range.clone())); }, ); } + editor.highlight_background::( + &ranges, + |_, theme| theme.colors().editor_highlighted_line_background, + cx, + ); + } + MultibufferSelectionMode::All => { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.clear_disjoint(); + selections.select_anchor_ranges(ranges); + }); } - editor.register_buffers_with_language_servers(cx); }); let item = Box::new(editor); - let item_id = item.item_id(); - if split { - let pane = workspace.adjacent_pane(window, cx); - workspace.add_item(pane, item, None, true, true, window, cx); - } else if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation { - let (preview_item_id, preview_item_idx) = - workspace.active_pane().read_with(cx, |pane, _| { - (pane.preview_item_id(), pane.preview_item_idx()) - }); - - workspace.add_item_to_active_pane(item, preview_item_idx, true, window, cx); - - if let Some(preview_item_id) = preview_item_id { - workspace.active_pane().update(cx, |pane, cx| { - pane.remove_item(preview_item_id, false, false, window, cx); - }); - } + let pane = if split { + workspace.adjacent_pane(window, cx) } else { - workspace.add_item_to_active_pane(item, None, true, window, cx); - } - workspace.active_pane().update(cx, |pane, cx| { - pane.set_preview_item_id(Some(item_id), cx); + workspace.active_pane().clone() + }; + let activate_pane = split; + + let mut destination_index = None; + pane.update(cx, |pane, cx| { + if allow_preview && !was_existing { + destination_index = pane.replace_preview_item_id(item.item_id(), window, cx); + } + if was_existing && !allow_preview { + pane.unpreview_item_if_preview(item.item_id()); + } + pane.add_item(item, activate_pane, true, destination_index, window, cx); }); } @@ -17001,7 +18074,8 @@ impl Editor { this.take_rename(false, window, cx); let buffer = this.buffer.read(cx).read(cx); let cursor_offset = selection.head().to_offset(&buffer); - let rename_start = cursor_offset.saturating_sub(cursor_offset_in_rename_range); + let rename_start = + cursor_offset.saturating_sub_usize(cursor_offset_in_rename_range); let rename_end = rename_start + rename_buffer_range.len(); let range = buffer.anchor_before(rename_start)..buffer.anchor_after(rename_end); let mut old_highlight_id = None; @@ -17023,8 +18097,16 @@ impl Editor { let rename_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, old_name.clone())], None, cx) + buffer.edit( + [(MultiBufferOffset(0)..MultiBufferOffset(0), old_name.clone())], + None, + cx, + ) }); + let cursor_offset_in_rename_range = + MultiBufferOffset(cursor_offset_in_rename_range); + let cursor_offset_in_rename_range_end = + MultiBufferOffset(cursor_offset_in_rename_range_end); let rename_selection_range = match cursor_offset_in_rename_range .cmp(&cursor_offset_in_rename_range_end) { @@ -17039,7 +18121,7 @@ impl Editor { cursor_offset_in_rename_range_end..cursor_offset_in_rename_range } }; - if rename_selection_range.end > old_name.len() { + if rename_selection_range.end.0 > old_name.len() { editor.select_all(&SelectAll, window, cx); } else { editor.change_selections(Default::default(), window, cx, |s| { @@ -17202,7 +18284,10 @@ impl Editor { if moving_cursor { let cursor_in_rename_editor = rename.editor.update(cx, |editor, cx| { - editor.selections.newest::(cx).head() + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head() }); // Update the selection to match the position of the selection inside @@ -17265,7 +18350,7 @@ impl Editor { let ranges = self .selections - .all_adjusted(cx) + .all_adjusted(&self.display_snapshot(cx)) .into_iter() .map(|selection| selection.range()) .collect_vec(); @@ -17457,9 +18542,9 @@ impl Editor { HashSet::default(), cx, ); - cx.emit(project::Event::RefreshInlayHints); }); }); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); } } @@ -17500,7 +18585,7 @@ impl Editor { let primary_range_start = active_diagnostics.active_range.start.to_offset(&buffer); let primary_range_end = active_diagnostics.active_range.end.to_offset(&buffer); let is_valid = buffer - .diagnostics_in_range::(primary_range_start..primary_range_end) + .diagnostics_in_range::(primary_range_start..primary_range_end) .any(|entry| { entry.diagnostic.is_primary && !entry.range.is_empty() @@ -17532,7 +18617,7 @@ impl Editor { fn activate_diagnostics( &mut self, buffer_id: BufferId, - diagnostic: DiagnosticEntryRef<'_, usize>, + diagnostic: DiagnosticEntryRef<'_, MultiBufferOffset>, window: &mut Window, cx: &mut Context, ) { @@ -17550,8 +18635,18 @@ impl Editor { .diagnostic_group(buffer_id, diagnostic.diagnostic.group_id) .collect::>(); - let blocks = - renderer.render_group(diagnostic_group, buffer_id, snapshot, cx.weak_entity(), cx); + let language_registry = self + .project() + .map(|project| project.read(cx).languages().clone()); + + let blocks = renderer.render_group( + diagnostic_group, + buffer_id, + snapshot, + cx.weak_entity(), + language_registry, + cx, + ); let blocks = self.display_map.update(cx, |display_map, cx| { display_map.insert_blocks(blocks, cx).into_iter().collect() @@ -17681,6 +18776,7 @@ impl Editor { .unwrap_or(self.diagnostics_max_severity); if !self.inline_diagnostics_enabled() + || !self.diagnostics_enabled() || !self.show_inline_diagnostics || max_severity == DiagnosticSeverity::Off { @@ -17713,7 +18809,9 @@ impl Editor { let new_inline_diagnostics = cx .background_spawn(async move { let mut inline_diagnostics = Vec::<(Anchor, InlineDiagnostic)>::new(); - for diagnostic_entry in snapshot.diagnostics_in_range(0..snapshot.len()) { + for diagnostic_entry in + snapshot.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) + { let message = diagnostic_entry .diagnostic .message @@ -17759,7 +18857,7 @@ impl Editor { window: &Window, cx: &mut Context, ) -> Option<()> { - if !self.mode().is_full() { + if self.ignore_lsp_data() || !self.diagnostics_enabled() { return None; } let pull_diagnostics_settings = ProjectSettings::get_global(cx) @@ -17769,48 +18867,101 @@ impl Editor { return None; } let project = self.project()?.downgrade(); - let debounce = Duration::from_millis(pull_diagnostics_settings.debounce_ms); - let mut buffers = self.buffer.read(cx).all_buffers(); - if let Some(buffer_id) = buffer_id { - buffers.retain(|buffer| buffer.read(cx).remote_id() == buffer_id); + + let mut edited_buffer_ids = HashSet::default(); + let mut edited_worktree_ids = HashSet::default(); + let edited_buffers = match buffer_id { + Some(buffer_id) => { + let buffer = self.buffer().read(cx).buffer(buffer_id)?; + let worktree_id = buffer.read(cx).file().map(|f| f.worktree_id(cx))?; + edited_buffer_ids.insert(buffer.read(cx).remote_id()); + edited_worktree_ids.insert(worktree_id); + vec![buffer] + } + None => self + .buffer() + .read(cx) + .all_buffers() + .into_iter() + .filter(|buffer| { + let buffer = buffer.read(cx); + match buffer.file().map(|f| f.worktree_id(cx)) { + Some(worktree_id) => { + edited_buffer_ids.insert(buffer.remote_id()); + edited_worktree_ids.insert(worktree_id); + true + } + None => false, + } + }) + .collect::>(), + }; + + if edited_buffers.is_empty() { + self.pull_diagnostics_task = Task::ready(()); + self.pull_diagnostics_background_task = Task::ready(()); + return None; } - self.pull_diagnostics_task = cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(debounce).await; - - let Ok(mut pull_diagnostics_tasks) = cx.update(|_, cx| { - buffers - .into_iter() - .filter_map(|buffer| { - project - .update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.pull_diagnostics_for_buffer(buffer, cx) - }) - }) - .ok() - }) - .collect::>() - }) else { - return; - }; - - while let Some(pull_task) = pull_diagnostics_tasks.next().await { - match pull_task { - Ok(()) => { - if editor - .update_in(cx, |editor, window, cx| { - editor.update_diagnostics_state(window, cx); - }) - .is_err() - { - return; - } + let mut already_used_buffers = HashSet::default(); + let related_open_buffers = self + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + .into_iter() + .flat_map(|workspace| workspace.read(cx).panes()) + .flat_map(|pane| pane.read(cx).items_of_type::()) + .filter(|editor| editor != &cx.entity()) + .flat_map(|editor| editor.read(cx).buffer().read(cx).all_buffers()) + .filter(|buffer| { + let buffer = buffer.read(cx); + let buffer_id = buffer.remote_id(); + if already_used_buffers.insert(buffer_id) { + if let Some(worktree_id) = buffer.file().map(|f| f.worktree_id(cx)) { + return !edited_buffer_ids.contains(&buffer_id) + && !edited_worktree_ids.contains(&worktree_id); } - Err(e) => log::error!("Failed to update project diagnostics: {e:#}"), } + false + }) + .collect::>(); + + let debounce = Duration::from_millis(pull_diagnostics_settings.debounce_ms); + let make_spawn = |buffers: Vec>, delay: Duration| { + if buffers.is_empty() { + return Task::ready(()); } - }); + let project_weak = project.clone(); + cx.spawn_in(window, async move |_, cx| { + cx.background_executor().timer(delay).await; + + let Ok(mut pull_diagnostics_tasks) = cx.update(|_, cx| { + buffers + .into_iter() + .filter_map(|buffer| { + project_weak + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }) + }) + .ok() + }) + .collect::>() + }) else { + return; + }; + + while let Some(pull_task) = pull_diagnostics_tasks.next().await { + if let Err(e) = pull_task { + log::error!("Failed to update project diagnostics: {e:#}"); + } + } + }) + }; + + self.pull_diagnostics_task = make_spawn(edited_buffers, debounce); + self.pull_diagnostics_background_task = make_spawn(related_open_buffers, debounce * 2); Some(()) } @@ -17823,14 +18974,15 @@ impl Editor { cx: &mut Context, ) { let old_cursor_position = self.selections.newest_anchor().head(); - self.selections.change_with(cx, |s| { - s.select_anchors(selections); - if let Some(pending_selection) = pending_selection { - s.set_pending(pending_selection, SelectMode::Character); - } else { - s.clear_pending(); - } - }); + self.selections + .change_with(&self.display_snapshot(cx), |s| { + s.select_anchors(selections); + if let Some(pending_selection) = pending_selection { + s.set_pending(pending_selection, SelectMode::Character); + } else { + s.clear_pending(); + } + }); self.selections_did_change( false, &old_cursor_position, @@ -17958,9 +19110,9 @@ impl Editor { cx: &mut Context, ) { if self.buffer_kind(cx) == ItemBufferKind::Singleton { - let selection = self.selections.newest::(cx); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selection = self.selections.newest::(&display_map); + let range = if selection.is_empty() { let point = selection.head().to_display_point(&display_map); let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); @@ -18003,7 +19155,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let range = if selection.is_empty() { @@ -18026,7 +19178,7 @@ impl Editor { if self.buffer_kind(cx) == ItemBufferKind::Singleton { let mut to_fold = Vec::new(); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(cx); + let selections = self.selections.all_adjusted(&display_map); for selection in selections { let range = selection.range().sorted(); @@ -18085,7 +19237,7 @@ impl Editor { if self.buffer.read(cx).is_singleton() { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let has_folds = display_map - .folds_in_range(0..display_map.buffer_snapshot().len()) + .folds_in_range(MultiBufferOffset(0)..display_map.buffer_snapshot().len()) .next() .is_some(); @@ -18133,7 +19285,7 @@ impl Editor { let row_ranges_to_keep: Vec> = self .selections - .all::(cx) + .all::(&self.display_snapshot(cx)) .into_iter() .map(|sel| sel.start.row..sel.end.row) .collect(); @@ -18170,6 +19322,87 @@ impl Editor { self.fold_creases(to_fold, true, window, cx); } + pub fn fold_at_level_1( + &mut self, + _: &actions::FoldAtLevel1, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(1), window, cx); + } + + pub fn fold_at_level_2( + &mut self, + _: &actions::FoldAtLevel2, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(2), window, cx); + } + + pub fn fold_at_level_3( + &mut self, + _: &actions::FoldAtLevel3, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(3), window, cx); + } + + pub fn fold_at_level_4( + &mut self, + _: &actions::FoldAtLevel4, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(4), window, cx); + } + + pub fn fold_at_level_5( + &mut self, + _: &actions::FoldAtLevel5, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(5), window, cx); + } + + pub fn fold_at_level_6( + &mut self, + _: &actions::FoldAtLevel6, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(6), window, cx); + } + + pub fn fold_at_level_7( + &mut self, + _: &actions::FoldAtLevel7, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(7), window, cx); + } + + pub fn fold_at_level_8( + &mut self, + _: &actions::FoldAtLevel8, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(8), window, cx); + } + + pub fn fold_at_level_9( + &mut self, + _: &actions::FoldAtLevel9, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(9), window, cx); + } + pub fn fold_all(&mut self, _: &actions::FoldAll, window: &mut Window, cx: &mut Context) { if self.buffer.read(cx).is_singleton() { let mut fold_ranges = Vec::new(); @@ -18207,7 +19440,10 @@ impl Editor { let snapshot = self.buffer.read(cx).snapshot(cx); let ranges = snapshot - .text_object_ranges(0..snapshot.len(), TreeSitterOptions::default()) + .text_object_ranges( + MultiBufferOffset(0)..snapshot.len(), + TreeSitterOptions::default(), + ) .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) .collect::>(); @@ -18227,7 +19463,7 @@ impl Editor { ) { let mut to_fold = Vec::new(); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(cx); + let selections = self.selections.all_adjusted(&display_map); for selection in selections { let range = selection.range().sorted(); @@ -18271,7 +19507,7 @@ impl Editor { if let Some(crease) = display_map.crease_for_buffer_row(buffer_row) { let autoscroll = self .selections - .all::(cx) + .all::(&display_map) .iter() .any(|selection| crease.range().overlaps(&selection.range())); @@ -18283,7 +19519,7 @@ impl Editor { if self.buffer_kind(cx) == ItemBufferKind::Singleton { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let buffer = display_map.buffer_snapshot(); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let ranges = selections .iter() .map(|s| { @@ -18317,7 +19553,7 @@ impl Editor { cx: &mut Context, ) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let ranges = selections .iter() .map(|s| { @@ -18349,7 +19585,7 @@ impl Editor { let autoscroll = self .selections - .all::(cx) + .all::(&display_map) .iter() .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range)); @@ -18364,7 +19600,12 @@ impl Editor { ) { if self.buffer.read(cx).is_singleton() { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - self.unfold_ranges(&[0..display_map.buffer_snapshot().len()], true, true, cx); + self.unfold_ranges( + &[MultiBufferOffset(0)..display_map.buffer_snapshot().len()], + true, + true, + cx, + ); } else { self.toggle_fold_multiple_buffers = cx.spawn(async move |editor, cx| { editor @@ -18384,8 +19625,8 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let selections = self.selections.all_adjusted(cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(&display_map); let ranges = selections .into_iter() .map(|s| Crease::simple(s.range(), display_map.fold_placeholder.clone())) @@ -18449,10 +19690,17 @@ impl Editor { if self.buffer().read(cx).is_singleton() || self.is_buffer_folded(buffer_id, cx) { return; } + let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx); self.display_map.update(cx, |display_map, cx| { display_map.fold_buffers([buffer_id], cx) }); + + let snapshot = self.display_snapshot(cx); + self.selections.change_with(&snapshot, |selections| { + selections.remove_selections_from_buffer(buffer_id); + }); + cx.emit(EditorEvent::BufferFoldToggled { ids: folded_excerpts.iter().map(|&(id, _)| id).collect(), folded: true, @@ -18547,6 +19795,10 @@ impl Editor { self.display_map.read(cx).fold_placeholder.clone() } + pub fn set_use_base_text_line_numbers(&mut self, show: bool, _cx: &mut Context) { + self.use_base_text_line_numbers = show; + } + pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) { self.buffer.update(cx, |buffer, cx| { buffer.set_all_diff_hunks_expanded(cx); @@ -18564,6 +19816,17 @@ impl Editor { }); } + pub fn collapse_all_diff_hunks( + &mut self, + _: &CollapseAllDiffHunks, + _window: &mut Window, + cx: &mut Context, + ) { + self.buffer.update(cx, |buffer, cx| { + buffer.collapse_diff_hunks(vec![Anchor::min()..Anchor::max()], cx) + }); + } + pub fn toggle_selected_diff_hunks( &mut self, _: &ToggleSelectedDiffHunks, @@ -18718,7 +19981,10 @@ impl Editor { self.stage_or_unstage_diff_hunks(stage, ranges, cx); let snapshot = self.snapshot(window, cx); - let position = self.selections.newest::(cx).head(); + let position = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); let mut row = snapshot .buffer_snapshot() .diff_hunks_in_range(position..snapshot.buffer_snapshot().max_point()) @@ -18768,7 +20034,12 @@ impl Editor { &hunks .map(|hunk| buffer_diff::DiffHunk { buffer_range: hunk.buffer_range, - diff_base_byte_range: hunk.diff_base_byte_range, + // We don't need to pass in word diffs here because they're only used for rendering and + // this function changes internal state + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), + diff_base_byte_range: hunk.diff_base_byte_range.start.0 + ..hunk.diff_base_byte_range.end.0, secondary_status: hunk.secondary_status, range: Point::zero()..Point::zero(), // unused }) @@ -18806,6 +20077,16 @@ impl Editor { }) } + fn has_any_expanded_diff_hunks(&self, cx: &App) -> bool { + if self.buffer.read(cx).all_diff_hunks_expanded() { + return true; + } + let ranges = vec![Anchor::min()..Anchor::max()]; + self.buffer + .read(cx) + .has_expanded_diff_hunks_in_ranges(&ranges, cx) + } + fn toggle_diff_hunks_in_ranges( &mut self, ranges: Vec>, @@ -18866,7 +20147,7 @@ impl Editor { let snapshot = self.snapshot(window, cx); let hunks = snapshot.hunks_for_ranges( self.selections - .all(cx) + .all(&snapshot.display_snapshot) .into_iter() .map(|selection| selection.range()), ); @@ -19185,8 +20466,11 @@ impl Editor { self.style = Some(style); } - pub fn style(&self) -> Option<&EditorStyle> { - self.style.as_ref() + pub fn style(&mut self, cx: &App) -> &EditorStyle { + if self.style.is_none() { + self.style = Some(self.create_style(cx)); + } + self.style.as_ref().unwrap() } // Called by the element. This method is not designed to be called outside of the editor @@ -19256,6 +20540,20 @@ impl Editor { self.show_indent_guides } + pub fn disable_indent_guides_for_buffer( + &mut self, + buffer_id: BufferId, + cx: &mut Context, + ) { + self.buffers_with_disabled_indent_guides.insert(buffer_id); + cx.notify(); + } + + pub fn has_indent_guides_disabled_for_buffer(&self, buffer_id: BufferId) -> bool { + self.buffers_with_disabled_indent_guides + .contains(&buffer_id) + } + pub fn toggle_line_numbers( &mut self, _: &ToggleLineNumbers, @@ -19274,9 +20572,16 @@ impl Editor { EditorSettings::get_global(cx).gutter.line_numbers } - pub fn should_use_relative_line_numbers(&self, cx: &mut App) -> bool { - self.use_relative_line_numbers - .unwrap_or(EditorSettings::get_global(cx).relative_line_numbers) + pub fn relative_line_numbers(&self, cx: &mut App) -> RelativeLineNumbers { + match ( + self.use_relative_line_numbers, + EditorSettings::get_global(cx).relative_line_numbers, + ) { + (None, setting) => setting, + (Some(false), _) => RelativeLineNumbers::Disabled, + (Some(true), RelativeLineNumbers::Wrapped) => RelativeLineNumbers::Wrapped, + (Some(true), _) => RelativeLineNumbers::Enabled, + } } pub fn toggle_relative_line_numbers( @@ -19285,8 +20590,8 @@ impl Editor { _: &mut Window, cx: &mut Context, ) { - let is_relative = self.should_use_relative_line_numbers(cx); - self.set_relative_line_number(Some(!is_relative), cx) + let is_relative = self.relative_line_numbers(cx); + self.set_relative_line_number(Some(!is_relative.enabled()), cx) } pub fn set_relative_line_number(&mut self, is_relative: Option, cx: &mut Context) { @@ -19546,18 +20851,20 @@ impl Editor { _: &mut Window, cx: &mut Context, ) { - if let Some(file) = self.target_file(cx) - && let Some(file_stem) = file.path().file_stem() - { + if let Some(file_stem) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { + let file = buffer.read(cx).file()?; + file.path().file_stem() + }) { cx.write_to_clipboard(ClipboardItem::new_string(file_stem.to_string())); } } pub fn copy_file_name(&mut self, _: &CopyFileName, _: &mut Window, cx: &mut Context) { - if let Some(file) = self.target_file(cx) - && let Some(name) = file.path().file_name() - { - cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); + if let Some(file_name) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { + let file = buffer.read(cx).file()?; + Some(file.file_name(cx)) + }) { + cx.write_to_clipboard(ClipboardItem::new_string(file_name.to_string())); } } @@ -19602,7 +20909,10 @@ impl Editor { ) -> Option<()> { let blame = self.blame.as_ref()?; let snapshot = self.snapshot(window, cx); - let cursor = self.selections.newest::(cx).head(); + let cursor = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); let (buffer, point, _) = snapshot.buffer_snapshot().point_to_buffer_point(cursor)?; let (_, blame_entry) = blame .update(cx, |blame, cx| { @@ -19744,7 +21054,7 @@ impl Editor { fn get_permalink_to_line(&self, cx: &mut Context) -> Task> { let buffer_and_selection = maybe!({ - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); let selection_range = selection.range(); let multi_buffer = self.buffer().read(cx); @@ -19757,9 +21067,22 @@ impl Editor { buffer_ranges.last() }?; - let selection = text::ToPoint::to_point(&range.start, buffer).row - ..text::ToPoint::to_point(&range.end, buffer).row; - Some((multi_buffer.buffer(buffer.remote_id()).unwrap(), selection)) + let start_row_in_buffer = text::ToPoint::to_point(&range.start, buffer).row; + let end_row_in_buffer = text::ToPoint::to_point(&range.end, buffer).row; + + let Some(buffer_diff) = multi_buffer.diff_for(buffer.remote_id()) else { + let selection = start_row_in_buffer..end_row_in_buffer; + + return Some((multi_buffer.buffer(buffer.remote_id()).unwrap(), selection)); + }; + + let buffer_diff_snapshot = buffer_diff.read(cx).snapshot(cx); + + Some(( + multi_buffer.buffer(buffer.remote_id()).unwrap(), + buffer_diff_snapshot.row_to_base_text_row(start_row_in_buffer, buffer) + ..buffer_diff_snapshot.row_to_base_text_row(end_row_in_buffer, buffer), + )) }); let Some((buffer, selection)) = buffer_and_selection else { @@ -19822,10 +21145,20 @@ impl Editor { _: &mut Window, cx: &mut Context, ) { - let selection = self.selections.newest::(cx).start.row + 1; - if let Some(file) = self.target_file(cx) { - let path = file.path().display(file.path_style(cx)); - cx.write_to_clipboard(ClipboardItem::new_string(format!("{path}:{selection}"))); + let selection = self + .selections + .newest::(&self.display_snapshot(cx)) + .start + .row + + 1; + if let Some(file_location) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { + let project = self.project()?.read(cx); + let file = buffer.read(cx).file()?; + let path = file.path().display(project.path_style(cx)); + + Some(format!("{path}:{selection}")) + }) { + cx.write_to_clipboard(ClipboardItem::new_string(file_location)); } } @@ -19893,7 +21226,7 @@ impl Editor { self.transact(window, cx, |this, window, cx| { let edits = this .selections - .all::(cx) + .all::(&this.display_snapshot(cx)) .into_iter() .map(|selection| { let uuid = match version { @@ -19928,7 +21261,7 @@ impl Editor { let locations = self .selections - .all_anchors(cx) + .all_anchors(&self.display_snapshot(cx)) .iter() .map(|selection| { ( @@ -19946,6 +21279,7 @@ impl Editor { locations, format!("Selections for '{title}'"), false, + false, MultibufferSelectionMode::All, window, cx, @@ -20101,8 +21435,7 @@ impl Editor { let start = highlight.range.start.to_display_point(&snapshot); let end = highlight.range.end.to_display_point(&snapshot); let start_row = start.row().0; - let end_row = if highlight.range.end.text_anchor != text::Anchor::MAX - && end.column() == 0 + let end_row = if !highlight.range.end.text_anchor.is_max() && end.column() == 0 { end.row().0.saturating_sub(1) } else { @@ -20149,7 +21482,7 @@ impl Editor { pub fn set_search_within_ranges(&mut self, ranges: &[Range], cx: &mut Context) { self.highlight_background::( ranges, - |colors| colors.colors().editor_document_highlight_read_background, + |_, colors| colors.colors().editor_document_highlight_read_background, cx, ) } @@ -20165,12 +21498,12 @@ impl Editor { pub fn highlight_background( &mut self, ranges: &[Range], - color_fetcher: fn(&Theme) -> Hsla, + color_fetcher: impl Fn(&usize, &Theme) -> Hsla + Send + Sync + 'static, cx: &mut Context, ) { self.background_highlights.insert( HighlightKey::Type(TypeId::of::()), - (color_fetcher, Arc::from(ranges)), + (Arc::new(color_fetcher), Arc::from(ranges)), ); self.scrollbar_marker_state.dirty = true; cx.notify(); @@ -20180,12 +21513,12 @@ impl Editor { &mut self, key: usize, ranges: &[Range], - color_fetcher: fn(&Theme) -> Hsla, + color_fetcher: impl Fn(&usize, &Theme) -> Hsla + Send + Sync + 'static, cx: &mut Context, ) { self.background_highlights.insert( HighlightKey::TypePlus(TypeId::of::(), key), - (color_fetcher, Arc::from(ranges)), + (Arc::new(color_fetcher), Arc::from(ranges)), ); self.scrollbar_marker_state.dirty = true; cx.notify(); @@ -20315,7 +21648,7 @@ impl Editor { ) -> Vec<(Range, Hsla)> { let snapshot = self.snapshot(window, cx); let buffer = &snapshot.buffer_snapshot(); - let start = buffer.anchor_before(0); + let start = buffer.anchor_before(MultiBufferOffset(0)); let end = buffer.anchor_after(buffer.len()); self.sorted_background_highlights_in_range(start..end, &snapshot, cx.theme()) } @@ -20410,7 +21743,6 @@ impl Editor { ) -> Vec<(Range, Hsla)> { let mut results = Vec::new(); for (color_fetcher, ranges) in self.background_highlights.values() { - let color = color_fetcher(theme); let start_ix = match ranges.binary_search_by(|probe| { let cmp = probe .end @@ -20423,7 +21755,7 @@ impl Editor { }) { Ok(i) | Err(i) => i, }; - for range in &ranges[start_ix..] { + for (index, range) in ranges[start_ix..].iter().enumerate() { if range .start .cmp(&search_range.end, &display_snapshot.buffer_snapshot()) @@ -20432,6 +21764,7 @@ impl Editor { break; } + let color = color_fetcher(&(start_ix + index), theme); let start = range.start.to_display_point(display_snapshot); let end = range.end.to_display_point(display_snapshot); results.push((start..end, color)) @@ -20514,13 +21847,16 @@ impl Editor { key: usize, ranges: Vec>, style: HighlightStyle, + merge: bool, cx: &mut Context, ) { - self.display_map.update(cx, |map, _| { + self.display_map.update(cx, |map, cx| { map.highlight_text( HighlightKey::TypePlus(TypeId::of::(), key), ranges, style, + merge, + cx, ); }); cx.notify(); @@ -20532,20 +21868,14 @@ impl Editor { style: HighlightStyle, cx: &mut Context, ) { - self.display_map.update(cx, |map, _| { - map.highlight_text(HighlightKey::Type(TypeId::of::()), ranges, style) - }); - cx.notify(); - } - - pub(crate) fn highlight_inlays( - &mut self, - highlights: Vec, - style: HighlightStyle, - cx: &mut Context, - ) { - self.display_map.update(cx, |map, _| { - map.highlight_inlays(TypeId::of::(), highlights, style) + self.display_map.update(cx, |map, cx| { + map.highlight_text( + HighlightKey::Type(TypeId::of::()), + ranges, + style, + false, + cx, + ) }); cx.notify(); } @@ -20661,7 +21991,7 @@ impl Editor { .for_each(|hint| { let inlay = Inlay::debugger( post_inc(&mut editor.next_inlay_id), - Anchor::in_buffer(excerpt_id, buffer_id, hint.position), + Anchor::in_buffer(excerpt_id, hint.position), hint.text(), ); if !inlay.text().chars().contains(&'\n') { @@ -20688,71 +22018,36 @@ impl Editor { cx: &mut Context, ) { match event { - multi_buffer::Event::Edited { - singleton_buffer_edited, - edited_buffer, - } => { + multi_buffer::Event::Edited { edited_buffer } => { self.scrollbar_marker_state.dirty = true; self.active_indent_guides_state.dirty = true; self.refresh_active_diagnostics(cx); self.refresh_code_actions(window, cx); - self.refresh_selected_text_highlights(true, window, cx); self.refresh_single_line_folds(window, cx); - refresh_matching_bracket_highlights(self, window, cx); + self.refresh_matching_bracket_highlights(window, cx); if self.has_active_edit_prediction() { self.update_visible_edit_prediction(window, cx); } - if let Some(project) = self.project.as_ref() - && let Some(edited_buffer) = edited_buffer - { - project.update(cx, |project, cx| { - self.registered_buffers - .entry(edited_buffer.read(cx).remote_id()) - .or_insert_with(|| { - project.register_buffer_with_language_servers(edited_buffer, cx) - }); - }); - } - cx.emit(EditorEvent::BufferEdited); - cx.emit(SearchEvent::MatchesInvalidated); if let Some(buffer) = edited_buffer { - self.update_lsp_data(false, Some(buffer.read(cx).remote_id()), window, cx); - } - - if *singleton_buffer_edited { - if let Some(buffer) = edited_buffer - && buffer.read(cx).file().is_none() - { + if buffer.read(cx).file().is_none() { cx.emit(EditorEvent::TitleChanged); } - if let Some(project) = &self.project { - #[allow(clippy::mutable_key_type)] - let languages_affected = multibuffer.update(cx, |multibuffer, cx| { - multibuffer - .all_buffers() - .into_iter() - .filter_map(|buffer| { - buffer.update(cx, |buffer, cx| { - let language = buffer.language()?; - let should_discard = project.update(cx, |project, cx| { - project.is_local() - && !project.has_language_servers_for(buffer, cx) - }); - should_discard.not().then_some(language.clone()) - }) - }) - .collect::>() - }); - if !languages_affected.is_empty() { - self.refresh_inlay_hints( - InlayHintRefreshReason::BufferEdited(languages_affected), - cx, - ); - } + + if self.project.is_some() { + let buffer_id = buffer.read(cx).remote_id(); + self.register_buffer(buffer_id, cx); + self.update_lsp_data(Some(buffer_id), window, cx); + self.refresh_inlay_hints( + InlayHintRefreshReason::BufferEdited(buffer_id), + cx, + ); } } + cx.emit(EditorEvent::BufferEdited); + cx.emit(SearchEvent::MatchesInvalidated); + let Some(project) = &self.project else { return }; let (telemetry, is_via_ssh) = { let project = project.read(cx); @@ -20760,7 +22055,6 @@ impl Editor { let is_via_ssh = project.is_via_remote_server(); (telemetry, is_via_ssh) }; - refresh_linked_ranges(self, window, cx); telemetry.log_edit_event("editor", is_via_ssh); } multi_buffer::Event::ExcerptsAdded { @@ -20782,24 +22076,26 @@ impl Editor { ) .detach(); } - if self.active_diagnostics != ActiveDiagnostic::All { - self.update_lsp_data(false, Some(buffer_id), window, cx); - } + self.update_lsp_data(Some(buffer_id), window, cx); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + self.colorize_brackets(false, cx); cx.emit(EditorEvent::ExcerptsAdded { buffer: buffer.clone(), predecessor: *predecessor, excerpts: excerpts.clone(), }); - self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); } multi_buffer::Event::ExcerptsRemoved { ids, removed_buffer_ids, } => { + if let Some(inlay_hints) = &mut self.inlay_hints { + inlay_hints.remove_inlay_chunk_data(removed_buffer_ids); + } self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); - let buffer = self.buffer.read(cx); - self.registered_buffers - .retain(|buffer_id, _| buffer.buffer(*buffer_id).is_some()); + for buffer_id in removed_buffer_ids { + self.registered_buffers.remove(buffer_id); + } jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone(), @@ -20819,10 +22115,17 @@ impl Editor { } multi_buffer::Event::ExcerptsExpanded { ids } => { self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + self.refresh_document_highlights(cx); + for id in ids { + self.fetched_tree_sitter_chunks.remove(id); + } + self.colorize_brackets(false, cx); cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) } multi_buffer::Event::Reparsed(buffer_id) => { self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.refresh_selected_text_highlights(true, window, cx); + self.colorize_brackets(true, cx); jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); @@ -20830,8 +22133,10 @@ impl Editor { multi_buffer::Event::DiffHunksToggled => { self.tasks_update_task = Some(self.refresh_runnables(window, cx)); } - multi_buffer::Event::LanguageChanged(buffer_id) => { - linked_editing_ranges::refresh_linked_ranges(self, window, cx); + multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => { + if !is_fresh_language { + self.registered_buffers.remove(&buffer_id); + } jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); cx.notify(); @@ -20893,7 +22198,69 @@ impl Editor { cx.notify(); } + fn fetch_accent_data(&self, cx: &App) -> Option { + if !self.mode.is_full() { + return None; + } + + let theme_settings = theme::ThemeSettings::get_global(cx); + let theme = cx.theme(); + let accent_colors = theme.accents().clone(); + + let accent_overrides = theme_settings + .theme_overrides + .get(theme.name.as_ref()) + .map(|theme_style| &theme_style.accents) + .into_iter() + .flatten() + .chain( + theme_settings + .experimental_theme_overrides + .as_ref() + .map(|overrides| &overrides.accents) + .into_iter() + .flatten(), + ) + .flat_map(|accent| accent.0.clone()) + .collect(); + + Some(AccentData { + colors: accent_colors, + overrides: accent_overrides, + }) + } + + fn fetch_applicable_language_settings( + &self, + cx: &App, + ) -> HashMap, LanguageSettings> { + if !self.mode.is_full() { + return HashMap::default(); + } + + self.buffer().read(cx).all_buffers().into_iter().fold( + HashMap::default(), + |mut acc, buffer| { + let buffer = buffer.read(cx); + let language = buffer.language().map(|language| language.name()); + if let hash_map::Entry::Vacant(v) = acc.entry(language.clone()) { + let file = buffer.file(); + v.insert(language_settings(language, file, cx).into_owned()); + } + acc + }, + ) + } + fn settings_changed(&mut self, window: &mut Window, cx: &mut Context) { + let new_language_settings = self.fetch_applicable_language_settings(cx); + let language_settings_changed = new_language_settings != self.applicable_language_settings; + self.applicable_language_settings = new_language_settings; + + let new_accents = self.fetch_accent_data(cx); + let accents_changed = new_accents != self.accent_data; + self.accent_data = new_accents; + if self.diagnostics_enabled() { let new_severity = EditorSettings::get_global(cx) .diagnostics_max_severity @@ -20933,8 +22300,9 @@ impl Editor { } let project_settings = ProjectSettings::get_global(cx); - self.serialize_dirty_buffers = - !self.mode.is_minimap() && project_settings.session.restore_unsaved_buffers; + self.buffer_serialization = self + .should_serialize_buffer() + .then(|| BufferSerialization::new(project_settings.session.restore_unsaved_buffers)); if self.mode.is_full() { let show_inline_diagnostics = project_settings.diagnostics.inline.enabled; @@ -20964,15 +22332,19 @@ impl Editor { }) } } - } - if let Some(inlay_splice) = self.colors.as_mut().and_then(|colors| { - colors.render_mode_updated(EditorSettings::get_global(cx).lsp_document_colors) - }) { - if !inlay_splice.to_insert.is_empty() || !inlay_splice.to_remove.is_empty() { - self.splice_inlays(&inlay_splice.to_remove, inlay_splice.to_insert, cx); + if language_settings_changed || accents_changed { + self.colorize_brackets(true, cx); + } + + if let Some(inlay_splice) = self.colors.as_mut().and_then(|colors| { + colors.render_mode_updated(EditorSettings::get_global(cx).lsp_document_colors) + }) { + if !inlay_splice.is_empty() { + self.splice_inlays(&inlay_splice.to_remove, inlay_splice.to_insert, cx); + } + self.refresh_colors_for_visible_range(None, window, cx); } - self.refresh_colors(false, None, window, cx); } cx.notify(); @@ -20986,65 +22358,6 @@ impl Editor { self.searchable } - fn open_proposed_changes_editor( - &mut self, - _: &OpenProposedChangesEditor, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.workspace() else { - cx.propagate(); - return; - }; - - let selections = self.selections.all::(cx); - let multi_buffer = self.buffer.read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let mut new_selections_by_buffer = HashMap::default(); - for selection in selections { - for (buffer, range, _) in - multi_buffer_snapshot.range_to_buffer_ranges(selection.start..selection.end) - { - let mut range = range.to_point(buffer); - range.start.column = 0; - range.end.column = buffer.line_len(range.end.row); - new_selections_by_buffer - .entry(multi_buffer.buffer(buffer.remote_id()).unwrap()) - .or_insert(Vec::new()) - .push(range) - } - } - - let proposed_changes_buffers = new_selections_by_buffer - .into_iter() - .map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges }) - .collect::>(); - let proposed_changes_editor = cx.new(|cx| { - ProposedChangesEditor::new( - "Proposed changes", - proposed_changes_buffers, - self.project.clone(), - window, - cx, - ) - }); - - window.defer(cx, move |window, cx| { - workspace.update(cx, |workspace, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(proposed_changes_editor), - true, - true, - None, - window, - cx, - ); - }); - }); - }); - } - pub fn open_excerpts_in_split( &mut self, _: &OpenExcerptsSplit, @@ -21098,7 +22411,7 @@ impl Editor { new_selections_by_buffer.insert( buffer, ( - vec![jump_to_offset..jump_to_offset], + vec![BufferOffset(jump_to_offset)..BufferOffset(jump_to_offset)], Some(*line_offset_from_top), ), ); @@ -21117,11 +22430,13 @@ impl Editor { .entry(buffer) .or_insert((Vec::new(), Some(*line_offset_from_top))) .0 - .push(buffer_offset..buffer_offset) + .push(BufferOffset(buffer_offset)..BufferOffset(buffer_offset)) } } None => { - let selections = self.selections.all::(cx); + let selections = self + .selections + .all::(&self.display_snapshot(cx)); let multi_buffer = self.buffer.read(cx); for selection in selections { for (snapshot, range, _, anchor) in multi_buffer @@ -21137,7 +22452,7 @@ impl Editor { &anchor.text_anchor, &buffer_handle.read(cx).snapshot(), ); - let range = offset..offset; + let range = BufferOffset(offset)..BufferOffset(offset); new_selections_by_buffer .entry(buffer_handle) .or_insert((Vec::new(), None)) @@ -21178,54 +22493,87 @@ impl Editor { }; for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { - let editor = buffer - .read(cx) - .file() - .is_none() + let buffer_read = buffer.read(cx); + let (has_file, is_project_file) = if let Some(file) = buffer_read.file() { + (true, project::File::from_dyn(Some(file)).is_some()) + } else { + (false, false) + }; + + // If project file is none workspace.open_project_item will fail to open the excerpt + // in a pre existing workspace item if one exists, because Buffer entity_id will be None + // so we check if there's a tab match in that case first + let editor = (!has_file || !is_project_file) .then(|| { // Handle file-less buffers separately: those are not really the project items, so won't have a project path or entity id, // so `workspace.open_project_item` will never find them, always opening a new editor. // Instead, we try to activate the existing editor in the pane first. - let (editor, pane_item_index) = + let (editor, pane_item_index, pane_item_id) = pane.read(cx).items().enumerate().find_map(|(i, item)| { let editor = item.downcast::()?; let singleton_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; if singleton_buffer == buffer { - Some((editor, i)) + Some((editor, i, item.item_id())) } else { None } })?; pane.update(cx, |pane, cx| { - pane.activate_item(pane_item_index, true, true, window, cx) + pane.activate_item(pane_item_index, true, true, window, cx); + if !PreviewTabsSettings::get_global(cx) + .enable_preview_from_multibuffer + { + pane.unpreview_item_if_preview(pane_item_id); + } }); Some(editor) }) .flatten() .unwrap_or_else(|| { + let keep_old_preview = PreviewTabsSettings::get_global(cx) + .enable_keep_preview_on_code_navigation; + let allow_new_preview = + PreviewTabsSettings::get_global(cx).enable_preview_from_multibuffer; workspace.open_project_item::( pane.clone(), buffer, true, true, + keep_old_preview, + allow_new_preview, window, cx, ) }); editor.update(cx, |editor, cx| { + if has_file && !is_project_file { + editor.set_read_only(true); + } let autoscroll = match scroll_offset { Some(scroll_offset) => Autoscroll::top_relative(scroll_offset as usize), None => Autoscroll::newest(), }; let nav_history = editor.nav_history.take(); + let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let Some((&excerpt_id, _, buffer_snapshot)) = + multibuffer_snapshot.as_singleton() + else { + return; + }; editor.change_selections( SelectionEffects::scroll(autoscroll), window, cx, |s| { - s.select_ranges(ranges); + s.select_ranges(ranges.into_iter().map(|range| { + let range = buffer_snapshot.anchor_before(range.start) + ..buffer_snapshot.anchor_after(range.end); + multibuffer_snapshot + .anchor_range_in_excerpt(excerpt_id, range) + .unwrap() + })); }, ); editor.nav_history = nav_history; @@ -21235,13 +22583,14 @@ impl Editor { }); } - // For now, don't allow opening excerpts in buffers that aren't backed by - // regular project files. + // Allow opening excerpts for buffers that either belong to the current project + // or represent synthetic/non-local files (e.g., git blobs). File-less buffers + // are also supported so tests and other in-memory views keep working. fn can_open_excerpts_in_file(file: Option<&Arc>) -> bool { - file.is_none_or(|file| project::File::from_dyn(Some(file)).is_some()) + file.is_none_or(|file| project::File::from_dyn(Some(file)).is_some() || !file.is_local()) } - fn marked_text_ranges(&self, cx: &App) -> Option>> { + fn marked_text_ranges(&self, cx: &App) -> Option>> { let snapshot = self.buffer.read(cx).read(cx); let (_, ranges) = self.text_highlights::(cx)?; Some( @@ -21256,23 +22605,25 @@ impl Editor { fn selection_replacement_ranges( &self, - range: Range, + range: Range, cx: &mut App, - ) -> Vec> { - let selections = self.selections.all::(cx); + ) -> Vec> { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); let newest_selection = selections .iter() .max_by_key(|selection| selection.id) .unwrap(); - let start_delta = range.start.0 as isize - newest_selection.start.0 as isize; - let end_delta = range.end.0 as isize - newest_selection.end.0 as isize; + let start_delta = range.start.0.0 as isize - newest_selection.start.0.0 as isize; + let end_delta = range.end.0.0 as isize - newest_selection.end.0.0 as isize; let snapshot = self.buffer.read(cx).read(cx); selections .into_iter() .map(|mut selection| { - selection.start.0 = - (selection.start.0 as isize).saturating_add(start_delta) as usize; - selection.end.0 = (selection.end.0 as isize).saturating_add(end_delta) as usize; + selection.start.0.0 = + (selection.start.0.0 as isize).saturating_add(start_delta) as usize; + selection.end.0.0 = (selection.end.0.0 as isize).saturating_add(end_delta) as usize; snapshot.clip_offset_utf16(selection.start, Bias::Left) ..snapshot.clip_offset_utf16(selection.end, Bias::Right) }) @@ -21303,7 +22654,9 @@ impl Editor { .and_then(|e| e.to_str()) .map(|a| a.to_string())); - let vim_mode = vim_enabled(cx); + let vim_mode = vim_mode_setting::VimModeSetting::try_get(cx) + .map(|vim_mode| vim_mode.0) + .unwrap_or(false); let edit_predictions_provider = all_language_settings(file, cx).edit_predictions.provider; let copilot_enabled = edit_predictions_provider @@ -21362,10 +22715,17 @@ impl Editor { if selection.range.is_empty() { None } else { - Some(selection.range) + Some( + snapshot.offset_utf16_to_offset(MultiBufferOffsetUtf16(OffsetUtf16( + selection.range.start, + ))) + ..snapshot.offset_utf16_to_offset(MultiBufferOffsetUtf16(OffsetUtf16( + selection.range.end, + ))), + ) } }) - .unwrap_or_else(|| 0..snapshot.len()); + .unwrap_or_else(|| MultiBufferOffset(0)..snapshot.len()); let chunks = snapshot.chunks(range, true); let mut lines = Vec::new(); @@ -21422,14 +22782,13 @@ impl Editor { cx: &mut Context, ) { self.request_autoscroll(Autoscroll::newest(), cx); - let position = self.selections.newest_display(cx).start; + let position = self + .selections + .newest_display(&self.display_snapshot(cx)) + .start; mouse_context_menu::deploy_context_menu(self, None, position, window, cx); } - pub fn inlay_hint_cache(&self) -> &InlayHintCache { - &self.inlay_hint_cache - } - pub fn replay_insert_event( &mut self, text: &str, @@ -21442,21 +22801,25 @@ impl Editor { return; } if let Some(relative_utf16_range) = relative_utf16_range { - let selections = self.selections.all::(cx); + let selections = self + .selections + .all::(&self.display_snapshot(cx)); self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { let new_ranges = selections.into_iter().map(|range| { - let start = OffsetUtf16( + let start = MultiBufferOffsetUtf16(OffsetUtf16( range .head() .0 + .0 .saturating_add_signed(relative_utf16_range.start), - ); - let end = OffsetUtf16( + )); + let end = MultiBufferOffsetUtf16(OffsetUtf16( range .head() .0 + .0 .saturating_add_signed(relative_utf16_range.end), - ); + )); start..end }); s.select_ranges(new_ranges); @@ -21466,21 +22829,6 @@ impl Editor { self.handle_input(text, window, cx); } - pub fn supports_inlay_hints(&self, cx: &mut App) -> bool { - let Some(provider) = self.semantics_provider.as_ref() else { - return false; - }; - - let mut supports = false; - self.buffer().update(cx, |this, cx| { - this.for_each_buffer(|buffer| { - supports |= provider.supports_inlay_hints(buffer, cx); - }); - }); - - supports - } - pub fn is_focused(&self, window: &Window) -> bool { self.focus_handle.is_focused(window) } @@ -21512,6 +22860,20 @@ impl Editor { ); } }); + + if let Some(position_map) = self.last_position_map.clone() { + EditorElement::mouse_moved( + self, + &MouseMoveEvent { + position: window.mouse_position(), + pressed_button: None, + modifiers: window.modifiers(), + }, + &position_map, + window, + cx, + ); + } } } @@ -21561,13 +22923,7 @@ impl Editor { .pending_input_keystrokes() .into_iter() .flatten() - .filter_map(|keystroke| { - if keystroke.modifiers.is_subset_of(&Modifiers::shift()) { - keystroke.key_char.clone() - } else { - None - } - }) + .filter_map(|keystroke| keystroke.key_char.clone()) .collect(); if !self.input_enabled || self.read_only || !self.focus_handle.is_focused(window) { @@ -21582,7 +22938,9 @@ impl Editor { } let transaction = self.transact(window, cx, |this, window, cx| { - let selections = this.selections.all::(cx); + let selections = this + .selections + .all::(&this.display_snapshot(cx)); let edits = selections .iter() .map(|selection| (selection.end..selection.end, pending.clone())); @@ -21601,7 +22959,7 @@ impl Editor { let snapshot = self.snapshot(window, cx); let ranges = self .selections - .all::(cx) + .all::(&snapshot.display_snapshot) .into_iter() .map(|selection| { snapshot.buffer_snapshot().anchor_after(selection.end) @@ -21690,10 +23048,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let workspace = self.workspace(); - let project = self.project(); - let save_tasks = self.buffer().update(cx, |multi_buffer, cx| { - let mut tasks = Vec::new(); + self.buffer().update(cx, |multi_buffer, cx| { for (buffer_id, changes) in revert_changes { if let Some(buffer) = multi_buffer.buffer(buffer_id) { buffer.update(cx, |buffer, cx| { @@ -21705,66 +23060,33 @@ impl Editor { cx, ); }); - - if let Some(project) = - project.filter(|_| multi_buffer.all_diff_hunks_expanded()) - { - project.update(cx, |project, cx| { - tasks.push((buffer.clone(), project.save_buffer(buffer, cx))); - }) - } } } - tasks }); - cx.spawn_in(window, async move |_, cx| { - for (buffer, task) in save_tasks { - let result = task.await; - if result.is_err() { - let Some(path) = buffer - .read_with(cx, |buffer, cx| buffer.project_path(cx)) - .ok() - else { - continue; - }; - if let Some((workspace, path)) = workspace.as_ref().zip(path) { - let Some(task) = cx - .update_window_entity(workspace, |workspace, window, cx| { - workspace - .open_path_preview(path, None, false, false, false, window, cx) - }) - .ok() - else { - continue; - }; - task.await.log_err(); - } - } - } - }) - .detach(); self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.refresh() }); } pub fn to_pixel_point( - &self, + &mut self, source: multi_buffer::Anchor, editor_snapshot: &EditorSnapshot, window: &mut Window, + cx: &App, ) -> Option> { let source_point = source.to_display_point(editor_snapshot); - self.display_to_pixel_point(source_point, editor_snapshot, window) + self.display_to_pixel_point(source_point, editor_snapshot, window, cx) } pub fn display_to_pixel_point( - &self, + &mut self, source: DisplayPoint, editor_snapshot: &EditorSnapshot, window: &mut Window, + cx: &App, ) -> Option> { - let line_height = self.style()?.text.line_height_in_pixels(window.rem_size()); + let line_height = self.style(cx).text.line_height_in_pixels(window.rem_size()); let text_layout_details = self.text_layout_details(window); let scroll_top = text_layout_details .scroll_anchor @@ -21853,8 +23175,8 @@ impl Editor { folds .into_iter() .map(|(start, end)| { - snapshot.clip_offset(start, Bias::Left) - ..snapshot.clip_offset(end, Bias::Right) + snapshot.clip_offset(MultiBufferOffset(start), Bias::Left) + ..snapshot.clip_offset(MultiBufferOffset(end), Bias::Right) }) .collect(), false, @@ -21871,8 +23193,8 @@ impl Editor { self.selection_history.mode = SelectionHistoryMode::Skipping; self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(selections.into_iter().map(|(start, end)| { - snapshot.clip_offset(start, Bias::Left) - ..snapshot.clip_offset(end, Bias::Right) + snapshot.clip_offset(MultiBufferOffset(start), Bias::Left) + ..snapshot.clip_offset(MultiBufferOffset(end), Bias::Right) })); }); self.selection_history.mode = SelectionHistoryMode::Normal; @@ -21884,22 +23206,108 @@ impl Editor { fn update_lsp_data( &mut self, - ignore_cache: bool, for_buffer: Option, window: &mut Window, cx: &mut Context<'_, Self>, ) { self.pull_diagnostics(for_buffer, window, cx); - self.refresh_colors(ignore_cache, for_buffer, window, cx); + self.refresh_colors_for_visible_range(for_buffer, window, cx); + } + + fn register_visible_buffers(&mut self, cx: &mut Context) { + if self.ignore_lsp_data() { + return; + } + for (_, (visible_buffer, _, _)) in self.visible_excerpts(true, cx) { + self.register_buffer(visible_buffer.read(cx).remote_id(), cx); + } + } + + fn register_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + if self.ignore_lsp_data() { + return; + } + + if !self.registered_buffers.contains_key(&buffer_id) + && let Some(project) = self.project.as_ref() + { + if let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) { + project.update(cx, |project, cx| { + self.registered_buffers.insert( + buffer_id, + project.register_buffer_with_language_servers(&buffer, cx), + ); + }); + } else { + self.registered_buffers.remove(&buffer_id); + } + } + } + + fn ignore_lsp_data(&self) -> bool { + // `ActiveDiagnostic::All` is a special mode where editor's diagnostics are managed by the external view, + // skip any LSP updates for it. + self.active_diagnostics == ActiveDiagnostic::All || !self.mode().is_full() + } + + fn create_style(&self, cx: &App) -> EditorStyle { + let settings = ThemeSettings::get_global(cx); + + let mut text_style = match self.mode { + EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: rems(0.875).into(), + font_weight: settings.ui_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + }; + if let Some(text_style_refinement) = &self.text_style_refinement { + text_style.refine(text_style_refinement) + } + + let background = match self.mode { + EditorMode::SingleLine => cx.theme().system().transparent, + EditorMode::AutoHeight { .. } => cx.theme().system().transparent, + EditorMode::Full { .. } => cx.theme().colors().editor_background, + EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), + }; + + EditorStyle { + background, + border: cx.theme().colors().border, + local_player: cx.theme().players().local(), + text: text_style, + scrollbar_width: EditorElement::SCROLLBAR_WIDTH, + syntax: cx.theme().syntax().clone(), + status: cx.theme().status().clone(), + inlay_hints_style: make_inlay_hints_style(cx), + edit_prediction_styles: make_suggestion_styles(cx), + unnecessary_code_fade: settings.unnecessary_code_fade, + show_underlines: self.diagnostics_enabled(), + } } } fn edit_for_markdown_paste<'a>( buffer: &MultiBufferSnapshot, - range: Range, + range: Range, to_insert: &'a str, url: Option, -) -> (Range, Cow<'a, str>) { +) -> (Range, Cow<'a, str>) { if url.is_none() { return (range, Cow::Borrowed(to_insert)); }; @@ -21914,12 +23322,6 @@ fn edit_for_markdown_paste<'a>( (range, new_text) } -fn vim_enabled(cx: &App) -> bool { - vim_mode_setting::VimModeSetting::try_get(cx) - .map(|vim_mode| vim_mode.0) - .unwrap_or(false) -} - fn process_completion_for_edit( completion: &Completion, intent: CompletionIntent, @@ -22055,22 +23457,23 @@ fn process_completion_for_edit( range_to_replace.end = *cursor_position; } + let replace_range = range_to_replace.to_offset(buffer); CompletionEdit { new_text, - replace_range: range_to_replace.to_offset(buffer), + replace_range: BufferOffset(replace_range.start)..BufferOffset(replace_range.end), snippet, } } struct CompletionEdit { new_text: String, - replace_range: Range, + replace_range: Range, snippet: Option, } fn insert_extra_newline_brackets( buffer: &MultiBufferSnapshot, - range: Range, + range: Range, language: &language::LanguageScope, ) -> bool { let leading_whitespace_len = buffer @@ -22092,22 +23495,28 @@ fn insert_extra_newline_brackets( enabled && pair.newline && buffer.contains_str_at(range.end, pair_end) - && buffer.contains_str_at(range.start.saturating_sub(pair_start.len()), pair_start) + && buffer.contains_str_at( + range.start.saturating_sub_usize(pair_start.len()), + pair_start, + ) }) } -fn insert_extra_newline_tree_sitter(buffer: &MultiBufferSnapshot, range: Range) -> bool { +fn insert_extra_newline_tree_sitter( + buffer: &MultiBufferSnapshot, + range: Range, +) -> bool { let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() { [(buffer, range, _)] => (*buffer, range.clone()), _ => return false, }; let pair = { - let mut result: Option = None; + let mut result: Option> = None; for pair in buffer - .all_bracket_ranges(range.clone()) + .all_bracket_ranges(range.start.0..range.end.0) .filter(move |pair| { - pair.open_range.start <= range.start && pair.close_range.end >= range.end + pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0 }) { let len = pair.close_range.end - pair.open_range.start; @@ -22129,8 +23538,8 @@ fn insert_extra_newline_tree_sitter(buffer: &MultiBufferSnapshot, range: Range Option>>>; + fn applicable_inlay_chunks( + &self, + buffer: &Entity, + ranges: &[Range], + cx: &mut App, + ) -> Vec>; + + fn invalidate_inlay_hints(&self, for_buffers: &HashSet, cx: &mut App); + fn inlay_hints( &self, - buffer_handle: Entity, - range: Range, + invalidate: InvalidationStrategy, + buffer: Entity, + ranges: Vec>, + known_chunks: Option<(clock::Global, HashSet>)>, cx: &mut App, - ) -> Option>>>; - - fn resolve_inlay_hint( - &self, - hint: InlayHint, - buffer_handle: Entity, - server_id: LanguageServerId, - cx: &mut App, - ) -> Option>>; + ) -> Option, Task>>>; fn supports_inlay_hints(&self, buffer: &Entity, cx: &mut App) -> bool; @@ -22678,7 +24090,6 @@ pub trait CompletionProvider { position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool; @@ -22691,6 +24102,10 @@ pub trait CompletionProvider { fn filter_completions(&self) -> bool { true } + + fn show_snippets(&self) -> bool { + false + } } pub trait CodeActionProvider { @@ -22765,10 +24180,11 @@ impl CodeActionProvider for Entity { fn snippet_completions( project: &Project, buffer: &Entity, - buffer_position: text::Anchor, + buffer_anchor: text::Anchor, + classifier: CharClassifier, cx: &mut App, ) -> Task> { - let languages = buffer.read(cx).languages_at(buffer_position); + let languages = buffer.read(cx).languages_at(buffer_anchor); let snippet_store = project.snippets().read(cx); let scopes: Vec<_> = languages @@ -22797,97 +24213,146 @@ fn snippet_completions( let executor = cx.background_executor().clone(); cx.background_spawn(async move { + let is_word_char = |c| classifier.is_word(c); + let mut is_incomplete = false; let mut completions: Vec = Vec::new(); - for (scope, snippets) in scopes.into_iter() { - let classifier = - CharClassifier::new(Some(scope)).scope_context(Some(CharScopeContext::Completion)); - const MAX_WORD_PREFIX_LEN: usize = 128; - let last_word: String = snapshot - .reversed_chars_for_range(text::Anchor::MIN..buffer_position) - .take(MAX_WORD_PREFIX_LEN) - .take_while(|c| classifier.is_word(*c)) - .collect::() - .chars() - .rev() - .collect(); + const MAX_PREFIX_LEN: usize = 128; + let buffer_offset = text::ToOffset::to_offset(&buffer_anchor, &snapshot); + let window_start = buffer_offset.saturating_sub(MAX_PREFIX_LEN); + let window_start = snapshot.clip_offset(window_start, Bias::Left); - if last_word.is_empty() { - return Ok(CompletionResponse { - completions: vec![], - display_options: CompletionDisplayOptions::default(), - is_incomplete: true, - }); + let max_buffer_window: String = snapshot + .text_for_range(window_start..buffer_offset) + .collect(); + + if max_buffer_window.is_empty() { + return Ok(CompletionResponse { + completions: vec![], + display_options: CompletionDisplayOptions::default(), + is_incomplete: true, + }); + } + + for (_scope, snippets) in scopes.into_iter() { + // Sort snippets by word count to match longer snippet prefixes first. + let mut sorted_snippet_candidates = snippets + .iter() + .enumerate() + .flat_map(|(snippet_ix, snippet)| { + snippet + .prefix + .iter() + .enumerate() + .map(move |(prefix_ix, prefix)| { + let word_count = + snippet_candidate_suffixes(prefix, is_word_char).count(); + ((snippet_ix, prefix_ix), prefix, word_count) + }) + }) + .collect_vec(); + sorted_snippet_candidates + .sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count)); + + // Each prefix may be matched multiple times; the completion menu must filter out duplicates. + + let buffer_windows = snippet_candidate_suffixes(&max_buffer_window, is_word_char) + .take( + sorted_snippet_candidates + .first() + .map(|(_, _, word_count)| *word_count) + .unwrap_or_default(), + ) + .collect_vec(); + + const MAX_RESULTS: usize = 100; + // Each match also remembers how many characters from the buffer it consumed + let mut matches: Vec<(StringMatch, usize)> = vec![]; + + let mut snippet_list_cutoff_index = 0; + for (buffer_index, buffer_window) in buffer_windows.iter().enumerate().rev() { + let word_count = buffer_index + 1; + // Increase `snippet_list_cutoff_index` until we have all of the + // snippets with sufficiently many words. + while sorted_snippet_candidates + .get(snippet_list_cutoff_index) + .is_some_and(|(_ix, _prefix, snippet_word_count)| { + *snippet_word_count >= word_count + }) + { + snippet_list_cutoff_index += 1; + } + + // Take only the candidates with at least `word_count` many words + let snippet_candidates_at_word_len = + &sorted_snippet_candidates[..snippet_list_cutoff_index]; + + let candidates = snippet_candidates_at_word_len + .iter() + .map(|(_snippet_ix, prefix, _snippet_word_count)| prefix) + .enumerate() // index in `sorted_snippet_candidates` + // First char must match + .filter(|(_ix, prefix)| { + itertools::equal( + prefix + .chars() + .next() + .into_iter() + .flat_map(|c| c.to_lowercase()), + buffer_window + .chars() + .next() + .into_iter() + .flat_map(|c| c.to_lowercase()), + ) + }) + .map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix)) + .collect::>(); + + matches.extend( + fuzzy::match_strings( + &candidates, + &buffer_window, + buffer_window.chars().any(|c| c.is_uppercase()), + true, + MAX_RESULTS - matches.len(), // always prioritize longer snippets + &Default::default(), + executor.clone(), + ) + .await + .into_iter() + .map(|string_match| (string_match, buffer_window.len())), + ); + + if matches.len() >= MAX_RESULTS { + break; + } } - let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); let to_lsp = |point: &text::Anchor| { let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); point_to_lsp(end) }; - let lsp_end = to_lsp(&buffer_position); - - let candidates = snippets - .iter() - .enumerate() - .flat_map(|(ix, snippet)| { - snippet - .prefix - .iter() - .map(move |prefix| StringMatchCandidate::new(ix, prefix)) - }) - .collect::>(); - - const MAX_RESULTS: usize = 100; - let mut matches = fuzzy::match_strings( - &candidates, - &last_word, - last_word.chars().any(|c| c.is_uppercase()), - true, - MAX_RESULTS, - &Default::default(), - executor.clone(), - ) - .await; + let lsp_end = to_lsp(&buffer_anchor); if matches.len() >= MAX_RESULTS { is_incomplete = true; } - // Remove all candidates where the query's start does not match the start of any word in the candidate - if let Some(query_start) = last_word.chars().next() { - matches.retain(|string_match| { - split_words(&string_match.string).any(|word| { - // Check that the first codepoint of the word as lowercase matches the first - // codepoint of the query as lowercase - word.chars() - .flat_map(|codepoint| codepoint.to_lowercase()) - .zip(query_start.to_lowercase()) - .all(|(word_cp, query_cp)| word_cp == query_cp) - }) - }); - } - - let matched_strings = matches - .into_iter() - .map(|m| m.string) - .collect::>(); - - completions.extend(snippets.iter().filter_map(|snippet| { - let matching_prefix = snippet - .prefix - .iter() - .find(|prefix| matched_strings.contains(*prefix))?; - let start = as_offset - last_word.len(); + completions.extend(matches.iter().map(|(string_match, buffer_window_len)| { + let ((snippet_index, prefix_index), matching_prefix, _snippet_word_count) = + sorted_snippet_candidates[string_match.candidate_id]; + let snippet = &snippets[snippet_index]; + let start = buffer_offset - buffer_window_len; let start = snapshot.anchor_before(start); - let range = start..buffer_position; + let range = start..buffer_anchor; let lsp_start = to_lsp(&start); let lsp_range = lsp::Range { start: lsp_start, end: lsp_end, }; - Some(Completion { + Completion { replace_range: range, new_text: snippet.body.clone(), source: CompletionSource::Lsp { @@ -22932,8 +24397,10 @@ fn snippet_completions( }), insert_text_mode: None, confirm: None, - }) - })) + match_start: Some(start), + snippet_deduplication_key: Some((snippet_index, prefix_index)), + } + })); } Ok(CompletionResponse { @@ -22955,16 +24422,8 @@ impl CompletionProvider for Entity { cx: &mut Context, ) -> Task>> { self.update(cx, |project, cx| { - let snippets = snippet_completions(project, buffer, buffer_position, cx); - let project_completions = project.completions(buffer, buffer_position, options, cx); - cx.background_spawn(async move { - let mut responses = project_completions.await?; - let snippets = snippets.await?; - if !snippets.completions.is_empty() { - responses.push(snippets); - } - Ok(responses) - }) + let task = project.completions(buffer, buffer_position, options, cx); + cx.background_spawn(task) }) } @@ -23009,7 +24468,6 @@ impl CompletionProvider for Entity { position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool { let mut chars = text.chars(); @@ -23024,9 +24482,6 @@ impl CompletionProvider for Entity { let buffer = buffer.read(cx); let snapshot = buffer.snapshot(); - if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input { - return false; - } let classifier = snapshot .char_classifier_at(position) .scope_context(Some(CharScopeContext::Completion)); @@ -23036,6 +24491,10 @@ impl CompletionProvider for Entity { buffer.completion_triggers().contains(text) } + + fn show_snippets(&self) -> bool { + true + } } impl SemanticsProvider for Entity { @@ -23102,26 +24561,33 @@ impl SemanticsProvider for Entity { }) } - fn inlay_hints( + fn applicable_inlay_chunks( &self, - buffer_handle: Entity, - range: Range, + buffer: &Entity, + ranges: &[Range], cx: &mut App, - ) -> Option>>> { - Some(self.update(cx, |project, cx| { - project.inlay_hints(buffer_handle, range, cx) - })) + ) -> Vec> { + self.read(cx).lsp_store().update(cx, |lsp_store, cx| { + lsp_store.applicable_inlay_chunks(buffer, ranges, cx) + }) } - fn resolve_inlay_hint( + fn invalidate_inlay_hints(&self, for_buffers: &HashSet, cx: &mut App) { + self.read(cx).lsp_store().update(cx, |lsp_store, _| { + lsp_store.invalidate_inlay_hints(for_buffers) + }); + } + + fn inlay_hints( &self, - hint: InlayHint, - buffer_handle: Entity, - server_id: LanguageServerId, + invalidate: InvalidationStrategy, + buffer: Entity, + ranges: Vec>, + known_chunks: Option<(clock::Global, HashSet>)>, cx: &mut App, - ) -> Option>> { - Some(self.update(cx, |project, cx| { - project.resolve_inlay_hint(hint, buffer_handle, server_id, cx) + ) -> Option, Task>>> { + Some(self.read(cx).lsp_store().update(cx, |lsp_store, cx| { + lsp_store.inlay_hints(invalidate, buffer, ranges, known_chunks, cx) })) } @@ -23170,16 +24636,6 @@ impl SemanticsProvider for Entity { } } -fn inlay_hint_settings( - location: Anchor, - snapshot: &MultiBufferSnapshot, - cx: &mut Context, -) -> InlayHintSettings { - let file = snapshot.file_at(location); - let language = snapshot.language_at(location).map(|l| l.name()); - language_settings(language, file, cx).inlay_hints -} - fn consume_contiguous_rows( contiguous_row_selections: &mut Vec>, selection: &Selection, @@ -23234,7 +24690,7 @@ impl EditorSnapshot { self.buffer_snapshot() .selections_in_range(range, false) .filter_map(move |(replica_id, line_mode, cursor_shape, selection)| { - if replica_id == AGENT_REPLICA_ID { + if replica_id == ReplicaId::AGENT { Some(RemoteSelection { replica_id, selection, @@ -23332,13 +24788,15 @@ impl EditorSnapshot { end_row.0 += 1; } let is_created_file = hunk.is_created_file(); + DisplayDiffHunk::Unfolded { status: hunk.status(), - diff_base_byte_range: hunk.diff_base_byte_range, + diff_base_byte_range: hunk.diff_base_byte_range.start.0 + ..hunk.diff_base_byte_range.end.0, + word_diffs: hunk.word_diffs, display_row_range: hunk_display_start.row()..end_row, multi_buffer_range: Anchor::range_in_buffer( hunk.excerpt_id, - hunk.buffer_id, hunk.buffer_range, ), is_created_file, @@ -23369,94 +24827,98 @@ impl EditorSnapshot { self.scroll_anchor.scroll_position(&self.display_snapshot) } - fn gutter_dimensions( + pub fn gutter_dimensions( &self, font_id: FontId, font_size: Pixels, - max_line_number_width: Pixels, + style: &EditorStyle, + window: &mut Window, cx: &App, - ) -> Option { - if !self.show_gutter { - return None; + ) -> GutterDimensions { + if self.show_gutter + && let Some(ch_width) = cx.text_system().ch_width(font_id, font_size).log_err() + && let Some(ch_advance) = cx.text_system().ch_advance(font_id, font_size).log_err() + { + let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { + matches!( + ProjectSettings::get_global(cx).git.git_gutter, + GitGutterSetting::TrackedFiles + ) + }); + let gutter_settings = EditorSettings::get_global(cx).gutter; + let show_line_numbers = self + .show_line_numbers + .unwrap_or(gutter_settings.line_numbers); + let line_gutter_width = if show_line_numbers { + // Avoid flicker-like gutter resizes when the line number gains another digit by + // only resizing the gutter on files with > 10**min_line_number_digits lines. + let min_width_for_number_on_gutter = + ch_advance * gutter_settings.min_line_number_digits as f32; + self.max_line_number_width(style, window) + .max(min_width_for_number_on_gutter) + } else { + 0.0.into() + }; + + let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); + let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); + + let git_blame_entries_width = + self.git_blame_gutter_max_author_length + .map(|max_author_length| { + let renderer = cx.global::().0.clone(); + const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; + + /// The number of characters to dedicate to gaps and margins. + const SPACING_WIDTH: usize = 4; + + let max_char_count = max_author_length.min(renderer.max_author_length()) + + ::git::SHORT_SHA_LENGTH + + MAX_RELATIVE_TIMESTAMP.len() + + SPACING_WIDTH; + + ch_advance * max_char_count + }); + + let is_singleton = self.buffer_snapshot().is_singleton(); + + let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); + left_padding += if !is_singleton { + ch_width * 4.0 + } else if show_runnables || show_breakpoints { + ch_width * 3.0 + } else if show_git_gutter && show_line_numbers { + ch_width * 2.0 + } else if show_git_gutter || show_line_numbers { + ch_width + } else { + px(0.) + }; + + let shows_folds = is_singleton && gutter_settings.folds; + + let right_padding = if shows_folds && show_line_numbers { + ch_width * 4.0 + } else if shows_folds || (!is_singleton && show_line_numbers) { + ch_width * 3.0 + } else if show_line_numbers { + ch_width + } else { + px(0.) + }; + + GutterDimensions { + left_padding, + right_padding, + width: line_gutter_width + left_padding + right_padding, + margin: GutterDimensions::default_gutter_margin(font_id, font_size, cx), + git_blame_entries_width, + } + } else if self.offset_content { + GutterDimensions::default_with_margin(font_id, font_size, cx) + } else { + GutterDimensions::default() } - - let ch_width = cx.text_system().ch_width(font_id, font_size).log_err()?; - let ch_advance = cx.text_system().ch_advance(font_id, font_size).log_err()?; - - let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { - matches!( - ProjectSettings::get_global(cx).git.git_gutter, - GitGutterSetting::TrackedFiles - ) - }); - let gutter_settings = EditorSettings::get_global(cx).gutter; - let show_line_numbers = self - .show_line_numbers - .unwrap_or(gutter_settings.line_numbers); - let line_gutter_width = if show_line_numbers { - // Avoid flicker-like gutter resizes when the line number gains another digit by - // only resizing the gutter on files with > 10**min_line_number_digits lines. - let min_width_for_number_on_gutter = - ch_advance * gutter_settings.min_line_number_digits as f32; - max_line_number_width.max(min_width_for_number_on_gutter) - } else { - 0.0.into() - }; - - let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); - let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); - - let git_blame_entries_width = - self.git_blame_gutter_max_author_length - .map(|max_author_length| { - let renderer = cx.global::().0.clone(); - const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; - - /// The number of characters to dedicate to gaps and margins. - const SPACING_WIDTH: usize = 4; - - let max_char_count = max_author_length.min(renderer.max_author_length()) - + ::git::SHORT_SHA_LENGTH - + MAX_RELATIVE_TIMESTAMP.len() - + SPACING_WIDTH; - - ch_advance * max_char_count - }); - - let is_singleton = self.buffer_snapshot().is_singleton(); - - let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); - left_padding += if !is_singleton { - ch_width * 4.0 - } else if show_runnables || show_breakpoints { - ch_width * 3.0 - } else if show_git_gutter && show_line_numbers { - ch_width * 2.0 - } else if show_git_gutter || show_line_numbers { - ch_width - } else { - px(0.) - }; - - let shows_folds = is_singleton && gutter_settings.folds; - - let right_padding = if shows_folds && show_line_numbers { - ch_width * 4.0 - } else if shows_folds || (!is_singleton && show_line_numbers) { - ch_width * 3.0 - } else if show_line_numbers { - ch_width - } else { - px(0.) - }; - - Some(GutterDimensions { - left_padding, - right_padding, - width: line_gutter_width + left_padding + right_padding, - margin: GutterDimensions::default_gutter_margin(font_id, font_size, cx), - git_blame_entries_width, - }) } pub fn render_crease_toggle( @@ -23539,6 +25001,28 @@ impl EditorSnapshot { None } } + + pub fn max_line_number_width(&self, style: &EditorStyle, window: &mut Window) -> Pixels { + let digit_count = self.widest_line_number().ilog10() + 1; + column_pixels(style, digit_count as usize, window) + } +} + +pub fn column_pixels(style: &EditorStyle, column: usize, window: &Window) -> Pixels { + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let layout = window.text_system().shape_line( + SharedString::from(" ".repeat(column)), + font_size, + &[TextRun { + len: column, + font: style.text.font(), + color: Hsla::default(), + ..Default::default() + }], + None, + ); + + layout.width } impl Deref for EditorSnapshot { @@ -23619,57 +25103,7 @@ impl Focusable for Editor { impl Render for Editor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - - let mut text_style = match self.mode { - EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_size: settings.buffer_font_size(cx).into(), - font_weight: settings.buffer_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - }; - if let Some(text_style_refinement) = &self.text_style_refinement { - text_style.refine(text_style_refinement) - } - - let background = match self.mode { - EditorMode::SingleLine => cx.theme().system().transparent, - EditorMode::AutoHeight { .. } => cx.theme().system().transparent, - EditorMode::Full { .. } => cx.theme().colors().editor_background, - EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), - }; - - EditorElement::new( - &cx.entity(), - EditorStyle { - background, - border: cx.theme().colors().border, - local_player: cx.theme().players().local(), - text: text_style, - scrollbar_width: EditorElement::SCROLLBAR_WIDTH, - syntax: cx.theme().syntax().clone(), - status: cx.theme().status().clone(), - inlay_hints_style: make_inlay_hints_style(cx), - edit_prediction_styles: make_suggestion_styles(cx), - unnecessary_code_fade: ThemeSettings::get_global(cx).unnecessary_code_fade, - show_underlines: self.diagnostics_enabled(), - }, - ) + EditorElement::new(&cx.entity(), self.create_style(cx)) } } @@ -23682,10 +25116,16 @@ impl EntityInputHandler for Editor { cx: &mut Context, ) -> Option { let snapshot = self.buffer.read(cx).read(cx); - let start = snapshot.clip_offset_utf16(OffsetUtf16(range_utf16.start), Bias::Left); - let end = snapshot.clip_offset_utf16(OffsetUtf16(range_utf16.end), Bias::Right); - if (start.0..end.0) != range_utf16 { - adjusted_range.replace(start.0..end.0); + let start = snapshot.clip_offset_utf16( + MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)), + Bias::Left, + ); + let end = snapshot.clip_offset_utf16( + MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)), + Bias::Right, + ); + if (start.0.0..end.0.0) != range_utf16 { + adjusted_range.replace(start.0.0..end.0.0); } Some(snapshot.text_for_range(start..end).collect()) } @@ -23702,11 +25142,13 @@ impl EntityInputHandler for Editor { return None; } - let selection = self.selections.newest::(cx); + let selection = self + .selections + .newest::(&self.display_snapshot(cx)); let range = selection.range(); Some(UTF16Selection { - range: range.start.0..range.end.0, + range: range.start.0.0..range.end.0.0, reversed: selection.reversed, }) } @@ -23714,7 +25156,7 @@ impl EntityInputHandler for Editor { fn marked_text_range(&self, _: &mut Window, cx: &mut Context) -> Option> { let snapshot = self.buffer.read(cx).read(cx); let range = self.text_highlights::(cx)?.1.first()?; - Some(range.start.to_offset_utf16(&snapshot).0..range.end.to_offset_utf16(&snapshot).0) + Some(range.start.to_offset_utf16(&snapshot).0.0..range.end.to_offset_utf16(&snapshot).0.0) } fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { @@ -23736,7 +25178,8 @@ impl EntityInputHandler for Editor { self.transact(window, cx, |this, window, cx| { let new_selected_ranges = if let Some(range_utf16) = range_utf16 { - let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); + let range_utf16 = MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)) + ..MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)); Some(this.selection_replacement_ranges(range_utf16, cx)) } else { this.marked_text_ranges(cx) @@ -23745,14 +25188,14 @@ impl EntityInputHandler for Editor { let range_to_replace = new_selected_ranges.as_ref().and_then(|ranges_to_replace| { let newest_selection_id = this.selections.newest_anchor().id; this.selections - .all::(cx) + .all::(&this.display_snapshot(cx)) .iter() .zip(ranges_to_replace.iter()) .find_map(|(selection, range)| { if selection.id == newest_selection_id { Some( - (range.start.0 as isize - selection.head().0 as isize) - ..(range.end.0 as isize - selection.head().0 as isize), + (range.start.0.0 as isize - selection.head().0.0 as isize) + ..(range.end.0.0 as isize - selection.head().0.0 as isize), ) } else { None @@ -23801,8 +25244,8 @@ impl EntityInputHandler for Editor { let snapshot = this.buffer.read(cx).read(cx); if let Some(relative_range_utf16) = range_utf16.as_ref() { for marked_range in &mut marked_ranges { - marked_range.end.0 = marked_range.start.0 + relative_range_utf16.end; - marked_range.start.0 += relative_range_utf16.start; + marked_range.end = marked_range.start + relative_range_utf16.end; + marked_range.start += relative_range_utf16.start; marked_range.start = snapshot.clip_offset_utf16(marked_range.start, Bias::Left); marked_range.end = @@ -23811,7 +25254,8 @@ impl EntityInputHandler for Editor { } Some(marked_ranges) } else if let Some(range_utf16) = range_utf16 { - let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); + let range_utf16 = MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)) + ..MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)); Some(this.selection_replacement_ranges(range_utf16, cx)) } else { None @@ -23820,14 +25264,14 @@ impl EntityInputHandler for Editor { let range_to_replace = ranges_to_replace.as_ref().and_then(|ranges_to_replace| { let newest_selection_id = this.selections.newest_anchor().id; this.selections - .all::(cx) + .all::(&this.display_snapshot(cx)) .iter() .zip(ranges_to_replace.iter()) .find_map(|(selection, range)| { if selection.id == newest_selection_id { Some( - (range.start.0 as isize - selection.head().0 as isize) - ..(range.end.0 as isize - selection.head().0 as isize), + (range.start.0.0 as isize - selection.head().0.0 as isize) + ..(range.end.0.0 as isize - selection.head().0.0 as isize), ) } else { None @@ -23889,8 +25333,12 @@ impl EntityInputHandler for Editor { .into_iter() .map(|marked_range| { let insertion_start = marked_range.start.to_offset_utf16(&snapshot).0; - let new_start = OffsetUtf16(new_selected_range.start + insertion_start); - let new_end = OffsetUtf16(new_selected_range.end + insertion_start); + let new_start = MultiBufferOffsetUtf16(OffsetUtf16( + insertion_start.0 + new_selected_range.start, + )); + let new_end = MultiBufferOffsetUtf16(OffsetUtf16( + insertion_start.0 + new_selected_range.end, + )); snapshot.clip_offset_utf16(new_start, Bias::Left) ..snapshot.clip_offset_utf16(new_end, Bias::Right) }) @@ -23933,7 +25381,8 @@ impl EntityInputHandler for Editor { let scroll_position = snapshot.scroll_position(); let scroll_left = scroll_position.x * ScrollOffset::from(em_advance); - let start = OffsetUtf16(range_utf16.start).to_display_point(&snapshot); + let start = + MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)).to_display_point(&snapshot); let x = Pixels::from( ScrollOffset::from( snapshot.x_for_display_point(start, &text_layout_details) @@ -23963,7 +25412,11 @@ impl EntityInputHandler for Editor { .snapshot .display_point_to_anchor(display_point, Bias::Left); let utf16_offset = anchor.to_offset_utf16(&position_map.snapshot.buffer_snapshot()); - Some(utf16_offset.0) + Some(utf16_offset.0.0) + } + + fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { + self.input_enabled } } @@ -24066,25 +25519,20 @@ impl InvalidationRegion for SnippetState { fn edit_prediction_edit_text( current_snapshot: &BufferSnapshot, - edits: &[(Range, String)], + edits: &[(Range, impl AsRef)], edit_preview: &EditPreview, include_deletions: bool, cx: &App, ) -> HighlightedText { let edits = edits .iter() - .map(|(anchor, text)| { - ( - anchor.start.text_anchor..anchor.end.text_anchor, - text.clone(), - ) - }) + .map(|(anchor, text)| (anchor.start.text_anchor..anchor.end.text_anchor, text)) .collect::>(); edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx) } -fn edit_prediction_fallback_text(edits: &[(Range, String)], cx: &App) -> HighlightedText { +fn edit_prediction_fallback_text(edits: &[(Range, Arc)], cx: &App) -> HighlightedText { // Fallback for providers that don't provide edit_preview (like Copilot/Supermaven) // Just show the raw edit text with basic styling let mut text = String::new(); @@ -24124,6 +25572,7 @@ pub fn diagnostic_style(severity: lsp::DiagnosticSeverity, colors: &StatusColors pub fn styled_runs_for_code_label<'a>( label: &'a CodeLabel, syntax_theme: &'a theme::SyntaxTheme, + local_player: &'a theme::PlayerColor, ) -> impl 'a + Iterator, HighlightStyle)> { let fade_out = HighlightStyle { fade_out: Some(0.35), @@ -24136,7 +25585,17 @@ pub fn styled_runs_for_code_label<'a>( .iter() .enumerate() .flat_map(move |(ix, (range, highlight_id))| { - let style = if let Some(style) = highlight_id.style(syntax_theme) { + let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { + HighlightStyle { + color: Some(local_player.cursor), + ..Default::default() + } + } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { + HighlightStyle { + background_color: Some(local_player.selection), + ..Default::default() + } + } else if let Some(style) = highlight_id.style(syntax_theme) { style } else { return Default::default(); @@ -24185,6 +25644,33 @@ pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + }) } +/// Given a string of text immediately before the cursor, iterates over possible +/// strings a snippet could match to. More precisely: returns an iterator over +/// suffixes of `text` created by splitting at word boundaries (before & after +/// every non-word character). +/// +/// Shorter suffixes are returned first. +pub(crate) fn snippet_candidate_suffixes( + text: &str, + is_word_char: impl Fn(char) -> bool, +) -> impl std::iter::Iterator { + let mut prev_index = text.len(); + let mut prev_codepoint = None; + text.char_indices() + .rev() + .chain([(0, '\0')]) + .filter_map(move |(index, codepoint)| { + let prev_index = std::mem::replace(&mut prev_index, index); + let prev_codepoint = prev_codepoint.replace(codepoint)?; + if is_word_char(prev_codepoint) && is_word_char(codepoint) { + None + } else { + let chunk = &text[prev_index..]; // go to end of string + Some(chunk) + } + }) +} + pub trait RangeToAnchorExt: Sized { fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range; @@ -24477,7 +25963,7 @@ impl Focusable for BreakpointPromptEditor { } fn all_edits_insertions_or_deletions( - edits: &Vec<(Range, String)>, + edits: &Vec<(Range, Arc)>, snapshot: &MultiBufferSnapshot, ) -> bool { let mut all_insertions = true; @@ -24528,7 +26014,7 @@ impl Render for MissingEditPredictionKeybindingTooltip { .items_end() .w_full() .child(Button::new("open-keymap", "Assign Keybinding").size(ButtonSize::Compact).on_click(|_ev, window, cx| { - window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx) + window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx) })) .child(Button::new("see-docs", "See Docs").size(ButtonSize::Compact).on_click(|_ev, _window, cx| { cx.open_url("https://zed.dev/docs/completions#edit-predictions-missing-keybinding"); @@ -24581,12 +26067,11 @@ fn render_diff_hunk_controls( .alpha(if status.is_pending() { 0.66 } else { 1.0 }) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Stage Hunk", &::git::ToggleStaged, &focus_handle, - window, cx, ) } @@ -24608,12 +26093,11 @@ fn render_diff_hunk_controls( .alpha(if status.is_pending() { 0.66 } else { 1.0 }) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Unstage Hunk", &::git::ToggleStaged, &focus_handle, - window, cx, ) } @@ -24635,14 +26119,8 @@ fn render_diff_hunk_controls( Button::new(("restore", row as u64), "Restore") .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Restore Hunk", - &::git::Restore, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Restore Hunk", &::git::Restore, &focus_handle, cx) } }) .on_click({ @@ -24667,14 +26145,8 @@ fn render_diff_hunk_controls( // .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Next Hunk", - &GoToHunk, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, cx) } }) .on_click({ @@ -24703,12 +26175,11 @@ fn render_diff_hunk_controls( // .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Previous Hunk", &GoToPreviousHunk, &focus_handle, - window, cx, ) } diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index e561522b1e..e1984311d4 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -1,30 +1,28 @@ use core::num; -use std::num::NonZeroU32; use gpui::App; use language::CursorShape; use project::project_settings::DiagnosticSeverity; pub use settings::{ - CurrentLineHighlight, DisplayIn, DocumentColorsRenderMode, DoubleClickInMultibuffer, + CurrentLineHighlight, DelayMs, DisplayIn, DocumentColorsRenderMode, DoubleClickInMultibuffer, GoToDefinitionFallback, HideMouseMode, MinimapThumb, MinimapThumbBorder, MultiCursorModifier, ScrollBeyondLastLine, ScrollbarDiagnostics, SeedQuerySetting, ShowMinimap, SnippetSortOrder, - VsCodeSettings, }; -use settings::{Settings, SettingsContent}; +use settings::{RegisterSetting, RelativeLineNumbers, Settings}; use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar}; /// Imports from the VSCode settings at /// https://code.visualstudio.com/docs/reference/default-settings -#[derive(Clone)] +#[derive(Clone, RegisterSetting)] pub struct EditorSettings { pub cursor_blink: bool, pub cursor_shape: Option, pub current_line_highlight: CurrentLineHighlight, pub selection_highlight: bool, pub rounded_selection: bool, - pub lsp_highlight_debounce: u64, + pub lsp_highlight_debounce: DelayMs, pub hover_popover_enabled: bool, - pub hover_popover_delay: u64, + pub hover_popover_delay: DelayMs, pub toolbar: Toolbar, pub scrollbar: Scrollbar, pub minimap: Minimap, @@ -35,7 +33,8 @@ pub struct EditorSettings { pub horizontal_scroll_margin: f32, pub scroll_sensitivity: f32, pub fast_scroll_sensitivity: f32, - pub relative_line_numbers: bool, + pub sticky_scroll: StickyScroll, + pub relative_line_numbers: RelativeLineNumbers, pub seed_search_query_from_cursor: SeedQuerySetting, pub use_smartcase_search: bool, pub multi_cursor_modifier: MultiCursorModifier, @@ -57,6 +56,7 @@ pub struct EditorSettings { pub drag_and_drop_selection: DragAndDropSelection, pub lsp_document_colors: DocumentColorsRenderMode, pub minimum_contrast_for_highlights: f32, + pub completion_menu_scrollbar: ShowScrollbar, } #[derive(Debug, Clone)] pub struct Jupyter { @@ -66,6 +66,11 @@ pub struct Jupyter { pub enabled: bool, } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct StickyScroll { + pub enabled: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Toolbar { pub breadcrumbs: bool, @@ -149,7 +154,7 @@ pub struct DragAndDropSelection { /// The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. /// /// Default: 300 - pub delay: u64, + pub delay: DelayMs, } /// Default options for buffer and project search items. @@ -157,10 +162,16 @@ pub struct DragAndDropSelection { pub struct SearchSettings { /// Whether to show the project search button in the status bar. pub button: bool, + /// Whether to only match on whole words. pub whole_word: bool, + /// Whether to match case sensitively. pub case_sensitive: bool, + /// Whether to include gitignored files in search results. pub include_ignored: bool, + /// Whether to interpret the search query as a regular expression. pub regex: bool, + /// Whether to center the cursor on each search match when navigating. + pub center_on_match: bool, } impl EditorSettings { @@ -176,7 +187,7 @@ impl ScrollbarVisibility for EditorSettings { } impl Settings for EditorSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let editor = content.editor.clone(); let scrollbar = editor.scrollbar.unwrap(); let minimap = editor.minimap.unwrap(); @@ -185,6 +196,7 @@ impl Settings for EditorSettings { let toolbar = editor.toolbar.unwrap(); let search = editor.search.unwrap(); let drag_and_drop_selection = editor.drag_and_drop_selection.unwrap(); + let sticky_scroll = editor.sticky_scroll.unwrap(); Self { cursor_blink: editor.cursor_blink.unwrap(), cursor_shape: editor.cursor_shape.map(Into::into), @@ -235,6 +247,9 @@ impl Settings for EditorSettings { horizontal_scroll_margin: editor.horizontal_scroll_margin.unwrap(), scroll_sensitivity: editor.scroll_sensitivity.unwrap(), fast_scroll_sensitivity: editor.fast_scroll_sensitivity.unwrap(), + sticky_scroll: StickyScroll { + enabled: sticky_scroll.enabled.unwrap(), + }, relative_line_numbers: editor.relative_line_numbers.unwrap(), seed_search_query_from_cursor: editor.seed_search_query_from_cursor.unwrap(), use_smartcase_search: editor.use_smartcase_search.unwrap(), @@ -251,6 +266,7 @@ impl Settings for EditorSettings { case_sensitive: search.case_sensitive.unwrap(), include_ignored: search.include_ignored.unwrap(), regex: search.regex.unwrap(), + center_on_match: search.center_on_match.unwrap(), }, auto_signature_help: editor.auto_signature_help.unwrap(), show_signature_help_after_edits: editor.show_signature_help_after_edits.unwrap(), @@ -268,210 +284,7 @@ impl Settings for EditorSettings { }, lsp_document_colors: editor.lsp_document_colors.unwrap(), minimum_contrast_for_highlights: editor.minimum_contrast_for_highlights.unwrap().0, - } - } - - fn import_from_vscode(vscode: &VsCodeSettings, current: &mut SettingsContent) { - vscode.enum_setting( - "editor.cursorBlinking", - &mut current.editor.cursor_blink, - |s| match s { - "blink" | "phase" | "expand" | "smooth" => Some(true), - "solid" => Some(false), - _ => None, - }, - ); - vscode.enum_setting( - "editor.cursorStyle", - &mut current.editor.cursor_shape, - |s| match s { - "block" => Some(settings::CursorShape::Block), - "block-outline" => Some(settings::CursorShape::Hollow), - "line" | "line-thin" => Some(settings::CursorShape::Bar), - "underline" | "underline-thin" => Some(settings::CursorShape::Underline), - _ => None, - }, - ); - - vscode.enum_setting( - "editor.renderLineHighlight", - &mut current.editor.current_line_highlight, - |s| match s { - "gutter" => Some(CurrentLineHighlight::Gutter), - "line" => Some(CurrentLineHighlight::Line), - "all" => Some(CurrentLineHighlight::All), - _ => None, - }, - ); - - vscode.bool_setting( - "editor.selectionHighlight", - &mut current.editor.selection_highlight, - ); - vscode.bool_setting( - "editor.roundedSelection", - &mut current.editor.rounded_selection, - ); - vscode.bool_setting( - "editor.hover.enabled", - &mut current.editor.hover_popover_enabled, - ); - vscode.u64_setting( - "editor.hover.delay", - &mut current.editor.hover_popover_delay, - ); - - let mut gutter = settings::GutterContent::default(); - vscode.enum_setting( - "editor.showFoldingControls", - &mut gutter.folds, - |s| match s { - "always" | "mouseover" => Some(true), - "never" => Some(false), - _ => None, - }, - ); - vscode.enum_setting( - "editor.lineNumbers", - &mut gutter.line_numbers, - |s| match s { - "on" | "relative" => Some(true), - "off" => Some(false), - _ => None, - }, - ); - if let Some(old_gutter) = current.editor.gutter.as_mut() { - if gutter.folds.is_some() { - old_gutter.folds = gutter.folds - } - if gutter.line_numbers.is_some() { - old_gutter.line_numbers = gutter.line_numbers - } - } else if gutter != settings::GutterContent::default() { - current.editor.gutter = Some(gutter) - } - if let Some(b) = vscode.read_bool("editor.scrollBeyondLastLine") { - current.editor.scroll_beyond_last_line = Some(if b { - ScrollBeyondLastLine::OnePage - } else { - ScrollBeyondLastLine::Off - }) - } - - let mut scrollbar_axes = settings::ScrollbarAxesContent::default(); - vscode.enum_setting( - "editor.scrollbar.horizontal", - &mut scrollbar_axes.horizontal, - |s| match s { - "auto" | "visible" => Some(true), - "hidden" => Some(false), - _ => None, - }, - ); - vscode.enum_setting( - "editor.scrollbar.vertical", - &mut scrollbar_axes.horizontal, - |s| match s { - "auto" | "visible" => Some(true), - "hidden" => Some(false), - _ => None, - }, - ); - - if scrollbar_axes != settings::ScrollbarAxesContent::default() { - let scrollbar_settings = current.editor.scrollbar.get_or_insert_default(); - let axes_settings = scrollbar_settings.axes.get_or_insert_default(); - - if let Some(vertical) = scrollbar_axes.vertical { - axes_settings.vertical = Some(vertical); - } - if let Some(horizontal) = scrollbar_axes.horizontal { - axes_settings.horizontal = Some(horizontal); - } - } - - // TODO: check if this does the int->float conversion? - vscode.f32_setting( - "editor.cursorSurroundingLines", - &mut current.editor.vertical_scroll_margin, - ); - vscode.f32_setting( - "editor.mouseWheelScrollSensitivity", - &mut current.editor.scroll_sensitivity, - ); - vscode.f32_setting( - "editor.fastScrollSensitivity", - &mut current.editor.fast_scroll_sensitivity, - ); - if Some("relative") == vscode.read_string("editor.lineNumbers") { - current.editor.relative_line_numbers = Some(true); - } - - vscode.enum_setting( - "editor.find.seedSearchStringFromSelection", - &mut current.editor.seed_search_query_from_cursor, - |s| match s { - "always" => Some(SeedQuerySetting::Always), - "selection" => Some(SeedQuerySetting::Selection), - "never" => Some(SeedQuerySetting::Never), - _ => None, - }, - ); - vscode.bool_setting("search.smartCase", &mut current.editor.use_smartcase_search); - vscode.enum_setting( - "editor.multiCursorModifier", - &mut current.editor.multi_cursor_modifier, - |s| match s { - "ctrlCmd" => Some(MultiCursorModifier::CmdOrCtrl), - "alt" => Some(MultiCursorModifier::Alt), - _ => None, - }, - ); - - vscode.bool_setting( - "editor.parameterHints.enabled", - &mut current.editor.auto_signature_help, - ); - vscode.bool_setting( - "editor.parameterHints.enabled", - &mut current.editor.show_signature_help_after_edits, - ); - - if let Some(use_ignored) = vscode.read_bool("search.useIgnoreFiles") { - let search = current.editor.search.get_or_insert_default(); - search.include_ignored = Some(use_ignored); - } - - let mut minimap = settings::MinimapContent::default(); - let minimap_enabled = vscode.read_bool("editor.minimap.enabled").unwrap_or(true); - let autohide = vscode.read_bool("editor.minimap.autohide"); - let mut max_width_columns: Option = None; - vscode.u32_setting("editor.minimap.maxColumn", &mut max_width_columns); - if minimap_enabled { - if let Some(false) = autohide { - minimap.show = Some(ShowMinimap::Always); - } else { - minimap.show = Some(ShowMinimap::Auto); - } - } else { - minimap.show = Some(ShowMinimap::Never); - } - if let Some(max_width_columns) = max_width_columns { - minimap.max_width_columns = NonZeroU32::new(max_width_columns); - } - - vscode.enum_setting( - "editor.minimap.showSlider", - &mut minimap.thumb, - |s| match s { - "always" => Some(MinimapThumb::Always), - "mouseover" => Some(MinimapThumb::Hover), - _ => None, - }, - ); - - if minimap != settings::MinimapContent::default() { - current.editor.minimap = Some(minimap) + completion_menu_scrollbar: editor.completion_menu_scrollbar.map(Into::into).unwrap(), } } } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 3dfed1905f..dfc8fd7f90 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -2,7 +2,8 @@ use super::*; use crate::{ JoinLines, code_context_menus::CodeContextMenu, - edit_prediction_tests::FakeEditPredictionProvider, + edit_prediction_tests::FakeEditPredictionDelegate, + element::StickyHeader, linked_editing_ranges::LinkedEditingRanges, scroll::scroll_amount::ScrollAmount, test::{ @@ -16,8 +17,8 @@ use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkS use collections::HashMap; use futures::{StreamExt, channel::oneshot}; use gpui::{ - BackgroundExecutor, DismissEvent, Rgba, SemanticVersion, TestAppContext, UpdateGlobal, - VisualTestContext, WindowBounds, WindowOptions, div, + BackgroundExecutor, DismissEvent, Rgba, TestAppContext, UpdateGlobal, VisualTestContext, + WindowBounds, WindowOptions, div, }; use indoc::indoc; use language::{ @@ -27,13 +28,16 @@ use language::{ LanguageConfigOverride, LanguageMatcher, LanguageName, Override, Point, language_settings::{ CompletionSettingsContent, FormatterList, LanguageSettingsContent, LspInsertMode, - SelectedFormatter, }, tree_sitter_python, }; use language_settings::Formatter; +use languages::markdown_lang; +use languages::rust_lang; use lsp::CompletionParams; -use multi_buffer::{IndentGuide, PathKey}; +use multi_buffer::{ + IndentGuide, MultiBufferFilterMode, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey, +}; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_ne}; use project::{ @@ -43,15 +47,15 @@ use project::{ }; use serde_json::{self, json}; use settings::{ - AllLanguageSettingsContent, IndentGuideBackgroundColoring, IndentGuideColoring, - ProjectSettingsContent, + AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring, + IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent, }; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; use std::{ iter, sync::atomic::{self, AtomicUsize}, }; -use test::{build_editor_with_project, editor_lsp_test_context::rust_lang}; +use test::build_editor_with_project; use text::ToPoint as _; use unindent::Unindent; use util::{ @@ -63,11 +67,17 @@ use util::{ use workspace::{ CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry, OpenOptions, ViewId, - invalid_buffer_view::InvalidBufferView, + invalid_item_view::InvalidItemView, item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions}, register_project_item, }; +fn display_ranges(editor: &Editor, cx: &mut Context<'_, Editor>) -> Vec> { + editor + .selections + .display_ranges(&editor.display_snapshot(cx)) +} + #[gpui::test] fn test_edit_events(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -189,7 +199,7 @@ fn test_edit_events(cx: &mut TestAppContext) { // No event is emitted when the mutation is a no-op. _ = editor2.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([0..0]) + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); editor.backspace(&Backspace, window, cx); @@ -214,61 +224,90 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.start_transaction_at(now, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([2..4]) + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(4)]) }); editor.insert("cd", window, cx); editor.end_transaction_at(now, cx); assert_eq!(editor.text(cx), "12cd56"); - assert_eq!(editor.selections.ranges(cx), vec![4..4]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(4)..MultiBufferOffset(4)] + ); editor.start_transaction_at(now, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([4..5]) + s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(5)]) }); editor.insert("e", window, cx); editor.end_transaction_at(now, cx); assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![5..5]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(5)..MultiBufferOffset(5)] + ); now += group_interval + Duration::from_millis(1); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([2..2]) + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(2)]) }); // Simulate an edit in another editor buffer.update(cx, |buffer, cx| { buffer.start_transaction_at(now, cx); - buffer.edit([(0..1, "a")], None, cx); - buffer.edit([(1..1, "b")], None, cx); + buffer.edit( + [(MultiBufferOffset(0)..MultiBufferOffset(1), "a")], + None, + cx, + ); + buffer.edit( + [(MultiBufferOffset(1)..MultiBufferOffset(1), "b")], + None, + cx, + ); buffer.end_transaction_at(now, cx); }); assert_eq!(editor.text(cx), "ab2cde6"); - assert_eq!(editor.selections.ranges(cx), vec![3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(3)..MultiBufferOffset(3)] + ); // Last transaction happened past the group interval in a different editor. // Undo it individually and don't restore selections. editor.undo(&Undo, window, cx); assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![2..2]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(2)..MultiBufferOffset(2)] + ); // First two transactions happened within the group interval in this editor. // Undo them together and restore selections. editor.undo(&Undo, window, cx); editor.undo(&Undo, window, cx); // Undo stack is empty here, so this is a no-op. assert_eq!(editor.text(cx), "123456"); - assert_eq!(editor.selections.ranges(cx), vec![0..0]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(0)..MultiBufferOffset(0)] + ); // Redo the first two transactions together. editor.redo(&Redo, window, cx); assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![5..5]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(5)..MultiBufferOffset(5)] + ); // Redo the last transaction on its own. editor.redo(&Redo, window, cx); assert_eq!(editor.text(cx), "ab2cde6"); - assert_eq!(editor.selections.ranges(cx), vec![6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(6)..MultiBufferOffset(6)] + ); // Test empty transactions. editor.start_transaction_at(now, window, cx); @@ -300,7 +339,9 @@ fn test_ime_composition(cx: &mut TestAppContext) { assert_eq!(editor.text(cx), "äbcde"); assert_eq!( editor.marked_text_ranges(cx), - Some(vec![OffsetUtf16(0)..OffsetUtf16(1)]) + Some(vec![ + MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(1)) + ]) ); // Finalize IME composition. @@ -320,7 +361,9 @@ fn test_ime_composition(cx: &mut TestAppContext) { editor.replace_and_mark_text_in_range(Some(0..1), "à", None, window, cx); assert_eq!( editor.marked_text_ranges(cx), - Some(vec![OffsetUtf16(0)..OffsetUtf16(1)]) + Some(vec![ + MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(1)) + ]) ); // Undoing during an IME composition cancels it. @@ -333,7 +376,9 @@ fn test_ime_composition(cx: &mut TestAppContext) { assert_eq!(editor.text(cx), "ābcdè"); assert_eq!( editor.marked_text_ranges(cx), - Some(vec![OffsetUtf16(4)..OffsetUtf16(5)]) + Some(vec![ + MultiBufferOffsetUtf16(OffsetUtf16(4))..MultiBufferOffsetUtf16(OffsetUtf16(5)) + ]) ); // Finalize IME composition with an invalid replacement range, ensuring it gets clipped. @@ -344,9 +389,9 @@ fn test_ime_composition(cx: &mut TestAppContext) { // Start a new IME composition with multiple cursors. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ - OffsetUtf16(1)..OffsetUtf16(1), - OffsetUtf16(3)..OffsetUtf16(3), - OffsetUtf16(5)..OffsetUtf16(5), + MultiBufferOffsetUtf16(OffsetUtf16(1))..MultiBufferOffsetUtf16(OffsetUtf16(1)), + MultiBufferOffsetUtf16(OffsetUtf16(3))..MultiBufferOffsetUtf16(OffsetUtf16(3)), + MultiBufferOffsetUtf16(OffsetUtf16(5))..MultiBufferOffsetUtf16(OffsetUtf16(5)), ]) }); editor.replace_and_mark_text_in_range(Some(4..5), "XYZ", None, window, cx); @@ -354,9 +399,9 @@ fn test_ime_composition(cx: &mut TestAppContext) { assert_eq!( editor.marked_text_ranges(cx), Some(vec![ - OffsetUtf16(0)..OffsetUtf16(3), - OffsetUtf16(4)..OffsetUtf16(7), - OffsetUtf16(8)..OffsetUtf16(11) + MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(3)), + MultiBufferOffsetUtf16(OffsetUtf16(4))..MultiBufferOffsetUtf16(OffsetUtf16(7)), + MultiBufferOffsetUtf16(OffsetUtf16(8))..MultiBufferOffsetUtf16(OffsetUtf16(11)) ]) ); @@ -366,9 +411,9 @@ fn test_ime_composition(cx: &mut TestAppContext) { assert_eq!( editor.marked_text_ranges(cx), Some(vec![ - OffsetUtf16(1)..OffsetUtf16(2), - OffsetUtf16(5)..OffsetUtf16(6), - OffsetUtf16(9)..OffsetUtf16(10) + MultiBufferOffsetUtf16(OffsetUtf16(1))..MultiBufferOffsetUtf16(OffsetUtf16(2)), + MultiBufferOffsetUtf16(OffsetUtf16(5))..MultiBufferOffsetUtf16(OffsetUtf16(6)), + MultiBufferOffsetUtf16(OffsetUtf16(9))..MultiBufferOffsetUtf16(OffsetUtf16(10)) ]) ); @@ -395,7 +440,7 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { }); assert_eq!( editor - .update(cx, |editor, _, cx| editor.selections.display_ranges(cx)) + .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] ); @@ -412,7 +457,7 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { assert_eq!( editor - .update(cx, |editor, _, cx| editor.selections.display_ranges(cx)) + .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] ); @@ -429,7 +474,7 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { assert_eq!( editor - .update(cx, |editor, _, cx| editor.selections.display_ranges(cx)) + .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1)] ); @@ -447,7 +492,7 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { assert_eq!( editor - .update(cx, |editor, _, cx| editor.selections.display_ranges(cx)) + .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1)] ); @@ -465,7 +510,7 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { assert_eq!( editor - .update(cx, |editor, _, cx| editor.selections.display_ranges(cx)) + .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [ DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1), @@ -479,7 +524,7 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { assert_eq!( editor - .update(cx, |editor, _, cx| editor.selections.display_ranges(cx)) + .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(3), 3)..DisplayPoint::new(DisplayRow(0), 0)] ); @@ -512,7 +557,7 @@ fn test_multiple_cursor_removal(cx: &mut TestAppContext) { assert_eq!( editor - .update(cx, |editor, _, cx| editor.selections.display_ranges(cx)) + .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [ DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), @@ -530,7 +575,7 @@ fn test_multiple_cursor_removal(cx: &mut TestAppContext) { assert_eq!( editor - .update(cx, |editor, _, cx| editor.selections.display_ranges(cx)) + .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 2)] ); @@ -548,7 +593,7 @@ fn test_canceling_pending_selection(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] ); }); @@ -562,7 +607,7 @@ fn test_canceling_pending_selection(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] ); }); @@ -577,7 +622,7 @@ fn test_canceling_pending_selection(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] ); }); @@ -595,30 +640,117 @@ fn test_movement_actions_with_pending_selection(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] ); editor.move_down(&Default::default(), window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 2)] ); editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] ); editor.move_up(&Default::default(), window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2)] ); }); } +#[gpui::test] +fn test_extending_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 1, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 10)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + editor.end_selection(window, cx); + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 2, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 11)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), true, 1, window, cx); + editor.end_selection(window, cx); + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), true, 2, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 11)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 6), + 0, + gpui::Point::::default(), + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + #[gpui::test] fn test_clone(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -641,7 +773,11 @@ fn test_clone(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(selection_ranges.clone()) + s.select_ranges( + selection_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) }); editor.fold_creases( vec![ @@ -678,24 +814,34 @@ fn test_clone(cx: &mut TestAppContext) { ); assert_eq!( cloned_snapshot - .folds_in_range(0..text.len()) + .folds_in_range(MultiBufferOffset(0)..MultiBufferOffset(text.len())) + .collect::>(), + snapshot + .folds_in_range(MultiBufferOffset(0)..MultiBufferOffset(text.len())) .collect::>(), - snapshot.folds_in_range(0..text.len()).collect::>(), ); assert_set_eq!( cloned_editor - .update(cx, |editor, _, cx| editor.selections.ranges::(cx)) + .update(cx, |editor, _, cx| editor + .selections + .ranges::(&editor.display_snapshot(cx))) .unwrap(), editor - .update(cx, |editor, _, cx| editor.selections.ranges(cx)) + .update(cx, |editor, _, cx| editor + .selections + .ranges(&editor.display_snapshot(cx))) .unwrap() ); assert_set_eq!( cloned_editor - .update(cx, |e, _window, cx| e.selections.display_ranges(cx)) + .update(cx, |e, _window, cx| e + .selections + .display_ranges(&e.display_snapshot(cx))) .unwrap(), editor - .update(cx, |e, _, cx| e.selections.display_ranges(cx)) + .update(cx, |e, _, cx| e + .selections + .display_ranges(&e.display_snapshot(cx))) .unwrap() ); } @@ -749,7 +895,9 @@ async fn test_navigation_history(cx: &mut TestAppContext) { editor.navigate(nav_entry.data.unwrap(), window, cx); assert_eq!(nav_entry.item.id(), cx.entity_id()); assert_eq!( - editor.selections.display_ranges(cx), + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), &[DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0)] ); assert!(pop_history(&mut editor, cx).is_none()); @@ -759,7 +907,9 @@ async fn test_navigation_history(cx: &mut TestAppContext) { editor.begin_selection(DisplayPoint::new(DisplayRow(5), 0), false, 1, window, cx); editor.end_selection(window, cx); assert_eq!( - editor.selections.display_ranges(cx), + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0)] ); assert!(pop_history(&mut editor, cx).is_none()); @@ -769,14 +919,18 @@ async fn test_navigation_history(cx: &mut TestAppContext) { editor.begin_selection(DisplayPoint::new(DisplayRow(15), 0), false, 1, window, cx); editor.end_selection(window, cx); assert_eq!( - editor.selections.display_ranges(cx), + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), &[DisplayPoint::new(DisplayRow(15), 0)..DisplayPoint::new(DisplayRow(15), 0)] ); let nav_entry = pop_history(&mut editor, cx).unwrap(); editor.navigate(nav_entry.data.unwrap(), window, cx); assert_eq!(nav_entry.item.id(), cx.entity_id()); assert_eq!( - editor.selections.display_ranges(cx), + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0)] ); assert!(pop_history(&mut editor, cx).is_none()); @@ -812,7 +966,9 @@ async fn test_navigation_history(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.display_ranges(cx), + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), &[editor.max_point(cx)..editor.max_point(cx)] ); assert_eq!( @@ -855,7 +1011,7 @@ fn test_cancel(cx: &mut TestAppContext) { ); editor.end_selection(window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(3), 4)..DisplayPoint::new(DisplayRow(1), 1), @@ -866,7 +1022,7 @@ fn test_cancel(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.cancel(&Cancel, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(3), 4)..DisplayPoint::new(DisplayRow(1), 1)] ); }); @@ -874,7 +1030,7 @@ fn test_cancel(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.cancel(&Cancel, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1)] ); }); @@ -1284,7 +1440,11 @@ fn test_fold_at_level(cx: &mut TestAppContext) { ); editor.change_selections(SelectionEffects::default(), window, cx, |s| { - s.select_ranges(positions) + s.select_ranges( + positions + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) }); editor.fold_at_level(&FoldAtLevel(2), window, cx); @@ -1335,43 +1495,43 @@ fn test_move_cursor(cx: &mut TestAppContext) { }); _ = editor.update(cx, |editor, window, cx| { assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.move_down(&MoveDown, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)] ); editor.move_right(&MoveRight, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4)] ); editor.move_left(&MoveLeft, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)] ); editor.move_up(&MoveUp, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.move_to_end(&MoveToEnd, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 6)] ); editor.move_to_beginning(&MoveToBeginning, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); @@ -1382,13 +1542,13 @@ fn test_move_cursor(cx: &mut TestAppContext) { }); editor.select_to_beginning(&SelectToBeginning, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.select_to_end(&SelectToEnd, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(5), 6)] ); }); @@ -1420,94 +1580,43 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { assert_eq!(editor.display_text(cx), "🟥🟧⋯🟦🟪\nab⋯e\nαβ⋯ε"); editor.move_right(&MoveRight, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(0, "🟥".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥".len())]); editor.move_right(&MoveRight, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(0, "🟥🟧".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧".len())]); editor.move_right(&MoveRight, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(0, "🟥🟧⋯".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧⋯".len())]); editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(1, "ab⋯e".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); editor.move_left(&MoveLeft, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(1, "ab⋯".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯".len())]); editor.move_left(&MoveLeft, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(1, "ab".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab".len())]); editor.move_left(&MoveLeft, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(1, "a".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "a".len())]); editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(2, "α".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "α".len())]); editor.move_right(&MoveRight, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(2, "αβ".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ".len())]); editor.move_right(&MoveRight, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(2, "αβ⋯".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯".len())]); editor.move_right(&MoveRight, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(2, "αβ⋯ε".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯ε".len())]); editor.move_up(&MoveUp, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(1, "ab⋯e".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(2, "αβ⋯ε".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯ε".len())]); editor.move_up(&MoveUp, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(1, "ab⋯e".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); editor.move_up(&MoveUp, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(0, "🟥🟧".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧".len())]); editor.move_left(&MoveLeft, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(0, "🟥".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥".len())]); editor.move_left(&MoveLeft, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(0, "".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "".len())]); }); } @@ -1527,65 +1636,35 @@ fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) { // moving above start of document should move selection to start of document, // but the next move down should still be at the original goal_x editor.move_up(&MoveUp, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(0, "".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "".len())]); editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(1, "abcd".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "abcd".len())]); editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(2, "αβγ".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβγ".len())]); editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(3, "abcd".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(3, "abcd".len())]); editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())]); // moving past end of document should not change goal_x editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(5, "".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(5, "".len())]); editor.move_down(&MoveDown, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(5, "".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(5, "".len())]); editor.move_up(&MoveUp, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())]); editor.move_up(&MoveUp, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(3, "abcd".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(3, "abcd".len())]); editor.move_up(&MoveUp, window, cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[empty_range(2, "αβγ".len())] - ); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβγ".len())]); }); } @@ -1621,7 +1700,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), @@ -1632,7 +1711,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), @@ -1643,7 +1722,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), @@ -1654,7 +1733,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), @@ -1666,7 +1745,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), @@ -1685,7 +1764,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), @@ -1703,7 +1782,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 0), @@ -1721,7 +1800,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), @@ -1738,7 +1817,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 5), @@ -1750,7 +1829,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { editor.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); assert_eq!(editor.display_text(cx), "ab\n de"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4), @@ -1762,7 +1841,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { editor.delete_to_beginning_of_line(&delete_to_beg, window, cx); assert_eq!(editor.display_text(cx), "\n"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), @@ -1815,14 +1894,14 @@ fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) { editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0),], - editor.selections.display_ranges(cx) + display_ranges(editor, cx) ); // Moving to the end of the line should put us at the end of the line. editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(0), 16)..DisplayPoint::new(DisplayRow(0), 16),], - editor.selections.display_ranges(cx) + display_ranges(editor, cx) ); // Now, let's assert behavior on the second line, that ended up being soft-wrapped. @@ -1838,14 +1917,14 @@ fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) { editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0),], - editor.selections.display_ranges(cx) + display_ranges(editor, cx) ); // Moving to the beginning of the line again should be a no-op. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0),], - editor.selections.display_ranges(cx) + display_ranges(editor, cx) ); // Moving to the end of the line should put us right after the `s` that was soft-wrapped to the @@ -1853,14 +1932,14 @@ fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) { editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5),], - editor.selections.display_ranges(cx) + display_ranges(editor, cx) ); // Moving to the end of the line again should be a no-op. editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5),], - editor.selections.display_ranges(cx) + display_ranges(editor, cx) ); }); } @@ -1904,7 +1983,7 @@ fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) { // and the second cursor at the first non-whitespace character in the line. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), @@ -1915,7 +1994,7 @@ fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) { // and should move the second cursor to the beginning of the line. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), @@ -1926,7 +2005,7 @@ fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) { // and should move the second cursor back to the first non-whitespace character in the line. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), @@ -1939,7 +2018,7 @@ fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) { editor.move_left(&MoveLeft, window, cx); editor.select_to_beginning_of_line(&select_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), @@ -1950,7 +2029,7 @@ fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) { // and should select to the beginning of the line for the second cursor. editor.select_to_beginning_of_line(&select_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 0), @@ -1991,21 +2070,21 @@ fn test_beginning_of_line_with_cursor_between_line_start_and_indent(cx: &mut Tes // cursor should move to line_start editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); // cursor should move to indent_start editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 4)] ); // cursor should move to back to line_start editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); }); @@ -2098,37 +2177,37 @@ fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) { editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 9)] ); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 14)..DisplayPoint::new(DisplayRow(1), 14)] ); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4)] ); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(2), 8)..DisplayPoint::new(DisplayRow(2), 8)] ); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4)] ); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 14)..DisplayPoint::new(DisplayRow(1), 14)] ); }); @@ -2139,10 +2218,9 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2255,10 +2333,9 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2321,8 +2398,7 @@ async fn test_autoscroll(cx: &mut TestAppContext) { let line_height = cx.update_editor(|editor, window, cx| { editor.set_vertical_scroll_margin(2, cx); editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2401,10 +2477,9 @@ async fn test_move_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _cx| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -3024,6 +3099,77 @@ fn test_newline(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_newline_yaml(cx: &mut TestAppContext) { + 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)); + + // Object (between 2 fields) + cx.set_state(indoc! {" + test:ˇ + hello: bye"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + ˇ + hello: bye"}); + + // Object (first and single line) + cx.set_state(indoc! {" + test:ˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + ˇ"}); + + // Array with objects (after first element) + cx.set_state(indoc! {" + test: + - foo: barˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + - foo: bar + ˇ"}); + + // Array with objects and comment + cx.set_state(indoc! {" + test: + - foo: bar + - bar: # testˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + - foo: bar + - bar: # test + ˇ"}); + + // Array with objects (after second element) + cx.set_state(indoc! {" + test: + - foo: bar + - bar: fooˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + - foo: bar + - bar: foo + ˇ"}); + + // Array with strings (after first element) + cx.set_state(indoc! {" + test: + - fooˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + - foo + ˇ"}); +} + #[gpui::test] fn test_newline_with_old_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -3075,7 +3221,7 @@ fn test_newline_with_old_selections(cx: &mut TestAppContext) { ); }); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(1, 2)..Point::new(1, 2), Point::new(2, 2)..Point::new(2, 2), @@ -3097,7 +3243,7 @@ fn test_newline_with_old_selections(cx: &mut TestAppContext) { // The selections are moved after the inserted newlines assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(2, 0)..Point::new(2, 0), Point::new(4, 0)..Point::new(4, 0), @@ -3576,7 +3722,11 @@ fn test_insert_with_old_selections(cx: &mut TestAppContext) { let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx); let mut editor = build_editor(buffer, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([3..4, 11..12, 19..20]) + s.select_ranges([ + MultiBufferOffset(3)..MultiBufferOffset(4), + MultiBufferOffset(11)..MultiBufferOffset(12), + MultiBufferOffset(19)..MultiBufferOffset(20), + ]) }); editor }); @@ -3584,16 +3734,38 @@ fn test_insert_with_old_selections(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { // Edit the buffer directly, deleting ranges surrounding the editor's selections editor.buffer.update(cx, |buffer, cx| { - buffer.edit([(2..5, ""), (10..13, ""), (18..21, "")], None, cx); + buffer.edit( + [ + (MultiBufferOffset(2)..MultiBufferOffset(5), ""), + (MultiBufferOffset(10)..MultiBufferOffset(13), ""), + (MultiBufferOffset(18)..MultiBufferOffset(21), ""), + ], + None, + cx, + ); assert_eq!(buffer.read(cx).text(), "a(), b(), c()".unindent()); }); - assert_eq!(editor.selections.ranges(cx), &[2..2, 7..7, 12..12],); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + MultiBufferOffset(2)..MultiBufferOffset(2), + MultiBufferOffset(7)..MultiBufferOffset(7), + MultiBufferOffset(12)..MultiBufferOffset(12) + ], + ); editor.insert("Z", window, cx); assert_eq!(editor.text(cx), "a(Z), b(Z), c(Z)"); // The selections are moved after the inserted characters - assert_eq!(editor.selections.ranges(cx), &[3..3, 9..9, 15..15],); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + MultiBufferOffset(3)..MultiBufferOffset(3), + MultiBufferOffset(9)..MultiBufferOffset(9), + MultiBufferOffset(15)..MultiBufferOffset(15) + ], + ); }); } @@ -4298,10 +4470,10 @@ fn test_delete_line(cx: &mut TestAppContext) { editor.delete_line(&DeleteLine, window, cx); assert_eq!(editor.display_text(cx), "ghi"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), - DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3), ] ); }); @@ -4319,10 +4491,28 @@ fn test_delete_line(cx: &mut TestAppContext) { editor.delete_line(&DeleteLine, window, cx); assert_eq!(editor.display_text(cx), "ghi\n"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1)] ); }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n\njkl\nmno", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(2), 1) + ]) + }); + editor.delete_line(&DeleteLine, window, cx); + assert_eq!(editor.display_text(cx), "\njkl\nmno"); + assert_eq!( + display_ranges(editor, cx), + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); } #[gpui::test] @@ -4335,7 +4525,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { let buffer = buffer.read(cx).as_singleton().unwrap(); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 0)..Point::new(0, 0)] ); @@ -4343,7 +4535,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 3)..Point::new(0, 3)] ); @@ -4354,7 +4548,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb ccc ddd\n\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 11)..Point::new(0, 11)] ); @@ -4362,7 +4558,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.undo(&Undo, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 5)..Point::new(2, 2)] ); @@ -4373,7 +4571,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); @@ -4381,7 +4581,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); @@ -4389,7 +4591,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); @@ -4446,7 +4650,9 @@ fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) { assert_eq!(buffer.read(cx).text(), "aaa bbb ccc\nddd\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 7)..Point::new(0, 7), Point::new(1, 3)..Point::new(1, 3) @@ -4528,7 +4734,7 @@ async fn test_custom_newlines_cause_no_false_positive_diffs( assert_eq!( snapshot .buffer_snapshot() - .diff_hunks_in_range(0..snapshot.buffer_snapshot().len()) + .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) .collect::>(), Vec::new(), "Should not have any diffs for files with custom newlines" @@ -5462,7 +5668,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) { editor.duplicate_line_down(&DuplicateLineDown, window, cx); assert_eq!(editor.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), @@ -5486,7 +5692,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) { editor.duplicate_line_down(&DuplicateLineDown, window, cx); assert_eq!(editor.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(4), 1), DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(5), 1), @@ -5494,8 +5700,8 @@ fn test_duplicate_line(cx: &mut TestAppContext) { ); }); - // With `move_upwards` the selections stay in place, except for - // the lines inserted above them + // With `duplicate_line_up` the selections move to the duplicated lines, + // which are inserted above the original lines let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) @@ -5512,12 +5718,12 @@ fn test_duplicate_line(cx: &mut TestAppContext) { editor.duplicate_line_up(&DuplicateLineUp, window, cx); assert_eq!(editor.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0), - DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(6), 0), + DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0), ] ); }); @@ -5536,7 +5742,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) { editor.duplicate_line_up(&DuplicateLineUp, window, cx); assert_eq!(editor.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), @@ -5558,7 +5764,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) { editor.duplicate_selection(&DuplicateSelection, window, cx); assert_eq!(editor.display_text(cx), "abc\ndbc\ndef\ngf\nghi\n"); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 1), @@ -5567,6 +5773,116 @@ fn test_duplicate_line(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_rotate_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Rotate text selections (horizontal) + cx.set_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state("x=«3ˇ», y=«1ˇ», z=«2ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + + // Rotate text selections (vertical) + cx.set_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«3ˇ» + y=«1ˇ» + z=«2ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + + // Rotate text selections (vertical, different lengths) + cx.set_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«aaˇ»\" + y=\"«ˇ»\" + z=\"«aˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + + // Rotate whole lines (cursor positions preserved) + cx.set_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + line3ˇ + ˇline123 + liˇne23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + + // Rotate whole lines, multiple cursors per line (positions preserved) + cx.set_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline3 + ˇliˇne123 + ˇline23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); +} + #[gpui::test] fn test_move_line_up_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -5605,7 +5921,7 @@ fn test_move_line_up_down(cx: &mut TestAppContext) { "aa⋯bbb\nccc⋯eeee\nggggg\n⋯i\njjjjj\nfffff" ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), @@ -5622,7 +5938,7 @@ fn test_move_line_up_down(cx: &mut TestAppContext) { "ccc⋯eeee\naa⋯bbb\nfffff\nggggg\n⋯i\njjjjj" ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1), @@ -5639,7 +5955,7 @@ fn test_move_line_up_down(cx: &mut TestAppContext) { "ccc⋯eeee\nfffff\naa⋯bbb\nggggg\n⋯i\njjjjj" ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1), @@ -5656,7 +5972,7 @@ fn test_move_line_up_down(cx: &mut TestAppContext) { "ccc⋯eeee\naa⋯bbb\nggggg\n⋯i\njjjjj\nfffff" ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), @@ -5800,19 +6116,28 @@ fn test_transpose(cx: &mut TestAppContext) { let mut editor = build_editor(MultiBuffer::build_simple("abc", cx), window, cx); editor.set_style(EditorStyle::default(), window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([1..1]) + s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bac"); - assert_eq!(editor.selections.ranges(cx), [2..2]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(2)..MultiBufferOffset(2)] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bca"); - assert_eq!(editor.selections.ranges(cx), [3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(3)..MultiBufferOffset(3)] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bac"); - assert_eq!(editor.selections.ranges(cx), [3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(3)..MultiBufferOffset(3)] + ); editor }); @@ -5821,26 +6146,38 @@ fn test_transpose(cx: &mut TestAppContext) { let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), window, cx); editor.set_style(EditorStyle::default(), window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([3..3]) + s.select_ranges([MultiBufferOffset(3)..MultiBufferOffset(3)]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acb\nde"); - assert_eq!(editor.selections.ranges(cx), [3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(3)..MultiBufferOffset(3)] + ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([4..4]) + s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(4)]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbd\ne"); - assert_eq!(editor.selections.ranges(cx), [5..5]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(5)..MultiBufferOffset(5)] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbde\n"); - assert_eq!(editor.selections.ranges(cx), [6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(6)..MultiBufferOffset(6)] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbd\ne"); - assert_eq!(editor.selections.ranges(cx), [6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(6)..MultiBufferOffset(6)] + ); editor }); @@ -5849,27 +6186,63 @@ fn test_transpose(cx: &mut TestAppContext) { let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), window, cx); editor.set_style(EditorStyle::default(), window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([1..1, 2..2, 4..4]) + s.select_ranges([ + MultiBufferOffset(1)..MultiBufferOffset(1), + MultiBufferOffset(2)..MultiBufferOffset(2), + MultiBufferOffset(4)..MultiBufferOffset(4), + ]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bacd\ne"); - assert_eq!(editor.selections.ranges(cx), [2..2, 3..3, 5..5]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(2)..MultiBufferOffset(2), + MultiBufferOffset(3)..MultiBufferOffset(3), + MultiBufferOffset(5)..MultiBufferOffset(5) + ] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcade\n"); - assert_eq!(editor.selections.ranges(cx), [3..3, 4..4, 6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(3)..MultiBufferOffset(3), + MultiBufferOffset(4)..MultiBufferOffset(4), + MultiBufferOffset(6)..MultiBufferOffset(6) + ] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcda\ne"); - assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(4)..MultiBufferOffset(4), + MultiBufferOffset(6)..MultiBufferOffset(6) + ] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcade\n"); - assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(4)..MultiBufferOffset(4), + MultiBufferOffset(6)..MultiBufferOffset(6) + ] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcaed\n"); - assert_eq!(editor.selections.ranges(cx), [5..5, 6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(5)..MultiBufferOffset(5), + MultiBufferOffset(6)..MultiBufferOffset(6) + ] + ); editor }); @@ -5878,19 +6251,28 @@ fn test_transpose(cx: &mut TestAppContext) { let mut editor = build_editor(MultiBuffer::build_simple("🍐🏀✋", cx), window, cx); editor.set_style(EditorStyle::default(), window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([4..4]) + s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(4)]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀🍐✋"); - assert_eq!(editor.selections.ranges(cx), [8..8]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(8)..MultiBufferOffset(8)] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀✋🍐"); - assert_eq!(editor.selections.ranges(cx), [11..11]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(11)..MultiBufferOffset(11)] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀🍐✋"); - assert_eq!(editor.selections.ranges(cx), [11..11]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(11)..MultiBufferOffset(11)] + ); editor }); @@ -7344,7 +7726,7 @@ fn test_select_all(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(2), 3)] ); }); @@ -7368,10 +7750,12 @@ fn test_select_line(cx: &mut TestAppContext) { ]) }); editor.select_line(&SelectLine, window, cx); + // Adjacent line selections should NOT merge (only overlapping ones do) assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ - DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(2), 0), + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(2), 0), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 0), ] ); @@ -7380,7 +7764,7 @@ fn test_select_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.select_line(&SelectLine, window, cx); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(3), 0), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), @@ -7390,9 +7774,13 @@ fn test_select_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.select_line(&SelectLine, window, cx); + // Adjacent but not overlapping, so they stay separate assert_eq!( - editor.selections.display_ranges(cx), - vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(5), 5)] + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(4), 0), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), + ] ); }); } @@ -7516,7 +7904,7 @@ async fn test_split_selection_into_lines_interacting_with_creases(cx: &mut TestA "aaaaa\nbbbbb\nccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiii" ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5), DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), @@ -8106,8 +8494,15 @@ async fn test_add_selection_above_below_multi_cursor_existing_state(cx: &mut Tes #[gpui::test] async fn test_select_next(cx: &mut TestAppContext) { init_test(cx, |_| {}); - let mut cx = EditorTestContext::new(cx).await; + + // Enable case sensitive search. + update_test_editor_settings(&mut cx, |settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(true); + settings.search = Some(search_settings); + }); + cx.set_state("abc\nˇabc abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) @@ -8138,14 +8533,41 @@ async fn test_select_next(cx: &mut TestAppContext) { cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state("abc\n«ˇabc» «ˇabc»\ndefabc\nabc"); + + // Test case sensitivity + cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo"); + cx.update_editor(|e, window, cx| { + e.select_next(&SelectNext::default(), window, cx).unwrap(); + }); + cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»"); + + // Disable case sensitive search. + update_test_editor_settings(&mut cx, |settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(false); + settings.search = Some(search_settings); + }); + + cx.set_state("«ˇfoo»\nFOO\nFoo"); + cx.update_editor(|e, window, cx| { + e.select_next(&SelectNext::default(), window, cx).unwrap(); + e.select_next(&SelectNext::default(), window, cx).unwrap(); + }); + cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»"); } #[gpui::test] async fn test_select_all_matches(cx: &mut TestAppContext) { init_test(cx, |_| {}); - let mut cx = EditorTestContext::new(cx).await; + // Enable case sensitive search. + update_test_editor_settings(&mut cx, |settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(true); + settings.search = Some(search_settings); + }); + // Test caret-only selections cx.set_state("abc\nˇabc abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) @@ -8190,6 +8612,26 @@ async fn test_select_all_matches(cx: &mut TestAppContext) { e.set_clip_at_line_ends(false, cx); }); cx.assert_editor_state("«abcˇ»"); + + // Test case sensitivity + cx.set_state("fˇoo\nFOO\nFoo"); + cx.update_editor(|e, window, cx| { + e.select_all_matches(&SelectAllMatches, window, cx).unwrap(); + }); + cx.assert_editor_state("«fooˇ»\nFOO\nFoo"); + + // Disable case sensitive search. + update_test_editor_settings(&mut cx, |settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(false); + settings.search = Some(search_settings); + }); + + cx.set_state("fˇoo\nFOO\nFoo"); + cx.update_editor(|e, window, cx| { + e.select_all_matches(&SelectAllMatches, window, cx).unwrap(); + }); + cx.assert_editor_state("«fooˇ»\n«FOOˇ»\n«Fooˇ»"); } #[gpui::test] @@ -8306,7 +8748,7 @@ async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext) let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionProvider::default()); + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(provider.clone()), window, cx); }); @@ -8329,7 +8771,7 @@ async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext) cx.update(|_, cx| { provider.update(cx, |provider, _| { - provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local { + provider.set_edit_prediction(Some(edit_prediction_types::EditPrediction::Local { id: None, edits: vec![(edit_position..edit_position, "X".into())], edit_preview: None, @@ -8561,8 +9003,15 @@ let foo = «2ˇ»;"#, #[gpui::test] async fn test_select_previous_with_single_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); - let mut cx = EditorTestContext::new(cx).await; + + // Enable case sensitive search. + update_test_editor_settings(&mut cx, |settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(true); + settings.search = Some(search_settings); + }); + cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) @@ -8587,6 +9036,32 @@ async fn test_select_previous_with_single_selection(cx: &mut TestAppContext) { cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«ˇabc»\n«ˇabc» «ˇabc»\ndef«ˇabc»\n«ˇabc»"); + + // Test case sensitivity + cx.set_state("foo\nFOO\nFoo\n«ˇfoo»"); + cx.update_editor(|e, window, cx| { + e.select_previous(&SelectPrevious::default(), window, cx) + .unwrap(); + e.select_previous(&SelectPrevious::default(), window, cx) + .unwrap(); + }); + cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»"); + + // Disable case sensitive search. + update_test_editor_settings(&mut cx, |settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(false); + settings.search = Some(search_settings); + }); + + cx.set_state("foo\nFOO\n«ˇFoo»"); + cx.update_editor(|e, window, cx| { + e.select_previous(&SelectPrevious::default(), window, cx) + .unwrap(); + e.select_previous(&SelectPrevious::default(), window, cx) + .unwrap(); + }); + cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»"); } #[gpui::test] @@ -8660,7 +9135,9 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut TestAppContext) { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); assert_eq!( - editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); @@ -8669,7 +9146,9 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut TestAppContext) { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); assert_eq!( - editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); @@ -9431,12 +9910,16 @@ async fn test_autoindent(cx: &mut TestAppContext) { editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([5..5, 8..8, 9..9]) + s.select_ranges([ + MultiBufferOffset(5)..MultiBufferOffset(5), + MultiBufferOffset(8)..MultiBufferOffset(8), + MultiBufferOffset(9)..MultiBufferOffset(9), + ]) }); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), "fn a(\n \n) {\n \n}\n"); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(1, 4)..Point::new(1, 4), Point::new(3, 4)..Point::new(3, 4), @@ -9496,7 +9979,11 @@ async fn test_autoindent_disabled(cx: &mut TestAppContext) { editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([5..5, 8..8, 9..9]) + s.select_ranges([ + MultiBufferOffset(5)..MultiBufferOffset(5), + MultiBufferOffset(8)..MultiBufferOffset(8), + MultiBufferOffset(9)..MultiBufferOffset(9), + ]) }); editor.newline(&Newline, window, cx); assert_eq!( @@ -9512,7 +9999,7 @@ async fn test_autoindent_disabled(cx: &mut TestAppContext) { ) ); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(1, 0)..Point::new(1, 0), Point::new(3, 0)..Point::new(3, 0), @@ -9595,7 +10082,7 @@ async fn test_autoindent_disabled_with_nested_language(cx: &mut TestAppContext) ], ..Default::default() }, - name: LanguageName::new("rust"), + name: LanguageName::new_static("rust"), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), @@ -10151,7 +10638,9 @@ async fn test_autoclose_with_embedded_language(cx: &mut TestAppContext) { // Precondition: different languages are active at different locations. cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); - let cursors = editor.selections.ranges::(cx); + let cursors = editor + .selections + .ranges::(&editor.display_snapshot(cx)); let languages = cursors .iter() .map(|c| snapshot.language_at(c.start).unwrap().name()) @@ -10380,6 +10869,115 @@ async fn test_autoclose_with_overrides(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_autoclose_quotes_with_scope_awareness(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Double quote inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['"', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"', "ˇ"] + "#}); + + // Two double quotes inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['""', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['""', "ˇ"] + "#}); + + // Single quote inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["'", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["'", 'ˇ'] + "#}); + + // Two single quotes inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["''", 'ˇ'] + "#}); + + // Mixed quotes on same line + cx.set_state(indoc! {r#" + def main(): + items = ['"""', "'''''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "ˇ"] + "#}); + cx.update_editor(|editor, window, cx| { + editor.move_right(&MoveRight, window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(", ", window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "", 'ˇ'] + "#}); +} + +#[gpui::test] +async fn test_autoclose_quotes_with_multibyte_characters(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(indoc! {r#" + def main(): + items = ["🎉", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["🎉", "ˇ"] + "#}); +} + #[gpui::test] async fn test_surround_with_pair(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -10446,7 +11044,7 @@ async fn test_surround_with_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 4), DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 4), @@ -10467,7 +11065,7 @@ async fn test_surround_with_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), @@ -10488,7 +11086,7 @@ async fn test_surround_with_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), @@ -10507,7 +11105,7 @@ async fn test_surround_with_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), @@ -10528,7 +11126,7 @@ async fn test_surround_with_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.display_ranges(cx), + display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), @@ -10596,7 +11194,9 @@ async fn test_delete_autoclose_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 4)..Point::new(0, 4), Point::new(1, 4)..Point::new(1, 4), @@ -10616,7 +11216,9 @@ async fn test_delete_autoclose_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 2)..Point::new(0, 2), Point::new(1, 2)..Point::new(1, 2), @@ -10635,7 +11237,9 @@ async fn test_delete_autoclose_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), @@ -10835,13 +11439,27 @@ async fn test_snippet_placeholder_choices(cx: &mut TestAppContext) { let snippet = Snippet::parse("type ${1|,i32,u32|} = $2").unwrap(); editor - .insert_snippet(&insertion_ranges, snippet, window, cx) + .insert_snippet( + &insertion_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>(), + snippet, + window, + cx, + ) .unwrap(); fn assert(editor: &mut Editor, cx: &mut Context, marked_text: &str) { let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); assert_eq!(editor.text(cx), expected_text); - assert_eq!(editor.selections.ranges::(cx), selection_ranges); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + selection_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>() + ); } assert( @@ -10856,6 +11474,138 @@ async fn test_snippet_placeholder_choices(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_snippet_tabstop_navigation_with_placeholders(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + fn assert_state(editor: &mut Editor, cx: &mut Context, marked_text: &str) { + let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); + assert_eq!(editor.text(cx), expected_text); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + selection_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>() + ); + } + + let (text, insertion_ranges) = marked_text_ranges( + indoc! {" + ˇ + "}, + false, + ); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + _ = editor.update_in(cx, |editor, window, cx| { + let snippet = Snippet::parse("type ${1|,i32,u32|} = $2; $3").unwrap(); + + editor + .insert_snippet( + &insertion_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>(), + snippet, + window, + cx, + ) + .unwrap(); + + assert_state( + editor, + cx, + indoc! {" + type «» = ;• + "}, + ); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible for placeholder choices" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = «»;• + "}, + ); + + assert!( + !editor.context_menu_visible(), + "Context menu should be hidden after moving to next tabstop" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = ; ˇ + "}, + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = ; ˇ + "}, + ); + }); + + _ = editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.backspace(&Backspace, window, cx); + + let snippet = Snippet::parse("fn ${1|,foo,bar|} = ${2:value}; $3").unwrap(); + let insertion_ranges = editor + .selections + .all(&editor.display_snapshot(cx)) + .iter() + .map(|s| s.range()) + .collect::>(); + + editor + .insert_snippet(&insertion_ranges, snippet, window, cx) + .unwrap(); + + assert_state(editor, cx, "fn «» = value;•"); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible for placeholder choices" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn = «valueˇ»;•"); + + editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn «» = value;•"); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible again after returning to first tabstop" + ); + + editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn «» = value;•"); + }); +} + #[gpui::test] async fn test_snippets(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -10872,7 +11622,7 @@ async fn test_snippets(cx: &mut TestAppContext) { let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap(); let insertion_ranges = editor .selections - .all(cx) + .all(&editor.display_snapshot(cx)) .iter() .map(|s| s.range()) .collect::>(); @@ -10952,7 +11702,7 @@ async fn test_snippet_indentation(cx: &mut TestAppContext) { .unwrap(); let insertion_ranges = editor .selections - .all(cx) + .all(&editor.display_snapshot(cx)) .iter() .map(|s| s.range()) .collect::>(); @@ -10979,6 +11729,53 @@ async fn test_snippet_indentation(cx: &mut TestAppContext) { ˇ"}); } +#[gpui::test] +async fn test_snippet_with_multi_word_prefix(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec!["multi word".to_string()], + body: "this is many words".to_string(), + description: Some("description".to_string()), + name: "multi-word snippet test".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + + for (input_to_simulate, should_match_snippet) in [ + ("m", true), + ("m ", true), + ("m w", true), + ("aa m w", true), + ("aa m g", false), + ] { + cx.set_state("ˇ"); + cx.simulate_input(input_to_simulate); // fails correctly + + cx.update_editor(|editor, _, _| { + let Some(CodeContextMenu::Completions(context_menu)) = &*editor.context_menu.borrow() + else { + assert!(!should_match_snippet); // no completions! don't even show the menu + return; + }; + assert!(context_menu.visible()); + let completions = context_menu.completions.borrow(); + + assert_eq!(!completions.is_empty(), should_match_snippet); + }); + } +} + #[gpui::test] async fn test_document_format_during_save(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -11163,7 +11960,7 @@ async fn test_redo_after_noop_format(cx: &mut TestAppContext) { }); editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::default(), window, cx, |s| { - s.select_ranges([0..0]) + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); }); assert!(!cx.read(|cx| editor.is_dirty(cx))); @@ -11329,7 +12126,7 @@ async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) { SelectionEffects::scroll(Autoscroll::Next), window, cx, - |s| s.select_ranges(Some(1..2)), + |s| s.select_ranges(Some(MultiBufferOffset(1)..MultiBufferOffset(2))), ); editor.insert("|one|two|three|", window, cx); }); @@ -11339,7 +12136,7 @@ async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) { SelectionEffects::scroll(Autoscroll::Next), window, cx, - |s| s.select_ranges(Some(60..70)), + |s| s.select_ranges(Some(MultiBufferOffset(60)..MultiBufferOffset(70))), ); editor.insert("|four|five|six|", window, cx); }); @@ -11507,7 +12304,7 @@ async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) { SelectionEffects::scroll(Autoscroll::Next), window, cx, - |s| s.select_ranges(Some(10..10)), + |s| s.select_ranges(Some(MultiBufferOffset(10)..MultiBufferOffset(10))), ); editor.insert("// edited", window, cx); }); @@ -11803,8 +12600,8 @@ async fn test_range_format_respects_language_tab_size_override(cx: &mut TestAppC #[gpui::test] async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::List(FormatterList::Single( - Formatter::LanguageServer { name: None }, + settings.defaults.formatter = Some(FormatterList::Single(Formatter::LanguageServer( + settings::LanguageServerFormatterSpecifier::Current, ))) }); @@ -11929,11 +12726,11 @@ async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { async fn test_multiple_formatters(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.remove_trailing_whitespace_on_save = Some(true); - settings.defaults.formatter = Some(SelectedFormatter::List(FormatterList::Vec(vec![ - Formatter::LanguageServer { name: None }, + settings.defaults.formatter = Some(FormatterList::Vec(vec![ + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), Formatter::CodeAction("code-action-1".into()), Formatter::CodeAction("code-action-2".into()), - ]))) + ])) }); let fs = FakeFs::new(cx.executor()); @@ -12188,9 +12985,9 @@ async fn test_multiple_formatters(cx: &mut TestAppContext) { #[gpui::test] async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::List(FormatterList::Vec(vec![ - Formatter::LanguageServer { name: None }, - ]))) + settings.defaults.formatter = Some(FormatterList::Vec(vec![Formatter::LanguageServer( + settings::LanguageServerFormatterSpecifier::Current, + )])) }); let fs = FakeFs::new(cx.executor()); @@ -12393,7 +13190,7 @@ async fn test_concurrent_format_requests(cx: &mut TestAppContext) { #[gpui::test] async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::Auto) + settings.defaults.formatter = Some(FormatterList::default()) }); let mut cx = EditorLspTestContext::new_rust( @@ -12405,22 +13202,6 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { ) .await; - // Set up a buffer white some trailing whitespace and no trailing newline. - cx.set_state( - &[ - "one ", // - "twoˇ", // - "three ", // - "four", // - ] - .join("\n"), - ); - - // Submit a format request. - let format = cx - .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) - .unwrap(); - // Record which buffer changes have been sent to the language server let buffer_changes = Arc::new(Mutex::new(Vec::new())); cx.lsp @@ -12435,34 +13216,34 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { ); } }); - // Handle formatting requests to the language server. cx.lsp .set_request_handler::({ let buffer_changes = buffer_changes.clone(); move |_, _| { - // When formatting is requested, trailing whitespace has already been stripped, - // and the trailing newline has already been added. - assert_eq!( - &buffer_changes.lock()[1..], - &[ - ( - lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)), - "".into() - ), - ( - lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)), - "".into() - ), - ( - lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)), - "\n".into() - ), - ] - ); - + let buffer_changes = buffer_changes.clone(); // Insert blank lines between each line of the buffer. async move { + // When formatting is requested, trailing whitespace has already been stripped, + // and the trailing newline has already been added. + assert_eq!( + &buffer_changes.lock()[1..], + &[ + ( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)), + "".into() + ), + ( + lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)), + "".into() + ), + ( + lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)), + "\n".into() + ), + ] + ); + Ok(Some(vec![ lsp::TextEdit { range: lsp::Range::new( @@ -12483,10 +13264,29 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { } }); + // Set up a buffer white some trailing whitespace and no trailing newline. + cx.set_state( + &[ + "one ", // + "twoˇ", // + "three ", // + "four", // + ] + .join("\n"), + ); + cx.run_until_parked(); + + // Submit a format request. + let format = cx + .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) + .unwrap(); + + cx.run_until_parked(); // After formatting the buffer, the trailing whitespace is stripped, // a newline is appended, and the edits provided by the language server // have been applied. format.await.unwrap(); + cx.assert_editor_state( &[ "one", // @@ -12942,7 +13742,7 @@ async fn test_signature_help(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([0..0]) + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); }); @@ -13476,7 +14276,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) { cx.set_state(&run.initial_state); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); let counter = Arc::new(AtomicUsize::new(0)); @@ -13536,7 +14336,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); let counter = Arc::new(AtomicUsize::new(0)); @@ -13572,7 +14372,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request_with_insert_and_replace( &mut cx, @@ -13659,7 +14459,7 @@ async fn test_completion_replacing_surrounding_text_with_multicursors(cx: &mut T "}; cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request_with_insert_and_replace( &mut cx, @@ -13713,7 +14513,7 @@ async fn test_completion_replacing_surrounding_text_with_multicursors(cx: &mut T "}; cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request_with_insert_and_replace( &mut cx, @@ -13762,7 +14562,7 @@ async fn test_completion_replacing_surrounding_text_with_multicursors(cx: &mut T "}; cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request_with_insert_and_replace( &mut cx, @@ -13881,7 +14681,7 @@ async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppConte EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, - sized_by_content: false, + sizing_behavior: SizingBehavior::Default, }, multi_buffer.clone(), Some(project.clone()), @@ -13913,7 +14713,7 @@ async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppConte }); editor.update_in(cx, |editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); fake_server @@ -14152,7 +14952,7 @@ async fn test_completion(cx: &mut TestAppContext) { cx.assert_editor_state("editor.cloˇ"); assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request( "editor.", @@ -14176,6 +14976,180 @@ async fn test_completion(cx: &mut TestAppContext) { apply_additional_edits.await.unwrap(); } +#[gpui::test] +async fn test_completion_can_run_commands(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let command_calls = Arc::new(AtomicUsize::new(0)); + let registered_command = "_the/command"; + + let closure_command_calls = command_calls.clone(); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..lsp::CompletionOptions::default() + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec![registered_command.to_owned()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + fake_server.set_request_handler::( + move |params, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "registered_command".to_owned(), + text_edit: gen_text_edit(¶ms, ""), + command: Some(lsp::Command { + title: registered_command.to_owned(), + command: "_the/command".to_owned(), + arguments: Some(vec![serde_json::Value::Bool(true)]), + }), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "unregistered_command".to_owned(), + text_edit: gen_text_edit(¶ms, ""), + command: Some(lsp::Command { + title: "????????????".to_owned(), + command: "????????????".to_owned(), + arguments: Some(vec![serde_json::Value::Null]), + }), + ..lsp::CompletionItem::default() + }, + ]))) + }, + ); + fake_server.set_request_handler::({ + let command_calls = closure_command_calls.clone(); + move |params, _| { + assert_eq!(params.command, registered_command); + let command_calls = command_calls.clone(); + async move { + command_calls.fetch_add(1, atomic::Ordering::Release); + Ok(Some(json!(null))) + } + } + }); + })), + ..FakeLspAdapter::default() + }, + ); + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let editor = workspace + .update(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/a/main.rs")), + OpenOptions::default(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + let _fake_server = fake_servers.next().await.unwrap(); + + editor.update_in(cx, |editor, window, cx| { + cx.focus_self(window); + editor.move_to_end(&MoveToEnd, window, cx); + editor.handle_input(".", window, cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, _| { + assert!(editor.context_menu_visible()); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + let completion_labels = menu + .completions + .borrow() + .iter() + .map(|c| c.label.text.clone()) + .collect::>(); + assert_eq!( + completion_labels, + &["registered_command", "unregistered_command",], + ); + } else { + panic!("expected completion menu to be open"); + } + }); + + editor + .update_in(cx, |editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + command_calls.load(atomic::Ordering::Acquire), + 1, + "For completion with a registered command, Zed should send a command execution request", + ); + + editor.update_in(cx, |editor, window, cx| { + cx.focus_self(window); + editor.handle_input(".", window, cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, _| { + assert!(editor.context_menu_visible()); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + let completion_labels = menu + .completions + .borrow() + .iter() + .map(|c| c.label.text.clone()) + .collect::>(); + assert_eq!( + completion_labels, + &["registered_command", "unregistered_command",], + ); + } else { + panic!("expected completion menu to be open"); + } + }); + editor + .update_in(cx, |editor, window, cx| { + editor.context_menu_next(&Default::default(), window, cx); + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + command_calls.load(atomic::Ordering::Acquire), + 1, + "For completion with an unregistered command, Zed should not send a command execution request", + ); +} + #[gpui::test] async fn test_completion_reuse(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -14551,7 +15525,7 @@ async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) { 4.5f32 "}); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions::default(), window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) @@ -14577,7 +15551,7 @@ async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) { 33.35f32 "}); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions::default(), window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) @@ -14705,6 +15679,35 @@ async fn test_word_completions_disabled(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_word_completions_disabled_with_no_provider(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + cx.update_editor(|editor, _, _| { + editor.set_completion_provider(None); + }); + cx.set_state(indoc! {"ˇ + wow + wowen + wowser + "}); + cx.simulate_keystroke("w"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!("expected completion menu to be hidden, as disabled in settings"); + } + }); +} + fn gen_text_edit(params: &CompletionParams, text: &str) -> Option { let position = || lsp::Position { line: params.text_document_position.position.line, @@ -14771,12 +15774,7 @@ async fn test_multiline_completion(cx: &mut TestAppContext) { } else { item.label.clone() }; - let len = text.len(); - Some(language::CodeLabel { - text, - runs: Vec::new(), - filter_range: 0..len, - }) + Some(language::CodeLabel::plain(text, None)) })), ..FakeLspAdapter::default() }, @@ -15006,13 +16004,7 @@ async fn test_as_is_completions(cx: &mut TestAppContext) { cx.set_state("fn a() {}\n nˇ"); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { - editor.show_completions( - &ShowCompletions { - trigger: Some("\n".into()), - }, - window, - cx, - ); + editor.trigger_completion_on_input("n", true, window, cx) }); cx.executor().run_until_parked(); @@ -15110,7 +16102,7 @@ int fn_branch(bool do_branch1, bool do_branch2); }))) }); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { @@ -15159,7 +16151,7 @@ int fn_branch(bool do_branch1, bool do_branch2); }))) }); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { @@ -15319,7 +16311,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { «b(); - c(); + ˇ»«c(); ˇ» d(); } "}); @@ -15331,8 +16323,8 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { // «b(); - // c(); - ˇ»// d(); + ˇ»// «c(); + ˇ» // d(); } "}); @@ -15341,7 +16333,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { fn a() { // b(); «// c(); - ˇ» // d(); + ˇ» // d(); } "}); @@ -15351,7 +16343,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { fn a() { // b(); «c(); - ˇ» // d(); + ˇ» // d(); } "}); @@ -15842,7 +16834,7 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { editor.handle_input("X", window, cx); assert_eq!(editor.text(cx), "Xaaaa\nXbbbb"); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), @@ -15856,7 +16848,7 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { editor.backspace(&Default::default(), window, cx); assert_eq!(editor.text(cx), "Xa\nbbb"); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(1, 0)..Point::new(1, 0)] ); @@ -15866,7 +16858,7 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { editor.backspace(&Default::default(), window, cx); assert_eq!(editor.text(cx), "X\nbb"); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(0, 1)..Point::new(0, 1)] ); }); @@ -15909,7 +16901,11 @@ fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) { ); assert_eq!(editor.text(cx), expected_text); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(selection_ranges) + s.select_ranges( + selection_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) }); editor.handle_input("X", window, cx); @@ -15924,7 +16920,13 @@ fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) { false, ); assert_eq!(editor.text(cx), expected_text); - assert_eq!(editor.selections.ranges(cx), expected_selections); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + expected_selections + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>() + ); editor.newline(&Newline, window, cx); let (expected_text, expected_selections) = marked_text_ranges( @@ -15941,7 +16943,13 @@ fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) { false, ); assert_eq!(editor.text(cx), expected_text); - assert_eq!(editor.selections.ranges(cx), expected_selections); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + expected_selections + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>() + ); }); } @@ -15982,7 +16990,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(1, 3)..Point::new(1, 3), Point::new(2, 1)..Point::new(2, 1), @@ -15995,7 +17003,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| s.refresh()); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(1, 3)..Point::new(1, 3), Point::new(2, 1)..Point::new(2, 1), @@ -16009,7 +17017,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { // Removing an excerpt causes the first selection to become degenerate. assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(0, 0)..Point::new(0, 0), Point::new(0, 1)..Point::new(0, 1) @@ -16020,7 +17028,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) { // location. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| s.refresh()); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(0, 1)..Point::new(0, 1), Point::new(0, 3)..Point::new(0, 3) @@ -16064,7 +17072,7 @@ fn test_refresh_selections_while_selecting_with_mouse(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(1, 3)..Point::new(1, 3)] ); editor @@ -16075,14 +17083,14 @@ fn test_refresh_selections_while_selecting_with_mouse(cx: &mut TestAppContext) { }); _ = editor.update(cx, |editor, window, cx| { assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(0, 0)..Point::new(0, 0)] ); // Ensure we don't panic when selections are refreshed and that the pending selection is finalized. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| s.refresh()); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(0, 3)..Point::new(0, 3)] ); assert!(editor.selections.pending_anchor().is_some()); @@ -16191,7 +17199,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { anchor_range(Point::new(6, 3)..Point::new(6, 5)), anchor_range(Point::new(8, 4)..Point::new(8, 6)), ], - |_| Hsla::red(), + |_, _| Hsla::red(), cx, ); editor.highlight_background::( @@ -16201,7 +17209,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { anchor_range(Point::new(7, 4)..Point::new(7, 7)), anchor_range(Point::new(9, 5)..Point::new(9, 8)), ], - |_| Hsla::green(), + |_, _| Hsla::green(), cx, ); @@ -16316,7 +17324,7 @@ async fn test_following(cx: &mut TestAppContext) { // Update the selections only _ = leader.update(cx, |leader, window, cx| { leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([1..1]) + s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) }); }); follower @@ -16332,7 +17340,10 @@ async fn test_following(cx: &mut TestAppContext) { .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { - assert_eq!(follower.selections.ranges(cx), vec![1..1]); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![MultiBufferOffset(1)..MultiBufferOffset(1)] + ); }); assert!(*is_still_following.borrow()); assert_eq!(*follower_edit_event_count.borrow(), 0); @@ -16366,7 +17377,7 @@ async fn test_following(cx: &mut TestAppContext) { // via autoscroll, not via the leader's exact scroll position. _ = leader.update(cx, |leader, window, cx| { leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([0..0]) + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); leader.request_autoscroll(Autoscroll::newest(), cx); leader.set_scroll_position(gpui::Point::new(1.5, 3.5), window, cx); @@ -16385,14 +17396,17 @@ async fn test_following(cx: &mut TestAppContext) { .unwrap(); _ = follower.update(cx, |follower, _, cx| { assert_eq!(follower.scroll_position(cx), gpui::Point::new(1.5, 0.0)); - assert_eq!(follower.selections.ranges(cx), vec![0..0]); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![MultiBufferOffset(0)..MultiBufferOffset(0)] + ); }); assert!(*is_still_following.borrow()); // Creating a pending selection that precedes another selection _ = leader.update(cx, |leader, window, cx| { leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([1..1]) + s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) }); leader.begin_selection(DisplayPoint::new(DisplayRow(0), 0), true, 1, window, cx); }); @@ -16409,7 +17423,13 @@ async fn test_following(cx: &mut TestAppContext) { .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { - assert_eq!(follower.selections.ranges(cx), vec![0..0, 1..1]); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![ + MultiBufferOffset(0)..MultiBufferOffset(0), + MultiBufferOffset(1)..MultiBufferOffset(1) + ] + ); }); assert!(*is_still_following.borrow()); @@ -16430,12 +17450,19 @@ async fn test_following(cx: &mut TestAppContext) { .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { - assert_eq!(follower.selections.ranges(cx), vec![0..2]); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![MultiBufferOffset(0)..MultiBufferOffset(2)] + ); }); // Scrolling locally breaks the follow _ = follower.update(cx, |follower, window, cx| { - let top_anchor = follower.buffer().read(cx).read(cx).anchor_after(0); + let top_anchor = follower + .buffer() + .read(cx) + .read(cx) + .anchor_after(MultiBufferOffset(0)); follower.set_scroll_anchor( ScrollAnchor { anchor: top_anchor, @@ -16515,7 +17542,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { leader.update(cx, |leader, cx| { leader.buffer.update(cx, |multibuffer, cx| { multibuffer.set_excerpts_for_path( - PathKey::namespaced(1, rel_path("b.txt").into_arc()), + PathKey::with_sort_prefix(1, rel_path("b.txt").into_arc()), buffer_1.clone(), vec![ Point::row_range(0..3), @@ -16526,7 +17553,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { cx, ); multibuffer.set_excerpts_for_path( - PathKey::namespaced(1, rel_path("a.txt").into_arc()), + PathKey::with_sort_prefix(1, rel_path("a.txt").into_arc()), buffer_2.clone(), vec![Point::row_range(0..6), Point::row_range(8..12)], 0, @@ -16895,12 +17922,49 @@ fn test_split_words() { assert_eq!(split(":do_the_thing"), &[":", "do_", "the_", "thing"]); } +#[test] +fn test_split_words_for_snippet_prefix() { + fn split(text: &str) -> Vec<&str> { + snippet_candidate_suffixes(text, |c| c.is_alphanumeric() || c == '_').collect() + } + + assert_eq!(split("HelloWorld"), &["HelloWorld"]); + assert_eq!(split("hello_world"), &["hello_world"]); + assert_eq!(split("_hello_world_"), &["_hello_world_"]); + assert_eq!(split("Hello_World"), &["Hello_World"]); + assert_eq!(split("helloWOrld"), &["helloWOrld"]); + assert_eq!(split("helloworld"), &["helloworld"]); + assert_eq!( + split("this@is!@#$^many . symbols"), + &[ + "symbols", + " symbols", + ". symbols", + " . symbols", + " . symbols", + " . symbols", + "many . symbols", + "^many . symbols", + "$^many . symbols", + "#$^many . symbols", + "@#$^many . symbols", + "!@#$^many . symbols", + "is!@#$^many . symbols", + "@is!@#$^many . symbols", + "this@is!@#$^many . symbols", + ], + ); + assert_eq!(split("a.s"), &["s", ".s", "a.s"]); +} + #[gpui::test] async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; - let mut assert = |before, after| { + + #[track_caller] + fn assert(before: &str, after: &str, cx: &mut EditorLspTestContext) { let _state_context = cx.set_state(before); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { @@ -16908,30 +17972,33 @@ async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { }); cx.run_until_parked(); cx.assert_editor_state(after); - }; + } // Outside bracket jumps to outside of matching bracket - assert("console.logˇ(var);", "console.log(var)ˇ;"); - assert("console.log(var)ˇ;", "console.logˇ(var);"); + assert("console.logˇ(var);", "console.log(var)ˇ;", &mut cx); + assert("console.log(var)ˇ;", "console.logˇ(var);", &mut cx); // Inside bracket jumps to inside of matching bracket - assert("console.log(ˇvar);", "console.log(varˇ);"); - assert("console.log(varˇ);", "console.log(ˇvar);"); + assert("console.log(ˇvar);", "console.log(varˇ);", &mut cx); + assert("console.log(varˇ);", "console.log(ˇvar);", &mut cx); // When outside a bracket and inside, favor jumping to the inside bracket assert( "console.log('foo', [1, 2, 3]ˇ);", - "console.log(ˇ'foo', [1, 2, 3]);", + "console.log('foo', ˇ[1, 2, 3]);", + &mut cx, ); assert( "console.log(ˇ'foo', [1, 2, 3]);", - "console.log('foo', [1, 2, 3]ˇ);", + "console.log('foo'ˇ, [1, 2, 3]);", + &mut cx, ); // Bias forward if two options are equally likely assert( "let result = curried_fun()ˇ();", "let result = curried_fun()()ˇ;", + &mut cx, ); // If directly adjacent to a smaller pair but inside a larger (not adjacent), pick the smaller @@ -16944,9 +18011,93 @@ async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { function test() { console.logˇ('test') }"}, + &mut cx, ); } +#[gpui::test] +async fn test_move_to_enclosing_bracket_in_markdown_code_block(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); + language_registry.add(markdown_lang()); + language_registry.add(rust_lang()); + let buffer = cx.new(|cx| { + let mut buffer = language::Buffer::local( + indoc! {" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } + } + ``` + "}, + cx, + ); + buffer.set_language_registry(language_registry.clone()); + buffer.set_language(Some(markdown_lang()), cx); + buffer + }); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); + cx.executor().run_until_parked(); + _ = editor.update(cx, |editor, window, cx| { + // Case 1: Test outer enclosing brackets + select_ranges( + editor, + &indoc! {" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } + }ˇ + ``` + "}, + window, + cx, + ); + editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx); + assert_text_with_selections( + editor, + &indoc! {" + ```rs + impl Worktree ˇ{ + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } + } + ``` + "}, + cx, + ); + // Case 2: Test inner enclosing brackets + select_ranges( + editor, + &indoc! {" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + }ˇ + } + ``` + "}, + window, + cx, + ); + editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx); + assert_text_with_selections( + editor, + &indoc! {" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> ˇ{ + } + } + ``` + "}, + cx, + ); + }); +} + #[gpui::test] async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -17631,7 +18782,7 @@ async fn test_context_menus_hide_hover_popover(cx: &mut gpui::TestAppContext) { } }); cx.update_editor(|editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); completion_requests.next().await; cx.condition(|editor, _| editor.context_menu_visible()) @@ -18079,9 +19230,7 @@ fn completion_menu_entries(menu: &CompletionsMenu) -> Vec { #[gpui::test] async fn test_document_format_with_prettier(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::List(FormatterList::Single( - Formatter::Prettier, - ))) + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) }); let fs = FakeFs::new(cx.executor()); @@ -18148,7 +19297,7 @@ async fn test_document_format_with_prettier(cx: &mut TestAppContext) { ); update_test_language_settings(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::Auto) + settings.defaults.formatter = Some(FormatterList::default()) }); let format = editor.update_in(cx, |editor, window, cx| { editor.perform_format( @@ -18167,6 +19316,109 @@ async fn test_document_format_with_prettier(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_document_format_with_prettier_explicit_language(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.settings"), Default::default()) + .await; + + let project = Project::test(fs, [path!("/file.settings").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + let ts_lang = Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..LanguageMatcher::default() + }, + prettier_parser_name: Some("typescript".to_string()), + ..LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + )); + + language_registry.add(ts_lang.clone()); + + update_test_language_settings(cx, |settings| { + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + + let test_plugin = "test_plugin"; + let _ = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + prettier_plugins: vec![test_plugin], + ..Default::default() + }, + ); + + let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.settings"), cx) + }) + .await + .unwrap(); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, ts_lang, cx) + }); + + let buffer_text = "one\ntwo\nthree\n"; + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_text(buffer_text, window, cx) + }); + + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + .await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + prettier_format_suffix + "\ntypescript", + "Test prettier formatting was not applied to the original buffer text", + ); + + update_test_language_settings(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::default()) + }); + let format = editor.update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }); + format.await.unwrap(); + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + + prettier_format_suffix + + "\ntypescript\n" + + prettier_format_suffix + + "\ntypescript", + "Autoformatting (via test prettier) was not applied to the original buffer text", + ); +} + #[gpui::test] async fn test_addition_reverts(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -18813,7 +20065,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { SelectionEffects::scroll(Autoscroll::Next), window, cx, - |s| s.select_ranges(Some(1..2)), + |s| s.select_ranges(Some(MultiBufferOffset(1)..MultiBufferOffset(2))), ); editor.open_excerpts(&OpenExcerpts, window, cx); }); @@ -18869,7 +20121,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { SelectionEffects::scroll(Autoscroll::Next), window, cx, - |s| s.select_ranges(Some(39..40)), + |s| s.select_ranges(Some(MultiBufferOffset(39)..MultiBufferOffset(40))), ); editor.open_excerpts(&OpenExcerpts, window, cx); }); @@ -18929,7 +20181,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { SelectionEffects::scroll(Autoscroll::Next), window, cx, - |s| s.select_ranges(Some(70..70)), + |s| s.select_ranges(Some(MultiBufferOffset(70)..MultiBufferOffset(70))), ); editor.open_excerpts(&OpenExcerpts, window, cx); }); @@ -20798,10 +22050,9 @@ async fn test_adjacent_diff_hunks(executor: BackgroundExecutor, cx: &mut TestApp .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) .collect::>(); let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; - let buffer_id = hunks[0].buffer_id; hunks .into_iter() - .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range)) + .map(|hunk| Anchor::range_in_buffer(excerpt_id, hunk.buffer_range)) .collect::>() }); assert_eq!(hunk_ranges.len(), 2); @@ -20889,10 +22140,9 @@ async fn test_adjacent_diff_hunks(executor: BackgroundExecutor, cx: &mut TestApp .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) .collect::>(); let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; - let buffer_id = hunks[0].buffer_id; hunks .into_iter() - .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range)) + .map(|hunk| Anchor::range_in_buffer(excerpt_id, hunk.buffer_range)) .collect::>() }); assert_eq!(hunk_ranges.len(), 2); @@ -20955,10 +22205,9 @@ async fn test_toggle_deletion_hunk_at_start_of_file( .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) .collect::>(); let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; - let buffer_id = hunks[0].buffer_id; hunks .into_iter() - .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range)) + .map(|hunk| Anchor::range_in_buffer(excerpt_id, hunk.buffer_range)) .collect::>() }); assert_eq!(hunk_ranges.len(), 1); @@ -20984,6 +22233,40 @@ async fn test_toggle_deletion_hunk_at_start_of_file( cx.assert_state_with_diff(hunk_expanded); } +#[gpui::test] +async fn test_expand_first_line_diff_hunk_keeps_deleted_lines_visible( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇnew\nsecond\nthird\n"); + cx.set_head_text("old\nsecond\nthird\n"); + cx.update_editor(|editor, window, cx| { + editor.scroll(gpui::Point { x: 0., y: 0. }, None, window, cx); + }); + executor.run_until_parked(); + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); + + // Expanding a diff hunk at the first line inserts deleted lines above the first buffer line. + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) + .collect::>(); + assert_eq!(hunks.len(), 1); + let hunk_range = Anchor::range_in_buffer(excerpt_id, hunks[0].buffer_range.clone()); + editor.toggle_single_diff_hunk(hunk_range, cx) + }); + executor.run_until_parked(); + cx.assert_state_with_diff("- old\n+ ˇnew\n second\n third\n".to_string()); + + // Keep the editor scrolled to the top so the full hunk remains visible. + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); +} + #[gpui::test] async fn test_display_diff_hunks(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -21029,7 +22312,7 @@ async fn test_display_diff_hunks(cx: &mut TestAppContext) { for buffer in &buffers { let snapshot = buffer.read(cx).snapshot(); multibuffer.set_excerpts_for_path( - PathKey::namespaced(0, buffer.read(cx).file().unwrap().path().clone()), + PathKey::with_sort_prefix(0, buffer.read(cx).file().unwrap().path().clone()), buffer.clone(), vec![text::Anchor::MIN.to_point(&snapshot)..text::Anchor::MAX.to_point(&snapshot)], 2, @@ -21551,7 +22834,7 @@ async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { }); let navigated = cx .update_editor(|editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await @@ -21587,7 +22870,7 @@ async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { ); let navigated = cx .update_editor(|editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await @@ -21639,7 +22922,7 @@ async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { }); let navigated = cx .update_editor(|editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await @@ -21719,7 +23002,7 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { (buffer.read(cx).remote_id(), 3), RunnableTasks { templates: vec![], - offset: snapshot.anchor_before(43), + offset: snapshot.anchor_before(MultiBufferOffset(43)), column: 0, extra_variables: HashMap::default(), context_range: BufferOffset(43)..BufferOffset(85), @@ -21729,7 +23012,7 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { (buffer.read(cx).remote_id(), 8), RunnableTasks { templates: vec![], - offset: snapshot.anchor_before(86), + offset: snapshot.anchor_before(MultiBufferOffset(86)), column: 0, extra_variables: HashMap::default(), context_range: BufferOffset(86)..BufferOffset(191), @@ -21906,7 +23189,7 @@ async fn test_folding_buffers(cx: &mut TestAppContext) { assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), - "\n\nB\n\n\n\n\n\n\nllll\nmmmm\nnnnn\n\n\nqqqq\nrrrr\n\n\nuuuu\n\n", + "\n\naaaa\nBbbbb\ncccc\n\n\nffff\ngggg\n\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\n\nqqqq\nrrrr\n\n\nuuuu\n\n", "After unfolding the first buffer, its and 2nd buffer's text should be displayed" ); @@ -21915,7 +23198,7 @@ async fn test_folding_buffers(cx: &mut TestAppContext) { }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), - "\n\nB\n\n\n\n\n\n\nllll\nmmmm\nnnnn\n\n\nqqqq\nrrrr\n\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n\n1111\n2222\n\n\n5555", + "\n\naaaa\nBbbbb\ncccc\n\n\nffff\ngggg\n\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\n\nqqqq\nrrrr\n\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n\n1111\n2222\n\n\n5555", "After unfolding the all buffers, all original text should be displayed" ); } @@ -22497,7 +23780,7 @@ async fn assert_highlighted_edits( let text_anchor_edits = edits .clone() .into_iter() - .map(|(range, edit)| (range.start.text_anchor..range.end.text_anchor, edit)) + .map(|(range, edit)| (range.start.text_anchor..range.end.text_anchor, edit.into())) .collect::>(); let edit_preview = window @@ -22567,11 +23850,11 @@ fn add_log_breakpoint_at_cursor( .first() .and_then(|(anchor, bp)| bp.as_ref().map(|bp| (*anchor, bp.clone()))) .unwrap_or_else(|| { - let cursor_position: Point = editor.selections.newest(cx).head(); + let snapshot = editor.snapshot(window, cx); + let cursor_position: Point = + editor.selections.newest(&snapshot.display_snapshot).head(); - let breakpoint_position = editor - .snapshot(window, cx) - .display_snapshot + let breakpoint_position = snapshot .buffer_snapshot() .anchor_before(Point::new(cursor_position.row, 0)); @@ -23048,7 +24331,7 @@ async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); editor.highlight_background::( &[highlight_range], - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); @@ -23126,7 +24409,7 @@ async fn test_rename_without_prepare(cx: &mut TestAppContext) { let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); editor.highlight_background::( &[highlight_range], - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); @@ -23518,7 +24801,7 @@ println!("5"); assert_eq!( editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), @@ -23561,7 +24844,7 @@ println!("5"); assert_eq!( editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), @@ -23687,7 +24970,7 @@ println!("5"); assert_eq!( editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), @@ -23713,7 +24996,7 @@ println!("5"); assert_eq!( editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), @@ -24029,7 +25312,7 @@ async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { ]))) }); editor.update_in(cx, |editor, window, cx| { - editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + editor.show_completions(&ShowCompletions, window, cx); }); cx.run_until_parked(); completion_handle.next().await.unwrap(); @@ -25110,8 +26393,11 @@ fn assert_selection_ranges(marked_text: &str, editor: &mut Editor, cx: &mut Cont let (text, ranges) = marked_text_ranges(marked_text, true); assert_eq!(editor.text(cx), text); assert_eq!( - editor.selections.ranges(cx), - ranges, + editor.selections.ranges(&editor.display_snapshot(cx)), + ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>(), "Assert selections are {}", marked_text ); @@ -25148,6 +26434,195 @@ pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorL }); } +#[gpui::test] +async fn test_mixed_completions_with_multi_word_snippet(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "unsafe".into(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 9, + }, + end: lsp::Position { + line: 0, + character: 11, + }, + }, + new_text: "unsafe".to_string(), + })), + insert_text_mode: Some(lsp::InsertTextMode::AS_IS), + ..Default::default() + }, + ]))) + }); + + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![ + Arc::new(project::snippet_provider::Snippet { + prefix: vec![ + "unlimited word count".to_string(), + "unlimit word count".to_string(), + "unlimited unknown".to_string(), + ], + body: "this is many words".to_string(), + description: Some("description".to_string()), + name: "multi-word snippet test".to_string(), + }), + Arc::new(project::snippet_provider::Snippet { + prefix: vec!["unsnip".to_string(), "@few".to_string()], + body: "fewer words".to_string(), + description: Some("alt description".to_string()), + name: "other name".to_string(), + }), + Arc::new(project::snippet_provider::Snippet { + prefix: vec!["ab aa".to_string()], + body: "abcd".to_string(), + description: None, + name: "alphabet".to_string(), + }), + ], + ); + }); + }) + }); + + let get_completions = |cx: &mut EditorLspTestContext| { + cx.update_editor(|editor, _, _| match &*editor.context_menu.borrow() { + Some(CodeContextMenu::Completions(context_menu)) => { + let entries = context_menu.entries.borrow(); + entries + .iter() + .map(|entry| entry.string.clone()) + .collect_vec() + } + _ => vec![], + }) + }; + + // snippets: + // @foo + // foo bar + // + // when typing: + // + // when typing: + // - if I type a symbol "open the completions with snippets only" + // - if I type a word character "open the completions menu" (if it had been open snippets only, clear it out) + // + // stuff we need: + // - filtering logic change? + // - remember how far back the completion started. + + let test_cases: &[(&str, &[&str])] = &[ + ( + "un", + &[ + "unsafe", + "unlimit word count", + "unlimited unknown", + "unlimited word count", + "unsnip", + ], + ), + ( + "u ", + &[ + "unlimit word count", + "unlimited unknown", + "unlimited word count", + ], + ), + ("u a", &["ab aa", "unsafe"]), // unsAfe + ( + "u u", + &[ + "unsafe", + "unlimit word count", + "unlimited unknown", // ranked highest among snippets + "unlimited word count", + "unsnip", + ], + ), + ("uw c", &["unlimit word count", "unlimited word count"]), + ( + "u w", + &[ + "unlimit word count", + "unlimited word count", + "unlimited unknown", + ], + ), + ("u w ", &["unlimit word count", "unlimited word count"]), + ( + "u ", + &[ + "unlimit word count", + "unlimited unknown", + "unlimited word count", + ], + ), + ("wor", &[]), + ("uf", &["unsafe"]), + ("af", &["unsafe"]), + ("afu", &[]), + ( + "ue", + &["unsafe", "unlimited unknown", "unlimited word count"], + ), + ("@", &["@few"]), + ("@few", &["@few"]), + ("@ ", &[]), + ("a@", &["@few"]), + ("a@f", &["@few", "unsafe"]), + ("a@fw", &["@few"]), + ("a", &["ab aa", "unsafe"]), + ("aa", &["ab aa"]), + ("aaa", &["ab aa"]), + ("ab", &["ab aa"]), + ("ab ", &["ab aa"]), + ("ab a", &["ab aa", "unsafe"]), + ("ab ab", &["ab aa"]), + ("ab ab aa", &["ab aa"]), + ]; + + for &(input_to_simulate, expected_completions) in test_cases { + cx.set_state("fn a() { ˇ }\n"); + for c in input_to_simulate.split("") { + cx.simulate_input(c); + cx.run_until_parked(); + } + let expected_completions = expected_completions + .iter() + .map(|s| s.to_string()) + .collect_vec(); + assert_eq!( + get_completions(&mut cx), + expected_completions, + "< actual / expected >, input = {input_to_simulate:?}", + ); + } +} + /// Handle completion request passing a marked string specifying where the completion /// should be triggered from using '|' character, what range should be replaced, and what completions /// should be returned using '<' and '>' to delimit the range. @@ -25168,10 +26643,12 @@ pub fn handle_completion_request( vec![complete_from_marker.clone(), replace_range_marker.clone()], ); - let complete_from_position = - cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); + let complete_from_position = cx.to_lsp(MultiBufferOffset( + marked_ranges.remove(&complete_from_marker).unwrap()[0].start, + )); + let range = marked_ranges.remove(&replace_range_marker).unwrap()[0].clone(); let replace_range = - cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + cx.to_lsp_range(MultiBufferOffset(range.start)..MultiBufferOffset(range.end)); let mut request = cx.set_request_handler::(move |url, params, _| { @@ -25232,13 +26709,18 @@ pub fn handle_completion_request_with_insert_and_replace( ], ); - let complete_from_position = - cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); + let complete_from_position = cx.to_lsp(MultiBufferOffset( + marked_ranges.remove(&complete_from_marker).unwrap()[0].start, + )); + let range = marked_ranges.remove(&replace_range_marker).unwrap()[0].clone(); let replace_range = - cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + cx.to_lsp_range(MultiBufferOffset(range.start)..MultiBufferOffset(range.end)); let insert_range = match marked_ranges.remove(&insert_range_marker) { - Some(ranges) if !ranges.is_empty() => cx.to_lsp_range(ranges[0].clone()), + Some(ranges) if !ranges.is_empty() => { + let range1 = ranges[0].clone(); + cx.to_lsp_range(MultiBufferOffset(range1.start)..MultiBufferOffset(range1.end)) + } _ => lsp::Range { start: replace_range.start, end: complete_from_position, @@ -25288,7 +26770,10 @@ fn handle_resolve_completion_request( .iter() .map(|(marked_string, new_text)| { let (_, marked_ranges) = marked_text_ranges(marked_string, false); - let replace_range = cx.to_lsp_range(marked_ranges[0].clone()); + let replace_range = cx.to_lsp_range( + MultiBufferOffset(marked_ranges[0].start) + ..MultiBufferOffset(marked_ranges[0].end), + ); lsp::TextEdit::new(replace_range, new_text.to_string()) }) .collect::>() @@ -25332,17 +26817,24 @@ pub(crate) fn update_test_project_settings( }); } +pub(crate) fn update_test_editor_settings( + cx: &mut TestAppContext, + f: impl Fn(&mut EditorSettingsContent), +) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| f(&mut settings.editor)); + }) + }) +} + pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) { cx.update(|cx| { assets::Assets.load_test_fonts(cx); let store = SettingsStore::test(cx); cx.set_global(store); theme::init(theme::LoadThemes::JustBase, cx); - release_channel::init(SemanticVersion::default(), cx); - client::init_settings(cx); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); zlog::init_test(); @@ -25365,7 +26857,7 @@ fn assert_hunk_revert( let snapshot = editor.snapshot(window, cx); let reverted_hunk_statuses = snapshot .buffer_snapshot() - .diff_hunks_in_range(0..snapshot.buffer_snapshot().len()) + .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) .map(|hunk| hunk.status().kind) .collect::>(); @@ -25455,7 +26947,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { } }); - let ensure_result_id = |expected: Option, cx: &mut TestAppContext| { + let ensure_result_id = |expected: Option, cx: &mut TestAppContext| { project.update(cx, |project, cx| { let buffer_id = editor .read(cx) @@ -25468,7 +26960,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { let buffer_result_id = project .lsp_store() .read(cx) - .result_id(server_id, buffer_id, cx); + .result_id_for_buffer_pull(server_id, buffer_id, &None, cx); assert_eq!(expected, buffer_result_id); }); }; @@ -25485,7 +26977,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { .next() .await .expect("should have sent the first diagnostics pull request"); - ensure_result_id(Some("1".to_string()), cx); + ensure_result_id(Some(SharedString::new("1")), cx); // Editing should trigger diagnostics editor.update_in(cx, |editor, window, cx| { @@ -25498,7 +26990,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { 2, "Editing should trigger diagnostic request" ); - ensure_result_id(Some("2".to_string()), cx); + ensure_result_id(Some(SharedString::new("2")), cx); // Moving cursor should not trigger diagnostic request editor.update_in(cx, |editor, window, cx| { @@ -25513,7 +27005,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { 2, "Cursor movement should not trigger diagnostic request" ); - ensure_result_id(Some("2".to_string()), cx); + ensure_result_id(Some(SharedString::new("2")), cx); // Multiple rapid edits should be debounced for _ in 0..5 { editor.update_in(cx, |editor, window, cx| { @@ -25528,7 +27020,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { final_requests <= 4, "Multiple rapid edits should be debounced (got {final_requests} requests)", ); - ensure_result_id(Some(final_requests.to_string()), cx); + ensure_result_id(Some(SharedString::new(final_requests.to_string())), cx); } #[gpui::test] @@ -25581,6 +27073,159 @@ async fn test_add_selection_after_moving_with_multiple_cursors(cx: &mut TestAppC ); } +#[gpui::test] +async fn test_add_selection_skip_soft_wrap_option(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc!( + r#"ˇThis is a very long line that will be wrapped when soft wrapping is enabled + Second line here"# + )); + + cx.update_editor(|editor, window, cx| { + // Enable soft wrapping with a narrow width to force soft wrapping and + // confirm that more than 2 rows are being displayed. + editor.set_wrap_width(Some(100.0.into()), cx); + assert!(editor.display_text(cx).lines().count() > 2); + + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(8), 0), + ] + ); + + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: true, + }, + window, + cx, + ); + + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: false, + }, + window, + cx, + ); + + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + ] + ); + + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: false, + }, + window, + cx, + ); + + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + +#[gpui::test] +async fn test_insert_snippet(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec![], // no prefix needed! + body: "an Unspecified".to_string(), + description: Some("shhhh it's a secret".to_string()), + name: "super secret snippet".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + + let snippet = project::snippet_provider::Snippet { + prefix: vec![], // no prefix needed! + body: " Location".to_string(), + description: Some("the word 'location'".to_string()), + name: "location word".to_string(), + }; + snippets.add_snippet_for_test( + Some("Markdown".to_string()), + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + + cx.set_state(indoc!(r#"First cursor at ˇ and second cursor at ˇ"#)); + + cx.update_editor(|editor, window, cx| { + editor.insert_snippet_at_selections( + &InsertSnippet { + language: None, + name: Some("super secret snippet".to_string()), + snippet: None, + }, + window, + cx, + ); + + // Language is specified in the action, + // so the buffer language does not need to match + editor.insert_snippet_at_selections( + &InsertSnippet { + language: Some("Markdown".to_string()), + name: Some("location word".to_string()), + snippet: None, + }, + window, + cx, + ); + + editor.insert_snippet_at_selections( + &InsertSnippet { + language: None, + name: None, + snippet: Some("$0 after".to_string()), + }, + window, + cx, + ); + }); + + cx.assert_editor_state( + r#"First cursor at an Unspecified Locationˇ after and second cursor at an Unspecified Locationˇ after"#, + ); +} + #[gpui::test(iterations = 10)] async fn test_document_colors(cx: &mut TestAppContext) { let expected_color = Rgba { @@ -25703,7 +27348,7 @@ async fn test_document_colors(cx: &mut TestAppContext) { .set_request_handler::(move |_, _| async move { panic!("Should not be called"); }); - cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT); color_request_handle.next().await.unwrap(); cx.run_until_parked(); assert_eq!( @@ -25787,9 +27432,9 @@ async fn test_document_colors(cx: &mut TestAppContext) { color_request_handle.next().await.unwrap(); cx.run_until_parked(); assert_eq!( - 3, + 2, requests_made.load(atomic::Ordering::Acquire), - "Should query for colors once per save and once per formatting after save" + "Should query for colors once per save (deduplicated) and once per formatting after save" ); drop(editor); @@ -25810,7 +27455,7 @@ async fn test_document_colors(cx: &mut TestAppContext) { .unwrap(); close.await.unwrap(); assert_eq!( - 3, + 2, requests_made.load(atomic::Ordering::Acquire), "After saving and closing all editors, no extra requests should be made" ); @@ -25830,7 +27475,7 @@ async fn test_document_colors(cx: &mut TestAppContext) { }) }) .unwrap(); - cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT); cx.run_until_parked(); let editor = workspace .update(cx, |workspace, _, cx| { @@ -25841,9 +27486,9 @@ async fn test_document_colors(cx: &mut TestAppContext) { .expect("Should be an editor") }) .unwrap(); - color_request_handle.next().await.unwrap(); + assert_eq!( - 3, + 2, requests_made.load(atomic::Ordering::Acquire), "Cache should be reused on buffer close and reopen" ); @@ -25884,10 +27529,11 @@ async fn test_document_colors(cx: &mut TestAppContext) { }); save.await.unwrap(); + cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT); empty_color_request_handle.next().await.unwrap(); cx.run_until_parked(); assert_eq!( - 4, + 3, requests_made.load(atomic::Ordering::Acquire), "Should query for colors once per save only, as formatting was not requested" ); @@ -25911,7 +27557,9 @@ async fn test_newline_replacement_in_single_line(cx: &mut TestAppContext) { editor.update(cx, |editor, cx| { assert_eq!(editor.display_text(cx), "oops⋯⋯wow⋯"); }); - editor.update(cx, |editor, cx| editor.edit([(3..5, "")], cx)); + editor.update(cx, |editor, cx| { + editor.edit([(MultiBufferOffset(3)..MultiBufferOffset(5), "")], cx) + }); cx.run_until_parked(); editor.update(cx, |editor, cx| { assert_eq!(editor.display_text(cx), "oop⋯wow⋯"); @@ -25948,8 +27596,8 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) { .unwrap(); assert_eq!( - handle.to_any().entity_type(), - TypeId::of::() + handle.to_any_view().entity_type(), + TypeId::of::() ); } @@ -25993,14 +27641,18 @@ async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) { ]); }); - let initial_selection = editor.selections.display_ranges(cx); + let initial_selection = editor + .selections + .display_ranges(&editor.display_snapshot(cx)); assert_eq!(initial_selection.len(), 1, "Should have one selection"); // Test select next sibling - should move up levels to find the next sibling // Since "let a = 1;" has no siblings in the if block, it should move up // to find "let b = 2;" which is a sibling of the if block editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); - let next_selection = editor.selections.display_ranges(cx); + let next_selection = editor + .selections + .display_ranges(&editor.display_snapshot(cx)); // Should have a selection and it should be different from the initial assert_eq!( @@ -26022,7 +27674,9 @@ async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) { }); editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); - let function_next_selection = editor.selections.display_ranges(cx); + let function_next_selection = editor + .selections + .display_ranges(&editor.display_snapshot(cx)); // Should move to the next function assert_eq!( @@ -26033,7 +27687,9 @@ async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) { // Test select previous sibling navigation editor.select_prev_syntax_node(&SelectPreviousSyntaxNode, window, cx); - let prev_selection = editor.selections.display_ranges(cx); + let prev_selection = editor + .selections + .display_ranges(&editor.display_snapshot(cx)); // Should have a selection and it should be different assert_eq!( @@ -26077,7 +27733,7 @@ let result = variable * 2;", editor.highlight_background::( &anchor_ranges, - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); @@ -26173,6 +27829,186 @@ async fn test_paste_url_from_other_app_creates_markdown_link_over_selected_text( )); } +#[gpui::test] +async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Test if adding a character with multi cursors preserves nested list indents + cx.set_state(&indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [ˇ] Item 2 + - [ˇ] Item 2.a + - [ˇ] Item 2.b + " + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("x", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [xˇ] Item 2 + - [xˇ] Item 2.a + - [xˇ] Item 2.b + " + }); + + // Case 2: Test adding new line after nested list preserves indent of previous line + cx.set_state(&indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.bˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + ˇ" + }); + + // Case 3: Test adding a new nested list item preserves indent + cx.set_state(&indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + ˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("-", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + -ˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(" [x] Item 2.c", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + - [x] Item 2.cˇ" + }); + + // Case 4: Test adding new line after nested ordered list preserves indent of previous line + cx.set_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.bˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + ˇ" + }); + + // Case 5: Adding new ordered list item preserves indent + cx.set_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + ˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("3", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + 3ˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(".", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + 3.ˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(" Item 2.c", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + 3. Item 2.cˇ" + }); + + // Case 6: Test adding new line after nested ordered list preserves indent of previous line + cx.set_state(indoc! {" + - Item 1 + - Item 1.a + - Item 1.a + ˇ"}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("-", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - Item 1 + - Item 1.a + - Item 1.a + -ˇ"}); + + // Case 7: Test blockquote newline preserves something + cx.set_state(indoc! {" + > Item 1ˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + > Item 1 + ˇ" + }); +} + #[gpui::test] async fn test_paste_url_from_zed_copy_creates_markdown_link_over_selected_text( cx: &mut gpui::TestAppContext, @@ -26475,3 +28311,1027 @@ fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec { .map(Rgba::from) .collect() } + +#[gpui::test] +fn test_duplicate_line_up_on_last_line_without_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("line1\nline2", cx); + build_editor(buffer, window, cx) + }); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) + ]) + }); + + editor.duplicate_line_up(&DuplicateLineUp, window, cx); + + assert_eq!( + editor.display_text(cx), + "line1\nline2\nline2", + "Duplicating last line upward should create duplicate above, not on same line" + ); + + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)], + "Selection should move to the duplicated line" + ); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_copy_line_without_trailing_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("line1\nline2ˇ"); + + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)); + + assert_eq!( + clipboard_text, + Some("line2\n".to_string()), + "Copying a line without trailing newline should include a newline" + ); + + cx.set_state("line1\nˇ"); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + + cx.assert_editor_state("line1\nline2\nˇ"); +} + +#[gpui::test] +async fn test_multi_selection_copy_with_newline_between_copied_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇline1\nˇline2\nˇline3\n"); + + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)); + + assert_eq!( + clipboard_text, + Some("line1\nline2\nline3\n".to_string()), + "Copying multiple lines should include a single newline between lines" + ); + + cx.set_state("lineA\nˇ"); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + + cx.assert_editor_state("lineA\nline1\nline2\nline3\nˇ"); +} + +#[gpui::test] +async fn test_multi_selection_cut_with_newline_between_copied_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇline1\nˇline2\nˇline3\n"); + + cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)); + + assert_eq!( + clipboard_text, + Some("line1\nline2\nline3\n".to_string()), + "Copying multiple lines should include a single newline between lines" + ); + + cx.set_state("lineA\nˇ"); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + + cx.assert_editor_state("lineA\nline1\nline2\nline3\nˇ"); +} + +#[gpui::test] +async fn test_end_of_editor_context(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("line1\nline2ˇ"); + cx.update_editor(|e, window, cx| { + e.set_mode(EditorMode::SingleLine); + assert!(e.key_context(window, cx).contains("end_of_input")); + }); + cx.set_state("ˇline1\nline2"); + cx.update_editor(|e, window, cx| { + assert!(!e.key_context(window, cx).contains("end_of_input")); + }); + cx.set_state("line1ˇ\nline2"); + cx.update_editor(|e, window, cx| { + assert!(!e.key_context(window, cx).contains("end_of_input")); + }); +} + +#[gpui::test] +async fn test_sticky_scroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let buffer = indoc! {" + ˇfn foo() { + let abc = 123; + } + struct Bar; + impl Bar { + fn new() -> Self { + Self + } + } + fn baz() { + } + "}; + cx.set_state(&buffer); + + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let mut sticky_headers = |offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); + let style = e.style(cx).clone(); + EditorElement::sticky_headers(&e, &e.snapshot(window, cx), &style, cx) + .into_iter() + .map( + |StickyHeader { + start_point, + offset, + .. + }| { (start_point, offset) }, + ) + .collect::>() + }) + }; + + let fn_foo = Point { row: 0, column: 0 }; + let impl_bar = Point { row: 4, column: 0 }; + let fn_new = Point { row: 5, column: 4 }; + + assert_eq!(sticky_headers(0.0), vec![]); + assert_eq!(sticky_headers(0.5), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(1.0), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(1.5), vec![(fn_foo, -0.5)]); + assert_eq!(sticky_headers(2.0), vec![]); + assert_eq!(sticky_headers(2.5), vec![]); + assert_eq!(sticky_headers(3.0), vec![]); + assert_eq!(sticky_headers(3.5), vec![]); + assert_eq!(sticky_headers(4.0), vec![]); + assert_eq!(sticky_headers(4.5), vec![(impl_bar, 0.0), (fn_new, 1.0)]); + assert_eq!(sticky_headers(5.0), vec![(impl_bar, 0.0), (fn_new, 1.0)]); + assert_eq!(sticky_headers(5.5), vec![(impl_bar, 0.0), (fn_new, 0.5)]); + assert_eq!(sticky_headers(6.0), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(6.5), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(7.0), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(7.5), vec![(impl_bar, -0.5)]); + assert_eq!(sticky_headers(8.0), vec![]); + assert_eq!(sticky_headers(8.5), vec![]); + assert_eq!(sticky_headers(9.0), vec![]); + assert_eq!(sticky_headers(9.5), vec![]); + assert_eq!(sticky_headers(10.0), vec![]); +} + +#[gpui::test] +async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }) + }); + }); + }); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + + let buffer = indoc! {" + ˇfn foo() { + let abc = 123; + } + struct Bar; + impl Bar { + fn new() -> Self { + Self + } + } + fn baz() { + } + "}; + cx.set_state(&buffer); + + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let fn_foo = || empty_range(0, 0); + let impl_bar = || empty_range(4, 0); + let fn_new = || empty_range(5, 4); + + let mut scroll_and_click = |scroll_offset: ScrollOffset, click_offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll( + gpui::Point { + x: 0., + y: scroll_offset, + }, + None, + window, + cx, + ); + }); + cx.simulate_click( + gpui::Point { + x: px(0.), + y: click_offset as f32 * line_height, + }, + Modifiers::none(), + ); + cx.update_editor(|e, _, cx| (e.scroll_position(cx), display_ranges(e, cx))) + }; + + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 0.0 // click top of screen + ), + // scrolled to impl Bar + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 0.25 // click middle of impl Bar + ), + // scrolled to impl Bar + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 1.5 // click below impl Bar (e.g. fn new()) + ), + // scrolled to fn new() - this is below the impl Bar header which has persisted + (gpui::Point { x: 0., y: 4. }, vec![fn_new()]) + ); + + assert_eq!( + scroll_and_click( + 5.5, // fn new is halfway underneath impl Bar + 0.75 // click on the overlap of impl Bar and fn new() + ), + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 5.5, // fn new is halfway underneath impl Bar + 1.25 // click on the visible part of fn new() + ), + (gpui::Point { x: 0., y: 4. }, vec![fn_new()]) + ); + + assert_eq!( + scroll_and_click( + 1.5, // fn foo is halfway off the screen + 0.0 // click top of screen + ), + (gpui::Point { x: 0., y: 0. }, vec![fn_foo()]) + ); + + assert_eq!( + scroll_and_click( + 1.5, // fn foo is halfway off the screen + 0.75 // click visible part of let abc... + ) + .0, + // no change in scroll + // we don't assert on the visible_range because if we clicked the gutter, our line is fully selected + (gpui::Point { x: 0., y: 1.5 }) + ); +} + +#[gpui::test] +async fn test_next_prev_reference(cx: &mut TestAppContext) { + const CYCLE_POSITIONS: &[&'static str] = &[ + indoc! {" + fn foo() { + let ˇabc = 123; + let x = abc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = ˇabc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = abc + 1; + let y = ˇabc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = abc + 1; + let y = abc + 2; + let z = ˇabc + 2; + } + "}, + ]; + + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + // importantly, the cursor is in the middle + cx.set_state(indoc! {" + fn foo() { + let aˇbc = 123; + let x = abc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}); + + let reference_ranges = [ + lsp::Position::new(1, 8), + lsp::Position::new(2, 12), + lsp::Position::new(3, 12), + lsp::Position::new(4, 12), + ] + .map(|start| lsp::Range::new(start, lsp::Position::new(start.line, start.character + 3))); + + cx.lsp + .set_request_handler::(move |params, _cx| async move { + Ok(Some( + reference_ranges + .map(|range| lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range, + }) + .to_vec(), + )) + }); + + let _move = async |direction, count, cx: &mut EditorLspTestContext| { + cx.update_editor(|editor, window, cx| { + editor.go_to_reference_before_or_after_position(direction, count, window, cx) + }) + .unwrap() + .await + .unwrap() + }; + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[2]); + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + // loops back to the start + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[0]); + + // loops back to the end + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[2]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[0]); + + _move(Direction::Next, 3, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + _move(Direction::Prev, 2, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); +} + +#[gpui::test] +async fn test_multibuffer_selections_with_folding(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("1\n2\n3\n", vec![Point::row_range(0..3)]), + ("1\n2\n3\n", vec![Point::row_range(0..3)]), + ], + cx, + ); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + let buffer_ids = cx.multibuffer(|mb, _| mb.excerpt_buffer_ids()); + + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + ˇ1 + 2 + 3 + [EXCERPT] + 1 + 2 + 3 + "}); + + // Scenario 1: Unfolded buffers, position cursor on "2", select all matches, then insert + cx.update_editor(|editor, window, cx| { + editor.change_selections(None.into(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); + }); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2ˇ + 3 + [EXCERPT] + 1 + 2 + 3 + "}); + + cx.update_editor(|editor, window, cx| { + editor + .select_all_matches(&SelectAllMatches, window, cx) + .unwrap(); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2ˇ + 3 + [EXCERPT] + 1 + 2ˇ + 3 + "}); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("X", window, cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + Xˇ + 3 + [EXCERPT] + 1 + Xˇ + 3 + "}); + + // Scenario 2: Select "2", then fold second buffer before insertion + cx.update_multibuffer(|mb, cx| { + for buffer_id in buffer_ids.iter() { + let buffer = mb.buffer(*buffer_id).unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), "1\n2\n3\n")], None, cx); + }); + } + }); + + // Select "2" and select all matches + cx.update_editor(|editor, window, cx| { + editor.change_selections(None.into(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); + }); + editor + .select_all_matches(&SelectAllMatches, window, cx) + .unwrap(); + }); + + // Fold second buffer - should remove selections from folded buffer + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(buffer_ids[1], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2ˇ + 3 + [EXCERPT] + [FOLDED] + "}); + + // Insert text - should only affect first buffer + cx.update_editor(|editor, window, cx| { + editor.handle_input("Y", window, cx); + }); + cx.update_editor(|editor, _, cx| { + editor.unfold_buffer(buffer_ids[1], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + Yˇ + 3 + [EXCERPT] + 1 + 2 + 3 + "}); + + // Scenario 3: Select "2", then fold first buffer before insertion + cx.update_multibuffer(|mb, cx| { + for buffer_id in buffer_ids.iter() { + let buffer = mb.buffer(*buffer_id).unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), "1\n2\n3\n")], None, cx); + }); + } + }); + + // Select "2" and select all matches + cx.update_editor(|editor, window, cx| { + editor.change_selections(None.into(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); + }); + editor + .select_all_matches(&SelectAllMatches, window, cx) + .unwrap(); + }); + + // Fold first buffer - should remove selections from folded buffer + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(buffer_ids[0], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + 1 + 2ˇ + 3 + "}); + + // Insert text - should only affect second buffer + cx.update_editor(|editor, window, cx| { + editor.handle_input("Z", window, cx); + }); + cx.update_editor(|editor, _, cx| { + editor.unfold_buffer(buffer_ids[0], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2 + 3 + [EXCERPT] + 1 + Zˇ + 3 + "}); + + // Test correct folded header is selected upon fold + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(buffer_ids[0], cx); + editor.fold_buffer(buffer_ids[1], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + ˇ[FOLDED] + "}); + + // Test selection inside folded buffer unfolds it on type + cx.update_editor(|editor, window, cx| { + editor.handle_input("W", window, cx); + }); + cx.update_editor(|editor, _, cx| { + editor.unfold_buffer(buffer_ids[0], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2 + 3 + [EXCERPT] + Wˇ1 + Z + 3 + "}); +} + +#[gpui::test] +async fn test_filtered_editor_pair(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut leader_cx = EditorTestContext::new(cx).await; + + let diff_base = indoc!( + r#" + one + two + three + four + five + six + "# + ); + + let initial_state = indoc!( + r#" + ˇone + two + THREE + four + five + six + "# + ); + + leader_cx.set_state(initial_state); + + leader_cx.set_head_text(&diff_base); + leader_cx.run_until_parked(); + + let follower = leader_cx.update_multibuffer(|leader, cx| { + leader.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions)); + leader.set_all_diff_hunks_expanded(cx); + leader.get_or_create_follower(cx) + }); + follower.update(cx, |follower, cx| { + follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions)); + follower.set_all_diff_hunks_expanded(cx); + }); + + let follower_editor = + leader_cx.new_window_entity(|window, cx| build_editor(follower, window, cx)); + // leader_cx.window.focus(&follower_editor.focus_handle(cx)); + + let mut follower_cx = EditorTestContext::for_editor_in(follower_editor, &mut leader_cx).await; + cx.run_until_parked(); + + leader_cx.assert_editor_state(initial_state); + follower_cx.assert_editor_state(indoc! { + r#" + ˇone + two + three + four + five + six + "# + }); + + follower_cx.editor(|editor, _window, cx| { + assert!(editor.read_only(cx)); + }); + + leader_cx.update_editor(|editor, _window, cx| { + editor.edit([(Point::new(4, 0)..Point::new(5, 0), "FIVE\n")], cx); + }); + cx.run_until_parked(); + + leader_cx.assert_editor_state(indoc! { + r#" + ˇone + two + THREE + four + FIVE + six + "# + }); + + follower_cx.assert_editor_state(indoc! { + r#" + ˇone + two + three + four + five + six + "# + }); + + leader_cx.update_editor(|editor, _window, cx| { + editor.edit([(Point::new(6, 0)..Point::new(6, 0), "SEVEN")], cx); + }); + cx.run_until_parked(); + + leader_cx.assert_editor_state(indoc! { + r#" + ˇone + two + THREE + four + FIVE + six + SEVEN"# + }); + + follower_cx.assert_editor_state(indoc! { + r#" + ˇone + two + three + four + five + six + "# + }); + + leader_cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.refresh_selected_text_highlights(true, window, cx); + }); + leader_cx.run_until_parked(); +} + +#[gpui::test] +async fn test_filtered_editor_pair_complex(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let base_text = "base\n"; + let buffer_text = "buffer\n"; + + let buffer1 = cx.new(|cx| Buffer::local(buffer_text, cx)); + let diff1 = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer1, cx)); + + let extra_buffer_1 = cx.new(|cx| Buffer::local("dummy text 1\n", cx)); + let extra_diff_1 = cx.new(|cx| BufferDiff::new_with_base_text("", &extra_buffer_1, cx)); + let extra_buffer_2 = cx.new(|cx| Buffer::local("dummy text 2\n", cx)); + let extra_diff_2 = cx.new(|cx| BufferDiff::new_with_base_text("", &extra_buffer_2, cx)); + + let leader = cx.new(|cx| { + let mut leader = MultiBuffer::new(Capability::ReadWrite); + leader.set_all_diff_hunks_expanded(cx); + leader.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions)); + leader + }); + let follower = leader.update(cx, |leader, cx| leader.get_or_create_follower(cx)); + follower.update(cx, |follower, _| { + follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions)); + }); + + leader.update(cx, |leader, cx| { + leader.insert_excerpts_after( + ExcerptId::min(), + extra_buffer_2.clone(), + vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], + cx, + ); + leader.add_diff(extra_diff_2.clone(), cx); + + leader.insert_excerpts_after( + ExcerptId::min(), + extra_buffer_1.clone(), + vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], + cx, + ); + leader.add_diff(extra_diff_1.clone(), cx); + + leader.insert_excerpts_after( + ExcerptId::min(), + buffer1.clone(), + vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], + cx, + ); + leader.add_diff(diff1.clone(), cx); + }); + + cx.run_until_parked(); + let mut cx = cx.add_empty_window(); + + let leader_editor = cx + .new_window_entity(|window, cx| Editor::for_multibuffer(leader.clone(), None, window, cx)); + let follower_editor = cx.new_window_entity(|window, cx| { + Editor::for_multibuffer(follower.clone(), None, window, cx) + }); + + let mut leader_cx = EditorTestContext::for_editor_in(leader_editor.clone(), &mut cx).await; + leader_cx.assert_editor_state(indoc! {" + ˇbuffer + + dummy text 1 + + dummy text 2 + "}); + let mut follower_cx = EditorTestContext::for_editor_in(follower_editor.clone(), &mut cx).await; + follower_cx.assert_editor_state(indoc! {" + ˇbase + + + "}); +} + +#[gpui::test] +async fn test_multibuffer_scroll_cursor_top_margin(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("1\n2\n3\n", vec![Point::row_range(0..3)]), + ("1\n2\n3\n4\n5\n6\n7\n8\n9\n", vec![Point::row_range(0..9)]), + ], + cx, + ); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + ˇ1 + 2 + 3 + [EXCERPT] + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(None.into(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(19)..MultiBufferOffset(19)]); + }); + }); + + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2 + 3 + [EXCERPT] + 1 + 2 + 3 + 4 + 5 + 6 + ˇ7 + 8 + 9 + "}); + + cx.update_editor(|editor, _window, cx| { + editor.set_vertical_scroll_margin(0, cx); + }); + + cx.update_editor(|editor, window, cx| { + assert_eq!(editor.vertical_scroll_margin(), 0); + editor.scroll_cursor_top(&ScrollCursorTop, window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 12.0) + ); + }); + + cx.update_editor(|editor, _window, cx| { + editor.set_vertical_scroll_margin(3, cx); + }); + + cx.update_editor(|editor, window, cx| { + assert_eq!(editor.vertical_scroll_margin(), 3); + editor.scroll_cursor_top(&ScrollCursorTop, window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 9.0) + ); + }); +} + +#[gpui::test] +async fn test_find_references_single_case(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let before = indoc!( + r#" + fn main() { + let aˇbc = 123; + let xyz = abc; + } + "# + ); + let after = indoc!( + r#" + fn main() { + let abc = 123; + let xyz = ˇabc; + } + "# + ); + + cx.lsp + .set_request_handler::(async move |params, _| { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 11)), + }, + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(2, 14), lsp::Position::new(2, 17)), + }, + ])) + }); + + cx.set_state(before); + + let action = FindAllReferences { + always_open_multibuffer: false, + }; + + let navigated = cx + .update_editor(|editor, window, cx| editor.find_all_references(&action, window, cx)) + .expect("should have spawned a task") + .await + .unwrap(); + + assert_eq!(navigated, Navigated::No); + + cx.run_until_parked(); + + cx.assert_editor_state(after); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 479f7ef04c..8de660275b 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -8,9 +8,10 @@ use crate::{ HandleInput, HoveredCursor, InlayHintRefreshReason, JumpData, LineDown, LineHighlight, 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, ToggleFoldAll, + SelectedTextHighlight, Selection, SelectionDragState, SelectionEffects, SizingBehavior, + SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, + column_pixels, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, HighlightKey, HighlightedChunk, ToDisplayPoint, @@ -29,7 +30,7 @@ use crate::{ items::BufferSearchHighlights, mouse_context_menu::{self, MenuPosition}, scroll::{ - ActiveScrollbarState, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, + ActiveScrollbarState, Autoscroll, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, scroll_amount::ScrollAmount, }, }; @@ -47,11 +48,11 @@ use gpui::{ DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, - ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, - Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, - linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, - transparent_black, + MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, + Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, + Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity, + Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, + quad, relative, size, solid_background, transparent_black, }; use itertools::Itertools; use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting}; @@ -61,6 +62,7 @@ use multi_buffer::{ MultiBufferRow, RowInfo, }; +use edit_prediction_types::EditPredictionGranularity; use project::{ Entry, ProjectPath, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, @@ -74,6 +76,7 @@ use smallvec::{SmallVec, smallvec}; use std::{ any::TypeId, borrow::Cow, + cell::Cell, cmp::{self, Ordering}, fmt::{self, Write}, iter, mem, @@ -88,7 +91,7 @@ use text::{BufferId, SelectionGoal}; use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; use ui::utils::ensure_minimum_contrast; use ui::{ - ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*, + ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, prelude::*, right_click_menu, scrollbars::ShowScrollbar, text_for_keystroke, }; use unicode_segmentation::UnicodeSegmentation; @@ -130,6 +133,7 @@ impl SelectionLayout { fn new( selection: Selection, line_mode: bool, + cursor_offset: bool, cursor_shape: CursorShape, map: &DisplaySnapshot, is_newest: bool, @@ -150,12 +154,9 @@ impl SelectionLayout { } // any vim visual mode (including line mode) - if (cursor_shape == CursorShape::Block || cursor_shape == CursorShape::Hollow) - && !range.is_empty() - && !selection.reversed - { + if cursor_offset && !range.is_empty() && !selection.reversed { if head.column() > 0 { - head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left) + head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left); } else if head.row().0 > 0 && head != map.max_point() { head = map.clip_point( DisplayPoint::new( @@ -185,6 +186,13 @@ impl SelectionLayout { } } +#[derive(Default)] +struct RenderBlocksOutput { + blocks: Vec, + row_block_types: HashMap, + resized_blocks: Option>, +} + pub struct EditorElement { editor: Entity, style: EditorStyle, @@ -232,6 +240,8 @@ impl EditorElement { register_action(editor, window, Editor::blame_hover); register_action(editor, window, Editor::delete); register_action(editor, window, Editor::tab); + register_action(editor, window, Editor::next_snippet_tabstop); + register_action(editor, window, Editor::previous_snippet_tabstop); register_action(editor, window, Editor::backtab); register_action(editor, window, Editor::indent); register_action(editor, window, Editor::outdent); @@ -243,6 +253,8 @@ impl EditorElement { register_action(editor, window, Editor::sort_lines_case_insensitive); register_action(editor, window, Editor::reverse_lines); register_action(editor, window, Editor::shuffle_lines); + register_action(editor, window, Editor::rotate_selections_forward); + register_action(editor, window, Editor::rotate_selections_backward); register_action(editor, window, Editor::convert_indentation_to_spaces); register_action(editor, window, Editor::convert_indentation_to_tabs); register_action(editor, window, Editor::convert_to_upper_case); @@ -355,6 +367,7 @@ impl EditorElement { register_action(editor, window, Editor::split_selection_into_lines); register_action(editor, window, Editor::add_selection_above); register_action(editor, window, Editor::add_selection_below); + register_action(editor, window, Editor::insert_snippet_at_selections); register_action(editor, window, |editor, action, window, cx| { editor.select_next(action, window, cx).log_err(); }); @@ -432,6 +445,15 @@ impl EditorElement { register_action(editor, window, Editor::open_selected_filename); register_action(editor, window, Editor::fold); register_action(editor, window, Editor::fold_at_level); + register_action(editor, window, Editor::fold_at_level_1); + register_action(editor, window, Editor::fold_at_level_2); + register_action(editor, window, Editor::fold_at_level_3); + register_action(editor, window, Editor::fold_at_level_4); + register_action(editor, window, Editor::fold_at_level_5); + register_action(editor, window, Editor::fold_at_level_6); + register_action(editor, window, Editor::fold_at_level_7); + register_action(editor, window, Editor::fold_at_level_8); + register_action(editor, window, Editor::fold_at_level_9); register_action(editor, window, Editor::fold_all); register_action(editor, window, Editor::fold_function_bodies); register_action(editor, window, Editor::fold_recursive); @@ -449,7 +471,6 @@ impl EditorElement { register_action(editor, window, Editor::toggle_code_actions); register_action(editor, window, Editor::open_excerpts); register_action(editor, window, Editor::open_excerpts_in_split); - register_action(editor, window, Editor::open_proposed_changes_editor); register_action(editor, window, Editor::toggle_soft_wrap); register_action(editor, window, Editor::toggle_tab_bar); register_action(editor, window, Editor::toggle_line_numbers); @@ -484,8 +505,11 @@ impl EditorElement { register_action(editor, window, Editor::stage_and_next); register_action(editor, window, Editor::unstage_and_next); register_action(editor, window, Editor::expand_all_diff_hunks); + register_action(editor, window, Editor::collapse_all_diff_hunks); register_action(editor, window, Editor::go_to_previous_change); register_action(editor, window, Editor::go_to_next_change); + register_action(editor, window, Editor::go_to_prev_reference); + register_action(editor, window, Editor::go_to_next_reference); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.format(action, window, cx) { @@ -580,7 +604,8 @@ impl EditorElement { register_action(editor, window, Editor::display_cursor_names); register_action(editor, window, Editor::unique_lines_case_insensitive); register_action(editor, window, Editor::unique_lines_case_sensitive); - register_action(editor, window, Editor::accept_partial_edit_prediction); + register_action(editor, window, Editor::accept_next_word_edit_prediction); + register_action(editor, window, Editor::accept_next_line_edit_prediction); register_action(editor, window, Editor::accept_edit_prediction); register_action(editor, window, Editor::restore_file); register_action(editor, window, Editor::git_restore); @@ -642,7 +667,6 @@ impl EditorElement { fn mouse_left_down( editor: &mut Editor, event: &MouseDownEvent, - hovered_hunk: Option>, position_map: &PositionMap, line_numbers: &HashMap, window: &mut Window, @@ -658,7 +682,20 @@ impl EditorElement { let mut click_count = event.click_count; let mut modifiers = event.modifiers; - if let Some(hovered_hunk) = hovered_hunk { + if let Some(hovered_hunk) = + position_map + .display_hunks + .iter() + .find_map(|(hunk, hunk_hitbox)| match hunk { + DisplayDiffHunk::Folded { .. } => None, + DisplayDiffHunk::Unfolded { + multi_buffer_range, .. + } => hunk_hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.is_hovered(window)) + .then(|| multi_buffer_range.clone()), + }) + { editor.toggle_single_diff_hunk(hovered_hunk, cx); cx.notify(); return; @@ -672,6 +709,7 @@ impl EditorElement { .drag_and_drop_selection .enabled && click_count == 1 + && !modifiers.shift { let newest_anchor = editor.selections.newest_anchor(); let snapshot = editor.snapshot(window, cx); @@ -730,6 +768,41 @@ impl EditorElement { } } + if !is_singleton { + let display_row = (ScrollPixelOffset::from( + (event.position - gutter_hitbox.bounds.origin).y / position_map.line_height, + ) + position_map.scroll_position.y) as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) + .row; + if line_numbers + .get(&MultiBufferRow(multi_buffer_row)) + .is_some_and(|line_layout| { + line_layout.segments.iter().any(|segment| { + segment + .hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.contains(&event.position)) + }) + }) + { + let line_offset_from_top = display_row - position_map.scroll_position.y as u32; + + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow { + row: MultiBufferRow(multi_buffer_row), + line_offset_from_top, + }), + modifiers.alt, + window, + cx, + ); + cx.stop_propagation(); + return; + } + } + let position = point_for_position.previous_valid; if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) { editor.select( @@ -759,7 +832,7 @@ impl EditorElement { editor.select( SelectPhase::Begin { position, - add: Editor::multi_cursor_modifier(true, &modifiers, cx), + add: Editor::is_alt_pressed(&modifiers, cx), click_count, }, window, @@ -767,34 +840,6 @@ impl EditorElement { ); } cx.stop_propagation(); - - if !is_singleton { - let display_row = (ScrollPixelOffset::from( - (event.position - gutter_hitbox.bounds.origin).y / position_map.line_height, - ) + position_map.scroll_position.y) as u32; - let multi_buffer_row = position_map - .snapshot - .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) - .row; - if line_numbers - .get(&MultiBufferRow(multi_buffer_row)) - .and_then(|line_number| line_number.hitbox.as_ref()) - .is_some_and(|hitbox| hitbox.contains(&event.position)) - { - let line_offset_from_top = display_row - position_map.scroll_position.y as u32; - - editor.open_excerpts_common( - Some(JumpData::MultiBufferRow { - row: MultiBufferRow(multi_buffer_row), - line_offset_from_top, - }), - modifiers.alt, - window, - cx, - ); - cx.stop_propagation(); - } - } } fn mouse_right_down( @@ -971,11 +1016,17 @@ impl EditorElement { let text_hitbox = &position_map.text_hitbox; let pending_nonempty_selections = editor.has_pending_nonempty_selection(); - let hovered_link_modifier = Editor::multi_cursor_modifier(false, &event.modifiers(), cx); + let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&event.modifiers(), cx); + let mouse_down_hovered_link_modifier = if let ClickEvent::Mouse(mouse_event) = event { + Editor::is_cmd_or_ctrl_pressed(&mouse_event.down.modifiers, cx) + } else { + true + }; if let Some(mouse_position) = event.mouse_position() && !pending_nonempty_selections && hovered_link_modifier + && mouse_down_hovered_link_modifier && text_hitbox.is_hovered(window) { let point = position_map.point_for_position(mouse_position); @@ -986,6 +1037,28 @@ impl EditorElement { } } + fn pressure_click( + editor: &mut Editor, + event: &MousePressureEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let force_click_possible = + matches!(editor.prev_pressure_stage, Some(PressureStage::Normal)) + && event.stage == PressureStage::Force; + + editor.prev_pressure_stage = Some(event.stage); + + if force_click_possible && text_hitbox.is_hovered(window) { + let point = position_map.point_for_position(event.position); + editor.handle_click_hovered_link(point, event.modifiers, window, cx); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + } + } + fn mouse_dragged( editor: &mut Editor, event: &MouseMoveEvent, @@ -1059,7 +1132,10 @@ impl EditorElement { ref mouse_down_time, } => { let drag_and_drop_delay = Duration::from_millis( - EditorSettings::get_global(cx).drag_and_drop_selection.delay, + EditorSettings::get_global(cx) + .drag_and_drop_selection + .delay + .0, ); if mouse_down_time.elapsed() >= drag_and_drop_delay { let drop_cursor = Selection { @@ -1114,7 +1190,7 @@ impl EditorElement { } } - fn mouse_moved( + pub(crate) fn mouse_moved( editor: &mut Editor, event: &MouseMoveEvent, position_map: &PositionMap, @@ -1125,7 +1201,7 @@ impl EditorElement { let gutter_hitbox = &position_map.gutter_hitbox; let modifiers = event.modifiers; let text_hovered = text_hitbox.is_hovered(window); - let gutter_hovered = gutter_hitbox.is_hovered(window); + let gutter_hovered = gutter_hitbox.bounds.contains(&event.position); editor.set_gutter_hovered(gutter_hovered, cx); editor.show_mouse_cursor(cx); @@ -1180,10 +1256,16 @@ impl EditorElement { if mouse_over_inline_blame || mouse_over_popover { editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx); } else if !keyboard_grace { - editor.hide_blame_popover(cx); + editor.hide_blame_popover(false, cx); } } else { - editor.hide_blame_popover(cx); + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .is_some_and(|state| state.keyboard_grace); + if !keyboard_grace { + editor.hide_blame_popover(false, cx); + } } let breakpoint_indicator = if gutter_hovered { @@ -1280,7 +1362,14 @@ impl EditorElement { hover_at(editor, Some(anchor), window, cx); Self::update_visible_cursor(editor, point, position_map, window, cx); } else { - hover_at(editor, None, window, cx); + editor.update_inlay_link_and_hover_points( + &position_map.snapshot, + point_for_position, + modifiers.secondary(), + modifiers.shift, + window, + cx, + ); } } else { editor.hide_hovered_link(cx); @@ -1366,7 +1455,7 @@ impl EditorElement { editor_with_selections.update(cx, |editor, cx| { if editor.show_local_selections { let mut layouts = Vec::new(); - let newest = editor.selections.newest(cx); + let newest = editor.selections.newest(&editor.display_snapshot(cx)); for selection in local_selections.iter().cloned() { let is_empty = selection.start == selection.end; let is_newest = selection == newest; @@ -1374,6 +1463,7 @@ impl EditorElement { let layout = SelectionLayout::new( selection, editor.selections.line_mode(), + editor.cursor_offset_on_selection, editor.cursor_shape, &snapshot.display_snapshot, is_newest, @@ -1395,7 +1485,11 @@ impl EditorElement { layouts.push(layout); } - let player = editor.current_user_player_color(cx); + let mut player = editor.current_user_player_color(cx); + if !editor.is_focused(window) { + const UNFOCUS_EDITOR_SELECTION_OPACITY: f32 = 0.5; + player.selection = player.selection.opacity(UNFOCUS_EDITOR_SELECTION_OPACITY); + } selections.push((player, layouts)); if let SelectionDragState::Dragging { @@ -1416,6 +1510,7 @@ impl EditorElement { let drag_cursor_layout = SelectionLayout::new( drop_cursor.clone(), false, + editor.cursor_offset_on_selection, CursorShape::Bar, &snapshot.display_snapshot, false, @@ -1479,6 +1574,7 @@ impl EditorElement { .push(SelectionLayout::new( selection.selection, selection.line_mode, + editor.cursor_offset_on_selection, selection.cursor_shape, &snapshot.display_snapshot, false, @@ -1489,6 +1585,8 @@ impl EditorElement { selections.extend(remote_selections.into_values()); } else if !editor.is_focused(window) && editor.show_cursor_when_unfocused { + let cursor_offset_on_selection = editor.cursor_offset_on_selection; + let layouts = snapshot .buffer_snapshot() .selections_in_range(&(start_anchor..end_anchor), true) @@ -1496,6 +1594,7 @@ impl EditorElement { SelectionLayout::new( selection, line_mode, + cursor_offset_on_selection, cursor_shape, &snapshot.display_snapshot, false, @@ -1656,9 +1755,7 @@ impl EditorElement { len, font, color, - background_color: None, - strikethrough: None, - underline: None, + ..Default::default() }], None, ) @@ -2207,7 +2304,8 @@ impl EditorElement { }; let padding = ProjectSettings::get_global(cx).diagnostics.inline.padding as f32 * em_width; - let min_x = self.column_pixels( + let min_x = column_pixels( + &self.style, ProjectSettings::get_global(cx) .diagnostics .inline @@ -2281,7 +2379,7 @@ impl EditorElement { .opacity(0.05)) .text_color(severity_to_color(&diagnostic_to_render.severity).color(cx)) .text_sm() - .font_family(style.text.font().family) + .font(style.text.font()) .child(diagnostic_to_render.message.clone()) .into_any(); @@ -2458,7 +2556,6 @@ impl EditorElement { scroll_position: gpui::Point, scroll_pixel_position: gpui::Point, line_height: Pixels, - text_hitbox: &Hitbox, window: &mut Window, cx: &mut App, ) -> Option { @@ -2511,7 +2608,8 @@ impl EditorElement { let padded_line_end = line_end + padding; - let min_column_in_pixels = self.column_pixels( + let min_column_in_pixels = column_pixels( + &self.style, ProjectSettings::get_global(cx).git.inline_blame.min_column as usize, window, ); @@ -2527,16 +2625,6 @@ impl EditorElement { let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); let bounds = Bounds::new(absolute_offset, size); - self.layout_blame_entry_popover( - entry.clone(), - blame, - line_height, - text_hitbox, - row_info.buffer_id?, - window, - cx, - ); - element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), window, cx); Some(InlineBlameLayout { @@ -2547,16 +2635,48 @@ impl EditorElement { }) } - fn layout_blame_entry_popover( + fn layout_blame_popover( &self, - blame_entry: BlameEntry, - blame: Entity, - line_height: Pixels, + editor_snapshot: &EditorSnapshot, text_hitbox: &Hitbox, - buffer: BufferId, + line_height: Pixels, window: &mut Window, cx: &mut App, ) { + if !self.editor.read(cx).inline_blame_popover.is_some() { + return; + } + + let Some(blame) = self.editor.read(cx).blame.clone() else { + return; + }; + let cursor_point = self + .editor + .read(cx) + .selections + .newest::(&editor_snapshot.display_snapshot) + .head(); + + let Some((buffer, buffer_point, _)) = editor_snapshot + .buffer_snapshot() + .point_to_buffer_point(cursor_point) + else { + return; + }; + + let row_info = RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(buffer_point.row), + ..Default::default() + }; + + let Some((buffer_id, blame_entry)) = blame + .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next()) + .flatten() + else { + return; + }; + let Some((popover_state, target_point)) = self.editor.read_with(cx, |editor, _| { editor .inline_blame_popover @@ -2578,7 +2698,7 @@ impl EditorElement { popover_state.markdown, workspace, &blame, - buffer, + buffer_id, window, cx, ) @@ -2713,7 +2833,7 @@ impl EditorElement { .enumerate() .filter_map(|(i, indent_guide)| { let single_indent_width = - self.column_pixels(indent_guide.tab_size as usize, window); + column_pixels(&self.style, indent_guide.tab_size as usize, window); let total_width = single_indent_width * indent_guide.depth as f32; let start_x = Pixels::from( ScrollOffset::from(content_origin.x + total_width) @@ -2770,7 +2890,7 @@ impl EditorElement { .wrap_guides(cx) .into_iter() .flat_map(|(guide, active)| { - let wrap_position = self.column_pixels(guide, window); + let wrap_position = column_pixels(&self.style, 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; @@ -3116,6 +3236,7 @@ impl EditorElement { snapshot: &EditorSnapshot, rows: &Range, relative_to: Option, + count_wrapped_lines: bool, ) -> HashMap { let mut relative_rows: HashMap = Default::default(); let Some(relative_to) = relative_to else { @@ -3133,8 +3254,15 @@ impl EditorElement { let head_idx = relative_to.minus(start); let mut delta = 1; let mut i = head_idx + 1; + let should_count_line = |row_info: &RowInfo| { + if count_wrapped_lines { + row_info.buffer_row.is_some() || row_info.wrapped_buffer_row.is_some() + } else { + row_info.buffer_row.is_some() + } + }; while i < buffer_rows.len() as u32 { - if buffer_rows[i as usize].buffer_row.is_some() { + if should_count_line(&buffer_rows[i as usize]) { if rows.contains(&DisplayRow(i + start.0)) { relative_rows.insert(DisplayRow(i + start.0), delta); } @@ -3143,14 +3271,14 @@ impl EditorElement { i += 1; } delta = 1; - i = head_idx.min(buffer_rows.len() as u32 - 1); - while i > 0 && buffer_rows[i as usize].buffer_row.is_none() { + i = head_idx.min(buffer_rows.len().saturating_sub(1) as u32); + while i > 0 && buffer_rows[i as usize].buffer_row.is_none() && !count_wrapped_lines { i -= 1; } while i > 0 { i -= 1; - if buffer_rows[i as usize].buffer_row.is_some() { + if should_count_line(&buffer_rows[i as usize]) { if rows.contains(&DisplayRow(i + start.0)) { relative_rows.insert(DisplayRow(i + start.0), delta); } @@ -3182,12 +3310,15 @@ impl EditorElement { return Arc::default(); } - let (newest_selection_head, is_relative) = self.editor.update(cx, |editor, cx| { + let (newest_selection_head, relative) = self.editor.update(cx, |editor, cx| { let newest_selection_head = newest_selection_head.unwrap_or_else(|| { - let newest = editor.selections.newest::(cx); + let newest = editor + .selections + .newest::(&editor.display_snapshot(cx)); SelectionLayout::new( newest, editor.selections.line_mode(), + editor.cursor_offset_on_selection, editor.cursor_shape, &snapshot.display_snapshot, true, @@ -3196,79 +3327,95 @@ impl EditorElement { ) .head }); - let is_relative = editor.should_use_relative_line_numbers(cx); - (newest_selection_head, is_relative) + let relative = editor.relative_line_numbers(cx); + (newest_selection_head, relative) }); - let relative_to = if is_relative { - Some(newest_selection_head.row()) - } else { - None - }; - let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to); + let relative_line_numbers_enabled = relative.enabled(); + let relative_to = relative_line_numbers_enabled.then(|| newest_selection_head.row()); + + let relative_rows = + self.calculate_relative_line_numbers(snapshot, &rows, relative_to, relative.wrapped()); let mut line_number = String::new(); - let line_numbers = buffer_rows - .iter() - .enumerate() - .flat_map(|(ix, row_info)| { - let display_row = DisplayRow(rows.start.0 + ix as u32); - line_number.clear(); - let non_relative_number = row_info.buffer_row? + 1; - let number = relative_rows - .get(&display_row) - .unwrap_or(&non_relative_number); - write!(&mut line_number, "{number}").unwrap(); - if row_info + let segments = buffer_rows.iter().enumerate().flat_map(|(ix, row_info)| { + let display_row = DisplayRow(rows.start.0 + ix as u32); + line_number.clear(); + let non_relative_number = if relative.wrapped() { + row_info.buffer_row.or(row_info.wrapped_buffer_row)? + 1 + } else if self.editor.read(cx).use_base_text_line_numbers { + row_info.base_text_row?.0 + 1 + } else { + row_info.buffer_row? + 1 + }; + let relative_number = relative_rows.get(&display_row); + if !(relative_line_numbers_enabled && relative_number.is_some()) + && row_info .diff_status .is_some_and(|status| status.is_deleted()) - { - return None; - } + && !self.editor.read(cx).use_base_text_line_numbers + { + return None; + } - let color = active_rows - .get(&display_row) - .map(|spec| { - if spec.breakpoint { - cx.theme().colors().debugger_accent - } else { - cx.theme().colors().editor_active_line_number - } - }) - .unwrap_or_else(|| cx.theme().colors().editor_line_number); - let shaped_line = - self.shape_line_number(SharedString::from(&line_number), color, window); - let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height); - let line_origin = gutter_hitbox.map(|hitbox| { - hitbox.origin - + point( - hitbox.size.width - shaped_line.width - gutter_dimensions.right_padding, - ix as f32 * line_height - - Pixels::from(scroll_top % ScrollPixelOffset::from(line_height)), - ) - }); + let number = relative_number.unwrap_or(&non_relative_number); + write!(&mut line_number, "{number}").unwrap(); - #[cfg(not(test))] - let hitbox = line_origin.map(|line_origin| { - window.insert_hitbox( - Bounds::new(line_origin, size(shaped_line.width, line_height)), - HitboxBehavior::Normal, + let color = active_rows + .get(&display_row) + .map(|spec| { + if spec.breakpoint { + cx.theme().colors().debugger_accent + } else { + cx.theme().colors().editor_active_line_number + } + }) + .unwrap_or_else(|| cx.theme().colors().editor_line_number); + let shaped_line = + self.shape_line_number(SharedString::from(&line_number), color, window); + let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height); + let line_origin = gutter_hitbox.map(|hitbox| { + hitbox.origin + + point( + hitbox.size.width - shaped_line.width - gutter_dimensions.right_padding, + ix as f32 * line_height + - Pixels::from(scroll_top % ScrollPixelOffset::from(line_height)), ) - }); - #[cfg(test)] - let hitbox = { - let _ = line_origin; - None - }; + }); - let multi_buffer_row = DisplayPoint::new(display_row, 0).to_point(snapshot).row; - let multi_buffer_row = MultiBufferRow(multi_buffer_row); - let line_number = LineNumberLayout { - shaped_line, - hitbox, - }; - Some((multi_buffer_row, line_number)) - }) - .collect(); + #[cfg(not(test))] + let hitbox = line_origin.map(|line_origin| { + window.insert_hitbox( + Bounds::new(line_origin, size(shaped_line.width, line_height)), + HitboxBehavior::Normal, + ) + }); + #[cfg(test)] + let hitbox = { + let _ = line_origin; + None + }; + + let segment = LineNumberSegment { + shaped_line, + hitbox, + }; + + let buffer_row = DisplayPoint::new(display_row, 0).to_point(snapshot).row; + let multi_buffer_row = MultiBufferRow(buffer_row); + + Some((multi_buffer_row, segment)) + }); + + let mut line_numbers: HashMap = HashMap::default(); + for (buffer_row, segment) in segments { + line_numbers + .entry(buffer_row) + .or_insert_with(|| LineNumberLayout { + segments: Default::default(), + }) + .segments + .push(segment); + } Arc::new(line_numbers) } @@ -3514,9 +3661,7 @@ impl EditorElement { len: line.len(), font: style.text.font(), color: placeholder_color, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let line = window.text_system().shape_line( line.to_string().into(), @@ -3598,8 +3743,10 @@ impl EditorElement { row_block_types: &mut HashMap, selections: &[Selection], selected_buffer_ids: &Vec, + latest_selection_anchors: &HashMap, is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, sticky_header_excerpt_id: Option, + block_resize_offset: &mut i32, window: &mut Window, cx: &mut App, ) -> Option<(AnyElement, Size, DisplayRow, Pixels)> { @@ -3673,7 +3820,13 @@ impl EditorElement { let selected = selected_buffer_ids.contains(&first_excerpt.buffer_id); let result = v_flex().id(block_id).w_full().pr(editor_margins.right); - let jump_data = header_jump_data(snapshot, block_row_start, *height, first_excerpt); + let jump_data = header_jump_data( + snapshot, + block_row_start, + *height, + first_excerpt, + latest_selection_anchors, + ); result .child(self.render_buffer_header( first_excerpt, @@ -3708,7 +3861,13 @@ impl EditorElement { Block::BufferHeader { excerpt, height } => { let mut result = v_flex().id(block_id).w_full(); - let jump_data = header_jump_data(snapshot, block_row_start, *height, excerpt); + let jump_data = header_jump_data( + snapshot, + block_row_start, + *height, + excerpt, + latest_selection_anchors, + ); if sticky_header_excerpt_id != Some(excerpt.id) { let selected = selected_buffer_ids.contains(&excerpt.buffer_id); @@ -3741,7 +3900,10 @@ impl EditorElement { }; let mut element_height_in_lines = ((final_size.height / line_height).ceil() as u32).max(1); - let mut row = block_row_start; + let effective_row_start = block_row_start.0 as i32 + *block_resize_offset; + debug_assert!(effective_row_start >= 0); + let mut row = DisplayRow(effective_row_start.max(0) as u32); + let mut x_offset = px(0.); let mut is_block = true; @@ -3771,6 +3933,7 @@ impl EditorElement { } }; if element_height_in_lines != block.height() { + *block_resize_offset += element_height_in_lines as i32 - block.height() as i32; resized_blocks.insert(custom_block_id, element_height_in_lines); } } @@ -3793,15 +3956,11 @@ impl EditorElement { ) -> impl IntoElement { let editor = self.editor.read(cx); let multi_buffer = editor.buffer.read(cx); + let is_read_only = self.editor.read(cx).read_only(cx); + let file_status = multi_buffer .all_diff_hunks_expanded() - .then(|| { - editor - .project - .as_ref()? - .read(cx) - .status_for_buffer_id(for_excerpt.buffer_id, cx) - }) + .then(|| editor.status_for_buffer_id(for_excerpt.buffer_id, cx)) .flatten(); let indicator = multi_buffer .buffer(for_excerpt.buffer_id) @@ -3844,14 +4003,14 @@ impl EditorElement { .child( h_flex() .size_full() - .gap_2() .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) - .pl_0p5() - .pr_5() + .pl_1() + .pr_2() .rounded_sm() + .gap_1p5() .when(is_sticky, |el| el.shadow_md()) .border_1() - .map(|div| { + .map(|border| { let border_color = if is_selected && is_folded && focus_handle.contains_focused(window, cx) @@ -3860,7 +4019,7 @@ impl EditorElement { } else { colors.border }; - div.border_color(border_color) + border.border_color(border_color) }) .bg(colors.editor_subheader_background) .hover(|style| style.bg(colors.element_hover)) @@ -3869,21 +4028,28 @@ impl EditorElement { let buffer_id = for_excerpt.buffer_id; let toggle_chevron_icon = FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); + let button_size = rems_from_px(28.); + header.child( div() .hover(|style| style.bg(colors.element_selected)) .rounded_xs() .child( ButtonLike::new("toggle-buffer-fold") - .style(ui::ButtonStyle::Transparent) - .height(px(28.).into()) - .width(px(28.)) + .style(ButtonStyle::Transparent) + .height(button_size.into()) + .width(button_size) .children(toggle_chevron_icon) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + let is_folded_for_tooltip = is_folded; + move |_window, cx| { Tooltip::with_meta_in( - "Toggle Excerpt Fold", + if is_folded_for_tooltip { + "Unfold Excerpt" + } else { + "Fold Excerpt" + }, Some(&ToggleFold), format!( "{} to toggle all", @@ -3894,7 +4060,6 @@ impl EditorElement { ) ), &focus_handle, - window, cx, ) } @@ -3934,88 +4099,113 @@ impl EditorElement { }) .take(1), ) - .child(h_flex().size(px(12.0)).justify_center().children(indicator)) + .when(!is_read_only, |this| { + this.child( + h_flex() + .size_3() + .justify_center() + .flex_shrink_0() + .children(indicator), + ) + }) .child( h_flex() .cursor_pointer() - .id("path header block") + .id("path_header_block") + .min_w_0() .size_full() .justify_between() .overflow_hidden() - .child( - h_flex() - .gap_2() - .map(|path_header| { - let filename = filename - .map(SharedString::from) - .unwrap_or_else(|| "untitled".into()); + .child(h_flex().min_w_0().flex_1().gap_0p5().map(|path_header| { + let filename = filename + .map(SharedString::from) + .unwrap_or_else(|| "untitled".into()); - path_header - .when(ItemSettings::get_global(cx).file_icons, |el| { - let path = path::Path::new(filename.as_str()); - let icon = FileIcons::get_icon(path, cx) - .unwrap_or_default(); - let icon = - Icon::from_path(icon).color(Color::Muted); - el.child(icon) - }) - .child(Label::new(filename).single_line().when_some( - file_status, - |el, status| { - el.color(if status.is_conflicted() { - Color::Conflict - } else if status.is_modified() { - Color::Modified - } else if status.is_deleted() { - Color::Disabled - } else { - Color::Created - }) - .when(status.is_deleted(), |el| { - el.strikethrough() - }) - }, - )) + path_header + .when(ItemSettings::get_global(cx).file_icons, |el| { + let path = path::Path::new(filename.as_str()); + let icon = + FileIcons::get_icon(path, cx).unwrap_or_default(); + + el.child(Icon::from_path(icon).color(Color::Muted)) }) + .child( + ButtonLike::new("filename-button") + .child( + Label::new(filename) + .single_line() + .color(file_status_label_color(file_status)) + .when( + file_status.is_some_and(|s| s.is_deleted()), + |label| label.strikethrough(), + ), + ) + .on_click(window.listener_for(&self.editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ) .when_some(parent_path, |then, path| { - then.child(div().child(path).text_color( + then.child(Label::new(path).truncate().color( if file_status.is_some_and(FileStatus::is_deleted) { - colors.text_disabled + Color::Custom(colors.text_disabled) } else { - colors.text_muted + Color::Custom(colors.text_muted) }, )) - }), - ) + }) + })) .when( can_open_excerpts && is_selected && relative_path.is_some(), |el| { el.child( - h_flex() - .id("jump-to-file-button") - .gap_2p5() - .child(Label::new("Jump To File")) - .children( - KeyBinding::for_action_in( - &OpenExcerpts, - &focus_handle, - window, - cx, - ) - .map(|binding| binding.into_any_element()), - ), + Button::new("open-file-button", "Open File") + .style(ButtonStyle::OutlinedGhost) + .key_binding(KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, + cx, + )) + .on_click(window.listener_for(&self.editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), ) }, ) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(window.listener_for(&self.editor, { + let buffer_id = for_excerpt.buffer_id; move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); + if e.modifiers().alt { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + return; + } + + if is_folded { + editor.unfold_buffer(buffer_id, cx); + } else { + editor.fold_buffer(buffer_id, cx); + } } })), ), @@ -4023,6 +4213,7 @@ impl EditorElement { let file = for_excerpt.buffer.file().cloned(); let editor = self.editor.clone(); + right_click_menu("buffer-header-context-menu") .trigger(move |_, _, _| header) .menu(move |window, cx| { @@ -4138,11 +4329,12 @@ impl EditorElement { line_layouts: &mut [LineWithInvisibles], selections: &[Selection], selected_buffer_ids: &Vec, + latest_selection_anchors: &HashMap, is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, sticky_header_excerpt_id: Option, window: &mut Window, cx: &mut App, - ) -> Result<(Vec, HashMap), HashMap> { + ) -> RenderBlocksOutput { let (fixed_blocks, non_fixed_blocks) = snapshot .blocks_in_range(rows.clone()) .partition::, _>(|(_, block)| block.style() == BlockStyle::Fixed); @@ -4154,6 +4346,7 @@ impl EditorElement { let mut blocks = Vec::new(); let mut resized_blocks = HashMap::default(); let mut row_block_types = HashMap::default(); + let mut block_resize_offset: i32 = 0; for (row, block) in fixed_blocks { let block_id = block.id(); @@ -4181,8 +4374,10 @@ impl EditorElement { &mut row_block_types, selections, selected_buffer_ids, + latest_selection_anchors, is_row_soft_wrapped, sticky_header_excerpt_id, + &mut block_resize_offset, window, cx, ) { @@ -4238,8 +4433,10 @@ impl EditorElement { &mut row_block_types, selections, selected_buffer_ids, + latest_selection_anchors, is_row_soft_wrapped, sticky_header_excerpt_id, + &mut block_resize_offset, window, cx, ) { @@ -4293,8 +4490,10 @@ impl EditorElement { &mut row_block_types, selections, selected_buffer_ids, + latest_selection_anchors, is_row_soft_wrapped, sticky_header_excerpt_id, + &mut block_resize_offset, window, cx, ) { @@ -4314,9 +4513,12 @@ impl EditorElement { if resized_blocks.is_empty() { *scroll_width = (*scroll_width).max(fixed_block_max_width - editor_margins.gutter.width); - Ok((blocks, row_block_types)) - } else { - Err(resized_blocks) + } + + RenderBlocksOutput { + blocks, + row_block_types, + resized_blocks: (!resized_blocks.is_empty()).then_some(resized_blocks), } } @@ -4375,6 +4577,7 @@ impl EditorElement { hitbox: &Hitbox, selected_buffer_ids: &Vec, blocks: &[BlockLayout], + latest_selection_anchors: &HashMap, window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -4383,6 +4586,7 @@ impl EditorElement { DisplayRow(scroll_position.y as u32), FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, excerpt, + latest_selection_anchors, ); let editor_bg_color = cx.theme().colors().editor_background; @@ -4442,6 +4646,140 @@ impl EditorElement { header } + fn layout_sticky_headers( + &self, + snapshot: &EditorSnapshot, + editor_width: Pixels, + is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + gutter_dimensions: &GutterDimensions, + gutter_hitbox: &Hitbox, + text_hitbox: &Hitbox, + style: &EditorStyle, + window: &mut Window, + cx: &mut App, + ) -> Option { + let show_line_numbers = snapshot + .show_line_numbers + .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); + + let rows = Self::sticky_headers(self.editor.read(cx), snapshot, style, cx); + + let mut lines = Vec::::new(); + + for StickyHeader { + item, + sticky_row, + start_point, + offset, + } in rows.into_iter().rev() + { + let line = layout_line( + sticky_row, + snapshot, + &self.style, + editor_width, + is_row_soft_wrapped, + window, + cx, + ); + + let line_number = show_line_numbers.then(|| { + let number = (start_point.row + 1).to_string(); + let color = cx.theme().colors().editor_line_number; + self.shape_line_number(SharedString::from(number), color, window) + }); + + lines.push(StickyHeaderLine::new( + sticky_row, + line_height * offset as f32, + line, + line_number, + item.range.start, + line_height, + scroll_pixel_position, + content_origin, + gutter_hitbox, + text_hitbox, + window, + cx, + )); + } + + lines.reverse(); + if lines.is_empty() { + return None; + } + + Some(StickyHeaders { + lines, + gutter_background: cx.theme().colors().editor_gutter_background, + content_background: self.style.background, + gutter_right_padding: gutter_dimensions.right_padding, + }) + } + + pub(crate) fn sticky_headers( + editor: &Editor, + snapshot: &EditorSnapshot, + style: &EditorStyle, + cx: &App, + ) -> Vec { + let scroll_top = snapshot.scroll_position().y; + + let mut end_rows = Vec::::new(); + let mut rows = Vec::::new(); + + let items = editor.sticky_headers(style, cx).unwrap_or_default(); + + for item in items { + let start_point = item.range.start.to_point(snapshot.buffer_snapshot()); + let end_point = item.range.end.to_point(snapshot.buffer_snapshot()); + + let sticky_row = snapshot + .display_snapshot + .point_to_display_point(start_point, Bias::Left) + .row(); + let end_row = snapshot + .display_snapshot + .point_to_display_point(end_point, Bias::Left) + .row(); + let max_sticky_row = end_row.previous_row(); + if max_sticky_row <= sticky_row { + continue; + } + + while end_rows + .last() + .is_some_and(|&last_end| last_end < sticky_row) + { + end_rows.pop(); + } + let depth = end_rows.len(); + let adjusted_scroll_top = scroll_top + depth as f64; + + if sticky_row.as_f64() >= adjusted_scroll_top || end_row.as_f64() <= adjusted_scroll_top + { + continue; + } + + let max_scroll_offset = max_sticky_row.as_f64() - scroll_top; + let offset = (depth as f64).min(max_scroll_offset); + + end_rows.push(end_row); + rows.push(StickyHeader { + item, + sticky_row, + start_point, + offset, + }); + } + + rows + } + fn layout_cursor_popovers( &self, line_height: Pixels, @@ -4564,8 +4902,11 @@ impl EditorElement { let edit_prediction = if edit_prediction_popover_visible { self.editor.update(cx, move |editor, cx| { - let accept_binding = - editor.accept_edit_prediction_keybind(false, window, cx); + let accept_binding = editor.accept_edit_prediction_keybind( + EditPredictionGranularity::Full, + window, + cx, + ); let mut element = editor.render_edit_prediction_cursor_popover( min_width, max_width, @@ -4957,7 +5298,7 @@ impl EditorElement { ) -> Option { let max_height_in_lines = ((height - POPOVER_Y_PADDING) / line_height).floor() as u32; self.editor.update(cx, |editor, cx| { - editor.render_context_menu(&self.style, max_height_in_lines, window, cx) + editor.render_context_menu(max_height_in_lines, window, cx) }) } @@ -4984,16 +5325,18 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> Option { - let position = self.editor.update(cx, |editor, _cx| { + let position = self.editor.update(cx, |editor, cx| { let visible_start_point = editor.display_to_pixel_point( DisplayPoint::new(visible_range.start, 0), editor_snapshot, window, + cx, )?; let visible_end_point = editor.display_to_pixel_point( DisplayPoint::new(visible_range.end, 0), editor_snapshot, window, + cx, )?; let mouse_context_menu = editor.mouse_context_menu.as_ref()?; @@ -5001,7 +5344,8 @@ impl EditorElement { MenuPosition::PinnedToScreen(point) => (None, point), MenuPosition::PinnedToEditor { source, offset } => { let source_display_point = source.to_display_point(editor_snapshot); - let source_point = editor.to_pixel_point(source, editor_snapshot, window)?; + let source_point = + editor.to_pixel_point(source, editor_snapshot, window, cx)?; let position = content_origin + source_point + offset; (Some(source_display_point), position) } @@ -5084,23 +5428,26 @@ impl EditorElement { snapshot, visible_display_row_range.clone(), max_size, + &editor.text_layout_details(window), window, cx, ) }); - let Some((position, hover_popovers)) = hover_popovers else { + let Some((popover_position, hover_popovers)) = hover_popovers else { return; }; // This is safe because we check on layout whether the required row is available - let hovered_row_layout = - &line_layouts[position.row().minus(visible_display_row_range.start) as usize]; + let hovered_row_layout = &line_layouts[popover_position + .row() + .minus(visible_display_row_range.start) + as usize]; // Compute Hovered Point - let x = hovered_row_layout.x_for_index(position.column() as usize) + let x = hovered_row_layout.x_for_index(popover_position.column() as usize) - Pixels::from(scroll_pixel_position.x); let y = Pixels::from( - position.row().as_f64() * ScrollPixelOffset::from(line_height) + popover_position.row().as_f64() * ScrollPixelOffset::from(line_height) - scroll_pixel_position.y, ); let hovered_point = content_origin + point(x, y); @@ -5305,6 +5652,50 @@ impl EditorElement { } } + fn layout_word_diff_highlights( + display_hunks: &[(DisplayDiffHunk, Option)], + row_infos: &[RowInfo], + start_row: DisplayRow, + snapshot: &EditorSnapshot, + highlighted_ranges: &mut Vec<(Range, Hsla)>, + cx: &mut App, + ) { + let colors = cx.theme().colors(); + + let word_highlights = display_hunks + .into_iter() + .filter_map(|(hunk, _)| match hunk { + DisplayDiffHunk::Unfolded { + word_diffs, status, .. + } => Some((word_diffs, status)), + _ => None, + }) + .filter(|(_, status)| status.is_modified()) + .flat_map(|(word_diffs, _)| word_diffs) + .filter_map(|word_diff| { + let start_point = word_diff.start.to_display_point(&snapshot.display_snapshot); + let end_point = word_diff.end.to_display_point(&snapshot.display_snapshot); + let start_row_offset = start_point.row().0.saturating_sub(start_row.0) as usize; + + row_infos + .get(start_row_offset) + .and_then(|row_info| row_info.diff_status) + .and_then(|diff_status| { + let background_color = match diff_status.kind { + DiffHunkStatusKind::Added => colors.version_control_word_added, + DiffHunkStatusKind::Deleted => colors.version_control_word_deleted, + DiffHunkStatusKind::Modified => { + debug_panic!("modified diff status for row info"); + return None; + } + }; + Some((start_point..end_point, background_color)) + }) + }); + + highlighted_ranges.extend(word_highlights); + } + fn layout_diff_hunk_controls( &self, row_range: Range, @@ -5817,34 +6208,36 @@ impl EditorElement { let line_height = layout.position_map.line_height; window.set_cursor_style(CursorStyle::Arrow, &layout.gutter_hitbox); - for LineNumberLayout { - shaped_line, - hitbox, - } in layout.line_numbers.values() - { - let Some(hitbox) = hitbox else { - continue; - }; + for line_layout in layout.line_numbers.values() { + for LineNumberSegment { + shaped_line, + hitbox, + } in &line_layout.segments + { + let Some(hitbox) = hitbox else { + continue; + }; - let Some(()) = (if !is_singleton && hitbox.is_hovered(window) { - let color = cx.theme().colors().editor_hover_line_number; + let Some(()) = (if !is_singleton && hitbox.is_hovered(window) { + let color = cx.theme().colors().editor_hover_line_number; - let line = self.shape_line_number(shaped_line.text.clone(), color, window); - line.paint(hitbox.origin, line_height, window, cx).log_err() - } else { - shaped_line - .paint(hitbox.origin, line_height, window, cx) - .log_err() - }) else { - continue; - }; + let line = self.shape_line_number(shaped_line.text.clone(), color, window); + line.paint(hitbox.origin, line_height, window, cx).log_err() + } else { + shaped_line + .paint(hitbox.origin, line_height, window, cx) + .log_err() + }) else { + continue; + }; - // In singleton buffers, we select corresponding lines on the line number click, so use | -like cursor. - // In multi buffers, we open file at the line number clicked, so use a pointing hand cursor. - if is_singleton { - window.set_cursor_style(CursorStyle::IBeam, hitbox); - } else { - window.set_cursor_style(CursorStyle::PointingHand, hitbox); + // In singleton buffers, we select corresponding lines on the line number click, so use | -like cursor. + // In multi buffers, we open file at the line number clicked, so use a pointing hand cursor. + if is_singleton { + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } else { + window.set_cursor_style(CursorStyle::PointingHand, hitbox); + } } } } @@ -6159,7 +6552,10 @@ impl EditorElement { } = &editor.selection_drag_state { let drag_and_drop_delay = Duration::from_millis( - EditorSettings::get_global(cx).drag_and_drop_selection.delay, + EditorSettings::get_global(cx) + .drag_and_drop_selection + .delay + .0, ); if mouse_down_time.elapsed() >= drag_and_drop_delay { window.set_cursor_style( @@ -6286,6 +6682,89 @@ impl EditorElement { } } + fn paint_sticky_headers( + &mut self, + layout: &mut EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + let Some(mut sticky_headers) = layout.sticky_headers.take() else { + return; + }; + + if sticky_headers.lines.is_empty() { + layout.sticky_headers = Some(sticky_headers); + return; + } + + let whitespace_setting = self + .editor + .read(cx) + .buffer + .read(cx) + .language_settings(cx) + .show_whitespaces; + sticky_headers.paint(layout, whitespace_setting, window, cx); + + let sticky_header_hitboxes: Vec = sticky_headers + .lines + .iter() + .map(|line| line.hitbox.clone()) + .collect(); + let hovered_hitbox = sticky_header_hitboxes + .iter() + .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); + + window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, _cx| { + if !phase.bubble() { + return; + } + + let current_hover = sticky_header_hitboxes + .iter() + .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); + if hovered_hitbox != current_hover { + window.refresh(); + } + }); + + for (line_index, line) in sticky_headers.lines.iter().enumerate() { + let editor = self.editor.clone(); + let hitbox = line.hitbox.clone(); + let target_anchor = line.target_anchor; + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + + if event.button == MouseButton::Left && hitbox.is_hovered(window) { + editor.update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::top_relative(line_index)), + window, + cx, + |selections| selections.select_ranges([target_anchor..target_anchor]), + ); + cx.stop_propagation(); + }); + } + }); + } + + let text_bounds = layout.position_map.text_hitbox.bounds; + let border_top = text_bounds.top() + + sticky_headers.lines.last().unwrap().offset + + layout.position_map.line_height; + let separator_height = px(1.); + let border_bounds = Bounds::from_corners( + point(layout.gutter_hitbox.bounds.left(), border_top), + point(text_bounds.right(), border_top + separator_height), + ); + window.paint_quad(fill(border_bounds, cx.theme().colors().border_variant)); + + layout.sticky_headers = Some(sticky_headers); + } + fn paint_lines_background( &mut self, layout: &mut EditorLayout, @@ -7236,26 +7715,6 @@ impl EditorElement { window.on_mouse_event({ let position_map = layout.position_map.clone(); let editor = self.editor.clone(); - let diff_hunk_range = - layout - .display_hunks - .iter() - .find_map(|(hunk, hunk_hitbox)| match hunk { - DisplayDiffHunk::Folded { .. } => None, - DisplayDiffHunk::Unfolded { - multi_buffer_range, .. - } => { - if hunk_hitbox - .as_ref() - .map(|hitbox| hitbox.is_hovered(window)) - .unwrap_or(false) - { - Some(multi_buffer_range.clone()) - } else { - None - } - } - }); let line_numbers = layout.line_numbers.clone(); move |event: &MouseDownEvent, phase, window, cx| { @@ -7272,7 +7731,6 @@ impl EditorElement { Self::mouse_left_down( editor, event, - diff_hunk_range.clone(), &position_map, line_numbers.as_ref(), window, @@ -7338,6 +7796,19 @@ impl EditorElement { } }); + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + + move |event: &MousePressureEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + Self::pressure_click(editor, &event, &position_map, window, cx); + }) + } + } + }); + window.on_mouse_event({ let position_map = layout.position_map.clone(); let editor = self.editor.clone(); @@ -7361,31 +7832,6 @@ impl EditorElement { }); } - 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( - SharedString::from(" ".repeat(column)), - font_size, - &[TextRun { - len: column, - font: style.text.font(), - color: Hsla::default(), - background_color: None, - underline: None, - strikethrough: None, - }], - None, - ); - - layout.width - } - - 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) - } - fn shape_line_number( &self, text: SharedString, @@ -7396,9 +7842,7 @@ impl EditorElement { len: text.len(), font: self.style.text.font(), color, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; window.text_system().shape_line( text, @@ -7446,8 +7890,8 @@ impl EditorElement { } let clipped_start = range.start.max(&buffer_range.start, buffer); let clipped_end = range.end.min(&buffer_range.end, buffer); - let range = buffer_snapshot.anchor_in_excerpt(excerpt_id, clipped_start)? - ..buffer_snapshot.anchor_in_excerpt(excerpt_id, clipped_end)?; + let range = buffer_snapshot + .anchor_range_in_excerpt(excerpt_id, *clipped_start..*clipped_end)?; let start = range.start.to_display_point(display_snapshot); let end = range.end.to_display_point(display_snapshot); let selection_layout = SelectionLayout { @@ -7467,19 +7911,69 @@ impl EditorElement { } } +fn file_status_label_color(file_status: Option) -> Color { + file_status.map_or(Color::Default, |status| { + if status.is_conflicted() { + Color::Conflict + } else if status.is_modified() { + Color::Modified + } else if status.is_deleted() { + Color::Disabled + } else if status.is_created() { + Color::Created + } else { + Color::Default + } + }) +} + fn header_jump_data( + editor_snapshot: &EditorSnapshot, + block_row_start: DisplayRow, + height: u32, + first_excerpt: &ExcerptInfo, + latest_selection_anchors: &HashMap, +) -> JumpData { + let jump_target = if let Some(anchor) = latest_selection_anchors.get(&first_excerpt.buffer_id) + && let Some(range) = editor_snapshot.context_range_for_excerpt(anchor.excerpt_id) + && let Some(buffer) = editor_snapshot + .buffer_snapshot() + .buffer_for_excerpt(anchor.excerpt_id) + { + JumpTargetInExcerptInput { + id: anchor.excerpt_id, + buffer, + excerpt_start_anchor: range.start, + jump_anchor: anchor.text_anchor, + } + } else { + JumpTargetInExcerptInput { + id: first_excerpt.id, + buffer: &first_excerpt.buffer, + excerpt_start_anchor: first_excerpt.range.context.start, + jump_anchor: first_excerpt.range.primary.start, + } + }; + header_jump_data_inner(editor_snapshot, block_row_start, height, &jump_target) +} + +struct JumpTargetInExcerptInput<'a> { + id: ExcerptId, + buffer: &'a language::BufferSnapshot, + excerpt_start_anchor: text::Anchor, + jump_anchor: text::Anchor, +} + +fn header_jump_data_inner( snapshot: &EditorSnapshot, block_row_start: DisplayRow, height: u32, - for_excerpt: &ExcerptInfo, + for_excerpt: &JumpTargetInExcerptInput, ) -> JumpData { - let range = &for_excerpt.range; let buffer = &for_excerpt.buffer; - let jump_anchor = range.primary.start; - - let excerpt_start = range.context.start; - let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); - let rows_from_excerpt_start = if jump_anchor == excerpt_start { + let jump_position = language::ToPoint::to_point(&for_excerpt.jump_anchor, buffer); + let excerpt_start = for_excerpt.excerpt_start_anchor; + let rows_from_excerpt_start = if for_excerpt.jump_anchor == excerpt_start { 0 } else { let excerpt_start_point = language::ToPoint::to_point(&excerpt_start, buffer); @@ -7496,7 +7990,7 @@ fn header_jump_data( JumpData::MultiBufferPoint { excerpt_id: for_excerpt.id, - anchor: jump_anchor, + anchor: for_excerpt.jump_anchor, position: jump_position, line_offset_from_top, } @@ -7995,6 +8489,27 @@ impl LineWithInvisibles { cx: &mut App, ) { let line_y = f32::from(line_height) * Pixels::from(row.as_f64() - scroll_position.y); + self.prepaint_with_custom_offset( + line_height, + scroll_pixel_position, + content_origin, + line_y, + line_elements, + window, + cx, + ); + } + + fn prepaint_with_custom_offset( + &mut self, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + line_y: Pixels, + line_elements: &mut SmallVec<[AnyElement; 1]>, + window: &mut Window, + cx: &mut App, + ) { let mut fragment_origin = content_origin + gpui::point(Pixels::from(-scroll_pixel_position.x), line_y); for fragment in &mut self.fragments { @@ -8029,9 +8544,31 @@ impl LineWithInvisibles { window: &mut Window, cx: &mut App, ) { - let line_height = layout.position_map.line_height; - let line_y = line_height * (row.as_f64() - layout.position_map.scroll_position.y) as f32; + self.draw_with_custom_offset( + layout, + row, + content_origin, + layout.position_map.line_height + * (row.as_f64() - layout.position_map.scroll_position.y) as f32, + whitespace_setting, + selection_ranges, + window, + cx, + ); + } + fn draw_with_custom_offset( + &self, + layout: &EditorLayout, + row: DisplayRow, + content_origin: gpui::Point, + line_y: Pixels, + whitespace_setting: ShowWhitespaceSetting, + selection_ranges: &[Range], + window: &mut Window, + cx: &mut App, + ) { + let line_height = layout.position_map.line_height; let mut fragment_origin = content_origin + gpui::point( Pixels::from(-layout.position_map.scroll_pixel_position.x), @@ -8364,8 +8901,48 @@ impl EditorElement { } } +#[derive(Default)] +pub struct EditorRequestLayoutState { + // We use prepaint depth to limit the number of times prepaint is + // called recursively. We need this so that we can update stale + // data for e.g. block heights in block map. + prepaint_depth: Rc>, +} + +impl EditorRequestLayoutState { + // In ideal conditions we only need one more subsequent prepaint call for resize to take effect. + // i.e. MAX_PREPAINT_DEPTH = 2, but since moving blocks inline (place_near), more lines from + // below get exposed, and we end up querying blocks for those lines too in subsequent renders. + // Setting MAX_PREPAINT_DEPTH = 3, passes all tests. Just to be on the safe side we set it to 5, so + // that subsequent shrinking does not lead to incorrect block placing. + const MAX_PREPAINT_DEPTH: usize = 5; + + fn increment_prepaint_depth(&self) -> EditorPrepaintGuard { + let depth = self.prepaint_depth.get(); + self.prepaint_depth.set(depth + 1); + EditorPrepaintGuard { + prepaint_depth: self.prepaint_depth.clone(), + } + } + + fn can_prepaint(&self) -> bool { + self.prepaint_depth.get() < Self::MAX_PREPAINT_DEPTH + } +} + +struct EditorPrepaintGuard { + prepaint_depth: Rc>, +} + +impl Drop for EditorPrepaintGuard { + fn drop(&mut self) { + let depth = self.prepaint_depth.get(); + self.prepaint_depth.set(depth.saturating_sub(1)); + } +} + impl Element for EditorElement { - type RequestLayoutState = (); + type RequestLayoutState = EditorRequestLayoutState; type PrepaintState = EditorLayout; fn id(&self) -> Option { @@ -8382,7 +8959,7 @@ impl Element for EditorElement { _inspector_id: Option<&gpui::InspectorElementId>, window: &mut Window, cx: &mut App, - ) -> (gpui::LayoutId, ()) { + ) -> (gpui::LayoutId, Self::RequestLayoutState) { let rem_size = self.rem_size(cx); window.with_rem_size(rem_size, |window| { self.editor.update(cx, |editor, cx| { @@ -8402,8 +8979,6 @@ impl Element for EditorElement { max_lines, } => { let editor_handle = cx.entity(); - let max_line_number_width = - self.max_line_number_width(&editor.snapshot(window, cx), window); window.request_measured_layout( Style::default(), move |known_dimensions, available_space, window, cx| { @@ -8413,7 +8988,6 @@ impl Element for EditorElement { editor, min_lines, max_lines, - max_line_number_width, known_dimensions, available_space.width, window, @@ -8431,11 +9005,11 @@ impl Element for EditorElement { window.request_layout(style, None, cx) } EditorMode::Full { - sized_by_content, .. + sizing_behavior, .. } => { let mut style = Style::default(); style.size.width = relative(1.).into(); - if sized_by_content { + if sizing_behavior == SizingBehavior::SizeByContent { let snapshot = editor.snapshot(window, cx); let line_height = self.style.text.line_height_in_pixels(window.rem_size()); @@ -8449,7 +9023,7 @@ impl Element for EditorElement { } }; - (layout_id, ()) + (layout_id, EditorRequestLayoutState::default()) }) }) } @@ -8459,10 +9033,11 @@ impl Element for EditorElement { _: Option<&GlobalElementId>, _inspector_id: Option<&gpui::InspectorElementId>, bounds: Bounds, - _: &mut Self::RequestLayoutState, + request_layout: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { + let _prepaint_depth_guard = request_layout.increment_prepaint_depth(); let text_style = TextStyleRefinement { font_size: Some(self.style.text.font_size), line_height: Some(self.style.text.line_height), @@ -8470,6 +9045,7 @@ impl Element for EditorElement { }; let is_minimap = self.editor.read(cx).mode.is_minimap(); + let is_singleton = self.editor.read(cx).buffer_kind(cx) == ItemBufferKind::Singleton; if !is_minimap { let focus_handle = self.editor.focus_handle(cx); @@ -8498,15 +9074,10 @@ impl Element for EditorElement { .gutter_dimensions( font_id, font_size, - self.max_line_number_width(&snapshot, window), + style, + window, cx, - ) - .or_else(|| { - self.editor.read(cx).offset_content.then(|| { - GutterDimensions::default_with_margin(font_id, font_size, cx) - }) - }) - .unwrap_or_default(); + ); let text_width = bounds.size.width - gutter_dimensions.width; let settings = EditorSettings::get_global(cx); @@ -8599,7 +9170,8 @@ impl Element for EditorElement { EditorMode::SingleLine | EditorMode::AutoHeight { .. } | EditorMode::Full { - sized_by_content: true, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin + | SizingBehavior::SizeByContent, .. } ) { @@ -8656,7 +9228,7 @@ impl Element for EditorElement { ); let end_row = DisplayRow(end_row); - let row_infos = snapshot + let row_infos = snapshot // note we only get the visual range .row_infos(start_row) .take((start_row..end_row).len()) .collect::>(); @@ -8687,16 +9259,27 @@ impl Element for EditorElement { let is_light = cx.theme().appearance().is_light(); + let mut highlighted_ranges = self + .editor_with_selections(cx) + .map(|editor| { + editor.read(cx).background_highlights_in_range( + start_anchor..end_anchor, + &snapshot.display_snapshot, + cx.theme(), + ) + }) + .unwrap_or_default(); + for (ix, row_info) in row_infos.iter().enumerate() { let Some(diff_status) = row_info.diff_status else { continue; }; let background_color = match diff_status.kind { - DiffHunkStatusKind::Added => cx.theme().colors().version_control_added, - DiffHunkStatusKind::Deleted => { - cx.theme().colors().version_control_deleted - } + DiffHunkStatusKind::Added => + cx.theme().colors().version_control_added, + DiffHunkStatusKind::Deleted => + cx.theme().colors().version_control_deleted, DiffHunkStatusKind::Modified => { debug_panic!("modified diff status for row info"); continue; @@ -8734,21 +9317,14 @@ impl Element for EditorElement { filled_highlight }; + let base_display_point = + DisplayPoint::new(start_row + DisplayRow(ix as u32), 0); + highlighted_rows - .entry(start_row + DisplayRow(ix as u32)) + .entry(base_display_point.row()) .or_insert(background); } - let highlighted_ranges = self - .editor_with_selections(cx) - .map(|editor| { - editor.read(cx).background_highlights_in_range( - start_anchor..end_anchor, - &snapshot.display_snapshot, - cx.theme(), - ) - }) - .unwrap_or_default(); let highlighted_gutter_ranges = self.editor.read(cx).gutter_highlights_in_range( start_anchor..end_anchor, @@ -8768,14 +9344,18 @@ impl Element for EditorElement { cx, ); - let (local_selections, selected_buffer_ids): ( + let (local_selections, selected_buffer_ids, latest_selection_anchors): ( Vec>, Vec, + HashMap, ) = self .editor_with_selections(cx) .map(|editor| { editor.update(cx, |editor, cx| { - let all_selections = editor.selections.all::(cx); + let all_selections = + editor.selections.all::(&snapshot.display_snapshot); + let all_anchor_selections = + editor.selections.all_anchors(&snapshot.display_snapshot); let selected_buffer_ids = if editor.buffer_kind(cx) == ItemBufferKind::Singleton { Vec::new() @@ -8797,15 +9377,38 @@ impl Element for EditorElement { selected_buffer_ids }; - let mut selections = editor - .selections - .disjoint_in_range(start_anchor..end_anchor, cx); - selections.extend(editor.selections.pending(cx)); + let mut selections = editor.selections.disjoint_in_range( + start_anchor..end_anchor, + &snapshot.display_snapshot, + ); + selections + .extend(editor.selections.pending(&snapshot.display_snapshot)); - (selections, selected_buffer_ids) + let mut anchors_by_buffer: HashMap = + HashMap::default(); + for selection in all_anchor_selections.iter() { + let head = selection.head(); + if let Some(buffer_id) = head.text_anchor.buffer_id { + anchors_by_buffer + .entry(buffer_id) + .and_modify(|(latest_id, latest_anchor)| { + if selection.id > *latest_id { + *latest_id = selection.id; + *latest_anchor = head; + } + }) + .or_insert((selection.id, head)); + } + } + let latest_selection_anchors = anchors_by_buffer + .into_iter() + .map(|(buffer_id, (_, anchor))| (buffer_id, anchor)) + .collect(); + + (selections, selected_buffer_ids, latest_selection_anchors) }) }) - .unwrap_or_default(); + .unwrap_or_else(|| (Vec::new(), Vec::new(), HashMap::default())); let (selections, mut active_rows, newest_selection_head) = self .layout_selections( @@ -8894,7 +9497,7 @@ impl Element for EditorElement { let crease_trailers = window.with_element_namespace("crease_trailers", |window| { self.layout_crease_trailers( - row_infos.iter().copied(), + row_infos.iter().cloned(), &snapshot, window, cx, @@ -8910,10 +9513,29 @@ impl Element for EditorElement { cx, ); + Self::layout_word_diff_highlights( + &display_hunks, + &row_infos, + start_row, + &snapshot, + &mut highlighted_ranges, + cx, + ); + + let merged_highlighted_ranges = + if let Some((_, colors)) = document_colors.as_ref() { + &highlighted_ranges + .clone() + .into_iter() + .chain(colors.clone()) + .collect() + } else { + &highlighted_ranges + }; let bg_segments_per_row = Self::bg_segments_per_row( start_row..end_row, &selections, - &highlighted_ranges, + &merged_highlighted_ranges, self.style.background, ); @@ -8947,7 +9569,20 @@ impl Element for EditorElement { // If the fold widths have changed, we need to prepaint // the element again to account for any changes in // wrapping. - return self.prepaint(None, _inspector_id, bounds, &mut (), window, cx); + if request_layout.can_prepaint() { + return self.prepaint( + None, + _inspector_id, + bounds, + request_layout, + window, + cx, + ); + } else { + debug_panic!( + "skipping recursive prepaint at max depth. renderer widths may be stale." + ); + } } let longest_line_blame_width = self @@ -9026,6 +9661,7 @@ impl Element for EditorElement { &mut line_layouts, &local_selections, &selected_buffer_ids, + &latest_selection_anchors, is_row_soft_wrapped, sticky_header_excerpt_id, window, @@ -9033,20 +9669,35 @@ impl Element for EditorElement { ) }) }) - .unwrap_or_else(|| Ok((Vec::default(), HashMap::default()))); - let (mut blocks, row_block_types) = match blocks { - Ok(blocks) => blocks, - Err(resized_blocks) => { - self.editor.update(cx, |editor, cx| { - editor.resize_blocks( - resized_blocks, - autoscroll_request.map(|(autoscroll, _)| autoscroll), - cx, - ) - }); - return self.prepaint(None, _inspector_id, bounds, &mut (), window, cx); + .unwrap_or_default(); + let RenderBlocksOutput { + mut blocks, + row_block_types, + resized_blocks, + } = blocks; + if let Some(resized_blocks) = resized_blocks { + self.editor.update(cx, |editor, cx| { + editor.resize_blocks( + resized_blocks, + autoscroll_request.map(|(autoscroll, _)| autoscroll), + cx, + ) + }); + if request_layout.can_prepaint() { + return self.prepaint( + None, + _inspector_id, + bounds, + request_layout, + window, + cx, + ); + } else { + debug_panic!( + "skipping recursive prepaint at max depth. block layout may be stale." + ); } - }; + } let sticky_buffer_header = sticky_header_excerpt.map(|sticky_header_excerpt| { window.with_element_namespace("blocks", |window| { @@ -9059,6 +9710,7 @@ impl Element for EditorElement { &hitbox, &selected_buffer_ids, &blocks, + &latest_selection_anchors, window, cx, ) @@ -9102,6 +9754,27 @@ impl Element for EditorElement { scroll_position.x * f64::from(em_advance), scroll_position.y * f64::from(line_height), ); + let sticky_headers = if !is_minimap + && is_singleton + && EditorSettings::get_global(cx).sticky_scroll.enabled + { + self.layout_sticky_headers( + &snapshot, + editor_width, + is_row_soft_wrapped, + line_height, + scroll_pixel_position, + content_origin, + &gutter_dimensions, + &gutter_hitbox, + &text_hitbox, + &style, + window, + cx, + ) + } else { + None + }; let indent_guides = self.layout_indent_guides( content_origin, text_hitbox.origin, @@ -9203,7 +9876,6 @@ impl Element for EditorElement { scroll_position, scroll_pixel_position, line_height, - &text_hitbox, window, cx, ) { @@ -9401,6 +10073,8 @@ impl Element for EditorElement { window, cx, ); + + self.layout_blame_popover(&snapshot, &hitbox, line_height, window, cx); } let mouse_context_menu = self.layout_mouse_context_menu( @@ -9469,9 +10143,7 @@ impl Element for EditorElement { len: tab_len, font: self.style.text.font(), color: cx.theme().colors().editor_invisible, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }], None, ); @@ -9485,9 +10157,7 @@ impl Element for EditorElement { len: space_len, font: self.style.text.font(), color: cx.theme().colors().editor_invisible, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }], None, ); @@ -9575,6 +10245,7 @@ impl Element for EditorElement { tab_invisible, space_invisible, sticky_buffer_header, + sticky_headers, expand_toggles, } }) @@ -9645,6 +10316,7 @@ impl Element for EditorElement { } }); + self.paint_sticky_headers(layout, window, cx); self.paint_minimap(layout, window, cx); self.paint_scrollbars(layout, window, cx); self.paint_edit_prediction_popover(layout, window, cx); @@ -9753,20 +10425,191 @@ pub struct EditorLayout { tab_invisible: ShapedLine, space_invisible: ShapedLine, sticky_buffer_header: Option, + sticky_headers: Option, document_colors: Option<(DocumentColorsRenderMode, Vec<(Range, Hsla)>)>, } +struct StickyHeaders { + lines: Vec, + gutter_background: Hsla, + content_background: Hsla, + gutter_right_padding: Pixels, +} + +struct StickyHeaderLine { + row: DisplayRow, + offset: Pixels, + line: LineWithInvisibles, + line_number: Option, + elements: SmallVec<[AnyElement; 1]>, + available_text_width: Pixels, + target_anchor: Anchor, + hitbox: Hitbox, +} + impl EditorLayout { fn line_end_overshoot(&self) -> Pixels { 0.15 * self.position_map.line_height } } -struct LineNumberLayout { +impl StickyHeaders { + fn paint( + &mut self, + layout: &mut EditorLayout, + whitespace_setting: ShowWhitespaceSetting, + window: &mut Window, + cx: &mut App, + ) { + let line_height = layout.position_map.line_height; + + for line in self.lines.iter_mut().rev() { + window.paint_layer( + Bounds::new( + layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), + size(line.hitbox.size.width, line_height), + ), + |window| { + let gutter_bounds = Bounds::new( + layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), + size(layout.gutter_hitbox.size.width, line_height), + ); + window.paint_quad(fill(gutter_bounds, self.gutter_background)); + + let text_bounds = Bounds::new( + layout.position_map.text_hitbox.origin + point(Pixels::ZERO, line.offset), + size(line.available_text_width, line_height), + ); + window.paint_quad(fill(text_bounds, self.content_background)); + + if line.hitbox.is_hovered(window) { + let hover_overlay = cx.theme().colors().panel_overlay_hover; + window.paint_quad(fill(gutter_bounds, hover_overlay)); + window.paint_quad(fill(text_bounds, hover_overlay)); + } + + line.paint( + layout, + self.gutter_right_padding, + line.available_text_width, + layout.content_origin, + line_height, + whitespace_setting, + window, + cx, + ); + }, + ); + + window.set_cursor_style(CursorStyle::PointingHand, &line.hitbox); + } + } +} + +impl StickyHeaderLine { + fn new( + row: DisplayRow, + offset: Pixels, + mut line: LineWithInvisibles, + line_number: Option, + target_anchor: Anchor, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + gutter_hitbox: &Hitbox, + text_hitbox: &Hitbox, + window: &mut Window, + cx: &mut App, + ) -> Self { + let mut elements = SmallVec::<[AnyElement; 1]>::new(); + line.prepaint_with_custom_offset( + line_height, + scroll_pixel_position, + content_origin, + offset, + &mut elements, + window, + cx, + ); + + let hitbox_bounds = Bounds::new( + gutter_hitbox.origin + point(Pixels::ZERO, offset), + size(text_hitbox.right() - gutter_hitbox.left(), line_height), + ); + let available_text_width = + (hitbox_bounds.size.width - gutter_hitbox.size.width).max(Pixels::ZERO); + + Self { + row, + offset, + line, + line_number, + elements, + available_text_width, + target_anchor, + hitbox: window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll), + } + } + + fn paint( + &mut self, + layout: &EditorLayout, + gutter_right_padding: Pixels, + available_text_width: Pixels, + content_origin: gpui::Point, + line_height: Pixels, + whitespace_setting: ShowWhitespaceSetting, + window: &mut Window, + cx: &mut App, + ) { + window.with_content_mask( + Some(ContentMask { + bounds: Bounds::new( + layout.position_map.text_hitbox.bounds.origin + + point(Pixels::ZERO, self.offset), + size(available_text_width, line_height), + ), + }), + |window| { + self.line.draw_with_custom_offset( + layout, + self.row, + content_origin, + self.offset, + whitespace_setting, + &[], + window, + cx, + ); + for element in &mut self.elements { + element.paint(window, cx); + } + }, + ); + + if let Some(line_number) = &self.line_number { + let gutter_origin = layout.gutter_hitbox.origin + point(Pixels::ZERO, self.offset); + let gutter_width = layout.gutter_hitbox.size.width; + let origin = point( + gutter_origin.x + gutter_width - gutter_right_padding - line_number.width, + gutter_origin.y, + ); + line_number.paint(origin, line_height, window, cx).log_err(); + } + } +} + +#[derive(Debug)] +struct LineNumberSegment { shaped_line: ShapedLine, hitbox: Option, } +#[derive(Debug)] +struct LineNumberLayout { + segments: SmallVec<[LineNumberSegment; 1]>, +} + struct ColoredRange { start: T, end: T, @@ -9985,9 +10828,9 @@ impl ScrollbarLayout { show_thumb: bool, axis: ScrollbarAxis, ) -> Self { - let text_units_per_page = f64::from(viewport_size / glyph_space); + let text_units_per_page = viewport_size.to_f64() / glyph_space.to_f64(); let visible_range = scroll_position..scroll_position + text_units_per_page; - let total_text_units = scroll_range / f64::from(glyph_space); + let total_text_units = scroll_range / glyph_space.to_f64(); let thumb_percentage = text_units_per_page / total_text_units; let thumb_size = Pixels::from(ScrollOffset::from(track_length) * thumb_percentage) @@ -10602,6 +11445,13 @@ impl HighlightedRange { } } +pub(crate) struct StickyHeader { + pub item: language::OutlineItem, + pub sticky_row: DisplayRow, + pub start_point: Point, + pub offset: ScrollOffset, +} + enum CursorPopoverType { CodeContextMenu, EditPrediction, @@ -10635,7 +11485,6 @@ fn compute_auto_height_layout( editor: &mut Editor, min_lines: usize, max_lines: Option, - max_line_number_width: Pixels, known_dimensions: Size>, available_width: AvailableSpace, window: &mut Window, @@ -10659,14 +11508,7 @@ fn compute_auto_height_layout( let em_width = window.text_system().em_width(font_id, font_size).unwrap(); let mut snapshot = editor.snapshot(window, cx); - let gutter_dimensions = snapshot - .gutter_dimensions(font_id, font_size, max_line_number_width, cx) - .or_else(|| { - editor - .offset_content - .then(|| GutterDimensions::default_with_margin(font_id, font_size, cx)) - }) - .unwrap_or_default(); + let gutter_dimensions = snapshot.gutter_dimensions(font_id, font_size, style, window, cx); editor.gutter_dimensions = gutter_dimensions; let text_width = width - gutter_dimensions.width; @@ -10729,7 +11571,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); for x in 1..=100 { let (_, state) = cx.draw( @@ -10757,7 +11599,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); for x in 1..=100 { let (_, state) = cx.draw( @@ -10782,7 +11624,7 @@ mod tests { }); let editor = window.root(cx).unwrap(); - let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); let line_height = window .update(cx, |_, window, _| { style.text.line_height_in_pixels(window.rem_size()) @@ -10830,6 +11672,7 @@ mod tests { &snapshot, &(DisplayRow(0)..DisplayRow(6)), Some(DisplayRow(3)), + false, ) }) .unwrap(); @@ -10848,6 +11691,7 @@ mod tests { &snapshot, &(DisplayRow(3)..DisplayRow(6)), Some(DisplayRow(1)), + false, ) }) .unwrap(); @@ -10864,6 +11708,7 @@ mod tests { &snapshot, &(DisplayRow(0)..DisplayRow(3)), Some(DisplayRow(6)), + false, ) }) .unwrap(); @@ -10871,6 +11716,177 @@ mod tests { assert_eq!(relative_rows[&DisplayRow(0)], 5); assert_eq!(relative_rows[&DisplayRow(1)], 4); assert_eq!(relative_rows[&DisplayRow(2)], 3); + + const DELETED_LINE: u32 = 3; + let layouts = cx + .update_window(*window, |_, window, cx| { + element.layout_line_numbers( + None, + GutterDimensions { + left_padding: Pixels::ZERO, + right_padding: Pixels::ZERO, + width: px(30.0), + margin: Pixels::ZERO, + git_blame_entries_width: None, + }, + line_height, + gpui::Point::default(), + DisplayRow(0)..DisplayRow(6), + &(0..6) + .map(|row| RowInfo { + buffer_row: Some(row), + diff_status: (row == DELETED_LINE).then(|| { + DiffHunkStatus::deleted( + buffer_diff::DiffHunkSecondaryStatus::NoSecondaryHunk, + ) + }), + ..Default::default() + }) + .collect::>(), + &BTreeMap::default(), + Some(DisplayPoint::new(DisplayRow(0), 0)), + &snapshot, + window, + cx, + ) + }) + .unwrap(); + assert_eq!(layouts.len(), 5,); + assert!( + layouts.get(&MultiBufferRow(DELETED_LINE)).is_none(), + "Deleted line should not have a line number" + ); + } + + #[gpui::test] + fn test_shape_line_numbers_wrapping(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); + Editor::new(EditorMode::full(), buffer, None, window, cx) + }); + + update_test_language_settings(cx, |s| { + s.defaults.preferred_line_length = Some(5_u32); + s.defaults.soft_wrap = Some(language_settings::SoftWrap::PreferredLineLength); + }); + + let editor = window.root(cx).unwrap(); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); + let line_height = window + .update(cx, |_, window, _| { + style.text.line_height_in_pixels(window.rem_size()) + }) + .unwrap(); + let element = EditorElement::new(&editor, style); + let snapshot = window + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + + let layouts = cx + .update_window(*window, |_, window, cx| { + element.layout_line_numbers( + None, + GutterDimensions { + left_padding: Pixels::ZERO, + right_padding: Pixels::ZERO, + width: px(30.0), + margin: Pixels::ZERO, + git_blame_entries_width: None, + }, + line_height, + gpui::Point::default(), + DisplayRow(0)..DisplayRow(6), + &(0..6) + .map(|row| RowInfo { + buffer_row: Some(row), + ..Default::default() + }) + .collect::>(), + &BTreeMap::default(), + Some(DisplayPoint::new(DisplayRow(0), 0)), + &snapshot, + window, + cx, + ) + }) + .unwrap(); + assert_eq!(layouts.len(), 3); + + let relative_rows = window + .update(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + element.calculate_relative_line_numbers( + &snapshot, + &(DisplayRow(0)..DisplayRow(6)), + Some(DisplayRow(3)), + true, + ) + }) + .unwrap(); + + assert_eq!(relative_rows[&DisplayRow(0)], 3); + assert_eq!(relative_rows[&DisplayRow(1)], 2); + assert_eq!(relative_rows[&DisplayRow(2)], 1); + // current line has no relative number + assert_eq!(relative_rows[&DisplayRow(4)], 1); + assert_eq!(relative_rows[&DisplayRow(5)], 2); + + let layouts = cx + .update_window(*window, |_, window, cx| { + element.layout_line_numbers( + None, + GutterDimensions { + left_padding: Pixels::ZERO, + right_padding: Pixels::ZERO, + width: px(30.0), + margin: Pixels::ZERO, + git_blame_entries_width: None, + }, + line_height, + gpui::Point::default(), + DisplayRow(0)..DisplayRow(6), + &(0..6) + .map(|row| RowInfo { + buffer_row: Some(row), + diff_status: Some(DiffHunkStatus::deleted( + buffer_diff::DiffHunkSecondaryStatus::NoSecondaryHunk, + )), + ..Default::default() + }) + .collect::>(), + &BTreeMap::from_iter([(DisplayRow(0), LineHighlightSpec::default())]), + Some(DisplayPoint::new(DisplayRow(0), 0)), + &snapshot, + window, + cx, + ) + }) + .unwrap(); + assert!( + layouts.is_empty(), + "Deleted lines should have no line number" + ); + + let relative_rows = window + .update(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + element.calculate_relative_line_numbers( + &snapshot, + &(DisplayRow(0)..DisplayRow(6)), + Some(DisplayRow(3)), + true, + ) + }) + .unwrap(); + + // Deleted lines should still have relative numbers + assert_eq!(relative_rows[&DisplayRow(0)], 3); + assert_eq!(relative_rows[&DisplayRow(1)], 2); + assert_eq!(relative_rows[&DisplayRow(2)], 1); + // current line, even if deleted, has no relative number + assert_eq!(relative_rows[&DisplayRow(4)], 1); + assert_eq!(relative_rows[&DisplayRow(5)], 2); } #[gpui::test] @@ -10883,11 +11899,11 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); window .update(cx, |editor, window, cx| { - editor.cursor_shape = CursorShape::Block; + editor.cursor_offset_on_selection = true; editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(0, 0)..Point::new(1, 0), @@ -10954,7 +11970,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); window .update(cx, |editor, window, cx| { editor.set_placeholder_text("hello", window, cx); @@ -10986,7 +12002,13 @@ mod tests { state .line_numbers .get(&MultiBufferRow(0)) - .map(|line_number| line_number.shaped_line.text.as_ref()), + .map(|line_number| line_number + .segments + .first() + .unwrap() + .shaped_line + .text + .as_ref()), Some("1") ); } @@ -11188,7 +12210,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); window .update(cx, |editor, _, cx| { editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); @@ -11384,11 +12406,8 @@ mod tests { fn generate_test_run(len: usize, color: Hsla) -> TextRun { TextRun { len, - font: gpui::font(".SystemUIFont"), color, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() } } diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 836b61d566..031795ff2d 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -1,6 +1,7 @@ use crate::Editor; -use anyhow::Result; +use anyhow::{Context as _, Result}; use collections::HashMap; + use git::{ GitHostingProviderRegistry, GitRemote, Oid, blame::{Blame, BlameEntry, ParsedCommitMessage}, @@ -16,7 +17,7 @@ use markdown::Markdown; use multi_buffer::{MultiBuffer, RowInfo}; use project::{ Project, ProjectItem as _, - git_store::{GitStoreEvent, Repository, RepositoryEvent}, + git_store::{GitStoreEvent, Repository}, }; use smallvec::SmallVec; use std::{sync::Arc, time::Duration}; @@ -66,7 +67,7 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 { struct GitBlameBuffer { entries: SumTree, buffer_snapshot: BufferSnapshot, - buffer_edits: text::Subscription, + buffer_edits: text::Subscription, commit_details: HashMap, } @@ -235,8 +236,8 @@ impl GitBlame { let git_store = project.read(cx).git_store().clone(); let git_store_subscription = cx.subscribe(&git_store, move |this, _, event, cx| match event { - GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, _) - | GitStoreEvent::RepositoryAdded(_) + GitStoreEvent::RepositoryUpdated(_, _, _) + | GitStoreEvent::RepositoryAdded | GitStoreEvent::RepositoryRemoved(_) => { log::debug!("Status of git repositories updated. Regenerating blame data...",); this.generate(cx); @@ -493,72 +494,102 @@ impl GitBlame { self.changed_while_blurred = true; return; } - let blame = self.project.update(cx, |project, cx| { - let Some(multi_buffer) = self.multi_buffer.upgrade() else { - return Vec::new(); - }; - multi_buffer - .read(cx) - .all_buffer_ids() - .into_iter() - .filter_map(|id| { - let buffer = multi_buffer.read(cx).buffer(id)?; - let snapshot = buffer.read(cx).snapshot(); - let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); - - let blame_buffer = project.blame_buffer(&buffer, None, cx); - Some((id, snapshot, buffer_edits, blame_buffer)) - }) - .collect::>() - }); - let provider_registry = GitHostingProviderRegistry::default_global(cx); + let buffers_to_blame = self + .multi_buffer + .update(cx, |multi_buffer, _| { + multi_buffer + .all_buffer_ids() + .into_iter() + .filter_map(|id| Some(multi_buffer.buffer(id)?.downgrade())) + .collect::>() + }) + .unwrap_or_default(); + let project = self.project.downgrade(); self.task = cx.spawn(async move |this, cx| { - let (result, errors) = cx - .background_spawn({ - async move { - let mut res = vec![]; - let mut errors = vec![]; - for (id, snapshot, buffer_edits, blame) in blame { - match blame.await { - Ok(Some(Blame { - entries, - messages, - remote_url, - })) => { - let entries = build_blame_entry_sum_tree( - entries, - snapshot.max_point().row, - ); - let commit_details = parse_commit_messages( - messages, - remote_url, - provider_registry.clone(), - ) - .await; + let mut all_results = Vec::new(); + let mut all_errors = Vec::new(); - res.push(( + for buffers in buffers_to_blame.chunks(4) { + let blame = cx.update(|cx| { + buffers + .iter() + .map(|buffer| { + let buffer = buffer.upgrade().context("buffer was dropped")?; + let project = project.upgrade().context("project was dropped")?; + let id = buffer.read(cx).remote_id(); + let snapshot = buffer.read(cx).snapshot(); + let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); + let remote_url = project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) + .and_then(|(repo, _)| { + repo.read(cx) + .remote_upstream_url + .clone() + .or(repo.read(cx).remote_origin_url.clone()) + }); + let blame_buffer = project + .update(cx, |project, cx| project.blame_buffer(&buffer, None, cx)); + Ok(async move { + (id, snapshot, buffer_edits, blame_buffer.await, remote_url) + }) + }) + .collect::>>() + })??; + let provider_registry = + cx.update(|cx| GitHostingProviderRegistry::default_global(cx))?; + let (results, errors) = cx + .background_spawn({ + async move { + let blame = futures::future::join_all(blame).await; + let mut res = vec![]; + let mut errors = vec![]; + for (id, snapshot, buffer_edits, blame, remote_url) in blame { + match blame { + Ok(Some(Blame { entries, messages })) => { + let entries = build_blame_entry_sum_tree( + entries, + snapshot.max_point().row, + ); + let commit_details = parse_commit_messages( + messages, + remote_url, + provider_registry.clone(), + ) + .await; + + res.push(( + id, + snapshot, + buffer_edits, + Some(entries), + commit_details, + )); + } + Ok(None) => res.push(( id, snapshot, buffer_edits, - Some(entries), - commit_details, - )); + None, + Default::default(), + )), + Err(e) => errors.push(e), } - Ok(None) => { - res.push((id, snapshot, buffer_edits, None, Default::default())) - } - Err(e) => errors.push(e), } + (res, errors) } - (res, errors) - } - }) - .await; + }) + .await; + all_results.extend(results); + all_errors.extend(errors) + } this.update(cx, |this, cx| { this.buffers.clear(); - for (id, snapshot, buffer_edits, entries, commit_details) in result { + for (id, snapshot, buffer_edits, entries, commit_details) in all_results { let Some(entries) = entries else { continue; }; @@ -573,11 +604,11 @@ impl GitBlame { ); } cx.notify(); - if !errors.is_empty() { + if !all_errors.is_empty() { this.project.update(cx, |_, cx| { if this.user_triggered { - log::error!("failed to get git blame data: {errors:?}"); - let notification = errors + log::error!("failed to get git blame data: {all_errors:?}"); + let notification = all_errors .into_iter() .format_with(",", |e, f| f(&format_args!("{:#}", e))) .to_string(); @@ -588,7 +619,7 @@ impl GitBlame { } else { // If we weren't triggered by a user, we just log errors in the background, instead of sending // notifications. - log::debug!("failed to get git blame data: {errors:?}"); + log::debug!("failed to get git blame data: {all_errors:?}"); } }) } @@ -597,6 +628,7 @@ impl GitBlame { } fn regenerate_on_edit(&mut self, cx: &mut Context) { + // todo(lw): hot foreground spawn self.regenerate_on_edit_task = cx.spawn(async move |this, cx| { cx.background_executor() .timer(REGENERATE_ON_EDIT_DEBOUNCE_INTERVAL) @@ -758,11 +790,6 @@ mod tests { theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); - client::init_settings(cx); - workspace::init_settings(cx); - Project::init_settings(cx); - crate::init(cx); }); } diff --git a/crates/editor/src/highlight_matching_bracket.rs b/crates/editor/src/highlight_matching_bracket.rs index 0457e457c1..3ead3e2a11 100644 --- a/crates/editor/src/highlight_matching_bracket.rs +++ b/crates/editor/src/highlight_matching_bracket.rs @@ -1,58 +1,62 @@ use crate::{Editor, RangeToAnchorExt}; use gpui::{Context, HighlightStyle, Window}; use language::CursorShape; +use multi_buffer::MultiBufferOffset; use theme::ActiveTheme; enum MatchingBracketHighlight {} -pub fn refresh_matching_bracket_highlights( - editor: &mut Editor, - window: &mut Window, - cx: &mut Context, -) { - editor.clear_highlights::(cx); +impl Editor { + #[ztracing::instrument(skip_all)] + pub fn refresh_matching_bracket_highlights( + &mut self, + window: &Window, + cx: &mut Context, + ) { + self.clear_highlights::(cx); - let newest_selection = editor.selections.newest::(cx); - // Don't highlight brackets if the selection isn't empty - if !newest_selection.is_empty() { - return; - } - - let snapshot = editor.snapshot(window, cx); - let head = newest_selection.head(); - if head > snapshot.buffer_snapshot().len() { - log::error!("bug: cursor offset is out of range while refreshing bracket highlights"); - return; - } - - let mut tail = head; - if (editor.cursor_shape == CursorShape::Block || editor.cursor_shape == CursorShape::Hollow) - && head < snapshot.buffer_snapshot().len() - { - if let Some(tail_ch) = snapshot.buffer_snapshot().chars_at(tail).next() { - tail += tail_ch.len_utf8(); + let snapshot = self.snapshot(window, cx); + let buffer_snapshot = snapshot.buffer_snapshot(); + let newest_selection = self.selections.newest::(&snapshot); + // Don't highlight brackets if the selection isn't empty + if !newest_selection.is_empty() { + return; } - } - if let Some((opening_range, closing_range)) = snapshot - .buffer_snapshot() - .innermost_enclosing_bracket_ranges(head..tail, None) - { - editor.highlight_text::( - vec![ - opening_range.to_anchors(&snapshot.buffer_snapshot()), - closing_range.to_anchors(&snapshot.buffer_snapshot()), - ], - HighlightStyle { - background_color: Some( - cx.theme() - .colors() - .editor_document_highlight_bracket_background, - ), - ..Default::default() - }, - cx, - ) + let head = newest_selection.head(); + if head > buffer_snapshot.len() { + log::error!("bug: cursor offset is out of range while refreshing bracket highlights"); + return; + } + + let mut tail = head; + if (self.cursor_shape == CursorShape::Block || self.cursor_shape == CursorShape::Hollow) + && head < buffer_snapshot.len() + { + if let Some(tail_ch) = buffer_snapshot.chars_at(tail).next() { + tail += tail_ch.len_utf8(); + } + } + + if let Some((opening_range, closing_range)) = + buffer_snapshot.innermost_enclosing_bracket_ranges(head..tail, None) + { + self.highlight_text::( + vec![ + opening_range.to_anchors(&buffer_snapshot), + closing_range.to_anchors(&buffer_snapshot), + ], + HighlightStyle { + background_color: Some( + cx.theme() + .colors() + .editor_document_highlight_bracket_background, + ), + ..Default::default() + }, + cx, + ) + } } } diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index d2073633dd..d7e4169a72 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1,21 +1,18 @@ use crate::{ Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinition, - GoToDefinitionSplit, GoToTypeDefinition, GoToTypeDefinitionSplit, GotoDefinitionKind, InlayId, - Navigated, PointForPosition, SelectPhase, - editor_settings::GoToDefinitionFallback, - hover_popover::{self, InlayHover}, + GoToDefinitionSplit, GoToTypeDefinition, GoToTypeDefinitionSplit, GotoDefinitionKind, + Navigated, PointForPosition, SelectPhase, editor_settings::GoToDefinitionFallback, scroll::ScrollAmount, }; use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window, px}; use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; -use project::{ - HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink, Project, - ResolveState, ResolvedPath, -}; +use project::{InlayId, LocationLink, Project, ResolvedPath}; +use regex::Regex; use settings::Settings; -use std::ops::Range; +use std::{ops::Range, sync::LazyLock}; +use text::OffsetRangeExt; use theme::ActiveTheme as _; use util::{ResultExt, TryFutureExt as _, maybe}; @@ -121,7 +118,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let hovered_link_modifier = Editor::multi_cursor_modifier(false, &modifiers, cx); + let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&modifiers, cx); if !hovered_link_modifier || self.has_pending_selection() { self.hide_hovered_link(cx); return; @@ -138,10 +135,9 @@ impl Editor { show_link_definition(modifiers.shift, self, trigger_point, snapshot, window, cx); } None => { - update_inlay_link_and_hover_points( + self.update_inlay_link_and_hover_points( snapshot, point_for_position, - self, hovered_link_modifier, modifiers.shift, window, @@ -174,7 +170,7 @@ impl Editor { match EditorSettings::get_global(cx).go_to_definition_fallback { GoToDefinitionFallback::None => None, GoToDefinitionFallback::FindAllReferences => { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) } } }) @@ -247,8 +243,8 @@ impl Editor { } }) .collect(); - let navigate_task = - self.navigate_to_hover_links(None, links, modifiers.alt, window, cx); + let split = Self::is_alt_pressed(&modifiers, cx); + let navigate_task = self.navigate_to_hover_links(None, links, split, window, cx); self.select(SelectPhase::End, window, cx); return navigate_task; } @@ -267,7 +263,8 @@ impl Editor { ); let navigate_task = if point.as_valid().is_some() { - match (modifiers.shift, modifiers.alt) { + let split = Self::is_alt_pressed(&modifiers, cx); + match (modifiers.shift, split) { (true, true) => { self.go_to_type_definition_split(&GoToTypeDefinitionSplit, window, cx) } @@ -283,183 +280,6 @@ impl Editor { } } -pub fn update_inlay_link_and_hover_points( - snapshot: &EditorSnapshot, - point_for_position: PointForPosition, - editor: &mut Editor, - secondary_held: bool, - shift_held: bool, - window: &mut Window, - cx: &mut Context, -) { - let hovered_offset = if point_for_position.column_overshoot_after_line_end == 0 { - Some(snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left)) - } else { - None - }; - let mut go_to_definition_updated = false; - let mut hover_updated = false; - if let Some(hovered_offset) = hovered_offset { - let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let previous_valid_anchor = - buffer_snapshot.anchor_before(point_for_position.previous_valid.to_point(snapshot)); - let next_valid_anchor = - buffer_snapshot.anchor_after(point_for_position.next_valid.to_point(snapshot)); - if let Some(hovered_hint) = editor - .visible_inlay_hints(cx) - .into_iter() - .skip_while(|hint| { - hint.position - .cmp(&previous_valid_anchor, &buffer_snapshot) - .is_lt() - }) - .take_while(|hint| { - hint.position - .cmp(&next_valid_anchor, &buffer_snapshot) - .is_le() - }) - .max_by_key(|hint| hint.id) - { - let inlay_hint_cache = editor.inlay_hint_cache(); - let excerpt_id = previous_valid_anchor.excerpt_id; - if let Some(cached_hint) = inlay_hint_cache.hint_by_id(excerpt_id, hovered_hint.id) { - match cached_hint.resolve_state { - ResolveState::CanResolve(_, _) => { - if let Some(buffer_id) = snapshot - .buffer_snapshot() - .buffer_id_for_anchor(previous_valid_anchor) - { - inlay_hint_cache.spawn_hint_resolve( - buffer_id, - excerpt_id, - hovered_hint.id, - window, - cx, - ); - } - } - ResolveState::Resolved => { - let mut extra_shift_left = 0; - let mut extra_shift_right = 0; - if cached_hint.padding_left { - extra_shift_left += 1; - extra_shift_right += 1; - } - if cached_hint.padding_right { - extra_shift_right += 1; - } - match cached_hint.label { - project::InlayHintLabel::String(_) => { - if let Some(tooltip) = cached_hint.tooltip { - hover_popover::hover_at_inlay( - editor, - InlayHover { - tooltip: match tooltip { - InlayHintTooltip::String(text) => HoverBlock { - text, - kind: HoverBlockKind::PlainText, - }, - InlayHintTooltip::MarkupContent(content) => { - HoverBlock { - text: content.value, - kind: content.kind, - } - } - }, - range: InlayHighlight { - inlay: hovered_hint.id, - inlay_position: hovered_hint.position, - range: extra_shift_left - ..hovered_hint.text().len() + extra_shift_right, - }, - }, - window, - cx, - ); - hover_updated = true; - } - } - project::InlayHintLabel::LabelParts(label_parts) => { - let hint_start = - snapshot.anchor_to_inlay_offset(hovered_hint.position); - if let Some((hovered_hint_part, part_range)) = - hover_popover::find_hovered_hint_part( - label_parts, - hint_start, - hovered_offset, - ) - { - let highlight_start = - (part_range.start - hint_start).0 + extra_shift_left; - let highlight_end = - (part_range.end - hint_start).0 + extra_shift_right; - let highlight = InlayHighlight { - inlay: hovered_hint.id, - inlay_position: hovered_hint.position, - range: highlight_start..highlight_end, - }; - if let Some(tooltip) = hovered_hint_part.tooltip { - hover_popover::hover_at_inlay( - editor, - InlayHover { - tooltip: match tooltip { - InlayHintLabelPartTooltip::String(text) => { - HoverBlock { - text, - kind: HoverBlockKind::PlainText, - } - } - InlayHintLabelPartTooltip::MarkupContent( - content, - ) => HoverBlock { - text: content.value, - kind: content.kind, - }, - }, - range: highlight.clone(), - }, - window, - cx, - ); - hover_updated = true; - } - if let Some((language_server_id, location)) = - hovered_hint_part.location - && secondary_held - && !editor.has_pending_nonempty_selection() - { - go_to_definition_updated = true; - show_link_definition( - shift_held, - editor, - TriggerPoint::InlayHint( - highlight, - location, - language_server_id, - ), - snapshot, - window, - cx, - ); - } - } - } - }; - } - ResolveState::Resolving => {} - } - } - } - } - - if !go_to_definition_updated { - editor.hide_hovered_link(cx) - } - if !hover_updated { - hover_popover::hover_at(editor, None, window, cx); - } -} - pub fn show_link_definition( shift_held: bool, editor: &mut Editor, @@ -494,22 +314,15 @@ pub fn show_link_definition( } let trigger_anchor = trigger_point.anchor(); - let Some((buffer, buffer_position)) = editor - .buffer - .read(cx) - .text_anchor_for_position(*trigger_anchor, cx) - else { + let anchor = snapshot.buffer_snapshot().anchor_before(*trigger_anchor); + let Some(buffer) = editor.buffer().read(cx).buffer_for_anchor(anchor, cx) else { return; }; - - let Some((excerpt_id, _, _)) = editor - .buffer() - .read(cx) - .excerpt_containing(*trigger_anchor, cx) - else { - return; - }; - + let Anchor { + excerpt_id, + text_anchor, + .. + } = anchor; let same_kind = hovered_link_state.preferred_kind == preferred_kind || hovered_link_state .links @@ -539,44 +352,40 @@ pub fn show_link_definition( async move { let result = match &trigger_point { TriggerPoint::Text(_) => { - if let Some((url_range, url)) = find_url(&buffer, buffer_position, cx.clone()) { + if let Some((url_range, url)) = find_url(&buffer, text_anchor, cx.clone()) { this.read_with(cx, |_, _| { let range = maybe!({ - let start = - snapshot.anchor_in_excerpt(excerpt_id, url_range.start)?; - let end = snapshot.anchor_in_excerpt(excerpt_id, url_range.end)?; - Some(RangeInEditor::Text(start..end)) + let range = + snapshot.anchor_range_in_excerpt(excerpt_id, url_range)?; + Some(RangeInEditor::Text(range)) }); (range, vec![HoverLink::Url(url)]) }) .ok() } else if let Some((filename_range, filename)) = - find_file(&buffer, project.clone(), buffer_position, cx).await + find_file(&buffer, project.clone(), text_anchor, cx).await { let range = maybe!({ - let start = - snapshot.anchor_in_excerpt(excerpt_id, filename_range.start)?; - let end = snapshot.anchor_in_excerpt(excerpt_id, filename_range.end)?; - Some(RangeInEditor::Text(start..end)) + let range = + snapshot.anchor_range_in_excerpt(excerpt_id, filename_range)?; + Some(RangeInEditor::Text(range)) }); Some((range, vec![HoverLink::File(filename)])) } else if let Some(provider) = provider { let task = cx.update(|_, cx| { - provider.definitions(&buffer, buffer_position, preferred_kind, cx) + provider.definitions(&buffer, text_anchor, preferred_kind, cx) })?; if let Some(task) = task { task.await.ok().flatten().map(|definition_result| { ( definition_result.iter().find_map(|link| { link.origin.as_ref().and_then(|origin| { - let start = snapshot.anchor_in_excerpt( + let range = snapshot.anchor_range_in_excerpt( excerpt_id, - origin.range.start, + origin.range.clone(), )?; - let end = snapshot - .anchor_in_excerpt(excerpt_id, origin.range.end)?; - Some(RangeInEditor::Text(start..end)) + Some(RangeInEditor::Text(range)) }) }), definition_result.into_iter().map(HoverLink::Text).collect(), @@ -788,7 +597,8 @@ pub(crate) async fn find_file( let project = project?; let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()).ok()?; let scope = snapshot.language_scope_at(position); - let (range, candidate_file_path) = surrounding_filename(snapshot, position)?; + let (range, candidate_file_path) = surrounding_filename(&snapshot, position)?; + let candidate_len = candidate_file_path.len(); async fn check_path( candidate_file_path: &str, @@ -805,29 +615,66 @@ pub(crate) async fn find_file( .filter(|s| s.is_file()) } - if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await { - return Some((range, existing_path)); + let pattern_candidates = link_pattern_file_candidates(&candidate_file_path); + + for (pattern_candidate, pattern_range) in &pattern_candidates { + if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + return Some(( + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), + existing_path, + )); + } } - if let Some(scope) = scope { - for suffix in scope.path_suffixes() { - if candidate_file_path.ends_with(format!(".{suffix}").as_str()) { - continue; - } + for (pattern_candidate, pattern_range) in pattern_candidates { + for suffix in scope.path_suffixes() { + if pattern_candidate.ends_with(format!(".{suffix}").as_str()) { + continue; + } - let suffixed_candidate = format!("{candidate_file_path}.{suffix}"); - if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await - { - return Some((range, existing_path)); + let suffixed_candidate = format!("{pattern_candidate}.{suffix}"); + if let Some(existing_path) = + check_path(&suffixed_candidate, &project, buffer, cx).await + { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + return Some(( + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), + existing_path, + )); + } } } } - None } +// Tries to capture potentially inlined links, like those found in markdown, +// e.g. [LinkTitle](link_file.txt) +// Since files can have parens, we should always return the full string +// (literally, [LinkTitle](link_file.txt)) as a candidate. +fn link_pattern_file_candidates(candidate: &str) -> Vec<(String, Range)> { + static MD_LINK_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\(([^)]*)\)").expect("Failed to create REGEX")); + + let candidate_len = candidate.len(); + + let mut candidates = vec![(candidate.to_string(), 0..candidate_len)]; + + if let Some(captures) = MD_LINK_REGEX.captures(candidate) { + if let Some(link) = captures.get(1) { + candidates.push((link.as_str().to_string(), link.range())); + } + } + candidates +} + fn surrounding_filename( - snapshot: language::BufferSnapshot, + snapshot: &language::BufferSnapshot, position: text::Anchor, ) -> Option<(Range, String)> { const LIMIT: usize = 2048; @@ -924,13 +771,14 @@ mod tests { DisplayPoint, display_map::ToDisplayPoint, editor_tests::init_test, - inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels}, + inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}, test::editor_lsp_test_context::EditorLspTestContext, }; use futures::StreamExt; - use gpui::Modifiers; + use gpui::{Modifiers, MousePressureEvent, PressureStage}; use indoc::indoc; use lsp::request::{GotoDefinition, GotoTypeDefinition}; + use multi_buffer::MultiBufferOffset; use settings::InlayHintSettingsContent; use util::{assert_set_eq, path}; use workspace::item::Item; @@ -1260,8 +1108,8 @@ mod tests { .clone(); cx.update_editor(|editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); - let anchor_range = snapshot.anchor_before(selection_range.start) - ..snapshot.anchor_after(selection_range.end); + let anchor_range = snapshot.anchor_before(MultiBufferOffset(selection_range.start)) + ..snapshot.anchor_after(MultiBufferOffset(selection_range.end)); editor.change_selections(Default::default(), window, cx, |s| { s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character) }); @@ -1315,7 +1163,7 @@ mod tests { } "})[0] .start; - let hint_position = cx.to_lsp(hint_start_offset); + let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset)); let target_range = cx.lsp_range(indoc! {" struct «TestStruct»; @@ -1355,7 +1203,7 @@ mod tests { cx.background_executor.run_until_parked(); cx.update_editor(|editor, _window, cx| { let expected_layers = vec![hint_label.to_string()]; - assert_eq!(expected_layers, cached_hint_labels(editor)); + assert_eq!(expected_layers, cached_hint_labels(editor, cx)); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); }); @@ -1372,8 +1220,8 @@ mod tests { .unwrap(); let midpoint = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); - let previous_valid = inlay_range.start.to_display_point(&snapshot); - let next_valid = inlay_range.end.to_display_point(&snapshot); + let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot); + let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot); assert_eq!(previous_valid.row(), next_valid.row()); assert!(previous_valid.column() < next_valid.column()); DisplayPoint::new( @@ -1396,7 +1244,7 @@ mod tests { let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); let expected_highlight = InlayHighlight { inlay: InlayId::Hint(0), - inlay_position: buffer_snapshot.anchor_after(inlay_range.start), + inlay_position: buffer_snapshot.anchor_after(MultiBufferOffset(inlay_range.start)), range: 0..hint_label.len(), }; assert_set_eq!(actual_highlights, vec![&expected_highlight]); @@ -1508,6 +1356,58 @@ mod tests { assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into())); } + #[test] + fn test_link_pattern_file_candidates() { + let candidates: Vec = link_pattern_file_candidates("[LinkTitle](link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + assert_eq!( + candidates, + vec!["[LinkTitle](link_file.txt)", "link_file.txt",] + ); + // Link title with spaces in it + let candidates: Vec = link_pattern_file_candidates("LinkTitle](link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + assert_eq!( + candidates, + vec!["LinkTitle](link_file.txt)", "link_file.txt",] + ); + + // Link with spaces + let candidates: Vec = link_pattern_file_candidates("LinkTitle](link\\ _file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!( + candidates, + vec!["LinkTitle](link\\ _file.txt)", "link\\ _file.txt",] + ); + // + // Square brackets not strictly necessary + let candidates: Vec = link_pattern_file_candidates("(link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!(candidates, vec!["(link_file.txt)", "link_file.txt",]); + + // No nesting + let candidates: Vec = + link_pattern_file_candidates("LinkTitle](link_(link_file)file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!( + candidates, + vec!["LinkTitle](link_(link_file)file.txt)", "link_(link_file",] + ) + } + #[gpui::test] async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -1566,7 +1466,7 @@ mod tests { (positions, snapshot) }); - let result = surrounding_filename(snapshot, position); + let result = surrounding_filename(&snapshot, position); if let Some(expected) = expected { assert!(result.is_some(), "Failed to find file path: {}", input); @@ -1898,4 +1798,77 @@ mod tests { cx.simulate_click(screen_coord, Modifiers::secondary_key()); cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1)); } + + #[gpui::test] + async fn test_pressure_links(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + definition_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + fn ˇtest() { do_work(); } + fn do_work() { test(); } + "}); + + // Position the mouse over a symbol that has a definition + let hover_point = cx.pixel_position(indoc! {" + fn test() { do_wˇork(); } + fn do_work() { test(); } + "}); + let symbol_range = cx.lsp_range(indoc! {" + fn test() { «do_work»(); } + fn do_work() { test(); } + "}); + let target_range = cx.lsp_range(indoc! {" + fn test() { do_work(); } + fn «do_work»() { test(); } + "}); + + let mut requests = + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: Some(symbol_range), + target_uri: url.clone(), + target_range, + target_selection_range: target_range, + }, + ]))) + }); + + cx.simulate_mouse_move(hover_point, None, Modifiers::none()); + + // First simulate Normal pressure to set up the previous stage + cx.simulate_event(MousePressureEvent { + pressure: 0.5, + stage: PressureStage::Normal, + position: hover_point, + modifiers: Modifiers::none(), + }); + cx.background_executor.run_until_parked(); + + // Now simulate Force pressure to trigger the force click and go-to definition + cx.simulate_event(MousePressureEvent { + pressure: 1.0, + stage: PressureStage::Force, + position: hover_point, + modifiers: Modifiers::none(), + }); + requests.next().await; + cx.background_executor.run_until_parked(); + + // Assert that we navigated to the definition + cx.assert_editor_state(indoc! {" + fn test() { do_work(); } + fn «do_workˇ»() { test(); } + "}); + } } diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 863ce297be..7c3e41e8c2 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -1,8 +1,9 @@ use crate::{ ActiveDiagnostic, Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot, GlobalDiagnosticRenderer, Hover, - display_map::{InlayOffset, ToDisplayPoint, invisibles::is_invisible}, + display_map::{InlayOffset, ToDisplayPoint, is_invisible}, hover_links::{InlayHighlight, RangeInEditor}, + movement::TextLayoutDetails, scroll::ScrollAmount, }; use anyhow::Context as _; @@ -16,7 +17,7 @@ use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; use lsp::DiagnosticSeverity; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use multi_buffer::{ToOffset, ToPoint}; +use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; use settings::Settings; use std::{borrow::Cow, cell::RefCell}; @@ -27,7 +28,6 @@ use ui::{Scrollbars, WithScrollbar, prelude::*, theme_is_transparent}; use url::Url; use util::TryFutureExt; use workspace::{OpenOptions, OpenVisible, Workspace}; -pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200; pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.; pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.; @@ -106,7 +106,7 @@ pub fn find_hovered_hint_part( hovered_offset: InlayOffset, ) -> Option<(InlayHintLabelPart, Range)> { if hovered_offset >= hint_start { - let mut hovered_character = (hovered_offset - hint_start).0; + let mut hovered_character = hovered_offset - hint_start; let mut part_start = hint_start; for part in label_parts { let part_len = part.value.chars().count(); @@ -151,10 +151,10 @@ pub fn hover_at_inlay( false }) { - hide_hover(editor, cx); + return; } - let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay; + let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; let task = cx.spawn_in(window, async move |this, cx| { async move { @@ -275,7 +275,7 @@ fn show_hover( return None; } - let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay; + let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; let all_diagnostics_active = editor.active_diagnostics == ActiveDiagnostic::All; let active_group_id = if let ActiveDiagnostic::Group(group) = &editor.active_diagnostics { Some(group.group_id) @@ -290,15 +290,18 @@ fn show_hover( let delay = if ignore_timeout { None } else { + let lsp_request_early = hover_popover_delay / 2; + cx.background_executor() + .timer(Duration::from_millis( + hover_popover_delay - lsp_request_early, + )) + .await; + // Construct delay task to wait for later let total_delay = Some( cx.background_executor() - .timer(Duration::from_millis(hover_popover_delay)), + .timer(Duration::from_millis(lsp_request_early)), ); - - cx.background_executor() - .timer(Duration::from_millis(HOVER_REQUEST_DELAY_MILLIS)) - .await; total_delay }; @@ -307,19 +310,18 @@ fn show_hover( if let Some(delay) = delay { delay.await; } - let offset = anchor.to_offset(&snapshot.buffer_snapshot()); let local_diagnostic = if all_diagnostics_active { None } else { snapshot .buffer_snapshot() - .diagnostics_with_buffer_ids_in_range::(offset..offset) + .diagnostics_with_buffer_ids_in_range::(offset..offset) .filter(|(_, diagnostic)| { Some(diagnostic.diagnostic.group_id) != active_group_id }) // Find the entry with the most specific range - .min_by_key(|(_, entry)| entry.range.len()) + .min_by_key(|(_, entry)| entry.range.end - entry.range.start) }; let diagnostic_popover = if let Some((buffer_id, local_diagnostic)) = local_diagnostic { @@ -339,7 +341,13 @@ fn show_hover( renderer .as_ref() .and_then(|renderer| { - renderer.render_hover(group, point_range, buffer_id, cx) + renderer.render_hover( + group, + point_range, + buffer_id, + language_registry.clone(), + cx, + ) }) .context("no rendered diagnostic") })??; @@ -467,13 +475,10 @@ fn show_hover( let range = hover_result .range .and_then(|range| { - let start = snapshot + let range = snapshot .buffer_snapshot() - .anchor_in_excerpt(excerpt_id, range.start)?; - let end = snapshot - .buffer_snapshot() - .anchor_in_excerpt(excerpt_id, range.end)?; - Some(start..end) + .anchor_range_in_excerpt(excerpt_id, range)?; + Some(range) }) .or_else(|| { let snapshot = &snapshot.buffer_snapshot(); @@ -513,7 +518,7 @@ fn show_hover( // Highlight the selected symbol using a background highlight editor.highlight_background::( &hover_highlights, - |theme| theme.colors().element_hover, // todo update theme + |_, theme| theme.colors().element_hover, // todo update theme cx, ); } @@ -602,23 +607,30 @@ async fn parse_blocks( pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let settings = ThemeSettings::get_global(cx); let ui_font_family = settings.ui_font.family.clone(); + let ui_font_features = settings.ui_font.features.clone(); let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); let buffer_font_family = settings.buffer_font.family.clone(); + let buffer_font_features = settings.buffer_font.features.clone(); let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); let mut base_text_style = window.text_style(); base_text_style.refine(&TextStyleRefinement { font_family: Some(ui_font_family), + font_features: Some(ui_font_features), font_fallbacks: ui_font_fallbacks, color: Some(cx.theme().colors().editor_foreground), ..Default::default() }); MarkdownStyle { base_text_style, - code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx), + code_block: StyleRefinement::default() + .my(rems(1.)) + .font_buffer(cx) + .font_features(buffer_font_features.clone()), inline_code: TextStyleRefinement { background_color: Some(cx.theme().colors().background), font_family: Some(buffer_font_family), + font_features: Some(buffer_font_features), font_fallbacks: buffer_font_fallbacks, ..Default::default() }, @@ -652,12 +664,15 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let settings = ThemeSettings::get_global(cx); let ui_font_family = settings.ui_font.family.clone(); let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); + let ui_font_features = settings.ui_font.features.clone(); let buffer_font_family = settings.buffer_font.family.clone(); + let buffer_font_features = settings.buffer_font.features.clone(); let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); let mut base_text_style = window.text_style(); base_text_style.refine(&TextStyleRefinement { font_family: Some(ui_font_family), + font_features: Some(ui_font_features), font_fallbacks: ui_font_fallbacks, color: Some(cx.theme().colors().editor_foreground), ..Default::default() @@ -668,6 +683,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { inline_code: TextStyleRefinement { background_color: Some(cx.theme().colors().editor_background.opacity(0.5)), font_family: Some(buffer_font_family), + font_features: Some(buffer_font_features), font_fallbacks: buffer_font_fallbacks, ..Default::default() }, @@ -768,9 +784,13 @@ impl HoverState { snapshot: &EditorSnapshot, visible_rows: Range, max_size: Size, + text_layout_details: &TextLayoutDetails, window: &mut Window, cx: &mut Context, ) -> Option<(DisplayPoint, Vec)> { + if !self.visible() { + return None; + } // If there is a diagnostic, position the popovers based on that. // Otherwise use the start of the hover range let anchor = self @@ -793,10 +813,32 @@ impl HoverState { } }) })?; - let point = anchor.to_display_point(&snapshot.display_snapshot); + let mut point = anchor.to_display_point(&snapshot.display_snapshot); + // Clamp the point within the visible rows in case the popup source spans multiple lines + if visible_rows.end <= point.row() { + point = crate::movement::up_by_rows( + &snapshot.display_snapshot, + point, + 1 + (point.row() - visible_rows.end).0, + text::SelectionGoal::None, + true, + text_layout_details, + ) + .0; + } else if point.row() < visible_rows.start { + point = crate::movement::down_by_rows( + &snapshot.display_snapshot, + point, + (visible_rows.start - point.row()).0, + text::SelectionGoal::None, + true, + text_layout_details, + ) + .0; + } - // Don't render if the relevant point isn't on screen - if !self.visible() || !visible_rows.contains(&point.row()) { + if !visible_rows.contains(&point.row()) { + log::error!("Hover popover point out of bounds after moving"); return None; } @@ -862,7 +904,6 @@ impl InfoPopover { *keyboard_grace = false; cx.stop_propagation(); }) - .p_2() .when_some(self.parsed_content.clone(), |this, markdown| { this.child( div() @@ -878,12 +919,13 @@ impl InfoPopover { copy_button_on_hover: false, border: false, }) - .on_url_click(open_markdown_url), + .on_url_click(open_markdown_url) + .p_2(), ), ) .custom_scrollbars( Scrollbars::for_settings::() - .tracked_scroll_handle(self.scroll_handle.clone()), + .tracked_scroll_handle(&self.scroll_handle), window, cx, ) @@ -961,6 +1003,11 @@ impl DiagnosticPopover { self.markdown.clone(), diagnostics_markdown_style(window, cx), ) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) .on_url_click( move |link, window, cx| { if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) @@ -976,7 +1023,7 @@ impl DiagnosticPopover { ) .custom_scrollbars( Scrollbars::for_settings::() - .tracked_scroll_handle(self.scroll_handle.clone()), + .tracked_scroll_handle(&self.scroll_handle), window, cx, ), @@ -989,17 +1036,17 @@ impl DiagnosticPopover { mod tests { use super::*; use crate::{ - InlayId, PointForPosition, + PointForPosition, actions::ConfirmCompletion, editor_tests::{handle_completion_request, init_test}, - hover_links::update_inlay_link_and_hover_points, - inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels}, + inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}, test::editor_lsp_test_context::EditorLspTestContext, }; use collections::BTreeSet; use gpui::App; use indoc::indoc; use markdown::parser::MarkdownEvent; + use project::InlayId; use settings::InlayHintSettingsContent; use smol::stream::StreamExt; use std::sync::atomic; @@ -1007,7 +1054,7 @@ mod tests { use text::Bias; fn get_hover_popover_delay(cx: &gpui::TestAppContext) -> u64 { - cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay }) + cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay.0 }) } impl InfoPopover { @@ -1597,7 +1644,7 @@ mod tests { } "})[0] .start; - let hint_position = cx.to_lsp(hint_start_offset); + let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset)); let new_type_target_range = cx.lsp_range(indoc! {" struct TestStruct; @@ -1651,7 +1698,7 @@ mod tests { cx.background_executor.run_until_parked(); cx.update_editor(|editor, _, cx| { let expected_layers = vec![entire_hint_label.to_string()]; - assert_eq!(expected_layers, cached_hint_labels(editor)); + assert_eq!(expected_layers, cached_hint_labels(editor, cx)); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); }); @@ -1672,8 +1719,8 @@ mod tests { .unwrap(); let new_type_hint_part_hover_position = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); - let previous_valid = inlay_range.start.to_display_point(&snapshot); - let next_valid = inlay_range.end.to_display_point(&snapshot); + let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot); + let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot); assert_eq!(previous_valid.row(), next_valid.row()); assert!(previous_valid.column() < next_valid.column()); let exact_unclipped = DisplayPoint::new( @@ -1690,10 +1737,9 @@ mod tests { } }); cx.update_editor(|editor, window, cx| { - update_inlay_link_and_hover_points( + editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, - editor, true, false, window, @@ -1761,10 +1807,9 @@ mod tests { cx.background_executor.run_until_parked(); cx.update_editor(|editor, window, cx| { - update_inlay_link_and_hover_points( + editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, - editor, true, false, window, @@ -1785,7 +1830,8 @@ mod tests { popover.symbol_range, RangeInEditor::Inlay(InlayHighlight { inlay: InlayId::Hint(0), - inlay_position: buffer_snapshot.anchor_after(inlay_range.start), + inlay_position: buffer_snapshot + .anchor_after(MultiBufferOffset(inlay_range.start)), range: ": ".len()..": ".len() + new_type_label.len(), }), "Popover range should match the new type label part" @@ -1798,8 +1844,8 @@ mod tests { let struct_hint_part_hover_position = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); - let previous_valid = inlay_range.start.to_display_point(&snapshot); - let next_valid = inlay_range.end.to_display_point(&snapshot); + let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot); + let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot); assert_eq!(previous_valid.row(), next_valid.row()); assert!(previous_valid.column() < next_valid.column()); let exact_unclipped = DisplayPoint::new( @@ -1816,10 +1862,9 @@ mod tests { } }); cx.update_editor(|editor, window, cx| { - update_inlay_link_and_hover_points( + editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), struct_hint_part_hover_position, - editor, true, false, window, @@ -1840,7 +1885,8 @@ mod tests { popover.symbol_range, RangeInEditor::Inlay(InlayHighlight { inlay: InlayId::Hint(0), - inlay_position: buffer_snapshot.anchor_after(inlay_range.start), + inlay_position: buffer_snapshot + .anchor_after(MultiBufferOffset(inlay_range.start)), range: ": ".len() + new_type_label.len() + "<".len() ..": ".len() + new_type_label.len() + "<".len() + struct_label.len(), }), diff --git a/crates/editor/src/indent_guides.rs b/crates/editor/src/indent_guides.rs index 22b57bd805..f186f9da77 100644 --- a/crates/editor/src/indent_guides.rs +++ b/crates/editor/src/indent_guides.rs @@ -69,7 +69,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option> { - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); let cursor_row = MultiBufferRow(selection.head().row); let state = &mut self.active_indent_guides_state; @@ -181,6 +181,10 @@ pub fn indent_guides_in_range( .buffer_snapshot() .indent_guides_in_range(start_anchor..end_anchor, ignore_disabled_for_language, cx) .filter(|indent_guide| { + if editor.has_indent_guides_disabled_for_buffer(indent_guide.buffer_id) { + return false; + } + if editor.is_buffer_folded(indent_guide.buffer_id, cx) { return false; } diff --git a/crates/editor/src/inlays.rs b/crates/editor/src/inlays.rs new file mode 100644 index 0000000000..f07bf0b315 --- /dev/null +++ b/crates/editor/src/inlays.rs @@ -0,0 +1,193 @@ +//! The logic, responsible for managing [`Inlay`]s in the editor. +//! +//! Inlays are "not real" text that gets mixed into the "real" buffer's text. +//! They are attached to a certain [`Anchor`], and display certain contents (usually, strings) +//! between real text around that anchor. +//! +//! Inlay examples in Zed: +//! * inlay hints, received from LSP +//! * inline values, shown in the debugger +//! * inline predictions, showing the Zeta/Copilot/etc. predictions +//! * document color values, if configured to be displayed as inlays +//! * ... anything else, potentially. +//! +//! Editor uses [`crate::DisplayMap`] and [`crate::display_map::InlayMap`] to manage what's rendered inside the editor, using +//! [`InlaySplice`] to update this state. + +/// Logic, related to managing LSP inlay hint inlays. +pub mod inlay_hints; + +use std::{any::TypeId, sync::OnceLock}; + +use gpui::{Context, HighlightStyle, Hsla, Rgba, Task}; +use multi_buffer::Anchor; +use project::{InlayHint, InlayId}; +use text::Rope; + +use crate::{Editor, hover_links::InlayHighlight}; + +/// A splice to send into the `inlay_map` for updating the visible inlays on the screen. +/// "Visible" inlays may not be displayed in the buffer right away, but those are ready to be displayed on further buffer scroll, pane item activations, etc. right away without additional LSP queries or settings changes. +/// The data in the cache is never used directly for displaying inlays on the screen, to avoid races with updates from LSP queries and sync overhead. +/// Splice is picked to help avoid extra hint flickering and "jumps" on the screen. +#[derive(Debug, Default)] +pub struct InlaySplice { + pub to_remove: Vec, + pub to_insert: Vec, +} + +impl InlaySplice { + pub fn is_empty(&self) -> bool { + self.to_remove.is_empty() && self.to_insert.is_empty() + } +} + +#[derive(Debug, Clone)] +pub struct Inlay { + pub id: InlayId, + pub position: Anchor, + pub content: InlayContent, +} + +#[derive(Debug, Clone)] +pub enum InlayContent { + Text(text::Rope), + Color(Hsla), +} + +impl Inlay { + pub fn hint(id: InlayId, position: Anchor, hint: &InlayHint) -> Self { + let mut text = hint.text(); + if hint.padding_right && text.reversed_chars_at(text.len()).next() != Some(' ') { + text.push(" "); + } + if hint.padding_left && text.chars_at(0).next() != Some(' ') { + text.push_front(" "); + } + Self { + id, + position, + content: InlayContent::Text(text), + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn mock_hint(id: usize, position: Anchor, text: impl Into) -> Self { + Self { + id: InlayId::Hint(id), + position, + content: InlayContent::Text(text.into()), + } + } + + pub fn color(id: usize, position: Anchor, color: Rgba) -> Self { + Self { + id: InlayId::Color(id), + position, + content: InlayContent::Color(color.into()), + } + } + + pub fn edit_prediction>(id: usize, position: Anchor, text: T) -> Self { + Self { + id: InlayId::EditPrediction(id), + position, + content: InlayContent::Text(text.into()), + } + } + + pub fn debugger>(id: usize, position: Anchor, text: T) -> Self { + Self { + id: InlayId::DebuggerValue(id), + position, + content: InlayContent::Text(text.into()), + } + } + + pub fn text(&self) -> &Rope { + static COLOR_TEXT: OnceLock = OnceLock::new(); + match &self.content { + InlayContent::Text(text) => text, + InlayContent::Color(_) => COLOR_TEXT.get_or_init(|| Rope::from("◼")), + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_color(&self) -> Option { + match self.content { + InlayContent::Color(color) => Some(color), + _ => None, + } + } +} + +pub struct InlineValueCache { + pub enabled: bool, + pub inlays: Vec, + pub refresh_task: Task>, +} + +impl InlineValueCache { + pub fn new(enabled: bool) -> Self { + Self { + enabled, + inlays: Vec::new(), + refresh_task: Task::ready(None), + } + } +} + +impl Editor { + /// Modify which hints are displayed in the editor. + pub fn splice_inlays( + &mut self, + to_remove: &[InlayId], + to_insert: Vec, + cx: &mut Context, + ) { + if let Some(inlay_hints) = &mut self.inlay_hints { + for id_to_remove in to_remove { + inlay_hints.added_hints.remove(id_to_remove); + } + } + self.display_map.update(cx, |display_map, cx| { + display_map.splice_inlays(to_remove, to_insert, cx) + }); + cx.notify(); + } + + pub(crate) fn highlight_inlays( + &mut self, + highlights: Vec, + style: HighlightStyle, + cx: &mut Context, + ) { + self.display_map.update(cx, |map, _| { + map.highlight_inlays(TypeId::of::(), highlights, style) + }); + cx.notify(); + } + + pub fn inline_values_enabled(&self) -> bool { + self.inline_value_cache.enabled + } + + #[cfg(any(test, feature = "test-support"))] + pub fn inline_value_inlays(&self, cx: &gpui::App) -> Vec { + self.display_map + .read(cx) + .current_inlays() + .filter(|inlay| matches!(inlay.id, InlayId::DebuggerValue(_))) + .cloned() + .collect() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn all_inlays(&self, cx: &gpui::App) -> Vec { + self.display_map + .read(cx) + .current_inlays() + .cloned() + .collect() + } +} diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlays/inlay_hints.rs similarity index 52% rename from crates/editor/src/inlay_hint_cache.rs rename to crates/editor/src/inlays/inlay_hints.rs index 63d74c73e1..18bbc56005 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -1,295 +1,116 @@ -/// Stores and updates all data received from LSP textDocument/inlayHint requests. -/// Has nothing to do with other inlays, e.g. copilot suggestions — those are stored elsewhere. -/// On every update, cache may query for more inlay hints and update inlays on the screen. -/// -/// Inlays stored on screen are in [`crate::display_map::inlay_map`] and this cache is the only way to update any inlay hint data in the visible hints in the inlay map. -/// For determining the update to the `inlay_map`, the cache requires a list of visible inlay hints — all other hints are not relevant and their separate updates are not influencing the cache work. -/// -/// Due to the way the data is stored for both visible inlays and the cache, every inlay (and inlay hint) collection is editor-specific, so a single buffer may have multiple sets of inlays of open on different panes. use std::{ - cmp, + collections::hash_map, ops::{ControlFlow, Range}, - sync::Arc, time::Duration, }; -use crate::{ - Anchor, Editor, ExcerptId, InlayId, MultiBuffer, MultiBufferSnapshot, display_map::Inlay, -}; -use anyhow::Context as _; use clock::Global; -use futures::future; -use gpui::{AppContext as _, AsyncApp, Context, Entity, Task, Window}; +use collections::{HashMap, HashSet}; +use futures::future::join_all; +use gpui::{App, Entity, Task}; use language::{ - Buffer, BufferSnapshot, - language_settings::{InlayHintKind, InlayHintSettings}, + BufferRow, + language_settings::{InlayHintKind, InlayHintSettings, language_settings}, }; -use parking_lot::RwLock; -use project::{InlayHint, ResolveState}; +use lsp::LanguageServerId; +use multi_buffer::{Anchor, ExcerptId, MultiBufferSnapshot}; +use project::{ + HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip, InlayHintTooltip, + InvalidationStrategy, ResolveState, + lsp_store::{CacheInlayHints, ResolvedHint}, +}; +use text::{Bias, BufferId}; +use ui::{Context, Window}; +use util::debug_panic; -use collections::{HashMap, HashSet, hash_map}; -use smol::lock::Semaphore; -use sum_tree::Bias; -use text::{BufferId, ToOffset, ToPoint}; -use util::{ResultExt, post_inc}; +use super::{Inlay, InlayId}; +use crate::{ + Editor, EditorSnapshot, PointForPosition, ToggleInlayHints, ToggleInlineValues, debounce_value, + hover_links::{InlayHighlight, TriggerPoint, show_link_definition}, + hover_popover::{self, InlayHover}, + inlays::InlaySplice, +}; -pub struct InlayHintCache { - hints: HashMap>>, - allowed_hint_kinds: HashSet>, - version: usize, - pub(super) enabled: bool, +pub fn inlay_hint_settings( + location: Anchor, + snapshot: &MultiBufferSnapshot, + cx: &mut Context, +) -> InlayHintSettings { + let file = snapshot.file_at(location); + let language = snapshot.language_at(location).map(|l| l.name()); + language_settings(language, file, cx).inlay_hints +} + +#[derive(Debug)] +pub struct LspInlayHintData { + enabled: bool, modifiers_override: bool, enabled_in_settings: bool, - update_tasks: HashMap, - refresh_task: Task<()>, + allowed_hint_kinds: HashSet>, invalidate_debounce: Option, append_debounce: Option, - lsp_request_limiter: Arc, + hint_refresh_tasks: HashMap>>, + hint_chunk_fetching: HashMap>)>, + invalidate_hints_for_buffers: HashSet, + pub added_hints: HashMap>, } -#[derive(Debug)] -struct TasksForRanges { - tasks: Vec>, - sorted_ranges: Vec>, -} - -#[derive(Debug)] -struct CachedExcerptHints { - version: usize, - buffer_version: Global, - buffer_id: BufferId, - ordered_hints: Vec, - hints_by_id: HashMap, -} - -/// A logic to apply when querying for new inlay hints and deciding what to do with the old entries in the cache in case of conflicts. -#[derive(Debug, Clone, Copy)] -pub(super) enum InvalidationStrategy { - /// Hints reset is requested by the LSP server. - /// Demands to re-query all inlay hints needed and invalidate all cached entries, but does not require instant update with invalidation. - /// - /// Despite nothing forbids language server from sending this request on every edit, it is expected to be sent only when certain internal server state update, invisible for the editor otherwise. - RefreshRequested, - /// Multibuffer excerpt(s) and/or singleton buffer(s) were edited at least on one place. - /// Neither editor nor LSP is able to tell which open file hints' are not affected, so all of them have to be invalidated, re-queried and do that fast enough to avoid being slow, but also debounce to avoid loading hints on every fast keystroke sequence. - BufferEdited, - /// A new file got opened/new excerpt was added to a multibuffer/a [multi]buffer was scrolled to a new position. - /// No invalidation should be done at all, all new hints are added to the cache. - /// - /// A special case is the settings change: in addition to LSP capabilities, Zed allows omitting certain hint kinds (defined by the corresponding LSP part: type/parameter/other). - /// This does not lead to cache invalidation, but would require cache usage for determining which hints are not displayed and issuing an update to inlays on the screen. - None, -} - -/// A splice to send into the `inlay_map` for updating the visible inlays on the screen. -/// "Visible" inlays may not be displayed in the buffer right away, but those are ready to be displayed on further buffer scroll, pane item activations, etc. right away without additional LSP queries or settings changes. -/// The data in the cache is never used directly for displaying inlays on the screen, to avoid races with updates from LSP queries and sync overhead. -/// Splice is picked to help avoid extra hint flickering and "jumps" on the screen. -#[derive(Debug, Default)] -pub(super) struct InlaySplice { - pub to_remove: Vec, - pub to_insert: Vec, -} - -#[derive(Debug)] -struct ExcerptHintsUpdate { - excerpt_id: ExcerptId, - remove_from_visible: HashSet, - remove_from_cache: HashSet, - add_to_cache: Vec, -} - -#[derive(Debug, Clone, Copy)] -struct ExcerptQuery { - buffer_id: BufferId, - excerpt_id: ExcerptId, - cache_version: usize, - invalidate: InvalidationStrategy, - reason: &'static str, -} - -impl InvalidationStrategy { - fn should_invalidate(&self) -> bool { - matches!( - self, - InvalidationStrategy::RefreshRequested | InvalidationStrategy::BufferEdited - ) - } -} - -impl TasksForRanges { - fn new(query_ranges: QueryRanges, task: Task<()>) -> Self { +impl LspInlayHintData { + pub fn new(settings: InlayHintSettings) -> Self { Self { - tasks: vec![task], - sorted_ranges: query_ranges.into_sorted_query_ranges(), - } - } - - fn update_cached_tasks( - &mut self, - buffer_snapshot: &BufferSnapshot, - query_ranges: QueryRanges, - invalidate: InvalidationStrategy, - spawn_task: impl FnOnce(QueryRanges) -> Task<()>, - ) { - let query_ranges = if invalidate.should_invalidate() { - self.tasks.clear(); - self.sorted_ranges = query_ranges.clone().into_sorted_query_ranges(); - query_ranges - } else { - let mut non_cached_query_ranges = query_ranges; - non_cached_query_ranges.before_visible = non_cached_query_ranges - .before_visible - .into_iter() - .flat_map(|query_range| { - self.remove_cached_ranges_from_query(buffer_snapshot, query_range) - }) - .collect(); - non_cached_query_ranges.visible = non_cached_query_ranges - .visible - .into_iter() - .flat_map(|query_range| { - self.remove_cached_ranges_from_query(buffer_snapshot, query_range) - }) - .collect(); - non_cached_query_ranges.after_visible = non_cached_query_ranges - .after_visible - .into_iter() - .flat_map(|query_range| { - self.remove_cached_ranges_from_query(buffer_snapshot, query_range) - }) - .collect(); - non_cached_query_ranges - }; - - if !query_ranges.is_empty() { - self.tasks.push(spawn_task(query_ranges)); - } - } - - fn remove_cached_ranges_from_query( - &mut self, - buffer_snapshot: &BufferSnapshot, - query_range: Range, - ) -> Vec> { - let mut ranges_to_query = Vec::new(); - let mut latest_cached_range = None::<&mut Range>; - for cached_range in self - .sorted_ranges - .iter_mut() - .skip_while(|cached_range| { - cached_range - .end - .cmp(&query_range.start, buffer_snapshot) - .is_lt() - }) - .take_while(|cached_range| { - cached_range - .start - .cmp(&query_range.end, buffer_snapshot) - .is_le() - }) - { - match latest_cached_range { - Some(latest_cached_range) => { - if latest_cached_range.end.offset.saturating_add(1) < cached_range.start.offset - { - ranges_to_query.push(latest_cached_range.end..cached_range.start); - cached_range.start = latest_cached_range.end; - } - } - None => { - if query_range - .start - .cmp(&cached_range.start, buffer_snapshot) - .is_lt() - { - ranges_to_query.push(query_range.start..cached_range.start); - cached_range.start = query_range.start; - } - } - } - latest_cached_range = Some(cached_range); - } - - match latest_cached_range { - Some(latest_cached_range) => { - if latest_cached_range.end.offset.saturating_add(1) < query_range.end.offset { - ranges_to_query.push(latest_cached_range.end..query_range.end); - latest_cached_range.end = query_range.end; - } - } - None => { - ranges_to_query.push(query_range.clone()); - self.sorted_ranges.push(query_range); - self.sorted_ranges - .sort_by(|range_a, range_b| range_a.start.cmp(&range_b.start, buffer_snapshot)); - } - } - - ranges_to_query - } - - fn invalidate_range(&mut self, buffer: &BufferSnapshot, range: &Range) { - self.sorted_ranges = self - .sorted_ranges - .drain(..) - .filter_map(|mut cached_range| { - if cached_range.start.cmp(&range.end, buffer).is_gt() - || cached_range.end.cmp(&range.start, buffer).is_lt() - { - Some(vec![cached_range]) - } else if cached_range.start.cmp(&range.start, buffer).is_ge() - && cached_range.end.cmp(&range.end, buffer).is_le() - { - None - } else if range.start.cmp(&cached_range.start, buffer).is_ge() - && range.end.cmp(&cached_range.end, buffer).is_le() - { - Some(vec![ - cached_range.start..range.start, - range.end..cached_range.end, - ]) - } else if cached_range.start.cmp(&range.start, buffer).is_ge() { - cached_range.start = range.end; - Some(vec![cached_range]) - } else { - cached_range.end = range.start; - Some(vec![cached_range]) - } - }) - .flatten() - .collect(); - } -} - -impl InlayHintCache { - pub(super) fn new(inlay_hint_settings: InlayHintSettings) -> Self { - Self { - allowed_hint_kinds: inlay_hint_settings.enabled_inlay_hint_kinds(), - enabled: inlay_hint_settings.enabled, modifiers_override: false, - enabled_in_settings: inlay_hint_settings.enabled, - hints: HashMap::default(), - update_tasks: HashMap::default(), - refresh_task: Task::ready(()), - invalidate_debounce: debounce_value(inlay_hint_settings.edit_debounce_ms), - append_debounce: debounce_value(inlay_hint_settings.scroll_debounce_ms), - version: 0, - lsp_request_limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_LSP_REQUESTS)), + enabled: settings.enabled, + enabled_in_settings: settings.enabled, + hint_refresh_tasks: HashMap::default(), + added_hints: HashMap::default(), + hint_chunk_fetching: HashMap::default(), + invalidate_hints_for_buffers: HashSet::default(), + invalidate_debounce: debounce_value(settings.edit_debounce_ms), + append_debounce: debounce_value(settings.scroll_debounce_ms), + allowed_hint_kinds: settings.enabled_inlay_hint_kinds(), } } + pub fn modifiers_override(&mut self, new_override: bool) -> Option { + if self.modifiers_override == new_override { + return None; + } + self.modifiers_override = new_override; + if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override) + { + self.clear(); + Some(false) + } else { + Some(true) + } + } + + pub fn toggle(&mut self, enabled: bool) -> bool { + if self.enabled == enabled { + return false; + } + self.enabled = enabled; + self.modifiers_override = false; + if !enabled { + self.clear(); + } + true + } + + pub fn clear(&mut self) { + self.hint_refresh_tasks.clear(); + self.hint_chunk_fetching.clear(); + self.added_hints.clear(); + } + /// Checks inlay hint settings for enabled hint kinds and general enabled state. /// Generates corresponding inlay_map splice updates on settings changes. /// Does not update inlay hint cache state on disabling or inlay hint kinds change: only reenabling forces new LSP queries. - pub(super) fn update_settings( + fn update_settings( &mut self, - multi_buffer: &Entity, new_hint_settings: InlayHintSettings, visible_hints: Vec, - cx: &mut Context, - ) -> ControlFlow> { + ) -> ControlFlow, Option> { let old_enabled = self.enabled; // If the setting for inlay hints has changed, update `enabled`. This condition avoids inlay // hint visibility changes when other settings change (such as theme). @@ -314,23 +135,30 @@ impl InlayHintCache { if new_allowed_hint_kinds == self.allowed_hint_kinds { ControlFlow::Break(None) } else { - let new_splice = self.new_allowed_hint_kinds_splice( - multi_buffer, - &visible_hints, - &new_allowed_hint_kinds, - cx, - ); - if new_splice.is_some() { - self.version += 1; - self.allowed_hint_kinds = new_allowed_hint_kinds; - } - ControlFlow::Break(new_splice) + self.allowed_hint_kinds = new_allowed_hint_kinds; + ControlFlow::Continue( + Some(InlaySplice { + to_remove: visible_hints + .iter() + .filter_map(|inlay| { + let inlay_kind = self.added_hints.get(&inlay.id).copied()?; + if !self.allowed_hint_kinds.contains(&inlay_kind) { + Some(inlay.id) + } else { + None + } + }) + .collect(), + to_insert: Vec::new(), + }) + .filter(|splice| !splice.is_empty()), + ) } } (true, false) => { self.modifiers_override = false; self.allowed_hint_kinds = new_allowed_hint_kinds; - if self.hints.is_empty() { + if visible_hints.is_empty() { ControlFlow::Break(None) } else { self.clear(); @@ -343,978 +171,804 @@ impl InlayHintCache { (false, true) => { self.modifiers_override = false; self.allowed_hint_kinds = new_allowed_hint_kinds; - ControlFlow::Continue(()) - } - } - } - - pub(super) fn modifiers_override(&mut self, new_override: bool) -> Option { - if self.modifiers_override == new_override { - return None; - } - self.modifiers_override = new_override; - if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override) - { - self.clear(); - Some(false) - } else { - Some(true) - } - } - - pub(super) fn toggle(&mut self, enabled: bool) -> bool { - if self.enabled == enabled { - return false; - } - self.enabled = enabled; - self.modifiers_override = false; - if !enabled { - self.clear(); - } - true - } - - /// If needed, queries LSP for new inlay hints, using the invalidation strategy given. - /// To reduce inlay hint jumping, attempts to query a visible range of the editor(s) first, - /// followed by the delayed queries of the same range above and below the visible one. - /// This way, subsequent refresh invocations are less likely to trigger LSP queries for the invisible ranges. - pub(super) fn spawn_hint_refresh( - &mut self, - reason_description: &'static str, - excerpts_to_query: HashMap, Global, Range)>, - invalidate: InvalidationStrategy, - ignore_debounce: bool, - cx: &mut Context, - ) -> Option { - if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override) - { - return None; - } - let mut invalidated_hints = Vec::new(); - if invalidate.should_invalidate() { - self.update_tasks - .retain(|task_excerpt_id, _| excerpts_to_query.contains_key(task_excerpt_id)); - self.hints.retain(|cached_excerpt, cached_hints| { - let retain = excerpts_to_query.contains_key(cached_excerpt); - if !retain { - invalidated_hints.extend(cached_hints.read().ordered_hints.iter().copied()); - } - retain - }); - } - if excerpts_to_query.is_empty() && invalidated_hints.is_empty() { - return None; - } - - let cache_version = self.version + 1; - let debounce_duration = if ignore_debounce { - None - } else if invalidate.should_invalidate() { - self.invalidate_debounce - } else { - self.append_debounce - }; - self.refresh_task = cx.spawn(async move |editor, cx| { - if let Some(debounce_duration) = debounce_duration { - cx.background_executor().timer(debounce_duration).await; - } - - editor - .update(cx, |editor, cx| { - spawn_new_update_tasks( - editor, - reason_description, - excerpts_to_query, - invalidate, - cache_version, - cx, - ) - }) - .ok(); - }); - - if invalidated_hints.is_empty() { - None - } else { - Some(InlaySplice { - to_remove: invalidated_hints, - to_insert: Vec::new(), - }) - } - } - - fn new_allowed_hint_kinds_splice( - &self, - multi_buffer: &Entity, - visible_hints: &[Inlay], - new_kinds: &HashSet>, - cx: &mut Context, - ) -> Option { - let old_kinds = &self.allowed_hint_kinds; - if new_kinds == old_kinds { - return None; - } - - let mut to_remove = Vec::new(); - let mut to_insert = Vec::new(); - let mut shown_hints_to_remove = visible_hints.iter().fold( - HashMap::>::default(), - |mut current_hints, inlay| { - current_hints - .entry(inlay.position.excerpt_id) - .or_default() - .push((inlay.position, inlay.id)); - current_hints - }, - ); - - let multi_buffer = multi_buffer.read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - - for (excerpt_id, excerpt_cached_hints) in &self.hints { - let shown_excerpt_hints_to_remove = - shown_hints_to_remove.entry(*excerpt_id).or_default(); - let excerpt_cached_hints = excerpt_cached_hints.read(); - let mut excerpt_cache = excerpt_cached_hints.ordered_hints.iter().fuse().peekable(); - shown_excerpt_hints_to_remove.retain(|(shown_anchor, shown_hint_id)| { - let Some(buffer) = multi_buffer.buffer_for_anchor(*shown_anchor, cx) else { - return false; - }; - let buffer_snapshot = buffer.read(cx).snapshot(); - loop { - match excerpt_cache.peek() { - Some(&cached_hint_id) => { - let cached_hint = &excerpt_cached_hints.hints_by_id[cached_hint_id]; - if cached_hint_id == shown_hint_id { - excerpt_cache.next(); - return !new_kinds.contains(&cached_hint.kind); - } - - match cached_hint - .position - .cmp(&shown_anchor.text_anchor, &buffer_snapshot) - { - cmp::Ordering::Less | cmp::Ordering::Equal => { - if !old_kinds.contains(&cached_hint.kind) - && new_kinds.contains(&cached_hint.kind) - && let Some(anchor) = multi_buffer_snapshot - .anchor_in_excerpt(*excerpt_id, cached_hint.position) - { - to_insert.push(Inlay::hint( - cached_hint_id.id(), - anchor, - cached_hint, - )); - } - excerpt_cache.next(); + ControlFlow::Continue( + Some(InlaySplice { + to_remove: visible_hints + .iter() + .filter_map(|inlay| { + let inlay_kind = self.added_hints.get(&inlay.id).copied()?; + if !self.allowed_hint_kinds.contains(&inlay_kind) { + Some(inlay.id) + } else { + None } - cmp::Ordering::Greater => return true, - } - } - None => return true, - } - } - }); - - for cached_hint_id in excerpt_cache { - let maybe_missed_cached_hint = &excerpt_cached_hints.hints_by_id[cached_hint_id]; - let cached_hint_kind = maybe_missed_cached_hint.kind; - if !old_kinds.contains(&cached_hint_kind) - && new_kinds.contains(&cached_hint_kind) - && let Some(anchor) = multi_buffer_snapshot - .anchor_in_excerpt(*excerpt_id, maybe_missed_cached_hint.position) - { - to_insert.push(Inlay::hint( - cached_hint_id.id(), - anchor, - maybe_missed_cached_hint, - )); - } + }) + .collect(), + to_insert: Vec::new(), + }) + .filter(|splice| !splice.is_empty()), + ) } } - - to_remove.extend( - shown_hints_to_remove - .into_values() - .flatten() - .map(|(_, hint_id)| hint_id), - ); - if to_remove.is_empty() && to_insert.is_empty() { - None - } else { - Some(InlaySplice { - to_remove, - to_insert, - }) - } } - /// Completely forget of certain excerpts that were removed from the multibuffer. - pub(super) fn remove_excerpts( - &mut self, - excerpts_removed: &[ExcerptId], - ) -> Option { - let mut to_remove = Vec::new(); - for excerpt_to_remove in excerpts_removed { - self.update_tasks.remove(excerpt_to_remove); - if let Some(cached_hints) = self.hints.remove(excerpt_to_remove) { - let cached_hints = cached_hints.read(); - to_remove.extend(cached_hints.ordered_hints.iter().copied()); - } - } - if to_remove.is_empty() { - None - } else { - self.version += 1; - Some(InlaySplice { - to_remove, - to_insert: Vec::new(), - }) - } - } - - pub(super) fn clear(&mut self) { - if !self.update_tasks.is_empty() || !self.hints.is_empty() { - self.version += 1; - } - self.update_tasks.clear(); - self.refresh_task = Task::ready(()); - self.hints.clear(); - } - - pub(super) fn hint_by_id(&self, excerpt_id: ExcerptId, hint_id: InlayId) -> Option { - self.hints - .get(&excerpt_id)? - .read() - .hints_by_id - .get(&hint_id) - .cloned() - } - - pub fn hints(&self) -> Vec { - let mut hints = Vec::new(); - for excerpt_hints in self.hints.values() { - let excerpt_hints = excerpt_hints.read(); - hints.extend( - excerpt_hints - .ordered_hints - .iter() - .map(|id| &excerpt_hints.hints_by_id[id]) - .cloned(), - ); - } - hints - } - - /// Queries a certain hint from the cache for extra data via the LSP resolve request. - pub(super) fn spawn_hint_resolve( - &self, - buffer_id: BufferId, - excerpt_id: ExcerptId, - id: InlayId, - window: &mut Window, - cx: &mut Context, + pub(crate) fn remove_inlay_chunk_data<'a>( + &'a mut self, + removed_buffer_ids: impl IntoIterator + 'a, ) { - if let Some(excerpt_hints) = self.hints.get(&excerpt_id) { - let mut guard = excerpt_hints.write(); - if let Some(cached_hint) = guard.hints_by_id.get_mut(&id) - && let ResolveState::CanResolve(server_id, _) = &cached_hint.resolve_state - { - let hint_to_resolve = cached_hint.clone(); - let server_id = *server_id; - cached_hint.resolve_state = ResolveState::Resolving; - drop(guard); - cx.spawn_in(window, async move |editor, cx| { - let resolved_hint_task = editor.update(cx, |editor, cx| { - let buffer = editor.buffer().read(cx).buffer(buffer_id)?; - editor.semantics_provider.as_ref()?.resolve_inlay_hint( - hint_to_resolve, - buffer, - server_id, - cx, - ) - })?; - if let Some(resolved_hint_task) = resolved_hint_task { - let mut resolved_hint = - resolved_hint_task.await.context("hint resolve task")?; - editor.read_with(cx, |editor, _| { - if let Some(excerpt_hints) = - editor.inlay_hint_cache.hints.get(&excerpt_id) - { - let mut guard = excerpt_hints.write(); - if let Some(cached_hint) = guard.hints_by_id.get_mut(&id) - && cached_hint.resolve_state == ResolveState::Resolving - { - resolved_hint.resolve_state = ResolveState::Resolved; - *cached_hint = resolved_hint; - } - } - })?; - } - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - } - } -} - -fn debounce_value(debounce_ms: u64) -> Option { - if debounce_ms > 0 { - Some(Duration::from_millis(debounce_ms)) - } else { - None - } -} - -fn spawn_new_update_tasks( - editor: &mut Editor, - reason: &'static str, - excerpts_to_query: HashMap, Global, Range)>, - invalidate: InvalidationStrategy, - update_cache_version: usize, - cx: &mut Context, -) { - for (excerpt_id, (excerpt_buffer, new_task_buffer_version, excerpt_visible_range)) in - excerpts_to_query - { - if excerpt_visible_range.is_empty() { - continue; - } - let buffer = excerpt_buffer.read(cx); - let buffer_id = buffer.remote_id(); - let buffer_snapshot = buffer.snapshot(); - if buffer_snapshot - .version() - .changed_since(&new_task_buffer_version) - { - continue; - } - - if let Some(cached_excerpt_hints) = editor.inlay_hint_cache.hints.get(&excerpt_id) { - let cached_excerpt_hints = cached_excerpt_hints.read(); - let cached_buffer_version = &cached_excerpt_hints.buffer_version; - if cached_excerpt_hints.version > update_cache_version - || cached_buffer_version.changed_since(&new_task_buffer_version) - { - continue; - } - }; - - let Some(query_ranges) = editor.buffer.update(cx, |multi_buffer, cx| { - determine_query_ranges( - multi_buffer, - excerpt_id, - &excerpt_buffer, - excerpt_visible_range, - cx, - ) - }) else { - return; - }; - let query = ExcerptQuery { - buffer_id, - excerpt_id, - cache_version: update_cache_version, - invalidate, - reason, - }; - - let mut new_update_task = - |query_ranges| new_update_task(query, query_ranges, excerpt_buffer.clone(), cx); - - match editor.inlay_hint_cache.update_tasks.entry(excerpt_id) { - hash_map::Entry::Occupied(mut o) => { - o.get_mut().update_cached_tasks( - &buffer_snapshot, - query_ranges, - invalidate, - new_update_task, - ); - } - hash_map::Entry::Vacant(v) => { - v.insert(TasksForRanges::new( - query_ranges.clone(), - new_update_task(query_ranges), - )); - } + for buffer_id in removed_buffer_ids { + self.hint_refresh_tasks.remove(buffer_id); + self.hint_chunk_fetching.remove(buffer_id); } } } #[derive(Debug, Clone)] -struct QueryRanges { - before_visible: Vec>, - visible: Vec>, - after_visible: Vec>, +pub enum InlayHintRefreshReason { + ModifiersChanged(bool), + Toggle(bool), + SettingsChange(InlayHintSettings), + NewLinesShown, + BufferEdited(BufferId), + RefreshRequested { + server_id: LanguageServerId, + request_id: Option, + }, + ExcerptsRemoved(Vec), } -impl QueryRanges { - fn is_empty(&self) -> bool { - self.before_visible.is_empty() && self.visible.is_empty() && self.after_visible.is_empty() +impl Editor { + pub fn supports_inlay_hints(&self, cx: &mut App) -> bool { + let Some(provider) = self.semantics_provider.as_ref() else { + return false; + }; + + let mut supports = false; + self.buffer().update(cx, |this, cx| { + this.for_each_buffer(|buffer| { + supports |= provider.supports_inlay_hints(buffer, cx); + }); + }); + + supports } - fn into_sorted_query_ranges(self) -> Vec> { - let mut sorted_ranges = Vec::with_capacity( - self.before_visible.len() + self.visible.len() + self.after_visible.len(), + pub fn toggle_inline_values( + &mut self, + _: &ToggleInlineValues, + _: &mut Window, + cx: &mut Context, + ) { + self.inline_value_cache.enabled = !self.inline_value_cache.enabled; + + self.refresh_inline_values(cx); + } + + pub fn toggle_inlay_hints( + &mut self, + _: &ToggleInlayHints, + _: &mut Window, + cx: &mut Context, + ) { + self.refresh_inlay_hints( + InlayHintRefreshReason::Toggle(!self.inlay_hints_enabled()), + cx, ); - sorted_ranges.extend(self.before_visible); - sorted_ranges.extend(self.visible); - sorted_ranges.extend(self.after_visible); - sorted_ranges } -} -fn determine_query_ranges( - multi_buffer: &mut MultiBuffer, - excerpt_id: ExcerptId, - excerpt_buffer: &Entity, - excerpt_visible_range: Range, - cx: &mut Context, -) -> Option { - let buffer = excerpt_buffer.read(cx); - let full_excerpt_range = multi_buffer - .excerpts_for_buffer(buffer.remote_id(), cx) - .into_iter() - .find(|(id, _)| id == &excerpt_id) - .map(|(_, range)| range.context)?; - let snapshot = buffer.snapshot(); - let excerpt_visible_len = excerpt_visible_range.end - excerpt_visible_range.start; + pub fn inlay_hints_enabled(&self) -> bool { + self.inlay_hints.as_ref().is_some_and(|cache| cache.enabled) + } - let visible_range = if excerpt_visible_range.start == excerpt_visible_range.end { - return None; - } else { - vec![ - buffer.anchor_before(snapshot.clip_offset(excerpt_visible_range.start, Bias::Left)) - ..buffer.anchor_after(snapshot.clip_offset(excerpt_visible_range.end, Bias::Right)), - ] - }; + /// Updates inlay hints for the visible ranges of the singleton buffer(s). + /// Based on its parameters, either invalidates the previous data, or appends to it. + pub(crate) fn refresh_inlay_hints( + &mut self, + reason: InlayHintRefreshReason, + cx: &mut Context, + ) { + if self.ignore_lsp_data() || self.inlay_hints.is_none() { + return; + } + let Some(semantics_provider) = self.semantics_provider() else { + return; + }; + let Some(invalidate_cache) = self.refresh_editor_data(&reason, cx) else { + return; + }; - let full_excerpt_range_end_offset = full_excerpt_range.end.to_offset(&snapshot); - let after_visible_range_start = excerpt_visible_range - .end - .saturating_add(1) - .min(full_excerpt_range_end_offset) - .min(buffer.len()); - let after_visible_range = if after_visible_range_start == full_excerpt_range_end_offset { - Vec::new() - } else { - let after_range_end_offset = after_visible_range_start - .saturating_add(excerpt_visible_len) - .min(full_excerpt_range_end_offset) - .min(buffer.len()); - vec![ - buffer.anchor_before(snapshot.clip_offset(after_visible_range_start, Bias::Left)) - ..buffer.anchor_after(snapshot.clip_offset(after_range_end_offset, Bias::Right)), - ] - }; + let debounce = match &reason { + InlayHintRefreshReason::SettingsChange(_) + | InlayHintRefreshReason::Toggle(_) + | InlayHintRefreshReason::ExcerptsRemoved(_) + | InlayHintRefreshReason::ModifiersChanged(_) => None, + _may_need_lsp_call => self.inlay_hints.as_ref().and_then(|inlay_hints| { + if invalidate_cache.should_invalidate() { + inlay_hints.invalidate_debounce + } else { + inlay_hints.append_debounce + } + }), + }; - let full_excerpt_range_start_offset = full_excerpt_range.start.to_offset(&snapshot); - let before_visible_range_end = excerpt_visible_range - .start - .saturating_sub(1) - .max(full_excerpt_range_start_offset); - let before_visible_range = if before_visible_range_end == full_excerpt_range_start_offset { - Vec::new() - } else { - let before_range_start_offset = before_visible_range_end - .saturating_sub(excerpt_visible_len) - .max(full_excerpt_range_start_offset); - vec![ - buffer.anchor_before(snapshot.clip_offset(before_range_start_offset, Bias::Left)) - ..buffer.anchor_after(snapshot.clip_offset(before_visible_range_end, Bias::Right)), - ] - }; + let mut visible_excerpts = self.visible_excerpts(true, cx); + let mut invalidate_hints_for_buffers = HashSet::default(); + let ignore_previous_fetches = match reason { + InlayHintRefreshReason::ModifiersChanged(_) + | InlayHintRefreshReason::Toggle(_) + | InlayHintRefreshReason::SettingsChange(_) => true, + InlayHintRefreshReason::NewLinesShown + | InlayHintRefreshReason::RefreshRequested { .. } + | InlayHintRefreshReason::ExcerptsRemoved(_) => false, + InlayHintRefreshReason::BufferEdited(buffer_id) => { + let Some(affected_language) = self + .buffer() + .read(cx) + .buffer(buffer_id) + .and_then(|buffer| buffer.read(cx).language().cloned()) + else { + return; + }; - Some(QueryRanges { - before_visible: before_visible_range, - visible: visible_range, - after_visible: after_visible_range, - }) -} + invalidate_hints_for_buffers.extend( + self.buffer() + .read(cx) + .all_buffers() + .into_iter() + .filter_map(|buffer| { + let buffer = buffer.read(cx); + if buffer.language() == Some(&affected_language) { + Some(buffer.remote_id()) + } else { + None + } + }), + ); -const MAX_CONCURRENT_LSP_REQUESTS: usize = 5; -const INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS: u64 = 400; + semantics_provider.invalidate_inlay_hints(&invalidate_hints_for_buffers, cx); + visible_excerpts.retain(|_, (visible_buffer, _, _)| { + visible_buffer.read(cx).language() == Some(&affected_language) + }); + false + } + }; -fn new_update_task( - query: ExcerptQuery, - query_ranges: QueryRanges, - excerpt_buffer: Entity, - cx: &mut Context, -) -> Task<()> { - cx.spawn(async move |editor, cx| { - let visible_range_update_results = future::join_all( - query_ranges - .visible - .into_iter() - .filter_map(|visible_range| { - let fetch_task = editor - .update(cx, |_, cx| { - fetch_and_update_hints( - excerpt_buffer.clone(), - query, - visible_range.clone(), - query.invalidate.should_invalidate(), - cx, - ) - }) - .log_err()?; - Some(async move { (visible_range, fetch_task.await) }) - }), - ) - .await; + let multi_buffer = self.buffer().clone(); + let Some(inlay_hints) = self.inlay_hints.as_mut() else { + return; + }; - let hint_delay = cx.background_executor().timer(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS, - )); + if invalidate_cache.should_invalidate() { + inlay_hints.clear(); + } + inlay_hints + .invalidate_hints_for_buffers + .extend(invalidate_hints_for_buffers); - let query_range_failed = - |range: &Range, e: anyhow::Error, cx: &mut AsyncApp| { - log::error!("inlay hint update task for range failed: {e:#?}"); - editor - .update(cx, |editor, cx| { - if let Some(task_ranges) = editor - .inlay_hint_cache - .update_tasks - .get_mut(&query.excerpt_id) - { - let buffer_snapshot = excerpt_buffer.read(cx).snapshot(); - task_ranges.invalidate_range(&buffer_snapshot, range); - } - }) - .ok() + let mut buffers_to_query = HashMap::default(); + for (_, (buffer, buffer_version, visible_range)) in visible_excerpts { + let buffer_id = buffer.read(cx).remote_id(); + if !self.registered_buffers.contains_key(&buffer_id) { + continue; + } + + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_anchor_range = buffer_snapshot.anchor_before(visible_range.start) + ..buffer_snapshot.anchor_after(visible_range.end); + + let visible_excerpts = + buffers_to_query + .entry(buffer_id) + .or_insert_with(|| VisibleExcerpts { + ranges: Vec::new(), + buffer_version: buffer_version.clone(), + buffer: buffer.clone(), + }); + visible_excerpts.buffer_version = buffer_version; + visible_excerpts.ranges.push(buffer_anchor_range); + } + + for (buffer_id, visible_excerpts) in buffers_to_query { + let Some(buffer) = multi_buffer.read(cx).buffer(buffer_id) else { + continue; }; - for (range, result) in visible_range_update_results { - if let Err(e) = result { - query_range_failed(&range, e, cx); + let (fetched_for_version, fetched_chunks) = inlay_hints + .hint_chunk_fetching + .entry(buffer_id) + .or_default(); + if visible_excerpts + .buffer_version + .changed_since(fetched_for_version) + { + *fetched_for_version = visible_excerpts.buffer_version.clone(); + fetched_chunks.clear(); + inlay_hints.hint_refresh_tasks.remove(&buffer_id); } + + let known_chunks = if ignore_previous_fetches { + None + } else { + Some((fetched_for_version.clone(), fetched_chunks.clone())) + }; + + let mut applicable_chunks = + semantics_provider.applicable_inlay_chunks(&buffer, &visible_excerpts.ranges, cx); + applicable_chunks.retain(|chunk| fetched_chunks.insert(chunk.clone())); + if applicable_chunks.is_empty() && !ignore_previous_fetches { + continue; + } + inlay_hints + .hint_refresh_tasks + .entry(buffer_id) + .or_default() + .push(spawn_editor_hints_refresh( + buffer_id, + invalidate_cache, + debounce, + visible_excerpts, + known_chunks, + applicable_chunks, + cx, + )); } + } - hint_delay.await; - let invisible_range_update_results = future::join_all( - query_ranges - .before_visible - .into_iter() - .chain(query_ranges.after_visible.into_iter()) - .filter_map(|invisible_range| { - let fetch_task = editor - .update(cx, |_, cx| { - fetch_and_update_hints( - excerpt_buffer.clone(), - query, - invisible_range.clone(), - false, // visible screen request already invalidated the entries - cx, - ) - }) - .log_err()?; - Some(async move { (invisible_range, fetch_task.await) }) - }), - ) - .await; - for (range, result) in invisible_range_update_results { - if let Err(e) = result { - query_range_failed(&range, e, cx); - } - } - }) -} + pub fn clear_inlay_hints(&mut self, cx: &mut Context) { + let to_remove = self + .visible_inlay_hints(cx) + .into_iter() + .map(|inlay| { + let inlay_id = inlay.id; + if let Some(inlay_hints) = &mut self.inlay_hints { + inlay_hints.added_hints.remove(&inlay_id); + } + inlay_id + }) + .collect::>(); + self.splice_inlays(&to_remove, Vec::new(), cx); + } -fn fetch_and_update_hints( - excerpt_buffer: Entity, - query: ExcerptQuery, - fetch_range: Range, - invalidate: bool, - cx: &mut Context, -) -> Task> { - cx.spawn(async move |editor, cx|{ - let buffer_snapshot = excerpt_buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - let (lsp_request_limiter, multi_buffer_snapshot) = - editor.update(cx, |editor, cx| { - let multi_buffer_snapshot = - editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); - let lsp_request_limiter = Arc::clone(&editor.inlay_hint_cache.lsp_request_limiter); - (lsp_request_limiter, multi_buffer_snapshot) - })?; - - let (lsp_request_guard, got_throttled) = if query.invalidate.should_invalidate() { - (None, false) - } else { - match lsp_request_limiter.try_acquire() { - Some(guard) => (Some(guard), false), - None => (Some(lsp_request_limiter.acquire().await), true), - } + fn refresh_editor_data( + &mut self, + reason: &InlayHintRefreshReason, + cx: &mut Context<'_, Editor>, + ) -> Option { + let visible_inlay_hints = self.visible_inlay_hints(cx); + let Some(inlay_hints) = self.inlay_hints.as_mut() else { + return None; }; - let fetch_range_to_log = fetch_range.start.to_point(&buffer_snapshot) - ..fetch_range.end.to_point(&buffer_snapshot); - let inlay_hints_fetch_task = editor - .update(cx, |editor, cx| { - if got_throttled { - let query_not_around_visible_range = match editor - .visible_excerpts(None, cx) - .remove(&query.excerpt_id) - { - Some((_, _, current_visible_range)) => { - let visible_offset_length = current_visible_range.len(); - let double_visible_range = current_visible_range - .start - .saturating_sub(visible_offset_length) - ..current_visible_range - .end - .saturating_add(visible_offset_length) - .min(buffer_snapshot.len()); - !double_visible_range - .contains(&fetch_range.start.to_offset(&buffer_snapshot)) - && !double_visible_range - .contains(&fetch_range.end.to_offset(&buffer_snapshot)) - } - None => true, - }; - if query_not_around_visible_range { - log::trace!("Fetching inlay hints for range {fetch_range_to_log:?} got throttled and fell off the current visible range, skipping."); - if let Some(task_ranges) = editor - .inlay_hint_cache - .update_tasks - .get_mut(&query.excerpt_id) - { - task_ranges.invalidate_range(&buffer_snapshot, &fetch_range); + + let invalidate_cache = match reason { + InlayHintRefreshReason::ModifiersChanged(enabled) => { + match inlay_hints.modifiers_override(*enabled) { + Some(enabled) => { + if enabled { + InvalidationStrategy::None + } else { + self.clear_inlay_hints(cx); + return None; } + } + None => return None, + } + } + InlayHintRefreshReason::Toggle(enabled) => { + if inlay_hints.toggle(*enabled) { + if *enabled { + InvalidationStrategy::None + } else { + self.clear_inlay_hints(cx); return None; } + } else { + return None; } - - let buffer = editor.buffer().read(cx).buffer(query.buffer_id)?; - - if !editor.registered_buffers.contains_key(&query.buffer_id) - && let Some(project) = editor.project.as_ref() { - project.update(cx, |project, cx| { - editor.registered_buffers.insert( - query.buffer_id, - project.register_buffer_with_language_servers(&buffer, cx), - ); - }) - } - - editor - .semantics_provider - .as_ref()? - .inlay_hints(buffer, fetch_range.clone(), cx) - }) - .ok() - .flatten(); - - let cached_excerpt_hints = editor.read_with(cx, |editor, _| { - editor - .inlay_hint_cache - .hints - .get(&query.excerpt_id) - .cloned() - })?; - - let visible_hints = editor.update(cx, |editor, cx| editor.visible_inlay_hints(cx))?; - let new_hints = match inlay_hints_fetch_task { - Some(fetch_task) => { - log::debug!( - "Fetching inlay hints for range {fetch_range_to_log:?}, reason: {query_reason}, invalidate: {invalidate}", - query_reason = query.reason, - ); - log::trace!( - "Currently visible hints: {visible_hints:?}, cached hints present: {}", - cached_excerpt_hints.is_some(), - ); - fetch_task.await.context("inlay hint fetch task")? } - None => return Ok(()), + InlayHintRefreshReason::SettingsChange(new_settings) => { + match inlay_hints.update_settings(*new_settings, visible_inlay_hints) { + ControlFlow::Break(Some(InlaySplice { + to_remove, + to_insert, + })) => { + self.splice_inlays(&to_remove, to_insert, cx); + return None; + } + ControlFlow::Break(None) => return None, + ControlFlow::Continue(splice) => { + if let Some(InlaySplice { + to_remove, + to_insert, + }) = splice + { + self.splice_inlays(&to_remove, to_insert, cx); + } + InvalidationStrategy::None + } + } + } + InlayHintRefreshReason::ExcerptsRemoved(excerpts_removed) => { + let to_remove = self + .display_map + .read(cx) + .current_inlays() + .filter_map(|inlay| { + if excerpts_removed.contains(&inlay.position.excerpt_id) { + Some(inlay.id) + } else { + None + } + }) + .collect::>(); + self.splice_inlays(&to_remove, Vec::new(), cx); + return None; + } + InlayHintRefreshReason::NewLinesShown => InvalidationStrategy::None, + InlayHintRefreshReason::BufferEdited(_) => InvalidationStrategy::BufferEdited, + InlayHintRefreshReason::RefreshRequested { + server_id, + request_id, + } => InvalidationStrategy::RefreshRequested { + server_id: *server_id, + request_id: *request_id, + }, }; - drop(lsp_request_guard); - log::debug!( - "Fetched {} hints for range {fetch_range_to_log:?}", - new_hints.len() - ); - log::trace!("Fetched hints: {new_hints:?}"); - let background_task_buffer_snapshot = buffer_snapshot.clone(); - let background_fetch_range = fetch_range.clone(); - let new_update = cx.background_spawn(async move { - calculate_hint_updates( - query.excerpt_id, - invalidate, - background_fetch_range, - new_hints, - &background_task_buffer_snapshot, - cached_excerpt_hints, - &visible_hints, + match &mut self.inlay_hints { + Some(inlay_hints) => { + if !inlay_hints.enabled + && !matches!(reason, InlayHintRefreshReason::ModifiersChanged(_)) + { + return None; + } + } + None => return None, + } + + Some(invalidate_cache) + } + + pub(crate) fn visible_inlay_hints(&self, cx: &Context) -> Vec { + self.display_map + .read(cx) + .current_inlays() + .filter(move |inlay| matches!(inlay.id, InlayId::Hint(_))) + .cloned() + .collect() + } + + pub fn update_inlay_link_and_hover_points( + &mut self, + snapshot: &EditorSnapshot, + point_for_position: PointForPosition, + secondary_held: bool, + shift_held: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(lsp_store) = self.project().map(|project| project.read(cx).lsp_store()) else { + return; + }; + let hovered_offset = if point_for_position.column_overshoot_after_line_end == 0 { + Some( + snapshot + .display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left), ) - }) - .await; - if let Some(new_update) = new_update { - log::debug!( - "Applying update for range {fetch_range_to_log:?}: remove from editor: {}, remove from cache: {}, add to cache: {}", - new_update.remove_from_visible.len(), - new_update.remove_from_cache.len(), - new_update.add_to_cache.len() + } else { + None + }; + let mut go_to_definition_updated = false; + let mut hover_updated = false; + if let Some(hovered_offset) = hovered_offset { + let buffer_snapshot = self.buffer().read(cx).snapshot(cx); + let previous_valid_anchor = buffer_snapshot.anchor_at( + point_for_position.previous_valid.to_point(snapshot), + Bias::Left, ); - log::trace!("New update: {new_update:?}"); - editor - .update(cx, |editor, cx| { - apply_hint_update( - editor, - new_update, - query, - invalidate, - buffer_snapshot, - multi_buffer_snapshot, - cx, - ); + let next_valid_anchor = buffer_snapshot.anchor_at( + point_for_position.next_valid.to_point(snapshot), + Bias::Right, + ); + if let Some(hovered_hint) = self + .visible_inlay_hints(cx) + .into_iter() + .skip_while(|hint| { + hint.position + .cmp(&previous_valid_anchor, &buffer_snapshot) + .is_lt() }) - .ok(); - } - anyhow::Ok(()) - }) -} - -fn calculate_hint_updates( - excerpt_id: ExcerptId, - invalidate: bool, - fetch_range: Range, - new_excerpt_hints: Vec, - buffer_snapshot: &BufferSnapshot, - cached_excerpt_hints: Option>>, - visible_hints: &[Inlay], -) -> Option { - let mut add_to_cache = Vec::::new(); - let mut excerpt_hints_to_persist = HashMap::default(); - for new_hint in new_excerpt_hints { - if !contains_position(&fetch_range, new_hint.position, buffer_snapshot) { - continue; - } - let missing_from_cache = match &cached_excerpt_hints { - Some(cached_excerpt_hints) => { - let cached_excerpt_hints = cached_excerpt_hints.read(); - match cached_excerpt_hints - .ordered_hints - .binary_search_by(|probe| { - cached_excerpt_hints.hints_by_id[probe] - .position - .cmp(&new_hint.position, buffer_snapshot) - }) { - Ok(ix) => { - let mut missing_from_cache = true; - for id in &cached_excerpt_hints.ordered_hints[ix..] { - let cached_hint = &cached_excerpt_hints.hints_by_id[id]; - if new_hint - .position - .cmp(&cached_hint.position, buffer_snapshot) - .is_gt() - { - break; + .take_while(|hint| { + hint.position + .cmp(&next_valid_anchor, &buffer_snapshot) + .is_le() + }) + .max_by_key(|hint| hint.id) + { + if let Some(ResolvedHint::Resolved(cached_hint)) = hovered_hint + .position + .text_anchor + .buffer_id + .and_then(|buffer_id| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.resolved_hint(buffer_id, hovered_hint.id, cx) + }) + }) + { + match cached_hint.resolve_state { + ResolveState::Resolved => { + let mut extra_shift_left = 0; + let mut extra_shift_right = 0; + if cached_hint.padding_left { + extra_shift_left += 1; + extra_shift_right += 1; } - if cached_hint == &new_hint { - excerpt_hints_to_persist.insert(*id, cached_hint.kind); - missing_from_cache = false; + if cached_hint.padding_right { + extra_shift_right += 1; + } + match cached_hint.label { + InlayHintLabel::String(_) => { + if let Some(tooltip) = cached_hint.tooltip { + hover_popover::hover_at_inlay( + self, + InlayHover { + tooltip: match tooltip { + InlayHintTooltip::String(text) => HoverBlock { + text, + kind: HoverBlockKind::PlainText, + }, + InlayHintTooltip::MarkupContent(content) => { + HoverBlock { + text: content.value, + kind: content.kind, + } + } + }, + range: InlayHighlight { + inlay: hovered_hint.id, + inlay_position: hovered_hint.position, + range: extra_shift_left + ..hovered_hint.text().len() + + extra_shift_right, + }, + }, + window, + cx, + ); + hover_updated = true; + } + } + InlayHintLabel::LabelParts(label_parts) => { + let hint_start = + snapshot.anchor_to_inlay_offset(hovered_hint.position); + if let Some((hovered_hint_part, part_range)) = + hover_popover::find_hovered_hint_part( + label_parts, + hint_start, + hovered_offset, + ) + { + let highlight_start = + (part_range.start - hint_start) + extra_shift_left; + let highlight_end = + (part_range.end - hint_start) + extra_shift_right; + let highlight = InlayHighlight { + inlay: hovered_hint.id, + inlay_position: hovered_hint.position, + range: highlight_start..highlight_end, + }; + if let Some(tooltip) = hovered_hint_part.tooltip { + hover_popover::hover_at_inlay( + self, + InlayHover { + tooltip: match tooltip { + InlayHintLabelPartTooltip::String(text) => { + HoverBlock { + text, + kind: HoverBlockKind::PlainText, + } + } + InlayHintLabelPartTooltip::MarkupContent( + content, + ) => HoverBlock { + text: content.value, + kind: content.kind, + }, + }, + range: highlight.clone(), + }, + window, + cx, + ); + hover_updated = true; + } + if let Some((language_server_id, location)) = + hovered_hint_part.location + && secondary_held + && !self.has_pending_nonempty_selection() + { + go_to_definition_updated = true; + show_link_definition( + shift_held, + self, + TriggerPoint::InlayHint( + highlight, + location, + language_server_id, + ), + snapshot, + window, + cx, + ); + } + } + } + }; + } + ResolveState::CanResolve(_, _) => debug_panic!( + "Expected resolved_hint retrieval to return a resolved hint" + ), + ResolveState::Resolving => {} + } + } + } + } + + if !go_to_definition_updated { + self.hide_hovered_link(cx) + } + if !hover_updated { + hover_popover::hover_at(self, None, window, cx); + } + } + + fn inlay_hints_for_buffer( + &mut self, + invalidate_cache: InvalidationStrategy, + buffer_excerpts: VisibleExcerpts, + known_chunks: Option<(Global, HashSet>)>, + cx: &mut Context, + ) -> Option, anyhow::Result)>>> { + let semantics_provider = self.semantics_provider()?; + + let new_hint_tasks = semantics_provider + .inlay_hints( + invalidate_cache, + buffer_excerpts.buffer, + buffer_excerpts.ranges, + known_chunks, + cx, + ) + .unwrap_or_default(); + + let mut hint_tasks = None; + for (row_range, new_hints_task) in new_hint_tasks { + hint_tasks + .get_or_insert_with(Vec::new) + .push(cx.spawn(async move |_, _| (row_range, new_hints_task.await))); + } + hint_tasks + } + + fn apply_fetched_hints( + &mut self, + buffer_id: BufferId, + query_version: Global, + invalidate_cache: InvalidationStrategy, + new_hints: Vec<(Range, anyhow::Result)>, + cx: &mut Context, + ) { + let visible_inlay_hint_ids = self + .visible_inlay_hints(cx) + .iter() + .filter(|inlay| inlay.position.text_anchor.buffer_id == Some(buffer_id)) + .map(|inlay| inlay.id) + .collect::>(); + let Some(inlay_hints) = &mut self.inlay_hints else { + return; + }; + + let mut hints_to_remove = Vec::new(); + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + + // If we've received hints from the cache, it means `invalidate_cache` had invalidated whatever possible there, + // and most probably there are no more hints with IDs from `visible_inlay_hint_ids` in the cache. + // So, if we hover such hints, no resolve will happen. + // + // Another issue is in the fact that changing one buffer may lead to other buffers' hints changing, so more cache entries may be removed. + // Hence, clear all excerpts' hints in the multi buffer: later, the invalidated ones will re-trigger the LSP query, the rest will be restored + // from the cache. + if invalidate_cache.should_invalidate() { + hints_to_remove.extend(visible_inlay_hint_ids); + } + + let excerpts = self.buffer.read(cx).excerpt_ids(); + let mut inserted_hint_text = HashMap::default(); + let hints_to_insert = new_hints + .into_iter() + .filter_map(|(chunk_range, hints_result)| { + let chunks_fetched = inlay_hints.hint_chunk_fetching.get_mut(&buffer_id); + match hints_result { + Ok(new_hints) => { + if new_hints.is_empty() { + if let Some((_, chunks_fetched)) = chunks_fetched { + chunks_fetched.remove(&chunk_range); } } - missing_from_cache + Some(new_hints) + } + Err(e) => { + log::error!( + "Failed to query inlays for buffer row range {chunk_range:?}, {e:#}" + ); + if let Some((for_version, chunks_fetched)) = chunks_fetched { + if for_version == &query_version { + chunks_fetched.remove(&chunk_range); + } + } + None } - Err(_) => true, } - } - None => true, - }; - if missing_from_cache { - add_to_cache.push(new_hint); - } - } + }) + .flat_map(|new_hints| { + let mut hints_deduplicated = Vec::new(); - let mut remove_from_visible = HashSet::default(); - let mut remove_from_cache = HashSet::default(); - if invalidate { - remove_from_visible.extend( - visible_hints - .iter() - .filter(|hint| hint.position.excerpt_id == excerpt_id) - .map(|inlay_hint| inlay_hint.id) - .filter(|hint_id| !excerpt_hints_to_persist.contains_key(hint_id)), - ); + if new_hints.len() > 1 { + for (server_id, new_hints) in new_hints { + for (new_id, new_hint) in new_hints { + let hints_text_for_position = inserted_hint_text + .entry(new_hint.position) + .or_insert_with(HashMap::default); + let insert = + match hints_text_for_position.entry(new_hint.text().to_string()) { + hash_map::Entry::Occupied(o) => o.get() == &server_id, + hash_map::Entry::Vacant(v) => { + v.insert(server_id); + true + } + }; - if let Some(cached_excerpt_hints) = &cached_excerpt_hints { - let cached_excerpt_hints = cached_excerpt_hints.read(); - remove_from_cache.extend( - cached_excerpt_hints - .ordered_hints + if insert { + hints_deduplicated.push((new_id, new_hint)); + } + } + } + } else { + hints_deduplicated.extend(new_hints.into_values().flatten()); + } + + hints_deduplicated + }) + .filter_map(|(hint_id, lsp_hint)| { + if inlay_hints.allowed_hint_kinds.contains(&lsp_hint.kind) + && inlay_hints + .added_hints + .insert(hint_id, lsp_hint.kind) + .is_none() + { + let position = excerpts.iter().find_map(|excerpt_id| { + multi_buffer_snapshot.anchor_in_excerpt(*excerpt_id, lsp_hint.position) + })?; + return Some(Inlay::hint(hint_id, position, &lsp_hint)); + } + None + }) + .collect::>(); + + let invalidate_hints_for_buffers = + std::mem::take(&mut inlay_hints.invalidate_hints_for_buffers); + if !invalidate_hints_for_buffers.is_empty() { + hints_to_remove.extend( + self.visible_inlay_hints(cx) .iter() - .filter(|cached_inlay_id| { - !excerpt_hints_to_persist.contains_key(cached_inlay_id) + .filter(|inlay| { + inlay + .position + .text_anchor + .buffer_id + .is_none_or(|buffer_id| { + invalidate_hints_for_buffers.contains(&buffer_id) + }) }) - .copied(), + .map(|inlay| inlay.id), ); - remove_from_visible.extend(remove_from_cache.iter().cloned()); } - } - if remove_from_visible.is_empty() && remove_from_cache.is_empty() && add_to_cache.is_empty() { - None - } else { - Some(ExcerptHintsUpdate { - excerpt_id, - remove_from_visible, - remove_from_cache, - add_to_cache, - }) + self.splice_inlays(&hints_to_remove, hints_to_insert, cx); } } -fn contains_position( - range: &Range, - position: language::Anchor, - buffer_snapshot: &BufferSnapshot, -) -> bool { - range.start.cmp(&position, buffer_snapshot).is_le() - && range.end.cmp(&position, buffer_snapshot).is_ge() +#[derive(Debug)] +struct VisibleExcerpts { + ranges: Vec>, + buffer_version: Global, + buffer: Entity, } -fn apply_hint_update( - editor: &mut Editor, - new_update: ExcerptHintsUpdate, - query: ExcerptQuery, - invalidate: bool, - buffer_snapshot: BufferSnapshot, - multi_buffer_snapshot: MultiBufferSnapshot, - cx: &mut Context, -) { - let cached_excerpt_hints = editor - .inlay_hint_cache - .hints - .entry(new_update.excerpt_id) - .or_insert_with(|| { - Arc::new(RwLock::new(CachedExcerptHints { - version: query.cache_version, - buffer_version: buffer_snapshot.version().clone(), - buffer_id: query.buffer_id, - ordered_hints: Vec::new(), - hints_by_id: HashMap::default(), - })) - }); - let mut cached_excerpt_hints = cached_excerpt_hints.write(); - match query.cache_version.cmp(&cached_excerpt_hints.version) { - cmp::Ordering::Less => return, - cmp::Ordering::Greater | cmp::Ordering::Equal => { - cached_excerpt_hints.version = query.cache_version; +fn spawn_editor_hints_refresh( + buffer_id: BufferId, + invalidate_cache: InvalidationStrategy, + debounce: Option, + buffer_excerpts: VisibleExcerpts, + known_chunks: Option<(Global, HashSet>)>, + applicable_chunks: Vec>, + cx: &mut Context<'_, Editor>, +) -> Task<()> { + cx.spawn(async move |editor, cx| { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; } - } - let mut cached_inlays_changed = !new_update.remove_from_cache.is_empty(); - cached_excerpt_hints - .ordered_hints - .retain(|hint_id| !new_update.remove_from_cache.contains(hint_id)); - cached_excerpt_hints - .hints_by_id - .retain(|hint_id, _| !new_update.remove_from_cache.contains(hint_id)); - let mut splice = InlaySplice::default(); - splice.to_remove.extend(new_update.remove_from_visible); - for new_hint in new_update.add_to_cache { - let insert_position = match cached_excerpt_hints - .ordered_hints - .binary_search_by(|probe| { - cached_excerpt_hints.hints_by_id[probe] - .position - .cmp(&new_hint.position, &buffer_snapshot) - }) { - Ok(i) => { - // When a hint is added to the same position where existing ones are present, - // do not deduplicate it: we split hint queries into non-overlapping ranges - // and each hint batch returned by the server should already contain unique hints. - i + cached_excerpt_hints.ordered_hints[i..].len() + 1 - } - Err(i) => i, + let query_version = buffer_excerpts.buffer_version.clone(); + let Some(hint_tasks) = editor + .update(cx, |editor, cx| { + editor.inlay_hints_for_buffer(invalidate_cache, buffer_excerpts, known_chunks, cx) + }) + .ok() + else { + return; }; - - let new_inlay_id = post_inc(&mut editor.next_inlay_id); - if editor - .inlay_hint_cache - .allowed_hint_kinds - .contains(&new_hint.kind) - && let Some(new_hint_position) = - multi_buffer_snapshot.anchor_in_excerpt(query.excerpt_id, new_hint.position) - { - splice - .to_insert - .push(Inlay::hint(new_inlay_id, new_hint_position, &new_hint)); + let hint_tasks = hint_tasks.unwrap_or_default(); + if hint_tasks.is_empty() { + editor + .update(cx, |editor, _| { + if let Some((_, hint_chunk_fetching)) = editor + .inlay_hints + .as_mut() + .and_then(|inlay_hints| inlay_hints.hint_chunk_fetching.get_mut(&buffer_id)) + { + for applicable_chunks in &applicable_chunks { + hint_chunk_fetching.remove(applicable_chunks); + } + } + }) + .ok(); + return; } - let new_id = InlayId::Hint(new_inlay_id); - cached_excerpt_hints.hints_by_id.insert(new_id, new_hint); - if cached_excerpt_hints.ordered_hints.len() <= insert_position { - cached_excerpt_hints.ordered_hints.push(new_id); - } else { - cached_excerpt_hints - .ordered_hints - .insert(insert_position, new_id); - } - - cached_inlays_changed = true; - } - cached_excerpt_hints.buffer_version = buffer_snapshot.version().clone(); - drop(cached_excerpt_hints); - - if invalidate { - let mut outdated_excerpt_caches = HashSet::default(); - for (excerpt_id, excerpt_hints) in &editor.inlay_hint_cache().hints { - let excerpt_hints = excerpt_hints.read(); - if excerpt_hints.buffer_id == query.buffer_id - && excerpt_id != &query.excerpt_id - && buffer_snapshot - .version() - .changed_since(&excerpt_hints.buffer_version) - { - outdated_excerpt_caches.insert(*excerpt_id); - splice - .to_remove - .extend(excerpt_hints.ordered_hints.iter().copied()); - } - } - cached_inlays_changed |= !outdated_excerpt_caches.is_empty(); + let new_hints = join_all(hint_tasks).await; editor - .inlay_hint_cache - .hints - .retain(|excerpt_id, _| !outdated_excerpt_caches.contains(excerpt_id)); - } - - let InlaySplice { - to_remove, - to_insert, - } = splice; - let displayed_inlays_changed = !to_remove.is_empty() || !to_insert.is_empty(); - if cached_inlays_changed || displayed_inlays_changed { - editor.inlay_hint_cache.version += 1; - } - if displayed_inlays_changed { - editor.splice_inlays(&to_remove, to_insert, cx) - } + .update(cx, |editor, cx| { + editor.apply_fetched_hints( + buffer_id, + query_version, + invalidate_cache, + new_hints, + cx, + ); + }) + .ok(); + }) } #[cfg(test)] pub mod tests { - use crate::SelectionEffects; use crate::editor_tests::update_test_language_settings; + use crate::inlays::inlay_hints::InlayHintRefreshReason; use crate::scroll::ScrollAmount; - use crate::{ExcerptRange, scroll::Autoscroll, test::editor_lsp_test_context::rust_lang}; - use futures::StreamExt; - use gpui::{AppContext as _, Context, SemanticVersion, TestAppContext, WindowHandle}; + use crate::{Editor, SelectionEffects}; + use crate::{ExcerptRange, scroll::Autoscroll}; + use collections::HashSet; + use futures::{StreamExt, future}; + use gpui::{AppContext as _, Context, TestAppContext, WindowHandle}; use itertools::Itertools as _; + use language::language_settings::InlayHintKind; use language::{Capability, FakeLspAdapter}; use language::{Language, LanguageConfig, LanguageMatcher}; + use languages::rust_lang; use lsp::FakeLanguageServer; + use multi_buffer::{MultiBuffer, MultiBufferOffset}; use parking_lot::Mutex; + use pretty_assertions::assert_eq; use project::{FakeFs, Project}; use serde_json::json; use settings::{AllLanguageSettingsContent, InlayHintSettingsContent, SettingsStore}; + use std::ops::Range; + use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; - use text::Point; + use std::time::Duration; + use text::{OffsetRangeExt, Point}; + use ui::App; use util::path; - - use super::*; + use util::paths::natural_sort; #[gpui::test] async fn test_basic_cache_update_with_duplicate_hints(cx: &mut gpui::TestAppContext) { @@ -1367,13 +1021,13 @@ pub mod tests { let expected_hints = vec!["1".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get its first hints when opening the editor" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.allowed_hint_kinds, allowed_hint_kinds, + allowed_hint_kinds_for_editor(editor), + allowed_hint_kinds, "Cache should use editor settings to get the allowed hint kinds" ); }) @@ -1382,7 +1036,7 @@ pub mod tests { editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input("some change", window, cx); }) @@ -1393,13 +1047,13 @@ pub mod tests { let expected_hints = vec!["2".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get new hints after an edit" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.allowed_hint_kinds, allowed_hint_kinds, + allowed_hint_kinds_for_editor(editor), + allowed_hint_kinds, "Cache should use editor settings to get the allowed hint kinds" ); }) @@ -1416,19 +1070,103 @@ pub mod tests { let expected_hints = vec!["3".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get new hints after hint refresh/ request" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.allowed_hint_kinds, allowed_hint_kinds, + allowed_hint_kinds_for_editor(editor), + allowed_hint_kinds, "Cache should use editor settings to get the allowed hint kinds" ); }) .unwrap(); } + #[gpui::test] + async fn test_racy_cache_updates(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + let (_, editor, fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + let lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server.set_request_handler::( + move |params, _| { + let task_lsp_request_count = Arc::clone(&lsp_request_count); + async move { + let i = task_lsp_request_count.fetch_add(1, Ordering::Release) + 1; + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + }) + .await; + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get its first hints when opening the editor" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + // Emulate simultaneous events: both editing, refresh and, slightly after, scroll updates are triggered. + editor + .update(cx, |editor, window, cx| { + editor.handle_input("foo", window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(5)); + editor + .update(cx, |editor, _window, cx| { + editor.refresh_inlay_hints( + InlayHintRefreshReason::RefreshRequested { + server_id: fake_server.server.server_id(), + request_id: Some(1), + }, + cx, + ); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(5)); + editor + .update(cx, |editor, _window, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["2".to_string()]; + assert_eq!(expected_hints, cached_hint_labels(editor, cx), "Despite multiple simultaneous refreshes, only one inlay hint query should be issued"); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + #[gpui::test] async fn test_cache_update_on_lsp_completion_tasks(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { @@ -1479,24 +1217,24 @@ pub mod tests { let expected_hints = vec!["0".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get its first hints when opening the editor" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); }) .unwrap(); - let progress_token = "test_progress_token"; + let progress_token = 42; fake_server .request::(lsp::WorkDoneProgressCreateParams { - token: lsp::ProgressToken::String(progress_token.to_string()), + token: lsp::ProgressToken::Number(progress_token), }) .await .into_response() .expect("work done progress create request failed"); cx.executor().run_until_parked(); - fake_server.notify::(&lsp::ProgressParams { - token: lsp::ProgressToken::String(progress_token.to_string()), + fake_server.notify::(lsp::ProgressParams { + token: lsp::ProgressToken::Number(progress_token), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin( lsp::WorkDoneProgressBegin::default(), )), @@ -1508,15 +1246,15 @@ pub mod tests { let expected_hints = vec!["0".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should not update hints while the work task is running" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); }) .unwrap(); - fake_server.notify::(&lsp::ProgressParams { - token: lsp::ProgressToken::String(progress_token.to_string()), + fake_server.notify::(lsp::ProgressParams { + token: lsp::ProgressToken::Number(progress_token), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End( lsp::WorkDoneProgressEnd::default(), )), @@ -1528,7 +1266,7 @@ pub mod tests { let expected_hints = vec!["1".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "New hints should be queried after the work task is done" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -1663,7 +1401,7 @@ pub mod tests { let expected_hints = vec!["1".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get its first hints when opening the editor" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -1688,7 +1426,7 @@ pub mod tests { let expected_hints = vec!["1".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Markdown editor should have a separate version, repeating Rust editor rules" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -1698,7 +1436,7 @@ pub mod tests { rs_editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input("some rs change", window, cx); }) @@ -1706,15 +1444,10 @@ pub mod tests { cx.executor().run_until_parked(); rs_editor .update(cx, |editor, _window, cx| { - // TODO: Here, we do not get "2", because inserting another language server will trigger `RefreshInlayHints` event from the `LspStore` - // A project is listened in every editor, so each of them will react to this event. - // - // We do not have language server IDs for remote projects, so cannot easily say on the editor level, - // whether we should ignore a particular `RefreshInlayHints` event. - let expected_hints = vec!["3".to_string()]; + let expected_hints = vec!["2".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Rust inlay cache should change after the edit" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -1725,7 +1458,7 @@ pub mod tests { let expected_hints = vec!["1".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Markdown editor should not be affected by Rust editor changes" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -1735,7 +1468,7 @@ pub mod tests { md_editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input("some md change", window, cx); }) @@ -1746,7 +1479,7 @@ pub mod tests { let expected_hints = vec!["2".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Rust editor should not be affected by Markdown editor changes" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -1754,10 +1487,10 @@ pub mod tests { .unwrap(); rs_editor .update(cx, |editor, _window, cx| { - let expected_hints = vec!["3".to_string()]; + let expected_hints = vec!["2".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Markdown editor should also change independently" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -1852,16 +1585,16 @@ pub mod tests { "parameter hint".to_string(), "other hint".to_string(), ], - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get its first hints when opening the editor" ); assert_eq!( vec!["type hint".to_string(), "other hint".to_string()], visible_hint_labels(editor, cx) ); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.allowed_hint_kinds, allowed_hint_kinds, + allowed_hint_kinds_for_editor(editor), + allowed_hint_kinds, "Cache should use editor settings to get the allowed hint kinds" ); }) @@ -1886,7 +1619,7 @@ pub mod tests { "parameter hint".to_string(), "other hint".to_string(), ], - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Cached hints should not change due to allowed hint kinds settings update" ); assert_eq!( @@ -1961,7 +1694,7 @@ pub mod tests { "parameter hint".to_string(), "other hint".to_string(), ], - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get its cached hints unchanged after the settings change for hint kinds {new_allowed_hint_kinds:?}" ); assert_eq!( @@ -1969,9 +1702,9 @@ pub mod tests { visible_hint_labels(editor, cx), "Should get its visible hints filtered after the settings change for hint kinds {new_allowed_hint_kinds:?}" ); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.allowed_hint_kinds, new_allowed_hint_kinds, + allowed_hint_kinds_for_editor(editor), + new_allowed_hint_kinds, "Cache should use editor settings to get the allowed hint kinds for hint kinds {new_allowed_hint_kinds:?}" ); }).unwrap(); @@ -2003,17 +1736,23 @@ pub mod tests { 2, "Should not load new hints when hints got disabled" ); - assert!( - cached_hint_labels(editor).is_empty(), - "Should clear the cache when hints got disabled" + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx), + "Should not clear the cache when hints got disabled" ); - assert!( - visible_hint_labels(editor, cx).is_empty(), + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), "Should clear visible hints when hints got disabled" ); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.allowed_hint_kinds, another_allowed_hint_kinds, + allowed_hint_kinds_for_editor(editor), + another_allowed_hint_kinds, "Should update its allowed hint kinds even when hints got disabled" ); }) @@ -2032,8 +1771,15 @@ pub mod tests { 2, "Should not load new hints when they got disabled" ); - assert!(cached_hint_labels(editor).is_empty()); - assert!(visible_hint_labels(editor, cx).is_empty()); + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx) + ); + assert_eq!(Vec::::new(), visible_hint_labels(editor, cx)); }) .unwrap(); @@ -2060,8 +1806,8 @@ pub mod tests { .update(cx, |editor, _, cx| { assert_eq!( lsp_request_count.load(Ordering::Relaxed), - 3, - "Should query for new hints when they got re-enabled" + 2, + "Should not query for new hints when they got re-enabled, as the file version did not change" ); assert_eq!( vec![ @@ -2069,7 +1815,7 @@ pub mod tests { "parameter hint".to_string(), "other hint".to_string(), ], - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get its cached hints fully repopulated after the hints got re-enabled" ); assert_eq!( @@ -2077,9 +1823,9 @@ pub mod tests { visible_hint_labels(editor, cx), "Should get its visible hints repopulated and filtered after the h" ); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.allowed_hint_kinds, final_allowed_hint_kinds, + allowed_hint_kinds_for_editor(editor), + final_allowed_hint_kinds, "Cache should update editor settings when hints got re-enabled" ); }) @@ -2095,7 +1841,7 @@ pub mod tests { .update(cx, |editor, _, cx| { assert_eq!( lsp_request_count.load(Ordering::Relaxed), - 4, + 3, "Should query for new hints again" ); assert_eq!( @@ -2104,7 +1850,7 @@ pub mod tests { "parameter hint".to_string(), "other hint".to_string(), ], - cached_hint_labels(editor), + cached_hint_labels(editor, cx), ); assert_eq!( vec!["parameter hint".to_string()], @@ -2170,7 +1916,7 @@ pub mod tests { editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input(change_after_opening, window, cx); }) @@ -2197,7 +1943,7 @@ pub mod tests { let expected_hints = vec!["2".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get hints from the last edit landed only" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -2216,7 +1962,7 @@ pub mod tests { task_editor .update(&mut cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) + s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)]) }); editor.handle_input(async_later_change, window, cx); }) @@ -2243,7 +1989,7 @@ pub mod tests { let expected_hints = vec!["3".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should get hints from the last edit landed only" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -2255,15 +2001,8 @@ pub mod tests { async fn test_large_buffer_inlay_requests_split(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { settings.defaults.inlay_hints = Some(InlayHintSettingsContent { - show_value_hints: Some(true), enabled: Some(true), - edit_debounce_ms: Some(0), - scroll_debounce_ms: Some(0), - show_type_hints: Some(true), - show_parameter_hints: Some(true), - show_other_hints: Some(true), - show_background: Some(false), - toggle_on_modifiers_press: None, + ..InlayHintSettingsContent::default() }) }); @@ -2289,7 +2028,7 @@ pub mod tests { FakeLspAdapter { capabilities: lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() + ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new({ let lsp_request_ranges = lsp_request_ranges.clone(); @@ -2311,7 +2050,7 @@ pub mod tests { task_lsp_request_ranges.lock().push(params.range); task_lsp_request_count.fetch_add(1, Ordering::Release); Ok(Some(vec![lsp::InlayHint { - position: params.range.end, + position: params.range.start, label: lsp::InlayHintLabel::String( params.range.end.line.to_string(), ), @@ -2327,7 +2066,7 @@ pub mod tests { ); } })), - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -2339,70 +2078,36 @@ pub mod tests { .unwrap(); let editor = cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); - cx.executor().run_until_parked(); - let _fake_server = fake_servers.next().await.unwrap(); - - // in large buffers, requests are made for more than visible range of a buffer. - // invisible parts are queried later, to avoid excessive requests on quick typing. - // wait the timeout needed to get all requests. - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); + cx.executor().advance_clock(Duration::from_millis(100)); cx.executor().run_until_parked(); - let initial_visible_range = editor_visible_range(&editor, cx); - let lsp_initial_visible_range = lsp::Range::new( - lsp::Position::new( - initial_visible_range.start.row, - initial_visible_range.start.column, - ), - lsp::Position::new( - initial_visible_range.end.row, - initial_visible_range.end.column, - ), + + let ranges = lsp_request_ranges + .lock() + .drain(..) + .sorted_by_key(|r| r.start) + .collect::>(); + assert_eq!( + ranges.len(), + 1, + "Should query 1 range initially, but got: {ranges:?}" ); - let expected_initial_query_range_end = - lsp::Position::new(initial_visible_range.end.row * 2, 2); - let mut expected_invisible_query_start = lsp_initial_visible_range.end; - expected_invisible_query_start.character += 1; - editor.update(cx, |editor, _window, cx| { - let ranges = lsp_request_ranges.lock().drain(..).collect::>(); - assert_eq!(ranges.len(), 2, - "When scroll is at the edge of a big document, its visible part and the same range further should be queried in order, but got: {ranges:?}"); - let visible_query_range = &ranges[0]; - assert_eq!(visible_query_range.start, lsp_initial_visible_range.start); - assert_eq!(visible_query_range.end, lsp_initial_visible_range.end); - let invisible_query_range = &ranges[1]; - - assert_eq!(invisible_query_range.start, expected_invisible_query_start, "Should initially query visible edge of the document"); - assert_eq!(invisible_query_range.end, expected_initial_query_range_end, "Should initially query visible edge of the document"); - - let requests_count = lsp_request_count.load(Ordering::Acquire); - assert_eq!(requests_count, 2, "Visible + invisible request"); - let expected_hints = vec!["47".to_string(), "94".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should have hints from both LSP requests made for a big file" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx), "Should display only hints from the visible range"); - }).unwrap(); editor .update(cx, |editor, window, cx| { editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); }) .unwrap(); + // Wait for the first hints request to fire off + cx.executor().advance_clock(Duration::from_millis(100)); cx.executor().run_until_parked(); editor .update(cx, |editor, window, cx| { editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); }) .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); + cx.executor().advance_clock(Duration::from_millis(100)); cx.executor().run_until_parked(); let visible_range_after_scrolls = editor_visible_range(&editor, cx); let visible_line_count = editor @@ -2425,37 +2130,25 @@ pub mod tests { let first_scroll = &ranges[0]; let second_scroll = &ranges[1]; assert_eq!( - first_scroll.end, second_scroll.start, + first_scroll.end.line, second_scroll.start.line, "Should query 2 adjacent ranges after the scrolls, but got: {ranges:?}" ); - assert_eq!( - first_scroll.start, expected_initial_query_range_end, - "First scroll should start the query right after the end of the original scroll", - ); - assert_eq!( - second_scroll.end, - lsp::Position::new( - visible_range_after_scrolls.end.row - + visible_line_count.ceil() as u32, - 1, - ), - "Second scroll should query one more screen down after the end of the visible range" - ); let lsp_requests = lsp_request_count.load(Ordering::Acquire); - assert_eq!(lsp_requests, 4, "Should query for hints after every scroll"); - let expected_hints = vec![ - "47".to_string(), - "94".to_string(), - "139".to_string(), - "184".to_string(), - ]; assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should have hints from the new LSP response after the edit" + lsp_requests, 3, + "Should query hints initially, and after each scroll (2 times)" + ); + assert_eq!( + vec!["50".to_string(), "100".to_string(), "150".to_string()], + cached_hint_labels(editor, cx), + "Chunks of 50 line width should have been queried each time" + ); + assert_eq!( + vec!["50".to_string(), "100".to_string(), "150".to_string()], + visible_hint_labels(editor, cx), + "Editor should show only hints that it's scrolled to" ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); let mut selection_in_cached_range = visible_range_after_scrolls.end; selection_in_cached_range.row -= visible_line_count.ceil() as u32; @@ -2473,9 +2166,7 @@ pub mod tests { ); }) .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); + cx.executor().advance_clock(Duration::from_millis(100)); cx.executor().run_until_parked(); editor.update(cx, |_, _, _| { let ranges = lsp_request_ranges @@ -2484,7 +2175,7 @@ pub mod tests { .sorted_by_key(|r| r.start) .collect::>(); assert!(ranges.is_empty(), "No new ranges or LSP queries should be made after returning to the selection with cached hints"); - assert_eq!(lsp_request_count.load(Ordering::Acquire), 4); + assert_eq!(lsp_request_count.load(Ordering::Acquire), 3, "No new requests should be made when selecting within cached chunks"); }).unwrap(); editor @@ -2492,38 +2183,26 @@ pub mod tests { editor.handle_input("++++more text++++", window, cx); }) .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); + cx.executor().advance_clock(Duration::from_secs(1)); cx.executor().run_until_parked(); editor.update(cx, |editor, _window, cx| { let mut ranges = lsp_request_ranges.lock().drain(..).collect::>(); ranges.sort_by_key(|r| r.start); - assert_eq!(ranges.len(), 3, - "On edit, should scroll to selection and query a range around it: visible + same range above and below. Instead, got query ranges {ranges:?}"); - let above_query_range = &ranges[0]; - let visible_query_range = &ranges[1]; - let below_query_range = &ranges[2]; - assert!(above_query_range.end.character < visible_query_range.start.character || above_query_range.end.line + 1 == visible_query_range.start.line, - "Above range {above_query_range:?} should be before visible range {visible_query_range:?}"); - assert!(visible_query_range.end.character < below_query_range.start.character || visible_query_range.end.line + 1 == below_query_range.start.line, - "Visible range {visible_query_range:?} should be before below range {below_query_range:?}"); - assert!(above_query_range.start.line < selection_in_cached_range.row, + assert_eq!(ranges.len(), 2, + "On edit, should scroll to selection and query a range around it: that range should split into 2 50 rows wide chunks. Instead, got query ranges {ranges:?}"); + let first_chunk = &ranges[0]; + let second_chunk = &ranges[1]; + assert!(first_chunk.end.line == second_chunk.start.line, + "First chunk {first_chunk:?} should be before second chunk {second_chunk:?}"); + assert!(first_chunk.start.line < selection_in_cached_range.row, "Hints should be queried with the selected range after the query range start"); - assert!(below_query_range.end.line > selection_in_cached_range.row, - "Hints should be queried with the selected range before the query range end"); - assert!(above_query_range.start.line <= selection_in_cached_range.row - (visible_line_count * 3.0 / 2.0) as u32, - "Hints query range should contain one more screen before"); - assert!(below_query_range.end.line >= selection_in_cached_range.row + (visible_line_count * 3.0 / 2.0) as u32, - "Hints query range should contain one more screen after"); let lsp_requests = lsp_request_count.load(Ordering::Acquire); - assert_eq!(lsp_requests, 7, "There should be a visible range and two ranges above and below it queried"); - let expected_hints = vec!["67".to_string(), "115".to_string(), "163".to_string()]; - assert_eq!(expected_hints, cached_hint_labels(editor), - "Should have hints from the new LSP response after the edit"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(lsp_requests, 5, "Two chunks should be re-queried"); + assert_eq!(vec!["100".to_string(), "150".to_string()], cached_hint_labels(editor, cx), + "Should have (less) hints from the new LSP response after the edit"); + assert_eq!(vec!["100".to_string(), "150".to_string()], visible_hint_labels(editor, cx), "Should show only visible hints (in the center) from the new cached set"); }).unwrap(); } @@ -2532,7 +2211,7 @@ pub mod tests { cx: &mut gpui::TestAppContext, ) -> Range { let ranges = editor - .update(cx, |editor, _window, cx| editor.visible_excerpts(None, cx)) + .update(cx, |editor, _window, cx| editor.visible_excerpts(true, cx)) .unwrap(); assert_eq!( ranges.len(), @@ -2541,14 +2220,7 @@ pub mod tests { ); let (_, (excerpt_buffer, _, excerpt_visible_range)) = ranges.into_iter().next().unwrap(); excerpt_buffer.read_with(cx, |buffer, _| { - let snapshot = buffer.snapshot(); - let start = buffer - .anchor_before(excerpt_visible_range.start) - .to_point(&snapshot); - let end = buffer - .anchor_after(excerpt_visible_range.end) - .to_point(&snapshot); - start..end + excerpt_visible_range.to_point(&buffer.snapshot()) }) } @@ -2588,9 +2260,9 @@ pub mod tests { FakeLspAdapter { capabilities: lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -2722,7 +2394,7 @@ pub mod tests { ]; assert_eq!( expected_hints, - sorted_cached_hint_labels(editor), + sorted_cached_hint_labels(editor, cx), "When scroll is at the edge of a multibuffer, its visible excerpts only should be queried for inlay hints" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -2747,11 +2419,28 @@ pub mod tests { SelectionEffects::scroll(Autoscroll::Next), window, cx, - |s| s.select_ranges([Point::new(50, 0)..Point::new(50, 0)]), + |s| s.select_ranges([Point::new(57, 0)..Point::new(57, 0)]), ); }) .unwrap(); cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + ]; + assert_eq!(expected_hints, sorted_cached_hint_labels(editor, cx), + "New hints are not shown right after scrolling, we need to wait for the buffer to be registered"); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); editor .update(cx, |editor, _window, cx| { let expected_hints = vec![ @@ -2764,10 +2453,17 @@ pub mod tests { "other hint #0".to_string(), "other hint #1".to_string(), "other hint #2".to_string(), + "other hint #3".to_string(), ]; - assert_eq!(expected_hints, sorted_cached_hint_labels(editor), - "With more scrolls of the multibuffer, more hints should be added into the cache and nothing invalidated without edits"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + expected_hints, + sorted_cached_hint_labels(editor, cx), + "After scrolling to the new buffer and waiting for it to be registered, new hints should appear"); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + "Editor should show only visible hints", + ); }) .unwrap(); @@ -2781,9 +2477,7 @@ pub mod tests { ); }) .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); + cx.executor().advance_clock(Duration::from_millis(100)); cx.executor().run_until_parked(); editor .update(cx, |editor, _window, cx| { @@ -2801,9 +2495,16 @@ pub mod tests { "other hint #4".to_string(), "other hint #5".to_string(), ]; - assert_eq!(expected_hints, sorted_cached_hint_labels(editor), - "After multibuffer was scrolled to the end, all hints for all excerpts should be fetched"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + expected_hints, + sorted_cached_hint_labels(editor, cx), + "After multibuffer was scrolled to the end, all hints for all excerpts should be fetched" + ); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + "Editor shows only hints for excerpts that were visible when scrolling" + ); }) .unwrap(); @@ -2817,9 +2518,6 @@ pub mod tests { ); }) .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); cx.executor().run_until_parked(); editor .update(cx, |editor, _window, cx| { @@ -2837,41 +2535,301 @@ pub mod tests { "other hint #4".to_string(), "other hint #5".to_string(), ]; - assert_eq!(expected_hints, sorted_cached_hint_labels(editor), - "After multibuffer was scrolled to the end, further scrolls up should not bring more hints"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + expected_hints, + sorted_cached_hint_labels(editor, cx), + "After multibuffer was scrolled to the end, further scrolls up should not bring more hints" + ); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + ); }) .unwrap(); - editor_edited.store(true, Ordering::Release); + // We prepare to change the scrolling on edit, but do not scroll yet editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(57, 0)..Point::new(57, 0)]) }); + }) + .unwrap(); + cx.executor().run_until_parked(); + // Edit triggers the scrolling too + editor_edited.store(true, Ordering::Release); + editor + .update(cx, |editor, window, cx| { editor.handle_input("++++more text++++", window, cx); }) .unwrap(); cx.executor().run_until_parked(); + // Wait again to trigger the inlay hints fetch on scroll + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); editor .update(cx, |editor, _window, cx| { let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), + "main hint(edited) #0".to_string(), + "main hint(edited) #1".to_string(), + "main hint(edited) #2".to_string(), + "main hint(edited) #3".to_string(), + "main hint(edited) #4".to_string(), + "main hint(edited) #5".to_string(), "other hint(edited) #0".to_string(), "other hint(edited) #1".to_string(), + "other hint(edited) #2".to_string(), + "other hint(edited) #3".to_string(), ]; assert_eq!( expected_hints, - sorted_cached_hint_labels(editor), + sorted_cached_hint_labels(editor, cx), "After multibuffer edit, editor gets scrolled back to the last selection; \ all hints should be invalidated and required for all of its visible excerpts" ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + "All excerpts should get their hints" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_editing_in_multi_buffer(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": format!("fn main() {{\n{}\n}}", (0..200).map(|i| format!("let i = {i};\n")).collect::>().join("")), + "lib.rs": r#"let a = 1; +let b = 2; +let c = 3;"# + }), + ) + .await; + + let lsp_request_ranges = Arc::new(Mutex::new(Vec::new())); + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let language = rust_lang(); + language_registry.add(language); + + let closure_ranges_fetched = lsp_request_ranges.clone(); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + let closure_ranges_fetched = closure_ranges_fetched.clone(); + fake_server.set_request_handler::( + move |params, _| { + let closure_ranges_fetched = closure_ranges_fetched.clone(); + async move { + let prefix = if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap() + { + closure_ranges_fetched + .lock() + .push(("main.rs", params.range)); + "main.rs" + } else if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap() + { + closure_ranges_fetched.lock().push(("lib.rs", params.range)); + "lib.rs" + } else { + panic!("Unexpected file path {:?}", params.text_document.uri); + }; + Ok(Some( + (params.range.start.line..params.range.end.line) + .map(|row| lsp::InlayHint { + position: lsp::Position::new(row, 0), + label: lsp::InlayHintLabel::String(format!( + "{prefix} Inlay hint #{row}" + )), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }) + .collect(), + )) + } + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let (buffer_1, _handle_1) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let (buffer_2, _handle_2) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/lib.rs"), cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ + // Have first excerpt to spawn over 2 chunks (50 lines each). + ExcerptRange::new(Point::new(49, 0)..Point::new(53, 0)), + // Have 2nd excerpt to be in the 2nd chunk only. + ExcerptRange::new(Point::new(70, 0)..Point::new(73, 0)), + ], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(4, 0))], + cx, + ); + multibuffer + }); + + let editor = cx.add_window(|window, cx| { + let mut editor = + Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) + }); + editor + }); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + vec![ + ( + "lib.rs", + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(2, 10)) + ), + ( + "main.rs", + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(50, 0)) + ), + ( + "main.rs", + lsp::Range::new(lsp::Position::new(50, 0), lsp::Position::new(100, 0)) + ), + ], + lsp_request_ranges + .lock() + .drain(..) + .sorted_by_key(|(prefix, r)| (prefix.to_owned(), r.start)) + .collect::>(), + "For large buffers, should query chunks that cover both visible excerpt" + ); + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + (0..2) + .map(|i| format!("lib.rs Inlay hint #{i}")) + .chain((0..100).map(|i| format!("main.rs Inlay hint #{i}"))) + .collect::>(), + sorted_cached_hint_labels(editor, cx), + "Both chunks should provide their inlay hints" + ); + assert_eq!( + vec![ + "main.rs Inlay hint #49".to_owned(), + "main.rs Inlay hint #50".to_owned(), + "main.rs Inlay hint #51".to_owned(), + "main.rs Inlay hint #52".to_owned(), + "main.rs Inlay hint #53".to_owned(), + "main.rs Inlay hint #70".to_owned(), + "main.rs Inlay hint #71".to_owned(), + "main.rs Inlay hint #72".to_owned(), + "main.rs Inlay hint #73".to_owned(), + "lib.rs Inlay hint #0".to_owned(), + "lib.rs Inlay hint #1".to_owned(), + ], + visible_hint_labels(editor, cx), + "Only hints from visible excerpt should be added into the editor" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.handle_input("a", window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(1000)); + cx.executor().run_until_parked(); + assert_eq!( + vec![ + ( + "lib.rs", + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(2, 10)) + ), + ( + "main.rs", + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(50, 0)) + ), + ( + "main.rs", + lsp::Range::new(lsp::Position::new(50, 0), lsp::Position::new(100, 0)) + ), + ], + lsp_request_ranges + .lock() + .drain(..) + .sorted_by_key(|(prefix, r)| (prefix.to_owned(), r.start)) + .collect::>(), + "Same chunks should be re-queried on edit" + ); + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + (0..2) + .map(|i| format!("lib.rs Inlay hint #{i}")) + .chain((0..100).map(|i| format!("main.rs Inlay hint #{i}"))) + .collect::>(), + sorted_cached_hint_labels(editor, cx), + "Same hints should be re-inserted after the edit" + ); + assert_eq!( + vec![ + "main.rs Inlay hint #49".to_owned(), + "main.rs Inlay hint #50".to_owned(), + "main.rs Inlay hint #51".to_owned(), + "main.rs Inlay hint #52".to_owned(), + "main.rs Inlay hint #53".to_owned(), + "main.rs Inlay hint #70".to_owned(), + "main.rs Inlay hint #71".to_owned(), + "main.rs Inlay hint #72".to_owned(), + "main.rs Inlay hint #73".to_owned(), + "lib.rs Inlay hint #0".to_owned(), + "lib.rs Inlay hint #1".to_owned(), + ], + visible_hint_labels(editor, cx), + "Same hints should be re-inserted into the editor after the edit" + ); }) .unwrap(); } @@ -2911,9 +2869,9 @@ pub mod tests { FakeLspAdapter { capabilities: lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -3018,18 +2976,29 @@ pub mod tests { }) .next() .await; + cx.executor().advance_clock(Duration::from_millis(100)); cx.executor().run_until_parked(); editor .update(cx, |editor, _, cx| { assert_eq!( - vec!["main hint #0".to_string(), "other hint #0".to_string()], - sorted_cached_hint_labels(editor), - "Cache should update for both excerpts despite hints display was disabled" + vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "other hint #0".to_string(), + "other hint #1".to_string(), + "other hint #2".to_string(), + "other hint #3".to_string(), + ], + sorted_cached_hint_labels(editor, cx), + "Cache should update for both excerpts despite hints display was disabled; after selecting 2nd buffer, it's now registered with the langserever and should get its hints" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "All hints are disabled and should not be shown despite being present in the cache" ); - assert!( - visible_hint_labels(editor, cx).is_empty(), - "All hints are disabled and should not be shown despite being present in the cache" - ); }) .unwrap(); @@ -3044,9 +3013,14 @@ pub mod tests { editor .update(cx, |editor, _, cx| { assert_eq!( - vec!["main hint #0".to_string()], - cached_hint_labels(editor), - "For the removed excerpt, should clean corresponding cached hints" + vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + ], + cached_hint_labels(editor, cx), + "For the removed excerpt, should clean corresponding cached hints as its buffer was dropped" ); assert!( visible_hint_labels(editor, cx).is_empty(), @@ -3071,16 +3045,22 @@ pub mod tests { cx.executor().run_until_parked(); editor .update(cx, |editor, _, cx| { - let expected_hints = vec!["main hint #0".to_string()]; assert_eq!( - expected_hints, - cached_hint_labels(editor), + vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + ], + cached_hint_labels(editor, cx), "Hint display settings change should not change the cache" ); assert_eq!( - expected_hints, + vec![ + "main hint #0".to_string(), + ], visible_hint_labels(editor, cx), - "Settings change should make cached hints visible" + "Settings change should make cached hints visible, but only the visible ones, from the remaining excerpt" ); }) .unwrap(); @@ -3121,7 +3101,7 @@ pub mod tests { FakeLspAdapter { capabilities: lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() + ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new(move |fake_server| { let lsp_request_count = Arc::new(AtomicU32::new(0)); @@ -3148,7 +3128,7 @@ pub mod tests { }, ); })), - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -3173,7 +3153,7 @@ pub mod tests { editor .update(cx, |editor, _, cx| { let expected_hints = vec!["1".to_string()]; - assert_eq!(expected_hints, cached_hint_labels(editor)); + assert_eq!(expected_hints, cached_hint_labels(editor, cx)); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); }) .unwrap(); @@ -3206,7 +3186,7 @@ pub mod tests { lsp::Uri::from_file_path(file_with_hints).unwrap(), ); - let i = lsp_request_count.fetch_add(1, Ordering::SeqCst) + 1; + let i = lsp_request_count.fetch_add(1, Ordering::AcqRel) + 1; Ok(Some(vec![lsp::InlayHint { position: lsp::Position::new(0, i), label: lsp::InlayHintLabel::String(i.to_string()), @@ -3235,7 +3215,7 @@ pub mod tests { let expected_hints = vec!["1".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Should display inlays after toggle despite them disabled in settings" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -3250,11 +3230,16 @@ pub mod tests { cx.executor().run_until_parked(); editor .update(cx, |editor, _, cx| { - assert!( - cached_hint_labels(editor).is_empty(), + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Cache does not change because of toggles in the editor" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), "Should clear hints after 2nd toggle" ); - assert!(visible_hint_labels(editor, cx).is_empty()); }) .unwrap(); @@ -3274,11 +3259,11 @@ pub mod tests { cx.executor().run_until_parked(); editor .update(cx, |editor, _, cx| { - let expected_hints = vec!["2".to_string()]; + let expected_hints = vec!["1".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), - "Should query LSP hints for the 2nd time after enabling hints in settings" + cached_hint_labels(editor, cx), + "Should not query LSP hints after enabling hints in settings, as file version is the same" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); }) @@ -3292,11 +3277,16 @@ pub mod tests { cx.executor().run_until_parked(); editor .update(cx, |editor, _, cx| { - assert!( - cached_hint_labels(editor).is_empty(), + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Cache does not change because of toggles in the editor" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), "Should clear hints after enabling in settings and a 3rd toggle" ); - assert!(visible_hint_labels(editor, cx).is_empty()); }) .unwrap(); @@ -3307,16 +3297,242 @@ pub mod tests { .unwrap(); cx.executor().run_until_parked(); editor.update(cx, |editor, _, cx| { - let expected_hints = vec!["3".to_string()]; + let expected_hints = vec!["1".to_string()]; assert_eq!( expected_hints, - cached_hint_labels(editor), - "Should query LSP hints for the 3rd time after enabling hints in settings and toggling them back on" + cached_hint_labels(editor,cx), + "Should not query LSP hints after enabling hints in settings and toggling them back on" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); }).unwrap(); } + #[gpui::test] + async fn test_modifiers_change(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let (_, editor, _fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + let lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server.set_request_handler::( + move |params, _| { + let lsp_request_count = lsp_request_count.clone(); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + + let i = lsp_request_count.fetch_add(1, Ordering::AcqRel) + 1; + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + }) + .await; + + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Should display inlays after toggle despite them disabled in settings" + ); + assert_eq!(vec!["1".to_string()], visible_hint_labels(editor, cx)); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Nothing happens with the cache on modifiers change" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "On modifiers change and hints toggled on, should hide editor inlays" + ); + }) + .unwrap(); + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["1".to_string()], cached_hint_labels(editor, cx)); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "Nothing changes on consequent modifiers change of the same kind" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "When modifiers change is off, no extra requests are sent" + ); + assert_eq!( + vec!["1".to_string()], + visible_hint_labels(editor, cx), + "When modifiers change is off, hints are back into the editor" + ); + }) + .unwrap(); + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["1".to_string()], cached_hint_labels(editor, cx)); + assert_eq!( + vec!["1".to_string()], + visible_hint_labels(editor, cx), + "Nothing changes on consequent modifiers change of the same kind (2)" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Nothing happens with the cache on modifiers change" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "When toggled off, should hide editor inlays" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Nothing happens with the cache on modifiers change" + ); + assert_eq!( + vec!["1".to_string()], + visible_hint_labels(editor, cx), + "On modifiers change & hints toggled off, should show editor inlays" + ); + }) + .unwrap(); + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["1".to_string()], cached_hint_labels(editor, cx)); + assert_eq!( + vec!["1".to_string()], + visible_hint_labels(editor, cx), + "Nothing changes on consequent modifiers change of the same kind" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "When modifiers change is off, no extra requests are sent" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "When modifiers change is off, editor hints are back into their toggled off state" + ); + }) + .unwrap(); + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["1".to_string()], cached_hint_labels(editor, cx)); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "Nothing changes on consequent modifiers change of the same kind (3)" + ); + }) + .unwrap(); + } + #[gpui::test] async fn test_inlays_at_the_same_place(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { @@ -3463,7 +3679,7 @@ pub mod tests { ]; assert_eq!( expected_hints, - cached_hint_labels(editor), + cached_hint_labels(editor, cx), "Editor inlay hints should repeat server's order when placed at the same spot" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); @@ -3471,16 +3687,389 @@ pub mod tests { .unwrap(); } + #[gpui::test] + async fn test_invalidation_and_addition_race(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": r#"fn main() { + let x = 1; + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + let x = "2"; + } +"#, + "lib.rs": r#"fn aaa() { + let aa = 22; + } + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + + fn bb() { + let bb = 33; + } +"# + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let language = rust_lang(); + language_registry.add(language); + + let requests_count = Arc::new(AtomicUsize::new(0)); + let closure_requests_count = requests_count.clone(); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + let requests_count = closure_requests_count.clone(); + fake_server.set_request_handler::( + move |params, _| { + let requests_count = requests_count.clone(); + async move { + requests_count.fetch_add(1, Ordering::Release); + if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap() + { + Ok(Some(vec![ + lsp::InlayHint { + position: lsp::Position::new(1, 9), + label: lsp::InlayHintLabel::String(": i32".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(19, 9), + label: lsp::InlayHintLabel::String(": i33".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + ])) + } else if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap() + { + Ok(Some(vec![ + lsp::InlayHint { + position: lsp::Position::new(1, 10), + label: lsp::InlayHintLabel::String(": i34".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(29, 10), + label: lsp::InlayHintLabel::String(": i35".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + ])) + } else { + panic!("Unexpected file path {:?}", params.text_document.uri); + } + } + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + // Add another server that does send the same, duplicate hints back + let mut fake_servers_2 = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "CrabLang-ls", + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + fake_server.set_request_handler::( + move |params, _| async move { + if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap() + { + Ok(Some(vec![ + lsp::InlayHint { + position: lsp::Position::new(1, 9), + label: lsp::InlayHintLabel::String(": i32".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(19, 9), + label: lsp::InlayHintLabel::String(": i33".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + ])) + } else if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap() + { + Ok(Some(vec![ + lsp::InlayHint { + position: lsp::Position::new(1, 10), + label: lsp::InlayHintLabel::String(": i34".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(29, 10), + label: lsp::InlayHintLabel::String(": i35".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + ])) + } else { + panic!("Unexpected file path {:?}", params.text_document.uri); + } + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let (buffer_1, _handle_1) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let (buffer_2, _handle_2) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/lib.rs"), cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); + multibuffer.push_excerpts( + buffer_2.clone(), + [ + ExcerptRange::new(Point::new(0, 0)..Point::new(10, 0)), + ExcerptRange::new(Point::new(23, 0)..Point::new(34, 0)), + ], + cx, + ); + multibuffer.push_excerpts( + buffer_1.clone(), + [ + ExcerptRange::new(Point::new(0, 0)..Point::new(10, 0)), + ExcerptRange::new(Point::new(13, 0)..Point::new(23, 0)), + ], + cx, + ); + multibuffer + }); + + let editor = cx.add_window(|window, cx| { + let mut editor = + Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(3, 3)..Point::new(3, 3)]) + }); + editor + }); + + let fake_server = fake_servers.next().await.unwrap(); + let _fake_server_2 = fake_servers_2.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + vec![ + ": i32".to_string(), + ": i32".to_string(), + ": i33".to_string(), + ": i33".to_string(), + ": i34".to_string(), + ": i34".to_string(), + ": i35".to_string(), + ": i35".to_string(), + ], + sorted_cached_hint_labels(editor, cx), + "We receive duplicate hints from 2 servers and cache them all" + ); + assert_eq!( + vec![ + ": i34".to_string(), + ": i35".to_string(), + ": i32".to_string(), + ": i33".to_string(), + ], + visible_hint_labels(editor, cx), + "lib.rs is added before main.rs , so its excerpts should be visible first; hints should be deduplicated per label" + ); + }) + .unwrap(); + assert_eq!( + requests_count.load(Ordering::Acquire), + 2, + "Should have queried hints once per each file" + ); + + // Scroll all the way down so the 1st buffer is out of sight. + // The selection is on the 1st buffer still. + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Line(88.0), window, cx); + }) + .unwrap(); + // Emulate a language server refresh request, coming in the background.. + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints( + InlayHintRefreshReason::RefreshRequested { + server_id: fake_server.server.server_id(), + request_id: Some(1), + }, + cx, + ); + }) + .unwrap(); + // Edit the 1st buffer while scrolled down and not seeing that. + // The edit will auto scroll to the edit (1st buffer). + editor + .update(cx, |editor, window, cx| { + editor.handle_input("a", window, cx); + }) + .unwrap(); + // Add more racy additive hint tasks. + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Line(0.2), window, cx); + }) + .unwrap(); + + cx.executor().advance_clock(Duration::from_millis(1000)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + vec![ + ": i32".to_string(), + ": i32".to_string(), + ": i33".to_string(), + ": i33".to_string(), + ": i34".to_string(), + ": i34".to_string(), + ": i35".to_string(), + ": i35".to_string(), + ], + sorted_cached_hint_labels(editor, cx), + "No hint changes/duplicates should occur in the cache", + ); + assert_eq!( + vec![ + ": i34".to_string(), + ": i35".to_string(), + ": i32".to_string(), + ": i33".to_string(), + ], + visible_hint_labels(editor, cx), + "No hint changes/duplicates should occur in the editor excerpts", + ); + }) + .unwrap(); + assert_eq!( + requests_count.load(Ordering::Acquire), + 4, + "Should have queried hints once more per each file, after editing the file once" + ); + } + pub(crate) fn init_test(cx: &mut TestAppContext, f: impl Fn(&mut AllLanguageSettingsContent)) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); theme::init(theme::LoadThemes::JustBase, cx); - release_channel::init(SemanticVersion::default(), cx); - client::init_settings(cx); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); @@ -3511,10 +4100,10 @@ pub mod tests { FakeLspAdapter { capabilities: lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() + ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new(move |server| initialize(server, file_path))), - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -3529,7 +4118,7 @@ pub mod tests { editor .update(cx, |editor, _, cx| { - assert!(cached_hint_labels(editor).is_empty()); + assert!(cached_hint_labels(editor, cx).is_empty()); assert!(visible_hint_labels(editor, cx).is_empty()); }) .unwrap(); @@ -3541,30 +4130,35 @@ pub mod tests { // Inlay hints in the cache are stored per excerpt as a key, and those keys are guaranteed to be ordered same as in the multi buffer. // Ensure a stable order for testing. - fn sorted_cached_hint_labels(editor: &Editor) -> Vec { - let mut labels = cached_hint_labels(editor); - labels.sort(); + fn sorted_cached_hint_labels(editor: &Editor, cx: &mut App) -> Vec { + let mut labels = cached_hint_labels(editor, cx); + labels.sort_by(|a, b| natural_sort(a, b)); labels } - pub fn cached_hint_labels(editor: &Editor) -> Vec { - let mut labels = Vec::new(); - for excerpt_hints in editor.inlay_hint_cache().hints.values() { - let excerpt_hints = excerpt_hints.read(); - for id in &excerpt_hints.ordered_hints { - let hint = &excerpt_hints.hints_by_id[id]; - let mut label = hint.text().to_string(); - if hint.padding_left { - label.insert(0, ' '); - } - if hint.padding_right { - label.push_str(" "); - } - labels.push(label); - } + pub fn cached_hint_labels(editor: &Editor, cx: &mut App) -> Vec { + let lsp_store = editor.project().unwrap().read(cx).lsp_store(); + + let mut all_cached_labels = Vec::new(); + let mut all_fetched_hints = Vec::new(); + for buffer in editor.buffer.read(cx).all_buffers() { + lsp_store.update(cx, |lsp_store, cx| { + let hints = lsp_store.latest_lsp_data(&buffer, cx).inlay_hints(); + all_cached_labels.extend(hints.all_cached_hints().into_iter().map(|hint| { + let mut label = hint.text().to_string(); + if hint.padding_left { + label.insert(0, ' '); + } + if hint.padding_right { + label.push_str(" "); + } + label + })); + all_fetched_hints.extend(hints.all_fetched_hints()); + }); } - labels + all_cached_labels } pub fn visible_hint_labels(editor: &Editor, cx: &Context) -> Vec { @@ -3574,4 +4168,13 @@ pub mod tests { .map(|hint| hint.text().to_string()) .collect() } + + fn allowed_hint_kinds_for_editor(editor: &Editor) -> HashSet> { + editor + .inlay_hints + .as_ref() + .unwrap() + .allowed_hint_kinds + .clone() + } } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index f56e7dbaf8..cfbb7c975c 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1,7 +1,7 @@ use crate::{ - Anchor, Autoscroll, Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, FormatTarget, - MultiBuffer, MultiBufferSnapshot, NavigationData, ReportEditorEvent, SearchWithinRange, - SelectionEffects, ToPoint as _, + Anchor, Autoscroll, BufferSerialization, Editor, EditorEvent, EditorSettings, ExcerptId, + ExcerptRange, FormatTarget, MultiBuffer, MultiBufferSnapshot, NavigationData, + ReportEditorEvent, SearchWithinRange, SelectionEffects, ToPoint as _, display_map::HighlightKey, editor_settings::SeedQuerySetting, persistence::{DB, SerializedEditor}, @@ -21,8 +21,9 @@ use language::{ SelectionGoal, proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; +use multi_buffer::MultiBufferOffset; use project::{ - Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger, + File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, }; use rpc::proto::{self, update_view}; @@ -42,7 +43,7 @@ use ui::{IconDecorationKind, prelude::*}; use util::{ResultExt, TryFutureExt, paths::PathExt}; use workspace::{ CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, - invalid_buffer_view::InvalidBufferView, + invalid_item_view::InvalidItemView, item::{FollowableItem, Item, ItemBufferKind, ItemEvent, ProjectItem, SaveOptions}, searchable::{ Direction, FilteredSearchRange, SearchEvent, SearchableItem, SearchableItemHandle, @@ -226,7 +227,7 @@ impl FollowableItem for Editor { Some(proto::view::Variant::Editor(proto::view::Editor { singleton: buffer.is_singleton(), - title: (!buffer.is_singleton()).then(|| buffer.title(cx).into()), + title: buffer.explicit_title().map(ToOwned::to_owned), excerpts, scroll_top_anchor: Some(serialize_anchor(&scroll_anchor.anchor, &snapshot)), scroll_x: scroll_anchor.offset.x, @@ -364,10 +365,9 @@ impl FollowableItem for Editor { ) { let buffer = self.buffer.read(cx); let buffer = buffer.read(cx); - let Some((excerpt_id, _, _)) = buffer.as_singleton() else { + let Some(position) = buffer.as_singleton_anchor(location) else { return; }; - let position = buffer.anchor_in_excerpt(*excerpt_id, location).unwrap(); let selection = Selection { id: 0, reversed: false, @@ -455,21 +455,13 @@ async fn update_editor_from_message( })??; // Deserialize the editor state. - let (selections, pending_selection, scroll_top_anchor) = this.update(cx, |editor, cx| { - let buffer = editor.buffer.read(cx).read(cx); - let selections = message - .selections - .into_iter() - .filter_map(|selection| deserialize_selection(&buffer, selection)) - .collect::>(); - let pending_selection = message - .pending_selection - .and_then(|selection| deserialize_selection(&buffer, selection)); - let scroll_top_anchor = message - .scroll_top_anchor - .and_then(|anchor| deserialize_anchor(&buffer, anchor)); - anyhow::Ok((selections, pending_selection, scroll_top_anchor)) - })??; + let selections = message + .selections + .into_iter() + .filter_map(deserialize_selection) + .collect::>(); + let pending_selection = message.pending_selection.and_then(deserialize_selection); + let scroll_top_anchor = message.scroll_top_anchor.and_then(deserialize_anchor); // Wait until the buffer has received all of the operations referenced by // the editor's new state. @@ -563,24 +555,20 @@ fn deserialize_excerpt_range( )) } -fn deserialize_selection( - buffer: &MultiBufferSnapshot, - selection: proto::Selection, -) -> Option> { +fn deserialize_selection(selection: proto::Selection) -> Option> { Some(Selection { id: selection.id as usize, - start: deserialize_anchor(buffer, selection.start?)?, - end: deserialize_anchor(buffer, selection.end?)?, + start: deserialize_anchor(selection.start?)?, + end: deserialize_anchor(selection.end?)?, reversed: selection.reversed, goal: SelectionGoal::None, }) } -fn deserialize_anchor(buffer: &MultiBufferSnapshot, anchor: proto::EditorAnchor) -> Option { +fn deserialize_anchor(anchor: proto::EditorAnchor) -> Option { let excerpt_id = ExcerptId::from_proto(anchor.excerpt_id); Some(Anchor::in_buffer( excerpt_id, - buffer.buffer_id_for_excerpt(excerpt_id)?, language::proto::deserialize_anchor(anchor.anchor?)?, )) } @@ -588,6 +576,21 @@ fn deserialize_anchor(buffer: &MultiBufferSnapshot, anchor: proto::EditorAnchor) impl Item for Editor { type Event = EditorEvent; + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if TypeId::of::() == type_id { + Some(self_handle.clone().into()) + } else if TypeId::of::() == type_id { + Some(self_handle.read(cx).buffer.clone().into()) + } else { + None + } + } + fn navigate( &mut self, data: Box, @@ -595,7 +598,7 @@ impl Item for Editor { cx: &mut Context, ) -> bool { if let Ok(data) = data.downcast::() { - let newest_selection = self.selections.newest::(cx); + let newest_selection = self.selections.newest::(&self.display_snapshot(cx)); let buffer = self.buffer.read(cx).read(cx); let offset = if buffer.can_resolve(&data.cursor_anchor) { data.cursor_anchor.to_point(&buffer) @@ -630,18 +633,20 @@ impl Item for Editor { } fn tab_tooltip_text(&self, cx: &App) -> Option { - let file_path = self - .buffer() + self.buffer() .read(cx) - .as_singleton()? - .read(cx) - .file() - .and_then(|f| f.as_local())? - .abs_path(cx); - - let file_path = file_path.compact().to_string_lossy().into_owned(); - - Some(file_path.into()) + .as_singleton() + .and_then(|buffer| buffer.read(cx).file()) + .and_then(|file| File::from_dyn(Some(file))) + .map(|file| { + file.worktree + .read(cx) + .absolutize(&file.path) + .compact() + .to_string_lossy() + .into_owned() + .into() + }) } fn telemetry_event_text(&self) -> Option<&'static str> { @@ -758,16 +763,20 @@ impl Item for Editor { self.buffer.read(cx).is_singleton() } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| self.clone(window, cx))) + Task::ready(Some(cx.new(|cx| self.clone(window, cx)))) } fn set_nav_history( @@ -833,7 +842,6 @@ impl Item for Editor { .map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone())) .collect::>(); - // let mut buffers_to_save = let buffers_to_save = if self.buffer.read(cx).is_singleton() && !options.autosave { buffers } else { @@ -920,7 +928,11 @@ impl Item for Editor { }) } - fn as_searchable(&self, handle: &Entity) -> Option> { + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { Some(Box::new(handle.clone())) } @@ -938,9 +950,10 @@ impl Item for Editor { fn breadcrumbs(&self, variant: &Theme, cx: &App) -> Option> { let cursor = self.selections.newest_anchor().head(); - let multibuffer = &self.buffer().read(cx); - let (buffer_id, symbols) = - multibuffer.symbols_containing(cursor, Some(variant.syntax()), cx)?; + let multibuffer = self.buffer().read(cx); + let (buffer_id, symbols) = multibuffer + .read(cx) + .symbols_containing(cursor, Some(variant.syntax()))?; let buffer = multibuffer.buffer(buffer_id)?; let buffer = buffer.read(cx); @@ -1080,12 +1093,17 @@ impl SerializableItem for Editor { } } Ok(None) => { - return Task::ready(Err(anyhow!("No path or contents found for buffer"))); + return Task::ready(Err(anyhow!( + "Unable to deserialize editor: No entry in database for item_id: {item_id} and workspace_id {workspace_id:?}" + ))); } Err(error) => { return Task::ready(Err(error)); } }; + log::debug!( + "Deserialized editor {item_id:?} in workspace {workspace_id:?}, {serialized_editor:?}" + ); match serialized_editor { SerializedEditor { @@ -1113,7 +1131,8 @@ impl SerializableItem for Editor { // First create the empty buffer let buffer = project .update(cx, |project, cx| project.create_buffer(true, cx))? - .await?; + .await + .context("Failed to create buffer while deserializing editor")?; // Then set the text so that the dirty bit is set correctly buffer.update(cx, |buffer, cx| { @@ -1155,7 +1174,9 @@ impl SerializableItem for Editor { match opened_buffer { Some(opened_buffer) => { window.spawn(cx, async move |cx| { - let (_, buffer) = opened_buffer.await?; + let (_, buffer) = opened_buffer + .await + .context("Failed to open path in project")?; // This is a bit wasteful: we're loading the whole buffer from // disk and then overwrite the content. @@ -1221,7 +1242,8 @@ impl SerializableItem for Editor { } => window.spawn(cx, async move |cx| { let buffer = project .update(cx, |project, cx| project.create_buffer(true, cx))? - .await?; + .await + .context("Failed to create buffer")?; cx.update(|window, cx| { cx.new(|cx| { @@ -1243,17 +1265,15 @@ impl SerializableItem for Editor { window: &mut Window, cx: &mut Context, ) -> Option>> { - if self.mode.is_minimap() { - return None; - } - let mut serialize_dirty_buffers = self.serialize_dirty_buffers; - + let buffer_serialization = self.buffer_serialization?; let project = self.project.clone()?; - if project.read(cx).visible_worktrees(cx).next().is_none() { + + let serialize_dirty_buffers = match buffer_serialization { // If we don't have a worktree, we don't serialize, because // projects without worktrees aren't deserialized. - serialize_dirty_buffers = false; - } + BufferSerialization::All => project.read(cx).visible_worktrees(cx).next().is_some(), + BufferSerialization::NonDirtyBuffers => false, + }; if closing && !serialize_dirty_buffers { return None; @@ -1310,10 +1330,11 @@ impl SerializableItem for Editor { } fn should_serialize(&self, event: &Self::Event) -> bool { - matches!( - event, - EditorEvent::Saved | EditorEvent::DirtyChanged | EditorEvent::BufferEdited - ) + self.should_serialize_buffer() + && matches!( + event, + EditorEvent::Saved | EditorEvent::DirtyChanged | EditorEvent::BufferEdited + ) } } @@ -1344,7 +1365,7 @@ impl ProjectItem for Editor { cx: &mut Context, ) -> Self { let mut editor = Self::for_buffer(buffer.clone(), Some(project), window, cx); - if let Some((excerpt_id, buffer_id, snapshot)) = + if let Some((excerpt_id, _, snapshot)) = editor.buffer().read(cx).snapshot(cx).as_singleton() && WorkspaceSettings::get(None, cx).restore_on_file_reopen && let Some(restoration_data) = Self::project_item_kind() @@ -1367,11 +1388,8 @@ impl ProjectItem for Editor { }); } let (top_row, offset) = restoration_data.scroll_position; - let anchor = Anchor::in_buffer( - *excerpt_id, - buffer_id, - snapshot.anchor_before(Point::new(top_row, 0)), - ); + let anchor = + Anchor::in_buffer(*excerpt_id, snapshot.anchor_before(Point::new(top_row, 0))); editor.set_scroll_anchor(ScrollAnchor { anchor, offset }, window, cx); } @@ -1384,8 +1402,8 @@ impl ProjectItem for Editor { e: &anyhow::Error, window: &mut Window, cx: &mut App, - ) -> Option { - Some(InvalidBufferView::new(abs_path, is_local, e, window, cx)) + ) -> Option { + Some(InvalidItemView::new(abs_path, is_local, e, window, cx)) } } @@ -1468,6 +1486,7 @@ impl SearchableItem for Editor { fn update_matches( &mut self, matches: &[Range], + active_match_index: Option, _: &mut Window, cx: &mut Context, ) { @@ -1478,7 +1497,13 @@ impl SearchableItem for Editor { let updated = existing_range != Some(matches); self.highlight_background::( matches, - |theme| theme.colors().search_match_background, + move |index, theme| { + if active_match_index == Some(*index) { + theme.colors().search_active_match_background + } else { + theme.colors().search_match_background + } + }, cx, ); if updated { @@ -1540,13 +1565,13 @@ impl SearchableItem for Editor { fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { let setting = EditorSettings::get_global(cx).seed_search_query_from_cursor; let snapshot = self.snapshot(window, cx); - let snapshot = snapshot.buffer_snapshot(); - let selection = self.selections.newest_adjusted(cx); + let selection = self.selections.newest_adjusted(&snapshot.display_snapshot); + let buffer_snapshot = snapshot.buffer_snapshot(); match setting { SeedQuerySetting::Never => String::new(), SeedQuerySetting::Selection | SeedQuerySetting::Always if !selection.is_empty() => { - let text: String = snapshot + let text: String = buffer_snapshot .text_for_range(selection.start..selection.end) .collect(); if text.contains('\n') { @@ -1557,10 +1582,10 @@ impl SearchableItem for Editor { } SeedQuerySetting::Selection => String::new(), SeedQuerySetting::Always => { - let (range, kind) = - snapshot.surrounding_word(selection.start, Some(CharScopeContext::Completion)); + let (range, kind) = buffer_snapshot + .surrounding_word(selection.start, Some(CharScopeContext::Completion)); if kind == Some(CharKind::Word) { - let text: String = snapshot.text_for_range(range).collect(); + let text: String = buffer_snapshot.text_for_range(range).collect(); if !text.trim().is_empty() { return text; } @@ -1579,7 +1604,12 @@ impl SearchableItem for Editor { ) { self.unfold_ranges(&[matches[index].clone()], false, true, cx); let range = self.range_for_match(&matches[index]); - self.change_selections(Default::default(), window, cx, |s| { + let autoscroll = if EditorSettings::get_global(cx).search.center_on_match { + Autoscroll::center() + } else { + Autoscroll::fit() + }; + self.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| { s.select_ranges([range]); }) } @@ -1718,7 +1748,7 @@ impl SearchableItem for Editor { let mut ranges = Vec::new(); let search_within_ranges = if search_within_ranges.is_empty() { - vec![buffer.anchor_before(0)..buffer.anchor_after(buffer.len())] + vec![buffer.anchor_before(MultiBufferOffset(0))..buffer.anchor_after(buffer.len())] } else { search_within_ranges }; @@ -1729,7 +1759,10 @@ impl SearchableItem for Editor { { ranges.extend( query - .search(search_buffer, Some(search_range.clone())) + .search( + search_buffer, + Some(search_range.start.0..search_range.end.0), + ) .await .into_iter() .map(|match_range| { @@ -1745,11 +1778,7 @@ impl SearchableItem for Editor { .anchor_after(search_range.start + match_range.start); let end = search_buffer .anchor_before(search_range.start + match_range.end); - Anchor::range_in_buffer( - excerpt_id, - search_buffer.remote_id(), - start..end, - ) + Anchor::range_in_buffer(excerpt_id, start..end) } }), ); @@ -1778,6 +1807,14 @@ impl SearchableItem for Editor { fn search_bar_visibility_changed(&mut self, _: bool, _: &mut Window, _: &mut Context) { self.expect_bounds_change = self.last_bounds; } + + fn set_search_is_case_sensitive( + &mut self, + case_sensitive: Option, + _cx: &mut Context, + ) { + self.select_next_is_case_sensitive = case_sensitive; + } } pub fn active_match_index( @@ -1860,15 +1897,20 @@ fn path_for_buffer<'a>( cx: &'a App, ) -> Option> { let file = buffer.read(cx).as_singleton()?.read(cx).file()?; - path_for_file(file.as_ref(), height, include_filename, cx) + path_for_file(file, height, include_filename, cx) } fn path_for_file<'a>( - file: &'a dyn language::File, + file: &'a Arc, mut height: usize, include_filename: bool, cx: &'a App, ) -> Option> { + if project::File::from_dyn(Some(file)).is_none() { + return None; + } + + let file = file.as_ref(); // Ensure we always render at least the filename. height += 1; @@ -1908,18 +1950,18 @@ mod tests { use super::*; use fs::MTime; use gpui::{App, VisualTestContext}; - use language::{LanguageMatcher, TestFile}; + use language::TestFile; use project::FakeFs; use std::path::{Path, PathBuf}; use util::{path, rel_path::RelPath}; #[gpui::test] fn test_path_for_file(cx: &mut App) { - let file = TestFile { + let file: Arc = Arc::new(TestFile { path: RelPath::empty().into(), root_name: String::new(), local_root: None, - }; + }); assert_eq!(path_for_file(&file, 0, false, cx), None); } @@ -1948,20 +1990,6 @@ mod tests { .unwrap() } - fn rust_language() -> Arc { - Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) - } - #[gpui::test] async fn test_deserialize(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -2043,7 +2071,9 @@ mod tests { { let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; // Add Rust to the language, so that we can restore the language of the buffer - project.read_with(cx, |project, _| project.languages().add(rust_language())); + project.read_with(cx, |project, _| { + project.languages().add(languages::rust_lang()) + }); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); diff --git a/crates/editor/src/jsx_tag_auto_close.rs b/crates/editor/src/jsx_tag_auto_close.rs index 0e32bc686a..e22fde313d 100644 --- a/crates/editor/src/jsx_tag_auto_close.rs +++ b/crates/editor/src/jsx_tag_auto_close.rs @@ -1,7 +1,7 @@ use anyhow::{Context as _, Result, anyhow}; use collections::HashMap; use gpui::{Context, Entity, Window}; -use multi_buffer::{MultiBuffer, ToOffset}; +use multi_buffer::{BufferOffset, MultiBuffer, ToOffset}; use std::ops::Range; use util::ResultExt as _; @@ -546,9 +546,10 @@ pub(crate) fn handle_from( if edit_range_offset.start != edit_range_offset.end { continue; } - if let Some(selection) = - buffer_selection_map.get_mut(&(edit_range_offset.start, edit_range_offset.end)) - { + if let Some(selection) = buffer_selection_map.get_mut(&( + BufferOffset(edit_range_offset.start), + BufferOffset(edit_range_offset.end), + )) { if selection.0.head().bias() != text::Bias::Right || selection.0.tail().bias() != text::Bias::Right { @@ -621,7 +622,7 @@ mod jsx_tag_autoclose_tests { use super::*; use gpui::{AppContext as _, TestAppContext}; use languages::language; - use multi_buffer::ExcerptRange; + use multi_buffer::{ExcerptRange, MultiBufferOffset}; use text::Selection; async fn test_setup(cx: &mut TestAppContext) -> EditorTestContext { @@ -842,9 +843,9 @@ mod jsx_tag_autoclose_tests { cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select(vec![ - Selection::from_offset(4), - Selection::from_offset(9), - Selection::from_offset(15), + Selection::from_offset(MultiBufferOffset(4)), + Selection::from_offset(MultiBufferOffset(9)), + Selection::from_offset(MultiBufferOffset(15)), ]) }) }); diff --git a/crates/editor/src/linked_editing_ranges.rs b/crates/editor/src/linked_editing_ranges.rs index 4f1313797f..ff3096961d 100644 --- a/crates/editor/src/linked_editing_ranges.rs +++ b/crates/editor/src/linked_editing_ranges.rs @@ -1,6 +1,7 @@ use collections::HashMap; -use gpui::{Context, Window}; +use gpui::{AppContext, Context, Window}; use itertools::Itertools; +use multi_buffer::MultiBufferOffset; use std::{ops::Range, time::Duration}; use text::{AnchorRangeExt, BufferId, ToPoint}; use util::ResultExt; @@ -48,7 +49,7 @@ pub(super) fn refresh_linked_ranges( window: &mut Window, cx: &mut Context, ) -> Option<()> { - if editor.pending_rename.is_some() { + if editor.ignore_lsp_data() || editor.pending_rename.is_some() { return None; } let project = editor.project()?.downgrade(); @@ -59,15 +60,18 @@ pub(super) fn refresh_linked_ranges( let mut applicable_selections = Vec::new(); editor .update(cx, |editor, cx| { - let selections = editor.selections.all::(cx); - let snapshot = editor.buffer.read(cx).snapshot(cx); + let display_snapshot = editor.display_snapshot(cx); + let selections = editor + .selections + .all::(&display_snapshot); + let snapshot = display_snapshot.buffer_snapshot(); let buffer = editor.buffer.read(cx); for selection in selections { let cursor_position = selection.head(); let start_position = snapshot.anchor_before(cursor_position); let end_position = snapshot.anchor_after(selection.tail()); - if start_position.buffer_id != end_position.buffer_id - || end_position.buffer_id.is_none() + if start_position.text_anchor.buffer_id != end_position.text_anchor.buffer_id + || end_position.text_anchor.buffer_id.is_none() { // Throw away selections spanning multiple buffers. continue; @@ -90,14 +94,16 @@ pub(super) fn refresh_linked_ranges( let highlights = project .update(cx, |project, cx| { let mut linked_edits_tasks = vec![]; - for (buffer, start, end) in &applicable_selections { - let snapshot = buffer.read(cx).snapshot(); - let buffer_id = buffer.read(cx).remote_id(); - let linked_edits_task = project.linked_edits(buffer, *start, cx); - let highlights = move || async move { + let cx = cx.to_async(); + let highlights = async move { let edits = linked_edits_task.await.log_err()?; + let snapshot = cx + .read_entity(&buffer, |buffer, _| buffer.snapshot()) + .ok()?; + let buffer_id = snapshot.remote_id(); + // Find the range containing our current selection. // We might not find one, because the selection contains both the start and end of the contained range // (think of selecting <`html>foo` - even though there's a matching closing tag, the selection goes beyond the range of the opening tag) @@ -128,7 +134,7 @@ pub(super) fn refresh_linked_ranges( siblings.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0, &snapshot)); Some((buffer_id, siblings)) }; - linked_edits_tasks.push(highlights()); + linked_edits_tasks.push(highlights); } linked_edits_tasks }) diff --git a/crates/editor/src/lsp_colors.rs b/crates/editor/src/lsp_colors.rs index 4d703d219f..2a98ad6bd4 100644 --- a/crates/editor/src/lsp_colors.rs +++ b/crates/editor/src/lsp_colors.rs @@ -2,19 +2,19 @@ use std::{cmp, ops::Range}; use collections::HashMap; use futures::future::join_all; -use gpui::{Hsla, Rgba}; +use gpui::{Hsla, Rgba, Task}; use itertools::Itertools; use language::point_from_lsp; use multi_buffer::Anchor; -use project::{DocumentColor, lsp_store::LspFetchStrategy}; +use project::{DocumentColor, InlayId}; use settings::Settings as _; use text::{Bias, BufferId, OffsetRangeExt as _}; use ui::{App, Context, Window}; use util::post_inc; use crate::{ - DisplayPoint, Editor, EditorSettings, EditorSnapshot, InlayId, InlaySplice, RangeToAnchorExt, - display_map::Inlay, editor_settings::DocumentColorsRenderMode, + DisplayPoint, Editor, EditorSettings, EditorSnapshot, FETCH_COLORS_DEBOUNCE_TIMEOUT, + InlaySplice, RangeToAnchorExt, editor_settings::DocumentColorsRenderMode, inlays::Inlay, }; #[derive(Debug)] @@ -143,14 +143,13 @@ impl LspColorData { } impl Editor { - pub(super) fn refresh_colors( + pub(super) fn refresh_colors_for_visible_range( &mut self, - ignore_cache: bool, buffer_id: Option, _: &Window, cx: &mut Context, ) { - if !self.mode().is_full() { + if self.ignore_lsp_data() { return; } let Some(project) = self.project.clone() else { @@ -165,11 +164,13 @@ impl Editor { } let visible_buffers = self - .visible_excerpts(None, cx) + .visible_excerpts(true, cx) .into_values() .map(|(buffer, ..)| buffer) .filter(|editor_buffer| { - buffer_id.is_none_or(|buffer_id| buffer_id == editor_buffer.read(cx).remote_id()) + let editor_buffer_id = editor_buffer.read(cx).remote_id(); + buffer_id.is_none_or(|buffer_id| buffer_id == editor_buffer_id) + && self.registered_buffers.contains_key(&editor_buffer_id) }) .unique_by(|buffer| buffer.read(cx).remote_id()) .collect::>(); @@ -179,21 +180,25 @@ impl Editor { .into_iter() .filter_map(|buffer| { let buffer_id = buffer.read(cx).remote_id(); - let fetch_strategy = if ignore_cache { - LspFetchStrategy::IgnoreCache - } else { - LspFetchStrategy::UseCache { - known_cache_version: self.colors.as_ref().and_then(|colors| { - Some(colors.buffer_colors.get(&buffer_id)?.cache_version_used) - }), - } - }; - let colors_task = lsp_store.document_colors(fetch_strategy, buffer, cx)?; + let known_cache_version = self.colors.as_ref().and_then(|colors| { + Some(colors.buffer_colors.get(&buffer_id)?.cache_version_used) + }); + let colors_task = lsp_store.document_colors(known_cache_version, buffer, cx)?; Some(async move { (buffer_id, colors_task.await) }) }) .collect::>() }); - cx.spawn(async move |editor, cx| { + + if all_colors_task.is_empty() { + self.refresh_colors_task = Task::ready(()); + return; + } + + self.refresh_colors_task = cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(FETCH_COLORS_DEBOUNCE_TIMEOUT) + .await; + let all_colors = join_all(all_colors_task).await; if all_colors.is_empty() { return; @@ -246,25 +251,14 @@ impl Editor { { continue; } - let Some(color_start_anchor) = multi_buffer_snapshot - .anchor_in_excerpt( - *excerpt_id, - buffer_snapshot.anchor_before( - buffer_snapshot - .clip_point_utf16(color_start, Bias::Left), - ), - ) - else { - continue; - }; - let Some(color_end_anchor) = multi_buffer_snapshot - .anchor_in_excerpt( - *excerpt_id, - buffer_snapshot.anchor_after( - buffer_snapshot - .clip_point_utf16(color_end, Bias::Right), - ), - ) + let start = buffer_snapshot.anchor_before( + buffer_snapshot.clip_point_utf16(color_start, Bias::Left), + ); + let end = buffer_snapshot.anchor_after( + buffer_snapshot.clip_point_utf16(color_end, Bias::Right), + ); + let Some(range) = multi_buffer_snapshot + .anchor_range_in_excerpt(*excerpt_id, start..end) else { continue; }; @@ -280,16 +274,14 @@ impl Editor { new_buffer_colors.binary_search_by(|(probe, _)| { probe .start - .cmp(&color_start_anchor, &multi_buffer_snapshot) + .cmp(&range.start, &multi_buffer_snapshot) .then_with(|| { - probe.end.cmp( - &color_end_anchor, - &multi_buffer_snapshot, - ) + probe + .end + .cmp(&range.end, &multi_buffer_snapshot) }) }); - new_buffer_colors - .insert(i, (color_start_anchor..color_end_anchor, color)); + new_buffer_colors.insert(i, (range, color)); break; } } @@ -408,8 +400,7 @@ impl Editor { } if colors.render_mode == DocumentColorsRenderMode::Inlay - && (!colors_splice.to_insert.is_empty() - || !colors_splice.to_remove.is_empty()) + && !colors_splice.is_empty() { editor.splice_inlays(&colors_splice.to_remove, colors_splice.to_insert, cx); updated = true; @@ -420,7 +411,6 @@ impl Editor { } }) .ok(); - }) - .detach(); + }); } } diff --git a/crates/editor/src/lsp_ext.rs b/crates/editor/src/lsp_ext.rs index 0c4760f568..37cc734ab1 100644 --- a/crates/editor/src/lsp_ext.rs +++ b/crates/editor/src/lsp_ext.rs @@ -37,7 +37,7 @@ where .selections .disjoint_anchors_arc() .iter() - .filter_map(|selection| Some((selection.head(), selection.head().buffer_id?))) + .filter_map(|selection| Some((selection.head(), selection.head().text_anchor.buffer_id?))) .unique_by(|(_, buffer_id)| *buffer_id) .find_map(|(trigger_anchor, buffer_id)| { let buffer = editor.buffer().read(cx).buffer(buffer_id)?; @@ -60,8 +60,10 @@ async fn lsp_task_context( buffer: &Entity, cx: &mut AsyncApp, ) -> Option { - let worktree_store = project - .read_with(cx, |project, _| project.worktree_store()) + let (worktree_store, environment) = project + .read_with(cx, |project, _| { + (project.worktree_store(), project.environment().clone()) + }) .ok()?; let worktree_abs_path = cx @@ -74,9 +76,9 @@ async fn lsp_task_context( }) .ok()?; - let project_env = project - .update(cx, |project, cx| { - project.buffer_environment(buffer, &worktree_store, cx) + let project_env = environment + .update(cx, |environment, cx| { + environment.buffer_environment(buffer, &worktree_store, cx) }) .ok()? .await; diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 3d8bbb3610..36521d46a6 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -8,9 +8,12 @@ use crate::{ }; use gpui::prelude::FluentBuilder; use gpui::{Context, DismissEvent, Entity, Focusable as _, Pixels, Point, Subscription, Window}; +use project::DisableAiSettings; +use settings::Settings; use std::ops::Range; use text::PointUtf16; use workspace::OpenInTerminal; +use zed_actions::agent::AddSelectionToThread; #[derive(Debug)] pub enum MenuPosition { @@ -56,7 +59,7 @@ impl MouseContextMenu { x: editor.gutter_dimensions.width, y: Pixels::ZERO, }; - let source_position = editor.to_pixel_point(source, &editor_snapshot, window)?; + let source_position = editor.to_pixel_point(source, &editor_snapshot, window, cx)?; let menu_position = MenuPosition::PinnedToEditor { source, offset: position - (source_position + content_origin), @@ -78,7 +81,19 @@ impl MouseContextMenu { cx: &mut Context, ) -> Self { let context_menu_focus = context_menu.focus_handle(cx); - window.focus(&context_menu_focus); + + // Since `ContextMenu` is rendered in a deferred fashion its focus + // handle is not linked to the Editor's until after the deferred draw + // callback runs. + // We need to wait for that to happen before focusing it, so that + // calling `contains_focused` on the editor's focus handle returns + // `true` when the `ContextMenu` is focused. + let focus_handle = context_menu_focus.clone(); + cx.on_next_frame(window, move |_, window, cx| { + cx.on_next_frame(window, move |_, window, _cx| { + window.focus(&focus_handle); + }); + }); let _dismiss_subscription = cx.subscribe_in(&context_menu, window, { let context_menu_focus = context_menu_focus.clone(); @@ -154,7 +169,7 @@ pub fn deploy_context_menu( return; } - let display_map = editor.selections.display_map(cx); + let display_map = editor.display_snapshot(cx); let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right); let context_menu = if let Some(custom) = editor.custom_context_menu.take() { let menu = custom(editor, point, window, cx); @@ -169,8 +184,8 @@ pub fn deploy_context_menu( return; }; - let display_map = editor.selections.display_map(cx); let snapshot = editor.snapshot(window, cx); + let display_map = editor.display_snapshot(cx); let buffer = snapshot.buffer_snapshot(); let anchor = buffer.anchor_before(point.to_point(&display_map)); if !display_ranges(&display_map, &editor.selections).any(|r| r.contains(&point)) { @@ -185,7 +200,7 @@ pub fn deploy_context_menu( let has_reveal_target = editor.target_file(cx).is_some(); let has_selections = editor .selections - .all::(cx) + .all::(&display_map) .into_iter() .any(|s| !s.is_empty()); let has_git_repo = buffer @@ -201,6 +216,7 @@ pub fn deploy_context_menu( let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx); let run_to_cursor = window.is_action_available(&RunToCursor, cx); + let disable_ai = DisableAiSettings::get_global(cx).disable_ai; ui::ContextMenu::build(window, cx, |menu, _window, _cx| { let builder = menu @@ -219,7 +235,10 @@ pub fn deploy_context_menu( .action("Go to Declaration", Box::new(GoToDeclaration)) .action("Go to Type Definition", Box::new(GoToTypeDefinition)) .action("Go to Implementation", Box::new(GoToImplementation)) - .action("Find All References", Box::new(FindAllReferences)) + .action( + "Find All References", + Box::new(FindAllReferences::default()), + ) .separator() .action("Rename Symbol", Box::new(Rename)) .action("Format Buffer", Box::new(Format)) @@ -233,6 +252,9 @@ pub fn deploy_context_menu( quick_launch: false, }), ) + .when(!disable_ai && has_selections, |this| { + this.action("Add to Agent Thread", Box::new(AddSelectionToThread)) + }) .separator() .action("Cut", Box::new(Cut)) .action("Copy", Box::new(Copy)) @@ -257,6 +279,11 @@ pub fn deploy_context_menu( !has_git_repo, "Copy Permalink", Box::new(CopyPermalinkToLine), + ) + .action_disabled_when( + !has_git_repo, + "View File History", + Box::new(git::FileHistory), ); match focus { Some(focus) => builder.context(focus), @@ -322,8 +349,18 @@ mod tests { } "}); cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none())); + cx.update_editor(|editor, window, cx| { - deploy_context_menu(editor, Some(Default::default()), point, window, cx) + deploy_context_menu(editor, Some(Default::default()), point, window, cx); + + // Assert that, even after deploying the editor's mouse context + // menu, the editor's focus handle still contains the focused + // element. The pane's tab bar relies on this to determine whether + // to show the tab bar buttons and there was a small flicker when + // deploying the mouse context menu that would cause this to not be + // true, making it so that the buttons would disappear for a couple + // of frames. + assert!(editor.focus_handle.contains_focused(window, cx)); }); cx.assert_editor_state(indoc! {" diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 486a14e374..8635d89ed1 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -8,7 +8,7 @@ use crate::{ }; use gpui::{Pixels, WindowTextSystem}; use language::{CharClassifier, Point}; -use multi_buffer::{MultiBufferRow, MultiBufferSnapshot}; +use multi_buffer::{MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot}; use serde::Deserialize; use workspace::searchable::Direction; @@ -358,28 +358,28 @@ pub fn adjust_greedy_deletion( let mut whitespace_sequences = Vec::new(); let mut current_offset = trimmed_delete_range.start; - let mut whitespace_sequence_length = 0; - let mut whitespace_sequence_start = 0; + let mut whitespace_sequence_length = MultiBufferOffset(0); + let mut whitespace_sequence_start = MultiBufferOffset(0); for ch in map .buffer_snapshot() .text_for_range(trimmed_delete_range.clone()) .flat_map(str::chars) { if ch.is_whitespace() { - if whitespace_sequence_length == 0 { + if whitespace_sequence_length == MultiBufferOffset(0) { whitespace_sequence_start = current_offset; } whitespace_sequence_length += 1; } else { - if whitespace_sequence_length >= 2 { + if whitespace_sequence_length >= MultiBufferOffset(2) { whitespace_sequences.push((whitespace_sequence_start, current_offset)); } - whitespace_sequence_start = 0; - whitespace_sequence_length = 0; + whitespace_sequence_start = MultiBufferOffset(0); + whitespace_sequence_length = MultiBufferOffset(0); } current_offset += ch.len_utf8(); } - if whitespace_sequence_length >= 2 { + if whitespace_sequence_length >= MultiBufferOffset(2) { whitespace_sequences.push((whitespace_sequence_start, current_offset)); } @@ -731,7 +731,7 @@ pub fn find_preceding_boundary_trail( } let trail = trail_offset - .map(|trail_offset: usize| map.clip_point(trail_offset.to_display_point(map), Bias::Left)); + .map(|trail_offset| map.clip_point(trail_offset.to_display_point(map), Bias::Left)); ( trail, @@ -779,7 +779,7 @@ pub fn find_boundary_trail( } let trail = trail_offset - .map(|trail_offset: usize| map.clip_point(trail_offset.to_display_point(map), Bias::Right)); + .map(|trail_offset| map.clip_point(trail_offset.to_display_point(map), Bias::Right)); ( trail, @@ -810,8 +810,8 @@ pub fn find_boundary_exclusive( /// the [`DisplaySnapshot`]. The offsets are relative to the start of a buffer. pub fn chars_after( map: &DisplaySnapshot, - mut offset: usize, -) -> impl Iterator)> + '_ { + mut offset: MultiBufferOffset, +) -> impl Iterator)> + '_ { map.buffer_snapshot().chars_at(offset).map(move |ch| { let before = offset; offset += ch.len_utf8(); @@ -824,8 +824,8 @@ pub fn chars_after( /// the [`DisplaySnapshot`]. The offsets are relative to the start of a buffer. pub fn chars_before( map: &DisplaySnapshot, - mut offset: usize, -) -> impl Iterator)> + '_ { + mut offset: MultiBufferOffset, +) -> impl Iterator)> + '_ { map.buffer_snapshot() .reversed_chars_at(offset) .map(move |ch| { @@ -872,12 +872,12 @@ mod tests { use super::*; use crate::{ Buffer, DisplayMap, DisplayRow, ExcerptRange, FoldPlaceholder, MultiBuffer, - display_map::Inlay, + inlays::Inlay, test::{editor_test_context::EditorTestContext, marked_display_snapshot}, }; use gpui::{AppContext as _, font, px}; use language::Capability; - use project::{Project, project_settings::DiagnosticSeverity}; + use project::project_settings::DiagnosticSeverity; use settings::SettingsStore; use util::post_inc; @@ -1018,8 +1018,9 @@ mod tests { // add all kinds of inlays between two word boundaries: we should be able to cross them all, when looking for another boundary let mut id = 0; - let inlays = (0..buffer_snapshot.len()) + let inlays = (0..buffer_snapshot.len().0) .flat_map(|offset| { + let offset = MultiBufferOffset(offset); [ Inlay::edit_prediction( post_inc(&mut id), @@ -1058,7 +1059,7 @@ mod tests { ), snapshot .buffer_snapshot() - .offset_to_point(5) + .offset_to_point(MultiBufferOffset(5)) .to_display_point(&snapshot), "Should not stop at inlays when looking for boundaries" ); @@ -1346,10 +1347,7 @@ mod tests { fn init_test(cx: &mut gpui::App) { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - workspace::init_settings(cx); theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); crate::init(cx); - Project::init_settings(cx); } } diff --git a/crates/editor/src/proposed_changes_editor.rs b/crates/editor/src/proposed_changes_editor.rs deleted file mode 100644 index 2d4710a8d4..0000000000 --- a/crates/editor/src/proposed_changes_editor.rs +++ /dev/null @@ -1,516 +0,0 @@ -use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SelectionEffects, SemanticsProvider}; -use buffer_diff::BufferDiff; -use collections::HashSet; -use futures::{channel::mpsc, future::join_all}; -use gpui::{App, Entity, EventEmitter, Focusable, Render, Subscription, Task}; -use language::{Buffer, BufferEvent, Capability}; -use multi_buffer::{ExcerptRange, MultiBuffer}; -use project::Project; -use smol::stream::StreamExt; -use std::{any::TypeId, ops::Range, rc::Rc, time::Duration}; -use text::ToOffset; -use ui::{ButtonLike, KeyBinding, prelude::*}; -use workspace::{ - Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, - item::SaveOptions, searchable::SearchableItemHandle, -}; - -pub struct ProposedChangesEditor { - editor: Entity, - multibuffer: Entity, - title: SharedString, - buffer_entries: Vec, - _recalculate_diffs_task: Task>, - recalculate_diffs_tx: mpsc::UnboundedSender, -} - -pub struct ProposedChangeLocation { - pub buffer: Entity, - pub ranges: Vec>, -} - -struct BufferEntry { - base: Entity, - branch: Entity, - _subscription: Subscription, -} - -pub struct ProposedChangesEditorToolbar { - current_editor: Option>, -} - -struct RecalculateDiff { - buffer: Entity, - debounce: bool, -} - -/// A provider of code semantics for branch buffers. -/// -/// Requests in edited regions will return nothing, but requests in unchanged -/// regions will be translated into the base buffer's coordinates. -struct BranchBufferSemanticsProvider(Rc); - -impl ProposedChangesEditor { - pub fn new( - title: impl Into, - locations: Vec>, - project: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); - let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded(); - let mut this = Self { - editor: cx.new(|cx| { - let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, window, cx); - editor.set_expand_all_diff_hunks(cx); - editor.set_completion_provider(None); - editor.clear_code_action_providers(); - editor.set_semantics_provider( - editor - .semantics_provider() - .map(|provider| Rc::new(BranchBufferSemanticsProvider(provider)) as _), - ); - editor - }), - multibuffer, - title: title.into(), - buffer_entries: Vec::new(), - recalculate_diffs_tx, - _recalculate_diffs_task: cx.spawn_in(window, async move |this, cx| { - let mut buffers_to_diff = HashSet::default(); - while let Some(mut recalculate_diff) = recalculate_diffs_rx.next().await { - buffers_to_diff.insert(recalculate_diff.buffer); - - while recalculate_diff.debounce { - cx.background_executor() - .timer(Duration::from_millis(50)) - .await; - let mut had_further_changes = false; - while let Ok(next_recalculate_diff) = recalculate_diffs_rx.try_next() { - let next_recalculate_diff = next_recalculate_diff?; - recalculate_diff.debounce &= next_recalculate_diff.debounce; - buffers_to_diff.insert(next_recalculate_diff.buffer); - had_further_changes = true; - } - if !had_further_changes { - break; - } - } - - let recalculate_diff_futures = this - .update(cx, |this, cx| { - buffers_to_diff - .drain() - .filter_map(|buffer| { - let buffer = buffer.read(cx); - let base_buffer = buffer.base_buffer()?; - let buffer = buffer.text_snapshot(); - let diff = - this.multibuffer.read(cx).diff_for(buffer.remote_id())?; - Some(diff.update(cx, |diff, cx| { - diff.set_base_text_buffer(base_buffer.clone(), buffer, cx) - })) - }) - .collect::>() - }) - .ok()?; - - join_all(recalculate_diff_futures).await; - } - None - }), - }; - this.reset_locations(locations, window, cx); - this - } - - pub fn branch_buffer_for_base(&self, base_buffer: &Entity) -> Option> { - self.buffer_entries.iter().find_map(|entry| { - if &entry.base == base_buffer { - Some(entry.branch.clone()) - } else { - None - } - }) - } - - pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { - self.title = title; - cx.notify(); - } - - pub fn reset_locations( - &mut self, - locations: Vec>, - window: &mut Window, - cx: &mut Context, - ) { - // Undo all branch changes - for entry in &self.buffer_entries { - let base_version = entry.base.read(cx).version(); - entry.branch.update(cx, |buffer, cx| { - let undo_counts = buffer - .operations() - .iter() - .filter_map(|(timestamp, _)| { - if !base_version.observed(*timestamp) { - Some((*timestamp, u32::MAX)) - } else { - None - } - }) - .collect(); - buffer.undo_operations(undo_counts, cx); - }); - } - - self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.clear(cx); - }); - - let mut buffer_entries = Vec::new(); - let mut new_diffs = Vec::new(); - for location in locations { - let branch_buffer; - if let Some(ix) = self - .buffer_entries - .iter() - .position(|entry| entry.base == location.buffer) - { - let entry = self.buffer_entries.remove(ix); - branch_buffer = entry.branch.clone(); - buffer_entries.push(entry); - } else { - branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx)); - new_diffs.push(cx.new(|cx| { - let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), cx); - let _ = diff.set_base_text_buffer( - location.buffer.clone(), - branch_buffer.read(cx).text_snapshot(), - cx, - ); - diff - })); - buffer_entries.push(BufferEntry { - branch: branch_buffer.clone(), - base: location.buffer.clone(), - _subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event), - }); - } - - self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.push_excerpts( - branch_buffer, - location - .ranges - .into_iter() - .map(|range| ExcerptRange::new(range)), - cx, - ); - }); - } - - self.buffer_entries = buffer_entries; - self.editor.update(cx, |editor, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - selections.refresh() - }); - editor.buffer.update(cx, |buffer, cx| { - for diff in new_diffs { - buffer.add_diff(diff, cx) - } - }) - }); - } - - pub fn recalculate_all_buffer_diffs(&self) { - for (ix, entry) in self.buffer_entries.iter().enumerate().rev() { - self.recalculate_diffs_tx - .unbounded_send(RecalculateDiff { - buffer: entry.branch.clone(), - debounce: ix > 0, - }) - .ok(); - } - } - - fn on_buffer_event( - &mut self, - buffer: Entity, - event: &BufferEvent, - _cx: &mut Context, - ) { - if let BufferEvent::Operation { .. } = event { - self.recalculate_diffs_tx - .unbounded_send(RecalculateDiff { - buffer, - debounce: true, - }) - .ok(); - } - } -} - -impl Render for ProposedChangesEditor { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .size_full() - .key_context("ProposedChangesEditor") - .child(self.editor.clone()) - } -} - -impl Focusable for ProposedChangesEditor { - fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - self.editor.focus_handle(cx) - } -} - -impl EventEmitter for ProposedChangesEditor {} - -impl Item for ProposedChangesEditor { - type Event = EditorEvent; - - fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { - Some(Icon::new(IconName::Diff)) - } - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - self.title.clone() - } - - fn as_searchable(&self, _: &Entity) -> Option> { - Some(Box::new(self.editor.clone())) - } - - fn act_as_type<'a>( - &'a self, - type_id: TypeId, - self_handle: &'a Entity, - _: &'a App, - ) -> Option { - if type_id == TypeId::of::() { - Some(self_handle.to_any()) - } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) - } else { - None - } - } - - fn added_to_workspace( - &mut self, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - self.editor.update(cx, |editor, cx| { - Item::added_to_workspace(editor, workspace, window, cx) - }); - } - - fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { - self.editor - .update(cx, |editor, cx| editor.deactivated(window, cx)); - } - - fn navigate( - &mut self, - data: Box, - window: &mut Window, - cx: &mut Context, - ) -> bool { - self.editor - .update(cx, |editor, cx| Item::navigate(editor, data, window, cx)) - } - - fn set_nav_history( - &mut self, - nav_history: workspace::ItemNavHistory, - window: &mut Window, - cx: &mut Context, - ) { - self.editor.update(cx, |editor, cx| { - Item::set_nav_history(editor, nav_history, window, cx) - }); - } - - fn can_save(&self, cx: &App) -> bool { - self.editor.read(cx).can_save(cx) - } - - fn save( - &mut self, - options: SaveOptions, - project: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.editor.update(cx, |editor, cx| { - Item::save(editor, options, project, window, cx) - }) - } -} - -impl ProposedChangesEditorToolbar { - pub fn new() -> Self { - Self { - current_editor: None, - } - } - - fn get_toolbar_item_location(&self) -> ToolbarItemLocation { - if self.current_editor.is_some() { - ToolbarItemLocation::PrimaryRight - } else { - ToolbarItemLocation::Hidden - } - } -} - -impl Render for ProposedChangesEditorToolbar { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let button_like = ButtonLike::new("apply-changes").child(Label::new("Apply All")); - - match &self.current_editor { - Some(editor) => { - let focus_handle = editor.focus_handle(cx); - let keybinding = - KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, window, cx) - .map(|binding| binding.into_any_element()); - - button_like.children(keybinding).on_click({ - move |_event, window, cx| { - focus_handle.dispatch_action(&ApplyAllDiffHunks, window, cx) - } - }) - } - None => button_like.disabled(true), - } - } -} - -impl EventEmitter for ProposedChangesEditorToolbar {} - -impl ToolbarItemView for ProposedChangesEditorToolbar { - fn set_active_pane_item( - &mut self, - active_pane_item: Option<&dyn workspace::ItemHandle>, - _window: &mut Window, - _cx: &mut Context, - ) -> workspace::ToolbarItemLocation { - self.current_editor = - active_pane_item.and_then(|item| item.downcast::()); - self.get_toolbar_item_location() - } -} - -impl BranchBufferSemanticsProvider { - fn to_base( - &self, - buffer: &Entity, - positions: &[text::Anchor], - cx: &App, - ) -> Option> { - let base_buffer = buffer.read(cx).base_buffer()?; - let version = base_buffer.read(cx).version(); - if positions - .iter() - .any(|position| !version.observed(position.timestamp)) - { - return None; - } - Some(base_buffer) - } -} - -impl SemanticsProvider for BranchBufferSemanticsProvider { - fn hover( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>> { - let buffer = self.to_base(buffer, &[position], cx)?; - self.0.hover(&buffer, position, cx) - } - - fn inlay_hints( - &self, - buffer: Entity, - range: Range, - cx: &mut App, - ) -> Option>>> { - let buffer = self.to_base(&buffer, &[range.start, range.end], cx)?; - self.0.inlay_hints(buffer, range, cx) - } - - fn inline_values( - &self, - _: Entity, - _: Range, - _: &mut App, - ) -> Option>>> { - None - } - - fn resolve_inlay_hint( - &self, - hint: project::InlayHint, - buffer: Entity, - server_id: lsp::LanguageServerId, - cx: &mut App, - ) -> Option>> { - let buffer = self.to_base(&buffer, &[], cx)?; - self.0.resolve_inlay_hint(hint, buffer, server_id, cx) - } - - fn supports_inlay_hints(&self, buffer: &Entity, cx: &mut App) -> bool { - if let Some(buffer) = self.to_base(buffer, &[], cx) { - self.0.supports_inlay_hints(&buffer, cx) - } else { - false - } - } - - fn document_highlights( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>> { - let buffer = self.to_base(buffer, &[position], cx)?; - self.0.document_highlights(&buffer, position, cx) - } - - fn definitions( - &self, - buffer: &Entity, - position: text::Anchor, - kind: crate::GotoDefinitionKind, - cx: &mut App, - ) -> Option>>>> { - let buffer = self.to_base(buffer, &[position], cx)?; - self.0.definitions(&buffer, position, kind, cx) - } - - fn range_for_rename( - &self, - _: &Entity, - _: text::Anchor, - _: &mut App, - ) -> Option>>>> { - None - } - - fn perform_rename( - &self, - _: &Entity, - _: text::Anchor, - _: String, - _: &mut App, - ) -> Option>> { - None - } -} diff --git a/crates/editor/src/rust_analyzer_ext.rs b/crates/editor/src/rust_analyzer_ext.rs index ffa0c017c0..f548db75ad 100644 --- a/crates/editor/src/rust_analyzer_ext.rs +++ b/crates/editor/src/rust_analyzer_ext.rs @@ -322,7 +322,11 @@ fn cancel_flycheck_action( .disjoint_anchors_arc() .iter() .find_map(|selection| { - let buffer_id = selection.start.buffer_id.or(selection.end.buffer_id)?; + let buffer_id = selection + .start + .text_anchor + .buffer_id + .or(selection.end.text_anchor.buffer_id)?; let project = project.read(cx); let entry_id = project .buffer_for_id(buffer_id, cx)? @@ -347,7 +351,11 @@ fn run_flycheck_action( .disjoint_anchors_arc() .iter() .find_map(|selection| { - let buffer_id = selection.start.buffer_id.or(selection.end.buffer_id)?; + let buffer_id = selection + .start + .text_anchor + .buffer_id + .or(selection.end.text_anchor.buffer_id)?; let project = project.read(cx); let entry_id = project .buffer_for_id(buffer_id, cx)? @@ -372,7 +380,11 @@ fn clear_flycheck_action( .disjoint_anchors_arc() .iter() .find_map(|selection| { - let buffer_id = selection.start.buffer_id.or(selection.end.buffer_id)?; + let buffer_id = selection + .start + .text_anchor + .buffer_id + .or(selection.end.text_anchor.buffer_id)?; let project = project.read(cx); let entry_id = project .buffer_for_id(buffer_id, cx)? diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index dae668a4b4..422be9a54e 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -251,7 +251,11 @@ impl ScrollManager { Bias::Left, ) .to_point(map); - let top_anchor = map.buffer_snapshot().anchor_after(scroll_top_buffer_point); + // Anchor the scroll position to the *left* of the first visible buffer point. + // + // This prevents the viewport from shifting down when blocks (e.g. expanded diff hunk + // deletions) are inserted *above* the first buffer character in the file. + let top_anchor = map.buffer_snapshot().anchor_before(scroll_top_buffer_point); self.set_anchor( ScrollAnchor { @@ -494,15 +498,16 @@ impl Editor { let opened_first_time = self.scroll_manager.visible_line_count.is_none(); self.scroll_manager.visible_line_count = Some(lines); if opened_first_time { - cx.spawn_in(window, async move |editor, cx| { + self.post_scroll_update = cx.spawn_in(window, async move |editor, cx| { editor .update_in(cx, |editor, window, cx| { + editor.register_visible_buffers(cx); editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); - editor.refresh_colors(false, None, window, cx); + editor.update_lsp_data(None, window, cx); + editor.colorize_brackets(false, cx); }) - .ok() - }) - .detach() + .ok(); + }); } } @@ -603,7 +608,7 @@ impl Editor { scroll_position }; - let editor_was_scrolled = self.scroll_manager.set_scroll_position( + self.scroll_manager.set_scroll_position( adjusted_position, &display_map, local, @@ -611,11 +616,7 @@ impl Editor { workspace_id, window, cx, - ); - - 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 { diff --git a/crates/editor/src/scroll/actions.rs b/crates/editor/src/scroll/actions.rs index 1d98cb537a..5a1c849b24 100644 --- a/crates/editor/src/scroll/actions.rs +++ b/crates/editor/src/scroll/actions.rs @@ -71,9 +71,20 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + let display_snapshot = self.display_snapshot(cx); let scroll_margin_rows = self.vertical_scroll_margin() as u32; - let new_screen_top = self.selections.newest_display(cx).head().row().0; - let new_screen_top = new_screen_top.saturating_sub(scroll_margin_rows); + let new_screen_top = self + .selections + .newest_display(&display_snapshot) + .head() + .row() + .0; + let header_offset = display_snapshot + .buffer_snapshot() + .show_headers() + .then(|| display_snapshot.buffer_header_height()) + .unwrap_or(0); + let new_screen_top = new_screen_top.saturating_sub(scroll_margin_rows + header_offset); self.set_scroll_top_row(DisplayRow(new_screen_top), window, cx); } @@ -86,7 +97,12 @@ impl Editor { let Some(visible_rows) = self.visible_line_count().map(|count| count as u32) else { return; }; - let new_screen_top = self.selections.newest_display(cx).head().row().0; + let new_screen_top = self + .selections + .newest_display(&self.display_snapshot(cx)) + .head() + .row() + .0; let new_screen_top = new_screen_top.saturating_sub(visible_rows / 2); self.set_scroll_top_row(DisplayRow(new_screen_top), window, cx); } @@ -101,7 +117,12 @@ impl Editor { let Some(visible_rows) = self.visible_line_count().map(|count| count as u32) else { return; }; - let new_screen_top = self.selections.newest_display(cx).head().row().0; + let new_screen_top = self + .selections + .newest_display(&self.display_snapshot(cx)) + .head() + .row() + .0; let new_screen_top = new_screen_top.saturating_sub(visible_rows.saturating_sub(scroll_margin_rows)); self.set_scroll_top_row(DisplayRow(new_screen_top), window, cx); diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 9130e3cbf8..28fd944219 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -148,7 +148,7 @@ impl Editor { target_top = first_highlighted_row.as_f64(); target_bottom = target_top + 1.; } else { - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); target_top = selections .first() @@ -293,7 +293,7 @@ impl Editor { let scroll_width = ScrollOffset::from(scroll_width); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut scroll_position = self.scroll_manager.scroll_position(&display_map); let mut target_left; diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index e9272e9e20..54bb7ceec1 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -1,33 +1,30 @@ use std::{ - cell::Ref, cmp, fmt, iter, mem, - ops::{Deref, DerefMut, Range, Sub}, + ops::{AddAssign, Deref, DerefMut, Range, Sub}, sync::Arc, }; use collections::HashMap; -use gpui::{App, Entity, Pixels}; -use itertools::Itertools; -use language::{Bias, Point, Selection, SelectionGoal, TextDimension}; +use gpui::Pixels; +use itertools::Itertools as _; +use language::{Bias, Point, Selection, SelectionGoal}; +use multi_buffer::{MultiBufferDimension, MultiBufferOffset}; use util::post_inc; use crate::{ - Anchor, DisplayPoint, DisplayRow, ExcerptId, MultiBuffer, MultiBufferSnapshot, SelectMode, - ToOffset, ToPoint, - display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint}, + Anchor, DisplayPoint, DisplayRow, ExcerptId, MultiBufferSnapshot, SelectMode, ToOffset, + display_map::{DisplaySnapshot, ToDisplayPoint}, movement::TextLayoutDetails, }; #[derive(Debug, Clone)] pub struct PendingSelection { - pub selection: Selection, - pub mode: SelectMode, + selection: Selection, + mode: SelectMode, } #[derive(Debug, Clone)] pub struct SelectionsCollection { - display_map: Entity, - buffer: Entity, next_selection_id: usize, line_mode: bool, /// The non-pending, non-overlapping selections. @@ -35,13 +32,13 @@ pub struct SelectionsCollection { disjoint: Arc<[Selection]>, /// A pending selection, such as when the mouse is being dragged pending: Option, + select_mode: SelectMode, + is_extending: bool, } impl SelectionsCollection { - pub fn new(display_map: Entity, buffer: Entity) -> Self { + pub fn new() -> Self { Self { - display_map, - buffer, next_selection_id: 1, line_mode: false, disjoint: Arc::default(), @@ -55,17 +52,11 @@ impl SelectionsCollection { }, mode: SelectMode::Character, }), + select_mode: SelectMode::Character, + is_extending: false, } } - pub fn display_map(&self, cx: &mut App) -> DisplaySnapshot { - self.display_map.update(cx, |map, cx| map.snapshot(cx)) - } - - fn buffer<'a>(&self, cx: &'a App) -> Ref<'a, MultiBufferSnapshot> { - self.buffer.read(cx).read(cx) - } - pub fn clone_state(&mut self, other: &SelectionsCollection) { self.next_selection_id = other.next_selection_id; self.line_mode = other.line_mode; @@ -102,15 +93,14 @@ impl SelectionsCollection { } /// Non-overlapping selections using anchors, including the pending selection. - pub fn all_anchors(&self, cx: &mut App) -> Arc<[Selection]> { + pub fn all_anchors(&self, snapshot: &DisplaySnapshot) -> Arc<[Selection]> { if self.pending.is_none() { self.disjoint_anchors_arc() } else { - let all_offset_selections = self.all::(cx); - let buffer = self.buffer(cx); + let all_offset_selections = self.all::(snapshot); all_offset_selections .into_iter() - .map(|selection| selection_to_anchor_selection(selection, &buffer)) + .map(|selection| selection_to_anchor_selection(selection, snapshot)) .collect() } } @@ -123,31 +113,36 @@ impl SelectionsCollection { self.pending.as_mut().map(|pending| &mut pending.selection) } - pub fn pending>( - &self, - cx: &mut App, - ) -> Option> { - let map = self.display_map(cx); - - resolve_selections(self.pending_anchor(), &map).next() + pub fn pending(&self, snapshot: &DisplaySnapshot) -> Option> + where + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, + { + resolve_selections_wrapping_blocks(self.pending_anchor(), &snapshot).next() } pub(crate) fn pending_mode(&self) -> Option { self.pending.as_ref().map(|pending| pending.mode.clone()) } - pub fn all<'a, D>(&self, cx: &mut App) -> Vec> + pub fn all(&self, snapshot: &DisplaySnapshot) -> Vec> where - D: 'a + TextDimension + Ord + Sub, + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, { - let map = self.display_map(cx); let disjoint_anchors = &self.disjoint; - let mut disjoint = resolve_selections::(disjoint_anchors.iter(), &map).peekable(); - let mut pending_opt = self.pending::(cx); + let mut disjoint = + resolve_selections_wrapping_blocks::(disjoint_anchors.iter(), &snapshot) + .peekable(); + let mut pending_opt = self.pending::(&snapshot); iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { - if pending.start <= next_selection.end && pending.end >= next_selection.start { + if should_merge( + pending.start, + pending.end, + next_selection.start, + next_selection.end, + false, + ) { let next_selection = disjoint.next().unwrap(); if next_selection.start < pending.start { pending.start = next_selection.start; @@ -171,12 +166,11 @@ impl SelectionsCollection { } /// Returns all of the selections, adjusted to take into account the selection line_mode - pub fn all_adjusted(&self, cx: &mut App) -> Vec> { - let mut selections = self.all::(cx); + pub fn all_adjusted(&self, snapshot: &DisplaySnapshot) -> Vec> { + let mut selections = self.all::(&snapshot); if self.line_mode { - let map = self.display_map(cx); for selection in &mut selections { - let new_range = map.expand_to_line(selection.range()); + let new_range = snapshot.expand_to_line(selection.range()); selection.start = new_range.start; selection.end = new_range.end; } @@ -185,11 +179,10 @@ impl SelectionsCollection { } /// Returns the newest selection, adjusted to take into account the selection line_mode - pub fn newest_adjusted(&self, cx: &mut App) -> Selection { - let mut selection = self.newest::(cx); + pub fn newest_adjusted(&self, snapshot: &DisplaySnapshot) -> Selection { + let mut selection = self.newest::(&snapshot); if self.line_mode { - let map = self.display_map(cx); - let new_range = map.expand_to_line(selection.range()); + let new_range = snapshot.expand_to_line(selection.range()); selection.start = new_range.start; selection.end = new_range.end; } @@ -198,56 +191,64 @@ impl SelectionsCollection { pub fn all_adjusted_display( &self, - cx: &mut App, - ) -> (DisplaySnapshot, Vec>) { + display_map: &DisplaySnapshot, + ) -> Vec> { if self.line_mode { - let selections = self.all::(cx); - let map = self.display_map(cx); + let selections = self.all::(&display_map); let result = selections .into_iter() .map(|mut selection| { - let new_range = map.expand_to_line(selection.range()); + let new_range = display_map.expand_to_line(selection.range()); selection.start = new_range.start; selection.end = new_range.end; - selection.map(|point| point.to_display_point(&map)) + selection.map(|point| point.to_display_point(&display_map)) }) .collect(); - (map, result) + result } else { - self.all_display(cx) + self.all_display(display_map) } } - pub fn disjoint_in_range<'a, D>(&self, range: Range, cx: &mut App) -> Vec> + pub fn disjoint_in_range( + &self, + range: Range, + snapshot: &DisplaySnapshot, + ) -> Vec> where - D: 'a + TextDimension + Ord + Sub + std::fmt::Debug, + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord + std::fmt::Debug, { - let map = self.display_map(cx); let start_ix = match self .disjoint - .binary_search_by(|probe| probe.end.cmp(&range.start, map.buffer_snapshot())) + .binary_search_by(|probe| probe.end.cmp(&range.start, snapshot.buffer_snapshot())) { Ok(ix) | Err(ix) => ix, }; let end_ix = match self .disjoint - .binary_search_by(|probe| probe.start.cmp(&range.end, map.buffer_snapshot())) + .binary_search_by(|probe| probe.start.cmp(&range.end, snapshot.buffer_snapshot())) { Ok(ix) => ix + 1, Err(ix) => ix, }; - resolve_selections(&self.disjoint[start_ix..end_ix], &map).collect() + resolve_selections_wrapping_blocks(&self.disjoint[start_ix..end_ix], snapshot).collect() } - pub fn all_display(&self, cx: &mut App) -> (DisplaySnapshot, Vec>) { - let map = self.display_map(cx); + pub fn all_display(&self, snapshot: &DisplaySnapshot) -> Vec> { let disjoint_anchors = &self.disjoint; - let mut disjoint = resolve_selections_display(disjoint_anchors.iter(), &map).peekable(); - let mut pending_opt = resolve_selections_display(self.pending_anchor(), &map).next(); - let selections = iter::from_fn(move || { + let mut disjoint = + resolve_selections_display(disjoint_anchors.iter(), &snapshot).peekable(); + let mut pending_opt = resolve_selections_display(self.pending_anchor(), &snapshot).next(); + iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { - if pending.start <= next_selection.end && pending.end >= next_selection.start { + if should_merge( + pending.start, + pending.end, + next_selection.start, + next_selection.end, + false, + ) { let next_selection = disjoint.next().unwrap(); if next_selection.start < pending.start { pending.start = next_selection.start; @@ -267,8 +268,7 @@ impl SelectionsCollection { disjoint.next() } }) - .collect(); - (map, selections) + .collect() } pub fn newest_anchor(&self) -> &Selection { @@ -279,21 +279,17 @@ impl SelectionsCollection { .unwrap() } - pub fn newest>( - &self, - cx: &mut App, - ) -> Selection { - let map = self.display_map(cx); - - resolve_selections([self.newest_anchor()], &map) + pub fn newest(&self, snapshot: &DisplaySnapshot) -> Selection + where + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, + { + resolve_selections_wrapping_blocks([self.newest_anchor()], &snapshot) .next() .unwrap() } - pub fn newest_display(&self, cx: &mut App) -> Selection { - let map = self.display_map(cx); - - resolve_selections_display([self.newest_anchor()], &map) + pub fn newest_display(&self, snapshot: &DisplaySnapshot) -> Selection { + resolve_selections_display([self.newest_anchor()], &snapshot) .next() .unwrap() } @@ -306,13 +302,11 @@ impl SelectionsCollection { .unwrap() } - pub fn oldest>( - &self, - cx: &mut App, - ) -> Selection { - let map = self.display_map(cx); - - resolve_selections([self.oldest_anchor()], &map) + pub fn oldest(&self, snapshot: &DisplaySnapshot) -> Selection + where + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, + { + resolve_selections_wrapping_blocks([self.oldest_anchor()], &snapshot) .next() .unwrap() } @@ -324,22 +318,28 @@ impl SelectionsCollection { .unwrap_or_else(|| self.disjoint.first().cloned().unwrap()) } - pub fn first>(&self, cx: &mut App) -> Selection { - self.all(cx).first().unwrap().clone() + pub fn first(&self, snapshot: &DisplaySnapshot) -> Selection + where + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, + { + self.all(snapshot).first().unwrap().clone() } - pub fn last>(&self, cx: &mut App) -> Selection { - self.all(cx).last().unwrap().clone() + pub fn last(&self, snapshot: &DisplaySnapshot) -> Selection + where + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, + { + self.all(snapshot).last().unwrap().clone() } /// Returns a list of (potentially backwards!) ranges representing the selections. /// Useful for test assertions, but prefer `.all()` instead. #[cfg(any(test, feature = "test-support"))] - pub fn ranges>( - &self, - cx: &mut App, - ) -> Vec> { - self.all::(cx) + pub fn ranges(&self, snapshot: &DisplaySnapshot) -> Vec> + where + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, + { + self.all::(snapshot) .iter() .map(|s| { if s.reversed { @@ -352,21 +352,27 @@ impl SelectionsCollection { } #[cfg(any(test, feature = "test-support"))] - pub fn display_ranges(&self, cx: &mut App) -> Vec> { - let display_map = self.display_map(cx); + pub fn display_ranges(&self, display_snapshot: &DisplaySnapshot) -> Vec> { self.disjoint_anchors_arc() .iter() .chain(self.pending_anchor()) .map(|s| { if s.reversed { - s.end.to_display_point(&display_map)..s.start.to_display_point(&display_map) + s.end.to_display_point(display_snapshot) + ..s.start.to_display_point(display_snapshot) } else { - s.start.to_display_point(&display_map)..s.end.to_display_point(&display_map) + s.start.to_display_point(display_snapshot) + ..s.end.to_display_point(display_snapshot) } }) .collect() } + /// Attempts to build a selection in the provided `DisplayRow` within the + /// same range as the provided range of `Pixels`. + /// Returns `None` if the range is not empty but it starts past the line's + /// length, meaning that the line isn't long enough to be contained within + /// part of the provided range. pub fn build_columnar_selection( &mut self, display_map: &DisplaySnapshot, @@ -407,13 +413,13 @@ impl SelectionsCollection { pub fn change_with( &mut self, - cx: &mut App, - change: impl FnOnce(&mut MutableSelectionsCollection) -> R, + snapshot: &DisplaySnapshot, + change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, ) -> (bool, R) { let mut mutable_collection = MutableSelectionsCollection { + snapshot, collection: self, selections_changed: false, - cx, }; let result = change(&mut mutable_collection); @@ -421,6 +427,37 @@ impl SelectionsCollection { !mutable_collection.disjoint.is_empty() || mutable_collection.pending.is_some(), "There must be at least one selection" ); + if cfg!(debug_assertions) { + mutable_collection.disjoint.iter().for_each(|selection| { + assert!( + snapshot.can_resolve(&selection.start), + "disjoint selection start is not resolvable for the given snapshot:\n{selection:?}, {excerpt:?}", + excerpt = snapshot.buffer_for_excerpt(selection.start.excerpt_id).map(|snapshot| snapshot.remote_id()), + ); + assert!( + snapshot.can_resolve(&selection.end), + "disjoint selection end is not resolvable for the given snapshot: {selection:?}, {excerpt:?}", + excerpt = snapshot.buffer_for_excerpt(selection.end.excerpt_id).map(|snapshot| snapshot.remote_id()), + ); + }); + if let Some(pending) = &mutable_collection.pending { + let selection = &pending.selection; + assert!( + snapshot.can_resolve(&selection.start), + "pending selection start is not resolvable for the given snapshot: {pending:?}, {excerpt:?}", + excerpt = snapshot + .buffer_for_excerpt(selection.start.excerpt_id) + .map(|snapshot| snapshot.remote_id()), + ); + assert!( + snapshot.can_resolve(&selection.end), + "pending selection end is not resolvable for the given snapshot: {pending:?}, {excerpt:?}", + excerpt = snapshot + .buffer_for_excerpt(selection.end.excerpt_id) + .map(|snapshot| snapshot.remote_id()), + ); + } + } (mutable_collection.selections_changed, result) } @@ -435,15 +472,31 @@ impl SelectionsCollection { pub fn set_line_mode(&mut self, line_mode: bool) { self.line_mode = line_mode; } + + pub fn select_mode(&self) -> &SelectMode { + &self.select_mode + } + + pub fn set_select_mode(&mut self, select_mode: SelectMode) { + self.select_mode = select_mode; + } + + pub fn is_extending(&self) -> bool { + self.is_extending + } + + pub fn set_is_extending(&mut self, is_extending: bool) { + self.is_extending = is_extending; + } } -pub struct MutableSelectionsCollection<'a> { +pub struct MutableSelectionsCollection<'snap, 'a> { collection: &'a mut SelectionsCollection, + snapshot: &'snap DisplaySnapshot, selections_changed: bool, - cx: &'a mut App, } -impl<'a> fmt::Debug for MutableSelectionsCollection<'a> { +impl<'snap, 'a> fmt::Debug for MutableSelectionsCollection<'snap, 'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("MutableSelectionsCollection") .field("collection", &self.collection) @@ -452,13 +505,9 @@ impl<'a> fmt::Debug for MutableSelectionsCollection<'a> { } } -impl<'a> MutableSelectionsCollection<'a> { - pub fn display_map(&mut self) -> DisplaySnapshot { - self.collection.display_map(self.cx) - } - - pub fn buffer(&self) -> Ref<'_, MultiBufferSnapshot> { - self.collection.buffer(self.cx) +impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { + pub fn display_snapshot(&self) -> DisplaySnapshot { + self.snapshot.clone() } pub fn clear_disjoint(&mut self) { @@ -481,6 +530,50 @@ impl<'a> MutableSelectionsCollection<'a> { self.selections_changed |= changed; } + pub fn remove_selections_from_buffer(&mut self, buffer_id: language::BufferId) { + let mut changed = false; + + let filtered_selections: Arc<[Selection]> = { + self.disjoint + .iter() + .filter(|selection| { + if let Some(selection_buffer_id) = + self.snapshot.buffer_id_for_anchor(selection.start) + { + let should_remove = selection_buffer_id == buffer_id; + changed |= should_remove; + !should_remove + } else { + true + } + }) + .cloned() + .collect() + }; + + if filtered_selections.is_empty() { + let buffer_snapshot = self.snapshot.buffer_snapshot(); + let anchor = buffer_snapshot + .excerpts() + .find(|(_, buffer, _)| buffer.remote_id() == buffer_id) + .and_then(|(excerpt_id, _, range)| { + buffer_snapshot.anchor_in_excerpt(excerpt_id, range.context.start) + }) + .unwrap_or_else(|| self.snapshot.anchor_before(MultiBufferOffset(0))); + self.collection.disjoint = Arc::from([Selection { + id: post_inc(&mut self.collection.next_selection_id), + start: anchor, + end: anchor, + reversed: false, + goal: SelectionGoal::None, + }]); + } else { + self.collection.disjoint = filtered_selections; + } + + self.selections_changed |= changed; + } + pub fn clear_pending(&mut self) { if self.collection.pending.is_some() { self.collection.pending = None; @@ -489,12 +582,11 @@ impl<'a> MutableSelectionsCollection<'a> { } pub(crate) fn set_pending_anchor_range(&mut self, range: Range, mode: SelectMode) { - let buffer = self.buffer.read(self.cx).snapshot(self.cx); self.collection.pending = Some(PendingSelection { selection: { let mut start = range.start; let mut end = range.end; - let reversed = if start.cmp(&end, &buffer).is_gt() { + let reversed = if start.cmp(&end, self.snapshot).is_gt() { mem::swap(&mut start, &mut end); true } else { @@ -534,7 +626,7 @@ impl<'a> MutableSelectionsCollection<'a> { return true; } - if !oldest.start.cmp(&oldest.end, &self.buffer()).is_eq() { + if !oldest.start.cmp(&oldest.end, self.snapshot).is_eq() { let head = oldest.head(); oldest.start = head; oldest.end = head; @@ -548,11 +640,12 @@ impl<'a> MutableSelectionsCollection<'a> { pub fn insert_range(&mut self, range: Range) where - T: 'a + ToOffset + ToPoint + TextDimension + Ord + Sub + std::marker::Copy, + T: ToOffset, { - let mut selections = self.collection.all(self.cx); - let mut start = range.start.to_offset(&self.buffer()); - let mut end = range.end.to_offset(&self.buffer()); + let display_map = self.display_snapshot(); + let mut selections = self.collection.all(&display_map); + let mut start = range.start.to_offset(self.snapshot); + let mut end = range.end.to_offset(self.snapshot); let reversed = if start > end { mem::swap(&mut start, &mut end); true @@ -569,21 +662,34 @@ impl<'a> MutableSelectionsCollection<'a> { self.select(selections); } - pub fn select(&mut self, mut selections: Vec>) + pub fn select(&mut self, selections: Vec>) where - T: ToOffset + ToPoint + Ord + std::marker::Copy + std::fmt::Debug, + T: ToOffset + std::marker::Copy + std::fmt::Debug, { - let buffer = self.buffer.read(self.cx).snapshot(self.cx); + let mut selections = selections + .into_iter() + .map(|selection| selection.map(|it| it.to_offset(self.snapshot))) + .map(|mut selection| { + if selection.start > selection.end { + mem::swap(&mut selection.start, &mut selection.end); + selection.reversed = true + } + selection + }) + .collect::>(); selections.sort_unstable_by_key(|s| s.start); - // Merge overlapping selections. + let mut i = 1; while i < selections.len() { - if selections[i - 1].end >= selections[i].start { + let prev = &selections[i - 1]; + let current = &selections[i]; + + if should_merge(prev.start, prev.end, current.start, current.end, true) { let removed = selections.remove(i); if removed.start < selections[i - 1].start { selections[i - 1].start = removed.start; } - if removed.end > selections[i - 1].end { + if selections[i - 1].end < removed.end { selections[i - 1].end = removed.end; } } else { @@ -594,16 +700,17 @@ impl<'a> MutableSelectionsCollection<'a> { self.collection.disjoint = Arc::from_iter( selections .into_iter() - .map(|selection| selection_to_anchor_selection(selection, &buffer)), + .map(|selection| selection_to_anchor_selection(selection, self.snapshot)), ); self.collection.pending = None; self.selections_changed = true; } pub fn select_anchors(&mut self, selections: Vec>) { - let map = self.display_map(); + let map = self.display_snapshot(); let resolved_selections = - resolve_selections::(&selections, &map).collect::>(); + resolve_selections_wrapping_blocks::(&selections, &map) + .collect::>(); self.select(resolved_selections); } @@ -612,16 +719,15 @@ impl<'a> MutableSelectionsCollection<'a> { I: IntoIterator>, T: ToOffset, { - let buffer = self.buffer.read(self.cx).snapshot(self.cx); let ranges = ranges .into_iter() - .map(|range| range.start.to_offset(&buffer)..range.end.to_offset(&buffer)); + .map(|range| range.start.to_offset(self.snapshot)..range.end.to_offset(self.snapshot)); self.select_offset_ranges(ranges); } fn select_offset_ranges(&mut self, ranges: I) where - I: IntoIterator>, + I: IntoIterator>, { let selections = ranges .into_iter() @@ -651,13 +757,12 @@ impl<'a> MutableSelectionsCollection<'a> { where I: IntoIterator>, { - let buffer = self.buffer.read(self.cx).snapshot(self.cx); let selections = ranges .into_iter() .map(|range| { let mut start = range.start; let mut end = range.end; - let reversed = if start.cmp(&end, &buffer).is_gt() { + let reversed = if start.cmp(&end, self.snapshot).is_gt() { mem::swap(&mut start, &mut end); true } else { @@ -683,7 +788,6 @@ impl<'a> MutableSelectionsCollection<'a> { where T: IntoIterator>, { - let display_map = self.display_map(); let selections = ranges .into_iter() .map(|range| { @@ -697,8 +801,8 @@ impl<'a> MutableSelectionsCollection<'a> { }; Selection { id: post_inc(&mut self.collection.next_selection_id), - start: start.to_point(&display_map), - end: end.to_point(&display_map), + start: start.to_point(self.snapshot), + end: end.to_point(self.snapshot), reversed, goal: SelectionGoal::None, } @@ -708,7 +812,6 @@ impl<'a> MutableSelectionsCollection<'a> { } pub fn reverse_selections(&mut self) { - let map = &self.display_map(); let mut new_selections: Vec> = Vec::new(); let disjoint = self.disjoint.clone(); for selection in disjoint @@ -718,8 +821,14 @@ impl<'a> MutableSelectionsCollection<'a> { { new_selections.push(Selection { id: self.new_selection_id(), - start: selection.start.to_display_point(map).to_point(map), - end: selection.end.to_display_point(map).to_point(map), + start: selection + .start + .to_display_point(self.snapshot) + .to_point(self.snapshot), + end: selection + .end + .to_display_point(self.snapshot) + .to_point(self.snapshot), reversed: selection.reversed, goal: selection.goal, }); @@ -732,8 +841,8 @@ impl<'a> MutableSelectionsCollection<'a> { mut move_selection: impl FnMut(&DisplaySnapshot, &mut Selection), ) { let mut changed = false; - let display_map = self.display_map(); - let (_, selections) = self.collection.all_display(self.cx); + let display_map = self.display_snapshot(); + let selections = self.collection.all_display(&display_map); let selections = selections .into_iter() .map(|selection| { @@ -753,24 +862,23 @@ impl<'a> MutableSelectionsCollection<'a> { pub fn move_offsets_with( &mut self, - mut move_selection: impl FnMut(&MultiBufferSnapshot, &mut Selection), + mut move_selection: impl FnMut(&MultiBufferSnapshot, &mut Selection), ) { let mut changed = false; - let snapshot = self.buffer().clone(); + let display_map = self.display_snapshot(); let selections = self .collection - .all::(self.cx) + .all::(&display_map) .into_iter() .map(|selection| { let mut moved_selection = selection.clone(); - move_selection(&snapshot, &mut moved_selection); + move_selection(self.snapshot, &mut moved_selection); if selection != moved_selection { changed = true; } moved_selection }) .collect(); - drop(snapshot); if changed { self.select(selections) @@ -822,11 +930,10 @@ impl<'a> MutableSelectionsCollection<'a> { &mut self, find_replacement_cursors: impl FnOnce(&DisplaySnapshot) -> Vec, ) { - let display_map = self.display_map(); - let new_selections = find_replacement_cursors(&display_map) + let new_selections = find_replacement_cursors(self.snapshot) .into_iter() .map(|cursor| { - let cursor_point = cursor.to_point(&display_map); + let cursor_point = cursor.to_point(self.snapshot); Selection { id: post_inc(&mut self.collection.next_selection_id), start: cursor_point, @@ -850,12 +957,11 @@ impl<'a> MutableSelectionsCollection<'a> { let mut selections_with_lost_position = HashMap::default(); let anchors_with_status = { - let buffer = self.buffer(); let disjoint_anchors = self .disjoint .iter() .flat_map(|selection| [&selection.start, &selection.end]); - buffer.refresh_anchors(disjoint_anchors) + self.snapshot.refresh_anchors(disjoint_anchors) }; let adjusted_disjoint: Vec<_> = anchors_with_status .chunks(2) @@ -883,15 +989,16 @@ impl<'a> MutableSelectionsCollection<'a> { .collect(); if !adjusted_disjoint.is_empty() { - let map = self.display_map(); - let resolved_selections = resolve_selections(adjusted_disjoint.iter(), &map).collect(); - self.select::(resolved_selections); + let map = self.display_snapshot(); + let resolved_selections = + resolve_selections_wrapping_blocks(adjusted_disjoint.iter(), &map).collect(); + self.select::(resolved_selections); } if let Some(pending) = pending.as_mut() { - let buffer = self.buffer(); - let anchors = - buffer.refresh_anchors([&pending.selection.start, &pending.selection.end]); + let anchors = self + .snapshot + .refresh_anchors([&pending.selection.start, &pending.selection.end]); let (_, start, kept_start) = anchors[0]; let (_, end, kept_end) = anchors[1]; let kept_head = if pending.selection.reversed { @@ -914,26 +1021,23 @@ impl<'a> MutableSelectionsCollection<'a> { } } -impl Deref for MutableSelectionsCollection<'_> { +impl Deref for MutableSelectionsCollection<'_, '_> { type Target = SelectionsCollection; fn deref(&self) -> &Self::Target { self.collection } } -impl DerefMut for MutableSelectionsCollection<'_> { +impl DerefMut for MutableSelectionsCollection<'_, '_> { fn deref_mut(&mut self) -> &mut Self::Target { self.collection } } -fn selection_to_anchor_selection( - selection: Selection, +fn selection_to_anchor_selection( + selection: Selection, buffer: &MultiBufferSnapshot, -) -> Selection -where - T: ToOffset + Ord, -{ +) -> Selection { let end_bias = if selection.start == selection.end { Bias::Right } else { @@ -971,7 +1075,8 @@ fn resolve_selections_point<'a>( }) } -// Panics if passed selections are not in order +/// Panics if passed selections are not in order +/// Resolves the anchors to display positions fn resolve_selections_display<'a>( selections: impl 'a + IntoIterator>, map: &'a DisplaySnapshot, @@ -1003,15 +1108,22 @@ fn resolve_selections_display<'a>( coalesce_selections(selections) } -// Panics if passed selections are not in order -pub(crate) fn resolve_selections<'a, D, I>( +/// Resolves the passed in anchors to [`MultiBufferDimension`]s `D` +/// wrapping around blocks inbetween. +/// +/// # Panics +/// +/// Panics if passed selections are not in order +pub(crate) fn resolve_selections_wrapping_blocks<'a, D, I>( selections: I, map: &'a DisplaySnapshot, ) -> impl 'a + Iterator> where - D: TextDimension + Ord + Sub, + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, I: 'a + IntoIterator>, { + // Transforms `Anchor -> DisplayPoint -> Point -> DisplayPoint -> D` + // todo(lw): We should be able to short circuit the `Anchor -> DisplayPoint -> Point` to `Anchor -> Point` let (to_convert, selections) = resolve_selections_display(selections, map).tee(); let mut converted_endpoints = map.buffer_snapshot() @@ -1042,7 +1154,13 @@ fn coalesce_selections( iter::from_fn(move || { let mut selection = selections.next()?; while let Some(next_selection) = selections.peek() { - if selection.end >= next_selection.start { + if should_merge( + selection.start, + selection.end, + next_selection.start, + next_selection.end, + true, + ) { if selection.reversed == next_selection.reversed { selection.end = cmp::max(selection.end, next_selection.end); selections.next(); @@ -1064,3 +1182,35 @@ fn coalesce_selections( Some(selection) }) } + +/// Determines whether two selections should be merged into one. +/// +/// Two selections should be merged when: +/// 1. They overlap: the selections share at least one position +/// 2. They have the same start position: one contains or equals the other +/// 3. A cursor touches a selection boundary: a zero-width selection (cursor) at the +/// start or end of another selection should be absorbed into it +/// +/// Note: two selections that merely touch (one ends exactly where the other begins) +/// but don't share any positions remain separate, see: https://github.com/zed-industries/zed/issues/24748 +fn should_merge(a_start: T, a_end: T, b_start: T, b_end: T, sorted: bool) -> bool { + let is_overlapping = if sorted { + // When sorted, `a` starts before or at `b`, so overlap means `b` starts before `a` ends + b_start < a_end + } else { + a_start < b_end && b_start < a_end + }; + + // Selections starting at the same position should always merge (one contains the other) + let same_start = a_start == b_start; + + // A cursor (zero-width selection) touching another selection's boundary should merge. + // This handles cases like a cursor at position X merging with a selection that + // starts or ends at X. + let is_cursor_a = a_start == a_end; + let is_cursor_b = b_start == b_end; + let cursor_at_boundary = (is_cursor_a && (a_start == b_start || a_end == b_end)) + || (is_cursor_b && (b_start == a_start || b_end == a_end)); + + is_overlapping || same_start || cursor_at_boundary +} diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index 150044391a..2554db2450 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -1,13 +1,13 @@ use crate::actions::ShowSignatureHelp; use crate::hover_popover::open_markdown_url; -use crate::{Editor, EditorSettings, ToggleAutoSignatureHelp, hover_markdown_style}; +use crate::{BufferOffset, Editor, EditorSettings, ToggleAutoSignatureHelp, hover_markdown_style}; use gpui::{ App, Context, Entity, HighlightStyle, MouseButton, ScrollHandle, Size, StyledText, Task, TextStyle, Window, combine_highlights, }; use language::BufferSnapshot; use markdown::{Markdown, MarkdownElement}; -use multi_buffer::{Anchor, ToOffset}; +use multi_buffer::{Anchor, MultiBufferOffset, ToOffset}; use settings::Settings; use std::ops::Range; use text::Rope; @@ -82,7 +82,9 @@ impl Editor { if !(self.signature_help_state.is_shown() || self.auto_signature_help_enabled(cx)) { return false; } - let newest_selection = self.selections.newest::(cx); + let newest_selection = self + .selections + .newest::(&self.display_snapshot(cx)); let head = newest_selection.head(); if !newest_selection.is_empty() && head != newest_selection.tail() { @@ -92,14 +94,14 @@ impl Editor { } let buffer_snapshot = self.buffer().read(cx).snapshot(cx); - let bracket_range = |position: usize| match (position, position + 1) { - (0, b) if b <= buffer_snapshot.len() => 0..b, - (0, b) => 0..b - 1, + let bracket_range = |position: MultiBufferOffset| match (position, position + 1usize) { + (MultiBufferOffset(0), b) if b <= buffer_snapshot.len() => MultiBufferOffset(0)..b, + (MultiBufferOffset(0), b) => MultiBufferOffset(0)..b - 1, (a, b) if b <= buffer_snapshot.len() => a - 1..b, (a, b) => a - 1..b - 1, }; let not_quote_like_brackets = - |buffer: &BufferSnapshot, start: Range, end: Range| { + |buffer: &BufferSnapshot, start: Range, end: Range| { let text_start = buffer.text_for_range(start).collect::(); let text_end = buffer.text_for_range(end).collect::(); QUOTE_PAIRS @@ -389,20 +391,15 @@ impl SignatureHelpPopover { ) }), ) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx); + .vertical_scrollbar_for(&self.scroll_handle, window, cx); let controls = if self.signatures.len() > 1 { let prev_button = IconButton::new("signature_help_prev", IconName::ChevronUp) .shape(IconButtonShape::Square) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) - .tooltip(move |window, cx| { - ui::Tooltip::for_action( - "Previous Signature", - &crate::SignatureHelpPrevious, - window, - cx, - ) + .tooltip(move |_window, cx| { + ui::Tooltip::for_action("Previous Signature", &crate::SignatureHelpPrevious, cx) }) .on_click(cx.listener(|editor, _, window, cx| { editor.signature_help_prev(&crate::SignatureHelpPrevious, window, cx); @@ -412,8 +409,8 @@ impl SignatureHelpPopover { .shape(IconButtonShape::Square) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) - .tooltip(move |window, cx| { - ui::Tooltip::for_action("Next Signature", &crate::SignatureHelpNext, window, cx) + .tooltip(move |_window, cx| { + ui::Tooltip::for_action("Next Signature", &crate::SignatureHelpNext, cx) }) .on_click(cx.listener(|editor, _, window, cx| { editor.signature_help_next(&crate::SignatureHelpNext, window, cx); diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs new file mode 100644 index 0000000000..b5090f06dc --- /dev/null +++ b/crates/editor/src/split.rs @@ -0,0 +1,267 @@ +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use gpui::{ + Action, AppContext as _, Entity, EventEmitter, Focusable, NoAction, Subscription, WeakEntity, +}; +use multi_buffer::{MultiBuffer, MultiBufferFilterMode}; +use project::Project; +use ui::{ + App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render, + Styled as _, Window, div, +}; +use workspace::{ + ActivePaneDecorator, Item, ItemHandle, Pane, PaneGroup, SplitDirection, Workspace, +}; + +use crate::{Editor, EditorEvent}; + +struct SplitDiffFeatureFlag; + +impl FeatureFlag for SplitDiffFeatureFlag { + const NAME: &'static str = "split-diff"; + + fn enabled_for_staff() -> bool { + true + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Action, Default)] +#[action(namespace = editor)] +struct SplitDiff; + +#[derive(Clone, Copy, PartialEq, Eq, Action, Default)] +#[action(namespace = editor)] +struct UnsplitDiff; + +pub struct SplittableEditor { + primary_editor: Entity, + secondary: Option, + panes: PaneGroup, + workspace: WeakEntity, + _subscriptions: Vec, +} + +struct SecondaryEditor { + editor: Entity, + pane: Entity, + has_latest_selection: bool, + _subscriptions: Vec, +} + +impl SplittableEditor { + pub fn primary_editor(&self) -> &Entity { + &self.primary_editor + } + + pub fn last_selected_editor(&self) -> &Entity { + if let Some(secondary) = &self.secondary + && secondary.has_latest_selection + { + &secondary.editor + } else { + &self.primary_editor + } + } + + pub fn new_unsplit( + buffer: Entity, + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let primary_editor = + cx.new(|cx| Editor::for_multibuffer(buffer, Some(project.clone()), window, cx)); + let pane = cx.new(|cx| { + let mut pane = Pane::new( + workspace.downgrade(), + project, + Default::default(), + None, + NoAction.boxed_clone(), + true, + window, + cx, + ); + pane.set_should_display_tab_bar(|_, _| false); + pane.add_item(primary_editor.boxed_clone(), true, true, None, window, cx); + pane + }); + let panes = PaneGroup::new(pane); + // TODO(split-diff) we might want to tag editor events with whether they came from primary/secondary + let subscriptions = + vec![ + cx.subscribe(&primary_editor, |this, _, event: &EditorEvent, cx| { + if let EditorEvent::SelectionsChanged { .. } = event + && let Some(secondary) = &mut this.secondary + { + secondary.has_latest_selection = false; + } + cx.emit(event.clone()) + }), + ]; + + window.defer(cx, { + let workspace = workspace.downgrade(); + let primary_editor = primary_editor.downgrade(); + move |window, cx| { + workspace + .update(cx, |workspace, cx| { + primary_editor.update(cx, |editor, cx| { + editor.added_to_workspace(workspace, window, cx); + }) + }) + .ok(); + } + }); + Self { + primary_editor, + secondary: None, + panes, + workspace: workspace.downgrade(), + _subscriptions: subscriptions, + } + } + + fn split(&mut self, _: &SplitDiff, window: &mut Window, cx: &mut Context) { + if !cx.has_flag::() { + return; + } + if self.secondary.is_some() { + return; + } + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let project = workspace.read(cx).project().clone(); + let follower = self.primary_editor.update(cx, |primary, cx| { + primary.buffer().update(cx, |buffer, cx| { + let follower = buffer.get_or_create_follower(cx); + buffer.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions)); + follower + }) + }); + follower.update(cx, |follower, _| { + follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions)); + }); + let secondary_editor = workspace.update(cx, |workspace, cx| { + cx.new(|cx| { + let mut editor = Editor::for_multibuffer(follower, Some(project), window, cx); + // TODO(split-diff) this should be at the multibuffer level + editor.set_use_base_text_line_numbers(true, cx); + editor.added_to_workspace(workspace, window, cx); + editor + }) + }); + let secondary_pane = cx.new(|cx| { + let mut pane = Pane::new( + workspace.downgrade(), + workspace.read(cx).project().clone(), + Default::default(), + None, + NoAction.boxed_clone(), + true, + window, + cx, + ); + pane.set_should_display_tab_bar(|_, _| false); + pane.add_item( + ItemHandle::boxed_clone(&secondary_editor), + false, + false, + None, + window, + cx, + ); + pane + }); + + let subscriptions = + vec![ + cx.subscribe(&secondary_editor, |this, _, event: &EditorEvent, cx| { + if let EditorEvent::SelectionsChanged { .. } = event + && let Some(secondary) = &mut this.secondary + { + secondary.has_latest_selection = true; + } + cx.emit(event.clone()) + }), + ]; + self.secondary = Some(SecondaryEditor { + editor: secondary_editor, + pane: secondary_pane.clone(), + has_latest_selection: false, + _subscriptions: subscriptions, + }); + let primary_pane = self.panes.first_pane(); + self.panes + .split(&primary_pane, &secondary_pane, SplitDirection::Left, cx) + .unwrap(); + cx.notify(); + } + + fn unsplit(&mut self, _: &UnsplitDiff, _: &mut Window, cx: &mut Context) { + let Some(secondary) = self.secondary.take() else { + return; + }; + self.panes.remove(&secondary.pane, cx).unwrap(); + self.primary_editor.update(cx, |primary, cx| { + primary.buffer().update(cx, |buffer, _| { + buffer.set_filter_mode(None); + }); + }); + cx.notify(); + } + + pub fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace = workspace.weak_handle(); + self.primary_editor.update(cx, |primary_editor, cx| { + primary_editor.added_to_workspace(workspace, window, cx); + }); + if let Some(secondary) = &self.secondary { + secondary.editor.update(cx, |secondary_editor, cx| { + secondary_editor.added_to_workspace(workspace, window, cx); + }); + } + } +} + +impl EventEmitter for SplittableEditor {} +impl Focusable for SplittableEditor { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.primary_editor.read(cx).focus_handle(cx) + } +} + +impl Render for SplittableEditor { + fn render( + &mut self, + window: &mut ui::Window, + cx: &mut ui::Context, + ) -> impl ui::IntoElement { + let inner = if self.secondary.is_none() { + self.primary_editor.clone().into_any_element() + } else if let Some(active) = self.panes.panes().into_iter().next() { + self.panes + .render( + None, + &ActivePaneDecorator::new(active, &self.workspace), + window, + cx, + ) + .into_any_element() + } else { + div().into_any_element() + }; + div() + .id("splittable-editor") + .on_action(cx.listener(Self::split)) + .on_action(cx.listener(Self::unsplit)) + .size_full() + .child(inner) + } +} diff --git a/crates/editor/src/tasks.rs b/crates/editor/src/tasks.rs index d27e456405..e39880ddc1 100644 --- a/crates/editor/src/tasks.rs +++ b/crates/editor/src/tasks.rs @@ -14,7 +14,7 @@ impl Editor { return Task::ready(None); }; let (selection, buffer, editor_snapshot) = { - let selection = self.selections.newest_adjusted(cx); + let selection = self.selections.newest_adjusted(&self.display_snapshot(cx)); let Some((buffer, _)) = self .buffer() .read(cx) diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index a3a8d81c64..5a0652bdd1 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -16,7 +16,7 @@ use gpui::{ AppContext as _, Context, Entity, EntityId, Font, FontFeatures, FontStyle, FontWeight, Pixels, VisualTestContext, Window, font, size, }; -use multi_buffer::ToPoint; +use multi_buffer::{MultiBufferOffset, ToPoint}; use pretty_assertions::assert_eq; use project::{Project, project_settings::DiagnosticSeverity}; use ui::{App, BorrowAppContext, px}; @@ -78,7 +78,7 @@ pub fn marked_display_snapshot( let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); let markers = markers .into_iter() - .map(|offset| offset.to_display_point(&snapshot)) + .map(|offset| MultiBufferOffset(offset).to_display_point(&snapshot)) .collect(); (snapshot, markers) @@ -94,7 +94,11 @@ pub fn select_ranges( let (unmarked_text, text_ranges) = marked_text_ranges(marked_text, true); assert_eq!(editor.text(cx), unmarked_text); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(text_ranges) + s.select_ranges( + text_ranges + .into_iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) }); } @@ -108,7 +112,12 @@ pub fn assert_text_with_selections( assert_eq!(editor.text(cx), unmarked_text, "text doesn't match"); let actual = generate_marked_text( &editor.text(cx), - &editor.selections.ranges(cx), + &editor + .selections + .ranges::(&editor.display_snapshot(cx)) + .into_iter() + .map(|range| range.start.0..range.end.0) + .collect::>(), marked_text.contains("«"), ); assert_eq!(actual, marked_text, "Selections don't match"); diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index b1b04f0183..3afe0e6134 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -6,6 +6,8 @@ use std::{ }; use anyhow::Result; +use language::{markdown_lang, rust_lang}; +use multi_buffer::MultiBufferOffset; use serde_json::json; use crate::{Editor, ToPoint}; @@ -18,7 +20,6 @@ use language::{ point_to_lsp, }; use lsp::{notification, request}; -use multi_buffer::ToPointUtf16; use project::Project; use smol::stream::StreamExt; use workspace::{AppState, Workspace, WorkspaceHandle}; @@ -32,55 +33,6 @@ pub struct EditorLspTestContext { pub buffer_lsp_url: lsp::Uri, } -pub(crate) fn rust_lang() -> Arc { - let language = Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()], - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_queries(LanguageQueries { - indents: Some(Cow::from(indoc! {r#" - [ - ((where_clause) _ @end) - (field_expression) - (call_expression) - (assignment_expression) - (let_declaration) - (let_chain) - (await_expression) - ] @indent - - (_ "[" "]" @end) @indent - (_ "<" ">" @end) @indent - (_ "{" "}" @end) @indent - (_ "(" ")" @end) @indent"#})), - brackets: Some(Cow::from(indoc! {r#" - ("(" @open ")" @close) - ("[" @open "]" @close) - ("{" @open "}" @close) - ("<" @open ">" @close) - ("\"" @open "\"" @close) - (closure_parameters "|" @open "|" @close)"#})), - text_objects: Some(Cow::from(indoc! {r#" - (function_item - body: (_ - "{" - (_)* @function.inside - "}" )) @function.around - "#})), - ..Default::default() - }) - .expect("Could not parse queries"); - Arc::new(language) -} - #[cfg(test)] pub(crate) fn git_commit_lang() -> Arc { Arc::new(Language::new( @@ -103,10 +55,8 @@ impl EditorLspTestContext { cx.update(|cx| { assets::Assets.load_test_fonts(cx); - language::init(cx); crate::init(cx); workspace::init(app_state.clone(), cx); - Project::init_settings(cx); }); let file_name = format!( @@ -262,6 +212,77 @@ impl EditorLspTestContext { Self::new(language, capabilities, cx).await } + pub async fn new_tsx( + capabilities: lsp::ServerCapabilities, + cx: &mut gpui::TestAppContext, + ) -> EditorLspTestContext { + let mut word_characters: HashSet = Default::default(); + word_characters.insert('$'); + word_characters.insert('#'); + let language = Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..Default::default() + }, + brackets: language::BracketPairConfig { + pairs: vec![language::BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }], + disabled_scopes_by_bracket_ix: Default::default(), + }, + word_characters, + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_queries(LanguageQueries { + brackets: Some(Cow::from(indoc! {r#" + ("(" @open ")" @close) + ("[" @open "]" @close) + ("{" @open "}" @close) + ("<" @open ">" @close) + ("<" @open "/>" @close) + ("" @close) + ("\"" @open "\"" @close) + ("'" @open "'" @close) + ("`" @open "`" @close) + ((jsx_element (jsx_opening_element) @open (jsx_closing_element) @close) (#set! newline.only))"#})), + indents: Some(Cow::from(indoc! {r#" + [ + (call_expression) + (assignment_expression) + (member_expression) + (lexical_declaration) + (variable_declaration) + (assignment_expression) + (if_statement) + (for_statement) + ] @indent + + (_ "[" "]" @end) @indent + (_ "<" ">" @end) @indent + (_ "{" "}" @end) @indent + (_ "(" ")" @end) @indent + + (jsx_opening_element ">" @end) @indent + + (jsx_element + (jsx_opening_element) @start + (jsx_closing_element)? @end) @indent + "#})), + ..Default::default() + }) + .expect("Could not parse queries"); + + Self::new(language, capabilities, cx).await + } + pub async fn new_html(cx: &mut gpui::TestAppContext) -> Self { let language = Language::new( LanguageConfig { @@ -293,54 +314,58 @@ impl EditorLspTestContext { Self::new(language, Default::default(), cx).await } + pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> Self { + let context = Self::new( + Arc::into_inner(markdown_lang()).unwrap(), + Default::default(), + cx, + ) + .await; + + let language_registry = context.workspace.read_with(cx, |workspace, cx| { + workspace.project().read(cx).languages().clone() + }); + language_registry.add(rust_lang()); + + context + } + /// Constructs lsp range using a marked string with '[', ']' range delimiters #[track_caller] pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range { let ranges = self.ranges(marked_text); - self.to_lsp_range(ranges[0].clone()) + self.to_lsp_range(MultiBufferOffset(ranges[0].start)..MultiBufferOffset(ranges[0].end)) } #[expect(clippy::wrong_self_convention, reason = "This is test code")] - pub fn to_lsp_range(&mut self, range: Range) -> lsp::Range { + pub fn to_lsp_range(&mut self, range: Range) -> lsp::Range { + use language::ToPointUtf16; let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx)); let start_point = range.start.to_point(&snapshot.buffer_snapshot()); let end_point = range.end.to_point(&snapshot.buffer_snapshot()); self.editor(|editor, _, cx| { let buffer = editor.buffer().read(cx); - let start = point_to_lsp( - buffer - .point_to_buffer_offset(start_point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ); - let end = point_to_lsp( - buffer - .point_to_buffer_offset(end_point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ); - + let (start_buffer, start_offset) = + buffer.point_to_buffer_offset(start_point, cx).unwrap(); + let start = point_to_lsp(start_offset.to_point_utf16(&start_buffer.read(cx))); + let (end_buffer, end_offset) = buffer.point_to_buffer_offset(end_point, cx).unwrap(); + let end = point_to_lsp(end_offset.to_point_utf16(&end_buffer.read(cx))); lsp::Range { start, end } }) } #[expect(clippy::wrong_self_convention, reason = "This is test code")] - pub fn to_lsp(&mut self, offset: usize) -> lsp::Position { + pub fn to_lsp(&mut self, offset: MultiBufferOffset) -> lsp::Position { + use language::ToPointUtf16; + let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx)); let point = offset.to_point(&snapshot.buffer_snapshot()); self.editor(|editor, _, cx| { let buffer = editor.buffer().read(cx); - point_to_lsp( - buffer - .point_to_buffer_offset(point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ) + let (buffer, offset) = buffer.point_to_buffer_offset(point, cx).unwrap(); + point_to_lsp(offset.to_point_utf16(&buffer.read(cx))) }) } @@ -369,7 +394,7 @@ impl EditorLspTestContext { } pub fn notify(&self, params: T::Params) { - self.lsp.notify::(¶ms); + self.lsp.notify::(params); } #[cfg(target_os = "windows")] diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index be59a1a16f..511629c59d 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -1,18 +1,19 @@ use crate::{ - AnchorRangeExt, DisplayPoint, Editor, MultiBuffer, RowExt, + AnchorRangeExt, DisplayPoint, Editor, ExcerptId, MultiBuffer, MultiBufferSnapshot, RowExt, display_map::{HighlightKey, ToDisplayPoint}, }; use buffer_diff::DiffHunkStatusKind; use collections::BTreeMap; use futures::Future; +use git::repository::RepoPath; use gpui::{ AnyWindowHandle, App, Context, Entity, Focusable as _, Keystroke, Pixels, Point, VisualTestContext, Window, WindowHandle, prelude::*, }; use itertools::Itertools; use language::{Buffer, BufferSnapshot, LanguageRegistry}; -use multi_buffer::{Anchor, ExcerptRange, MultiBufferRow}; +use multi_buffer::{Anchor, ExcerptRange, MultiBufferOffset, MultiBufferRow}; use parking_lot::RwLock; use project::{FakeFs, Project}; use std::{ @@ -24,6 +25,7 @@ use std::{ atomic::{AtomicUsize, Ordering}, }, }; +use text::Selection; use util::{ assert_set_eq, test::{generate_marked_text, marked_text_ranges}, @@ -57,6 +59,17 @@ impl EditorTestContext { }) .await .unwrap(); + + let language = project + .read_with(cx, |project, _cx| { + project.languages().language_for_name("Plain Text") + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(language), cx); + }); + let editor = cx.add_window(|window, cx| { let editor = build_editor_with_project( project, @@ -254,7 +267,7 @@ impl EditorTestContext { let snapshot = self.editor.update_in(&mut self.cx, |editor, window, cx| { editor.snapshot(window, cx) }); - ranges[0].start.to_display_point(&snapshot) + MultiBufferOffset(ranges[0].start).to_display_point(&snapshot) } pub fn pixel_position(&mut self, marked_text: &str) -> Point { @@ -264,11 +277,13 @@ impl EditorTestContext { pub fn pixel_position_for(&mut self, display_point: DisplayPoint) -> Point { self.update_editor(|editor, window, cx| { - let newest_point = editor.selections.newest_display(cx).head(); + let newest_point = editor + .selections + .newest_display(&editor.display_snapshot(cx)) + .head(); let pixel_position = editor.pixel_position_of_newest_cursor.unwrap(); let line_height = editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()); let snapshot = editor.snapshot(window, cx); @@ -330,7 +345,10 @@ impl EditorTestContext { let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); let mut found = None; fs.with_git_state(&Self::root_path().join(".git"), false, |git_state| { - found = git_state.index_contents.get(&path.into()).cloned(); + found = git_state + .index_contents + .get(&RepoPath::from_rel_path(&path)) + .cloned(); }) .unwrap(); assert_eq!(expected, found.as_deref()); @@ -354,7 +372,11 @@ impl EditorTestContext { self.editor.update_in(&mut self.cx, |editor, window, cx| { editor.set_text(unmarked_text, window, cx); editor.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(selection_ranges) + s.select_ranges( + selection_ranges + .into_iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) }) }); state_context @@ -371,7 +393,11 @@ impl EditorTestContext { self.editor.update_in(&mut self.cx, |editor, window, cx| { assert_eq!(editor.text(cx), unmarked_text); editor.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(selection_ranges) + s.select_ranges( + selection_ranges + .into_iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) }) }); state_context @@ -388,6 +414,23 @@ impl EditorTestContext { #[track_caller] pub fn assert_excerpts_with_selections(&mut self, marked_text: &str) { + let actual_text = self.to_format_multibuffer_as_marked_text(); + let fmt_additional_notes = || { + struct Format<'a, T: std::fmt::Display>(&'a str, &'a T); + + impl std::fmt::Display for Format<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "\n\n----- EXPECTED: -----\n\n{}\n\n----- ACTUAL: -----\n\n{}\n\n", + self.0, self.1 + ) + } + } + + Format(marked_text, &actual_text) + }; + let expected_excerpts = marked_text .strip_prefix("[EXCERPT]\n") .unwrap() @@ -408,9 +451,10 @@ impl EditorTestContext { assert!( excerpts.len() == expected_excerpts.len(), - "should have {} excerpts, got {}", + "should have {} excerpts, got {}{}", expected_excerpts.len(), - excerpts.len() + excerpts.len(), + fmt_additional_notes(), ); for (ix, (excerpt_id, snapshot, range)) in excerpts.into_iter().enumerate() { @@ -424,27 +468,32 @@ impl EditorTestContext { if !expected_selections.is_empty() { assert!( is_selected, - "excerpt {ix} should be selected. got {:?}", + "excerpt {ix} should contain selections. got {:?}{}", self.editor_state(), + fmt_additional_notes(), ); } else { assert!( !is_selected, - "excerpt {ix} should not be selected, got: {selections:?}", + "excerpt {ix} should not contain selections, got: {selections:?}{}", + fmt_additional_notes(), ); } continue; } - assert!(!is_folded, "excerpt {} should not be folded", ix); + assert!( + !is_folded, + "excerpt {} should not be folded{}", + ix, + fmt_additional_notes() + ); assert_eq!( multibuffer_snapshot - .text_for_range(Anchor::range_in_buffer( - excerpt_id, - snapshot.remote_id(), - range.context.clone() - )) + .text_for_range(Anchor::range_in_buffer(excerpt_id, range.context.clone())) .collect::(), - expected_text + expected_text, + "{}", + fmt_additional_notes(), ); let selections = selections @@ -460,13 +509,38 @@ impl EditorTestContext { .collect::>(); // todo: selections that cross excerpt boundaries.. assert_eq!( - selections, expected_selections, - "excerpt {} has incorrect selections", + selections, + expected_selections, + "excerpt {} has incorrect selections{}", ix, + fmt_additional_notes() ); } } + fn to_format_multibuffer_as_marked_text(&mut self) -> FormatMultiBufferAsMarkedText { + let (multibuffer_snapshot, selections, excerpts) = self.update_editor(|editor, _, cx| { + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + + let selections = editor.selections.disjoint_anchors_arc().to_vec(); + let excerpts = multibuffer_snapshot + .excerpts() + .map(|(e_id, snapshot, range)| { + let is_folded = editor.is_buffer_folded(snapshot.remote_id(), cx); + (e_id, snapshot.clone(), range, is_folded) + }) + .collect::>(); + + (multibuffer_snapshot, selections, excerpts) + }); + + FormatMultiBufferAsMarkedText { + multibuffer_snapshot, + selections, + excerpts, + } + } + /// Make an assertion about the editor's text and the ranges and directions /// of its selections using a string containing embedded range markers. /// @@ -505,6 +579,7 @@ impl EditorTestContext { .unwrap_or_default() .iter() .map(|range| range.to_offset(&snapshot.buffer_snapshot())) + .map(|range| range.start.0..range.end.0) .collect() }); assert_set_eq!(actual_ranges, expected_ranges); @@ -520,6 +595,7 @@ impl EditorTestContext { .unwrap_or_default() .into_iter() .map(|range| range.to_offset(&snapshot.buffer_snapshot())) + .map(|range| range.start.0..range.end.0) .collect(); assert_set_eq!(actual_ranges, expected_ranges); } @@ -537,14 +613,16 @@ impl EditorTestContext { fn editor_selections(&mut self) -> Vec> { self.editor .update(&mut self.cx, |editor, cx| { - editor.selections.all::(cx) + editor + .selections + .all::(&editor.display_snapshot(cx)) }) .into_iter() .map(|s| { if s.reversed { - s.end..s.start + s.end.0..s.start.0 } else { - s.start..s.end + s.start.0..s.end.0 } }) .collect::>() @@ -571,6 +649,59 @@ impl EditorTestContext { } } +struct FormatMultiBufferAsMarkedText { + multibuffer_snapshot: MultiBufferSnapshot, + selections: Vec>, + excerpts: Vec<(ExcerptId, BufferSnapshot, ExcerptRange, bool)>, +} + +impl std::fmt::Display for FormatMultiBufferAsMarkedText { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + multibuffer_snapshot, + selections, + excerpts, + } = self; + + for (excerpt_id, snapshot, range, is_folded) in excerpts.into_iter() { + write!(f, "[EXCERPT]\n")?; + if *is_folded { + write!(f, "[FOLDED]\n")?; + } + + let mut text = multibuffer_snapshot + .text_for_range(Anchor::range_in_buffer(*excerpt_id, range.context.clone())) + .collect::(); + + let selections = selections + .iter() + .filter(|&s| s.head().excerpt_id == *excerpt_id) + .map(|s| { + let head = text::ToOffset::to_offset(&s.head().text_anchor, &snapshot) + - text::ToOffset::to_offset(&range.context.start, &snapshot); + let tail = text::ToOffset::to_offset(&s.head().text_anchor, &snapshot) + - text::ToOffset::to_offset(&range.context.start, &snapshot); + tail..head + }) + .rev() + .collect::>(); + + for selection in selections { + if selection.is_empty() { + text.insert(selection.start, 'ˇ'); + continue; + } + text.insert(selection.end, '»'); + text.insert(selection.start, '«'); + } + + write!(f, "{text}")?; + } + + Ok(()) + } +} + #[track_caller] pub fn assert_state_with_diff( editor: &Entity, @@ -578,9 +709,15 @@ pub fn assert_state_with_diff( expected_diff_text: &str, ) { let (snapshot, selections) = editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); ( - editor.snapshot(window, cx).buffer_snapshot().clone(), - editor.selections.ranges::(cx), + snapshot.buffer_snapshot().clone(), + editor + .selections + .ranges::(&snapshot.display_snapshot) + .into_iter() + .map(|range| range.start.0..range.end.0) + .collect::>(), ) }); diff --git a/crates/eval/Cargo.toml b/crates/eval/Cargo.toml index a0214c76a1..30908be1e2 100644 --- a/crates/eval/Cargo.toml +++ b/crates/eval/Cargo.toml @@ -18,18 +18,17 @@ name = "explorer" path = "src/explorer.rs" [dependencies] -agent.workspace = true +acp_thread.workspace = true +agent = { workspace = true, features = ["eval"] } +agent-client-protocol.workspace = true agent_settings.workspace = true agent_ui.workspace = true anyhow.workspace = true -assistant_tool.workspace = true -assistant_tools.workspace = true async-trait.workspace = true buffer_diff.workspace = true chrono.workspace = true clap.workspace = true client.workspace = true -cloud_llm_client.workspace = true collections.workspace = true debug_adapter_extension.workspace = true dirs.workspace = true @@ -54,13 +53,13 @@ pretty_assertions.workspace = true project.workspace = true prompt_store.workspace = true regex.workspace = true +rand.workspace = true release_channel.workspace = true reqwest_client.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true shellexpand.workspace = true -smol.workspace = true telemetry.workspace = true terminal_view.workspace = true toml.workspace = true @@ -68,4 +67,3 @@ unindent.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true -workspace-hack.workspace = true diff --git a/crates/eval/runner_settings.json b/crates/eval/runner_settings.json index 91f193d7b3..ea2ccb0511 100644 --- a/crates/eval/runner_settings.json +++ b/crates/eval/runner_settings.json @@ -1,7 +1,5 @@ { - "assistant": { - "always_allow_tool_actions": true, - "stream_edits": true, - "version": "2" + "agent": { + "always_allow_tool_actions": true } } diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index 40d8c14f4f..80633696b7 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -23,10 +23,9 @@ use gpui_tokio::Tokio; use language::LanguageRegistry; use language_model::{ConfiguredModel, LanguageModel, LanguageModelRegistry, SelectedModel}; use node_runtime::{NodeBinaryOptions, NodeRuntime}; -use project::Project; use project::project_settings::ProjectSettings; use prompt_store::PromptBuilder; -use release_channel::AppVersion; +use release_channel::{AppCommitSha, AppVersion}; use reqwest_client::ReqwestClient; use settings::{Settings, SettingsStore}; use std::cell::RefCell; @@ -61,9 +60,22 @@ struct Args { /// Maximum number of examples to run concurrently. #[arg(long, default_value = "4")] concurrency: usize, + /// Output current environment variables as JSON to stdout + #[arg(long, hide = true)] + printenv: bool, } fn main() { + let args = Args::parse(); + + // This prevents errors showing up in the logs, because + // project::environment::load_shell_environment() calls + // std::env::current_exe().unwrap() --printenv + if args.printenv { + util::shell_env::print_env(); + return; + } + dotenvy::from_filename(CARGO_MANIFEST_DIR.join(".env")).ok(); env_logger::init(); @@ -99,7 +111,6 @@ fn main() { let zed_commit_sha = commit_sha_for_path(&root_dir); let zed_branch_name = git_branch_for_path(&root_dir); - let args = Args::parse(); let languages: HashSet = args.languages.into_iter().collect(); let http_client = Arc::new(ReqwestClient::new()); @@ -126,19 +137,20 @@ fn main() { let mut cumulative_tool_metrics = ToolMetrics::default(); - let agent_model = load_model(&args.model, cx).unwrap(); - let judge_model = load_model(&args.judge_model, cx).unwrap(); - - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.set_default_model(Some(agent_model.clone()), cx); + let tasks = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.providers().iter().map(|p| p.authenticate(cx)).collect::>() }); - let auth1 = agent_model.provider.authenticate(cx); - let auth2 = judge_model.provider.authenticate(cx); - cx.spawn(async move |cx| { - auth1.await?; - auth2.await?; + future::join_all(tasks).await; + let judge_model = cx.update(|cx| { + let agent_model = load_model(&args.model, cx).unwrap(); + let judge_model = load_model(&args.judge_model, cx).unwrap(); + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.set_default_model(Some(agent_model.clone()), cx); + }); + judge_model + })?; let mut examples = Vec::new(); @@ -268,7 +280,6 @@ fn main() { future::join_all((0..args.concurrency).map(|_| { let app_state = app_state.clone(); - let model = agent_model.model.clone(); let judge_model = judge_model.model.clone(); let zed_commit_sha = zed_commit_sha.clone(); let zed_branch_name = zed_branch_name.clone(); @@ -283,7 +294,7 @@ fn main() { let result = async { example.setup().await?; let run_output = cx - .update(|cx| example.run(model.clone(), app_state.clone(), cx))? + .update(|cx| example.run(app_state.clone(), cx))? .await?; let judge_output = judge_example( example.clone(), @@ -336,13 +347,19 @@ pub struct AgentAppState { } pub fn init(cx: &mut App) -> Arc { - let app_version = AppVersion::load(env!("ZED_PKG_VERSION")); - release_channel::init(app_version, cx); + let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned())); + + let app_version = AppVersion::load( + env!("ZED_PKG_VERSION"), + option_env!("ZED_BUILD_ID"), + app_commit_sha, + ); + + release_channel::init(app_version.clone(), cx); gpui_tokio::init(cx); let settings_store = SettingsStore::new(cx, &settings::default_settings()); cx.set_global(settings_store); - client::init_settings(cx); // Set User-Agent so we can download language servers from GitHub let user_agent = format!( @@ -364,8 +381,6 @@ pub fn init(cx: &mut App) -> Arc { }; cx.set_http_client(Arc::new(http)); - Project::init_settings(cx); - let client = Client::production(cx); cx.set_http_client(client.http_client()); @@ -410,8 +425,6 @@ pub fn init(cx: &mut App) -> Arc { let node_runtime = NodeRuntime::new(client.http_client(), None, rx); let extension_host_proxy = ExtensionHostProxy::global(cx); - - language::init(cx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); language_model::init(client.clone(), cx); @@ -429,7 +442,6 @@ pub fn init(cx: &mut App) -> Arc { true, cx, ); - assistant_tools::init(client.http_client(), cx); SettingsStore::update_global(cx, |store, cx| { store.set_user_settings(include_str!("../runner_settings.json"), cx) @@ -458,8 +470,8 @@ pub fn find_model( .ok_or_else(|| { anyhow::anyhow!( "No language model with ID {}/{} was available. Available models: {}", - selected.model.0, selected.provider.0, + selected.model.0, model_registry .available_models(cx) .map(|model| format!("{}/{}", model.provider_id().0, model.id().0)) @@ -525,7 +537,6 @@ async fn judge_example( diff_evaluation = judge_output.diff.clone(), thread_evaluation = judge_output.thread, tool_metrics = run_output.tool_metrics, - response_count = run_output.response_count, token_usage = run_output.token_usage, model = model.telemetry_id(), model_provider = model.provider_id().to_string(), diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index c0f0900a6c..c4d076037f 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -3,22 +3,24 @@ use std::{ fmt::{self, Debug}, sync::{Arc, Mutex}, time::Duration, + u32, }; use crate::{ ToolMetrics, assertions::{AssertionsReport, RanAssertion, RanAssertionResult}, }; -use agent::{ContextLoadResult, Thread, ThreadEvent}; +use acp_thread::UserMessageId; +use agent::{Thread, ThreadEvent, UserMessageContent}; +use agent_client_protocol as acp; use agent_settings::AgentProfileId; use anyhow::{Result, anyhow}; use async_trait::async_trait; use buffer_diff::DiffHunkStatus; -use cloud_llm_client::CompletionIntent; use collections::HashMap; -use futures::{FutureExt as _, StreamExt, channel::mpsc, select_biased}; +use futures::{FutureExt as _, StreamExt, select_biased}; use gpui::{App, AppContext, AsyncApp, Entity}; -use language_model::{LanguageModel, Role, StopReason}; +use language_model::Role; use util::rel_path::RelPath; pub const THREAD_EVENT_TIMEOUT: Duration = Duration::from_secs(60 * 2); @@ -91,7 +93,6 @@ pub struct ExampleContext { log_prefix: String, agent_thread: Entity, app: AsyncApp, - model: Arc, pub assertions: AssertionsReport, pub tool_metrics: Arc>, } @@ -101,7 +102,6 @@ impl ExampleContext { meta: ExampleMetadata, log_prefix: String, agent_thread: Entity, - model: Arc, app: AsyncApp, ) -> Self { let assertions = AssertionsReport::new(meta.max_assertions); @@ -111,26 +111,11 @@ impl ExampleContext { log_prefix, agent_thread, assertions, - model, app, tool_metrics: Arc::new(Mutex::new(ToolMetrics::default())), } } - pub fn push_user_message(&mut self, text: impl ToString) { - self.app - .update_entity(&self.agent_thread, |thread, cx| { - thread.insert_user_message( - text.to_string(), - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ); - }) - .unwrap(); - } - pub fn assert(&mut self, expected: bool, message: impl ToString) -> Result<()> { let message = message.to_string(); self.log_assertion( @@ -202,156 +187,173 @@ impl ExampleContext { result } - pub async fn run_to_end(&mut self) -> Result { - self.run_turns(u32::MAX).await + pub async fn prompt(&mut self, prompt: impl Into) -> Result { + self.prompt_with_max_turns(prompt, u32::MAX).await } - pub async fn run_turn(&mut self) -> Result { - self.run_turns(1).await + pub async fn prompt_with_max_turns( + &mut self, + prompt: impl Into, + max_turns: u32, + ) -> Result { + let content = vec![UserMessageContent::Text(prompt.into())]; + self.run_turns(Some(content), max_turns).await } - pub async fn run_turns(&mut self, iterations: u32) -> Result { - let (mut tx, mut rx) = mpsc::channel(1); + pub async fn proceed_with_max_turns(&mut self, max_turns: u32) -> Result { + self.run_turns(None, max_turns).await + } + async fn run_turns( + &mut self, + prompt: Option>, + max_turns: u32, + ) -> Result { let tool_metrics = self.tool_metrics.clone(); let log_prefix = self.log_prefix.clone(); - let _subscription = self.app.subscribe( - &self.agent_thread, - move |thread, event: &ThreadEvent, cx| match event { - ThreadEvent::ShowError(thread_error) => { - tx.try_send(Err(anyhow!(thread_error.clone()))).ok(); - } - ThreadEvent::Stopped(reason) => match reason { - Ok(StopReason::EndTurn) => { - tx.close_channel(); + + let mut remaining_turns = max_turns; + + let mut event_stream = self.agent_thread.update(&mut self.app, |thread, cx| { + if let Some(prompt) = prompt { + let id = UserMessageId::new(); + thread.send(id, prompt, cx) + } else { + thread.proceed(cx) + } + })??; + + let task = self.app.background_spawn(async move { + let mut messages = Vec::new(); + let mut tool_uses_by_id = HashMap::default(); + while let Some(event) = event_stream.next().await { + match event? { + ThreadEvent::UserMessage(user_message) => { + messages.push(Message { + role: Role::User, + text: user_message.to_markdown(), + tool_use: Vec::new(), + }); } - Ok(StopReason::ToolUse) => { - if thread.read(cx).remaining_turns() == 0 { - tx.close_channel(); + ThreadEvent::AgentThinking(text) | ThreadEvent::AgentText(text) => { + if matches!( + messages.last(), + Some(Message { + role: Role::Assistant, + .. + }) + ) { + messages.last_mut().unwrap().text.push_str(&text); + } else { + messages.push(Message { + role: Role::Assistant, + text, + tool_use: Vec::new(), + }); } } - Ok(StopReason::MaxTokens) => { - tx.try_send(Err(anyhow!("Exceeded maximum tokens"))).ok(); + ThreadEvent::ToolCall(tool_call) => { + let meta = tool_call.meta.expect("Missing meta field in tool_call"); + let tool_name = meta + .get("tool_name") + .expect("Missing tool_name field in meta") + .as_str() + .expect("Unknown tool_name content in meta"); + + tool_uses_by_id.insert( + tool_call.tool_call_id, + ToolUse { + name: tool_name.to_string(), + value: tool_call.raw_input.unwrap_or_default(), + }, + ); + if matches!( + tool_call.status, + acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed + ) { + panic!("Tool call completed without update"); + } } - Ok(StopReason::Refusal) => { - tx.try_send(Err(anyhow!("Model refused to generate content"))) - .ok(); - } - Err(err) => { - tx.try_send(Err(anyhow!(err.clone()))).ok(); - } - }, - ThreadEvent::NewRequest - | ThreadEvent::StreamedAssistantText(_, _) - | ThreadEvent::StreamedAssistantThinking(_, _) - | ThreadEvent::UsePendingTools { .. } - | ThreadEvent::CompletionCanceled => {} - ThreadEvent::ToolUseLimitReached => {} - ThreadEvent::ToolFinished { - tool_use_id, - pending_tool_use, - .. - } => { - thread.update(cx, |thread, _cx| { - if let Some(tool_use) = pending_tool_use { - let mut tool_metrics = tool_metrics.lock().unwrap(); - if let Some(tool_result) = thread.tool_result(tool_use_id) { - let message = if tool_result.is_error { - format!("✖︎ {}", tool_use.name) - } else { + ThreadEvent::ToolCallUpdate(tool_call_update) => { + if let acp_thread::ToolCallUpdate::UpdateFields(update) = tool_call_update { + if let Some(raw_input) = update.fields.raw_input { + if let Some(tool_use) = + tool_uses_by_id.get_mut(&update.tool_call_id) + { + tool_use.value = raw_input; + } + } + + if matches!( + update.fields.status, + Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) + ) { + let succeeded = + update.fields.status == Some(acp::ToolCallStatus::Completed); + + let tool_use = tool_uses_by_id + .remove(&update.tool_call_id) + .expect("Unrecognized tool call completed"); + + let log_message = if succeeded { format!("✔︎ {}", tool_use.name) + } else { + format!("✖︎ {}", tool_use.name) }; - println!("{log_prefix}{message}"); + println!("{log_prefix}{log_message}"); + tool_metrics - .insert(tool_result.tool_name.clone(), !tool_result.is_error); - } else { - let message = - format!("TOOL FINISHED WITHOUT RESULT: {}", tool_use.name); - println!("{log_prefix}{message}"); - tool_metrics.insert(tool_use.name.clone(), true); + .lock() + .unwrap() + .insert(tool_use.name.clone().into(), succeeded); + + if let Some(message) = messages.last_mut() { + message.tool_use.push(tool_use); + } else { + messages.push(Message { + role: Role::Assistant, + text: "".to_string(), + tool_use: vec![tool_use], + }); + } + + remaining_turns -= 1; + if remaining_turns == 0 { + return Ok(messages); + } } } - }); - } - ThreadEvent::InvalidToolInput { .. } => { - println!("{log_prefix} invalid tool input"); - } - ThreadEvent::MissingToolUse { - tool_use_id: _, - ui_text, - } => { - println!("{log_prefix} {ui_text}"); - } - ThreadEvent::ToolConfirmationNeeded => { - panic!( + } + ThreadEvent::ToolCallAuthorization(_) => panic!( "{}Bug: Tool confirmation should not be required in eval", log_prefix - ); - } - ThreadEvent::StreamedCompletion - | ThreadEvent::MessageAdded(_) - | ThreadEvent::MessageEdited(_) - | ThreadEvent::MessageDeleted(_) - | ThreadEvent::SummaryChanged - | ThreadEvent::SummaryGenerated - | ThreadEvent::ProfileChanged - | ThreadEvent::ReceivedTextChunk - | ThreadEvent::StreamedToolUse { .. } - | ThreadEvent::CheckpointChanged - | ThreadEvent::CancelEditing => { - tx.try_send(Ok(())).ok(); - if std::env::var("ZED_EVAL_DEBUG").is_ok() { - println!("{}Event: {:#?}", log_prefix, event); + ), + ThreadEvent::Retry(status) => { + println!("{log_prefix} Got retry: {status:?}"); } + ThreadEvent::Stop(stop_reason) => match stop_reason { + acp::StopReason::EndTurn => {} + acp::StopReason::MaxTokens => { + return Err(anyhow!("Exceeded maximum tokens")); + } + acp::StopReason::MaxTurnRequests => { + return Err(anyhow!("Exceeded maximum turn requests")); + } + stop_reason => return Err(anyhow!("{stop_reason:?}")), + }, } - }, - ); + } + Ok(messages) + }); - let model = self.model.clone(); - - let message_count_before = self.app.update_entity(&self.agent_thread, |thread, cx| { - thread.set_remaining_turns(iterations); - thread.send_to_model(model, CompletionIntent::UserPrompt, None, cx); - thread.messages().len() - })?; - - loop { - select_biased! { - result = rx.next() => { - if let Some(result) = result { - result?; - } else { - break; - } - } - _ = self.app.background_executor().timer(THREAD_EVENT_TIMEOUT).fuse() => { - anyhow::bail!("Agentic loop stalled - waited {THREAD_EVENT_TIMEOUT:?} without any events"); - } + select_biased! { + result = task.fuse() => { + Ok(Response::new(result?)) + } + _ = self.app.background_executor().timer(THREAD_EVENT_TIMEOUT).fuse() => { + anyhow::bail!("Agentic loop stalled - waited {THREAD_EVENT_TIMEOUT:?} without any events"); } } - - let messages = self.app.read_entity(&self.agent_thread, |thread, cx| { - let mut messages = Vec::new(); - for message in thread.messages().skip(message_count_before) { - messages.push(Message { - _role: message.role, - text: message.to_message_content(), - tool_use: thread - .tool_uses_for_message(message.id, cx) - .into_iter() - .map(|tool_use| ToolUse { - name: tool_use.name.to_string(), - value: tool_use.input, - }) - .collect(), - }); - } - messages - })?; - - let response = Response::new(messages); - - Ok(response) } pub fn edits(&self) -> HashMap, FileEdits> { @@ -486,7 +488,7 @@ impl Response { Self { messages } } - pub fn expect_tool( + pub fn expect_tool_call( &self, tool_name: &'static str, cx: &mut ExampleContext, @@ -503,8 +505,7 @@ impl Response { }) } - #[allow(dead_code)] - pub fn tool_uses(&self) -> impl Iterator { + pub fn tool_calls(&self) -> impl Iterator { self.messages.iter().flat_map(|msg| &msg.tool_use) } @@ -515,7 +516,7 @@ impl Response { #[derive(Debug)] pub struct Message { - _role: Role, + role: Role, text: String, tool_use: Vec, } diff --git a/crates/eval/src/examples/add_arg_to_trait_method.rs b/crates/eval/src/examples/add_arg_to_trait_method.rs index 41fa7c3dc6..1692932b33 100644 --- a/crates/eval/src/examples/add_arg_to_trait_method.rs +++ b/crates/eval/src/examples/add_arg_to_trait_method.rs @@ -27,14 +27,12 @@ impl Example for AddArgToTraitMethod { async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { const FILENAME: &str = "assistant_tool.rs"; - cx.push_user_message(format!( + let _ = cx.prompt(format!( r#" Add a `window: Option` argument to the `Tool::run` trait method in {FILENAME}, and update all the implementations of the trait and call sites accordingly. "# - )); - - let _ = cx.run_to_end().await?; + )).await?; // Adds ignored argument to all but `batch_tool` diff --git a/crates/eval/src/examples/code_block_citations.rs b/crates/eval/src/examples/code_block_citations.rs index 8150d68ac3..c8ba75e99f 100644 --- a/crates/eval/src/examples/code_block_citations.rs +++ b/crates/eval/src/examples/code_block_citations.rs @@ -29,16 +29,19 @@ impl Example for CodeBlockCitations { async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { const FILENAME: &str = "assistant_tool.rs"; - cx.push_user_message(format!( - r#" - Show me the method bodies of all the methods of the `Tool` trait in {FILENAME}. - - Please show each method in a separate code snippet. - "# - )); // Verify that the messages all have the correct formatting. - let texts: Vec = cx.run_to_end().await?.texts().collect(); + let texts: Vec = cx + .prompt(format!( + r#" + Show me the method bodies of all the methods of the `Tool` trait in {FILENAME}. + + Please show each method in a separate code snippet. + "# + )) + .await? + .texts() + .collect(); let closing_fence = format!("\n{FENCE}"); for text in texts.iter() { diff --git a/crates/eval/src/examples/comment_translation.rs b/crates/eval/src/examples/comment_translation.rs index b6c9f7376f..421999893a 100644 --- a/crates/eval/src/examples/comment_translation.rs +++ b/crates/eval/src/examples/comment_translation.rs @@ -1,7 +1,7 @@ use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion}; +use agent::{EditFileMode, EditFileToolInput}; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tools::{EditFileMode, EditFileToolInput}; use async_trait::async_trait; pub struct CommentTranslation; @@ -22,30 +22,26 @@ impl Example for CommentTranslation { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - cx.push_user_message(r#" - Edit the following files and translate all their comments to italian, in this exact order: + let response = cx.prompt( + r#" + Edit the following files and translate all their comments to italian, in this exact order: - - font-kit/src/family.rs - - font-kit/src/canvas.rs - - font-kit/src/error.rs - "#); - cx.run_to_end().await?; + - font-kit/src/family.rs + - font-kit/src/canvas.rs + - font-kit/src/error.rs + "# + ).await?; let mut create_or_overwrite_count = 0; - cx.agent_thread().read_with(cx, |thread, cx| { - for message in thread.messages() { - for tool_use in thread.tool_uses_for_message(message.id, cx) { - if tool_use.name == "edit_file" { - let input: EditFileToolInput = serde_json::from_value(tool_use.input)?; - if !matches!(input.mode, EditFileMode::Edit) { - create_or_overwrite_count += 1; - } - } + for tool_call in response.tool_calls() { + if tool_call.name == "edit_file" { + let input = tool_call.parse_input::()?; + if !matches!(input.mode, EditFileMode::Edit) { + create_or_overwrite_count += 1; } } + } - anyhow::Ok(()) - })??; cx.assert_eq(create_or_overwrite_count, 0, "no_creation_or_overwrite")?; Ok(()) diff --git a/crates/eval/src/examples/file_change_notification.rs b/crates/eval/src/examples/file_change_notification.rs index 7879ad6f2e..41ce10cd22 100644 --- a/crates/eval/src/examples/file_change_notification.rs +++ b/crates/eval/src/examples/file_change_notification.rs @@ -48,8 +48,8 @@ impl Example for FileChangeNotificationExample { })?; // Start conversation (specific message is not important) - cx.push_user_message("Find all files in this repo"); - cx.run_turn().await?; + cx.prompt_with_max_turns("Find all files in this repo", 1) + .await?; // Edit the README buffer - the model should get a notification on next turn buffer.update(cx, |buffer, cx| { @@ -58,7 +58,7 @@ impl Example for FileChangeNotificationExample { // Run for some more turns. // The model shouldn't thank us for letting it know about the file change. - cx.run_turns(3).await?; + cx.proceed_with_max_turns(3).await?; Ok(()) } diff --git a/crates/eval/src/examples/file_search.rs b/crates/eval/src/examples/file_search.rs index f1a482a41a..7de7a07d19 100644 --- a/crates/eval/src/examples/file_search.rs +++ b/crates/eval/src/examples/file_search.rs @@ -1,6 +1,6 @@ +use agent::FindPathToolInput; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tools::FindPathToolInput; use async_trait::async_trait; use regex::Regex; @@ -25,18 +25,19 @@ impl Example for FileSearchExample { async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { const FILENAME: &str = "find_replace_file_tool.rs"; - cx.push_user_message(format!( - r#" + + let prompt = format!( + r#" Look at the `{FILENAME}`. I want to implement a card for it. The card should implement the `Render` trait. The card should show a diff. It should be a beautifully presented diff. The card "box" should look like what we show for markdown codeblocks (look at `MarkdownElement`). I want to see a red background for lines that were deleted and a green background for lines that were added. We should have a div per diff line. "# - )); + ); - let response = cx.run_turn().await?; - let tool_use = response.expect_tool("find_path", cx)?; + let response = cx.prompt_with_max_turns(prompt, 1).await?; + let tool_use = response.expect_tool_call("find_path", cx)?; let input = tool_use.parse_input::()?; let glob = input.glob; diff --git a/crates/eval/src/examples/grep_params_escapement.rs b/crates/eval/src/examples/grep_params_escapement.rs index 0532698ba2..57086a1b9b 100644 --- a/crates/eval/src/examples/grep_params_escapement.rs +++ b/crates/eval/src/examples/grep_params_escapement.rs @@ -1,6 +1,6 @@ +use agent::GrepToolInput; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tools::GrepToolInput; use async_trait::async_trait; use crate::example::{Example, ExampleContext, ExampleMetadata}; @@ -36,9 +36,9 @@ impl Example for GrepParamsEscapementExample { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - // cx.push_user_message("How does the precedence/specificity work with Keymap contexts? I am seeing that `MessageEditor > Editor` is lower precendence than `Editor` which is surprising to me, but might be how it works"); - cx.push_user_message("Search for files containing the characters `>` or `<`"); - let response = cx.run_turns(2).await?; + let response = cx + .prompt_with_max_turns("Search for files containing the characters `>` or `<`", 2) + .await?; let grep_input = response .find_tool_call("grep") .and_then(|tool_use| tool_use.parse_input::().ok()); diff --git a/crates/eval/src/examples/mod.rs b/crates/eval/src/examples/mod.rs index afe258aa76..aec1bce079 100644 --- a/crates/eval/src/examples/mod.rs +++ b/crates/eval/src/examples/mod.rs @@ -144,9 +144,8 @@ impl Example for DeclarativeExample { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - cx.push_user_message(&self.prompt); let max_turns = self.metadata.max_turns.unwrap_or(1000); - let _ = cx.run_turns(max_turns).await; + let _ = cx.prompt_with_max_turns(&self.prompt, max_turns).await; Ok(()) } diff --git a/crates/eval/src/examples/overwrite_file.rs b/crates/eval/src/examples/overwrite_file.rs index df0b75294c..a4df1e97a3 100644 --- a/crates/eval/src/examples/overwrite_file.rs +++ b/crates/eval/src/examples/overwrite_file.rs @@ -1,6 +1,6 @@ +use agent::{EditFileMode, EditFileToolInput}; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tools::{EditFileMode, EditFileToolInput}; use async_trait::async_trait; use crate::example::{Example, ExampleContext, ExampleMetadata}; @@ -36,17 +36,14 @@ impl Example for FileOverwriteExample { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - let response = cx.run_turns(1).await?; - let file_overwritten = if let Some(tool_use) = response.find_tool_call("edit_file") { - let input = tool_use.parse_input::()?; - match input.mode { - EditFileMode::Edit => false, - EditFileMode::Create | EditFileMode::Overwrite => { - input.path.ends_with("src/language_model_selector.rs") - } + let response = cx.proceed_with_max_turns(1).await?; + let tool_use = response.expect_tool_call("edit_file", cx)?; + let input = tool_use.parse_input::()?; + let file_overwritten = match input.mode { + EditFileMode::Edit => false, + EditFileMode::Create | EditFileMode::Overwrite => { + input.path.ends_with("src/language_model_selector.rs") } - } else { - false }; cx.assert(!file_overwritten, "File should be edited, not overwritten") diff --git a/crates/eval/src/examples/planets.rs b/crates/eval/src/examples/planets.rs index f3a69332d2..6b6ca0e3fe 100644 --- a/crates/eval/src/examples/planets.rs +++ b/crates/eval/src/examples/planets.rs @@ -1,7 +1,6 @@ +use agent::{AgentTool, OpenTool, TerminalTool}; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tool::Tool; -use assistant_tools::{OpenTool, TerminalTool}; use async_trait::async_trait; use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion}; @@ -24,23 +23,22 @@ impl Example for Planets { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - cx.push_user_message( - r#" + let response = cx + .prompt( + r#" Make a plain JavaScript web page which renders an animated 3D solar system. Let me drag to rotate the camera around. Do not use npm. - "# - .to_string(), - ); - - let response = cx.run_to_end().await?; + "#, + ) + .await?; let mut open_tool_uses = 0; let mut terminal_tool_uses = 0; - for tool_use in response.tool_uses() { - if tool_use.name == OpenTool.name() { + for tool_use in response.tool_calls() { + if tool_use.name == OpenTool::name() { open_tool_uses += 1; - } else if tool_use.name == TerminalTool::NAME { + } else if tool_use.name == TerminalTool::name() { terminal_tool_uses += 1; } } diff --git a/crates/eval/src/examples/threads/overwrite-file.json b/crates/eval/src/examples/threads/overwrite-file.json index ffef258193..392ccde5b8 100644 --- a/crates/eval/src/examples/threads/overwrite-file.json +++ b/crates/eval/src/examples/threads/overwrite-file.json @@ -116,7 +116,7 @@ ], "tool_results": [ { - "content": "[package]\nname = \"language_model_selector\"\nversion = \"0.1.0\"\nedition.workspace = true\npublish.workspace = true\nlicense = \"GPL-3.0-or-later\"\n\n[lints]\nworkspace = true\n\n[lib]\npath = \"src/language_model_selector.rs\"\n\n[dependencies]\ncollections.workspace = true\nfeature_flags.workspace = true\nfuzzy.workspace = true\ngpui.workspace = true\nlanguage_model.workspace = true\nlog.workspace = true\npicker.workspace = true\nproto.workspace = true\nui.workspace = true\nworkspace-hack.workspace = true\nzed_actions.workspace = true\n", + "content": "[package]\nname = \"language_model_selector\"\nversion = \"0.1.0\"\nedition.workspace = true\npublish.workspace = true\nlicense = \"GPL-3.0-or-later\"\n\n[lints]\nworkspace = true\n\n[lib]\npath = \"src/language_model_selector.rs\"\n\n[dependencies]\ncollections.workspace = true\nfeature_flags.workspace = true\nfuzzy.workspace = true\ngpui.workspace = true\nlanguage_model.workspace = true\nlog.workspace = true\npicker.workspace = true\nproto.workspace = true\nui.workspace = true\n\nzed_actions.workspace = true\n", "is_error": false, "output": null, "tool_use_id": "toolu_019Je2MLfJhpJr93g5igoRAH" diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 208147e2f0..4c71a5a82b 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -1,37 +1,38 @@ -use agent::{Message, MessageSegment, SerializedThread, ThreadStore}; +use agent::ContextServerRegistry; +use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow, bail}; -use assistant_tool::ToolWorkingSet; use client::proto::LspWorkProgress; use futures::channel::mpsc; +use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _, future}; use gpui::{App, AppContext as _, AsyncApp, Entity, Task}; use handlebars::Handlebars; use language::{Buffer, DiagnosticSeverity, OffsetRangeExt as _}; use language_model::{ - LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelToolResultContent, MessageContent, Role, TokenUsage, + LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelToolResultContent, MessageContent, Role, TokenUsage, }; -use project::lsp_store::OpenLspBufferHandle; -use project::{DiagnosticSummary, Project, ProjectPath}; +use project::{DiagnosticSummary, Project, ProjectPath, lsp_store::OpenLspBufferHandle}; +use prompt_store::{ProjectContext, WorktreeContext}; +use rand::{distr, prelude::*}; use serde::{Deserialize, Serialize}; -use std::cell::RefCell; -use std::fmt::Write as _; -use std::fs; -use std::fs::File; -use std::io::Write as _; -use std::path::Path; -use std::path::PathBuf; -use std::rc::Rc; -use std::sync::Arc; -use std::time::Duration; +use std::{ + fmt::Write as _, + fs::{self, File}, + io::Write as _, + path::{Path, PathBuf}, + rc::Rc, + sync::{Arc, Mutex}, + time::Duration, +}; use unindent::Unindent as _; -use util::ResultExt as _; -use util::command::new_smol_command; -use util::markdown::MarkdownCodeBlock; +use util::{ResultExt as _, command::new_smol_command, markdown::MarkdownCodeBlock}; -use crate::assertions::{AssertionsReport, RanAssertion, RanAssertionResult}; -use crate::example::{Example, ExampleContext, FailedAssertion, JudgeAssertion}; -use crate::{AgentAppState, ToolMetrics}; +use crate::{ + AgentAppState, ToolMetrics, + assertions::{AssertionsReport, RanAssertion, RanAssertionResult}, + example::{Example, ExampleContext, FailedAssertion, JudgeAssertion}, +}; pub const ZED_REPO_URL: &str = "https://github.com/zed-industries/zed.git"; @@ -57,10 +58,9 @@ pub struct RunOutput { pub diagnostic_summary_after: DiagnosticSummary, pub diagnostics_before: Option, pub diagnostics_after: Option, - pub response_count: usize, pub token_usage: TokenUsage, pub tool_metrics: ToolMetrics, - pub all_messages: String, + pub thread_markdown: String, pub programmatic_assertions: AssertionsReport, } @@ -194,12 +194,7 @@ impl ExampleInstance { .join(self.thread.meta().repo_name()) } - pub fn run( - &self, - model: Arc, - app_state: Arc, - cx: &mut App, - ) -> Task> { + pub fn run(&self, app_state: Arc, cx: &mut App) -> Task> { let project = Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -214,15 +209,6 @@ impl ExampleInstance { project.create_worktree(self.worktree_path(), true, cx) }); - let tools = cx.new(|_| ToolWorkingSet::default()); - let prompt_store = None; - let thread_store = ThreadStore::load( - project.clone(), - tools, - prompt_store, - app_state.prompt_builder.clone(), - cx, - ); let meta = self.thread.meta(); let this = self.clone(); @@ -301,74 +287,61 @@ impl ExampleInstance { // history using undo/redo. std::fs::write(&last_diff_file_path, "")?; - let thread_store = thread_store.await?; + let thread = cx.update(|cx| { + //todo: Do we want to load rules files here? + let worktrees = project.read(cx).visible_worktrees(cx).map(|worktree| { + let root_name = worktree.read(cx).root_name_str().into(); + let abs_path = worktree.read(cx).abs_path(); - - let thread = - thread_store.update(cx, |thread_store, cx| { - let thread = if let Some(json) = &meta.existing_thread_json { - let serialized = SerializedThread::from_json(json.as_bytes()).expect("Can't read serialized thread"); - thread_store.create_thread_from_serialized(serialized, cx) - } else { - thread_store.create_thread(cx) - }; - thread.update(cx, |thread, cx| { - thread.set_profile(meta.profile_id.clone(), cx); - }); - thread - })?; - - - thread.update(cx, |thread, _cx| { - let mut request_count = 0; - let previous_diff = Rc::new(RefCell::new("".to_string())); - let example_output_dir = this.run_directory.clone(); - let last_diff_file_path = last_diff_file_path.clone(); - let messages_json_file_path = example_output_dir.join("last.messages.json"); - let this = this.clone(); - thread.set_request_callback(move |request, response_events| { - request_count += 1; - let messages_file_path = example_output_dir.join(format!("{request_count}.messages.md")); - let diff_file_path = example_output_dir.join(format!("{request_count}.diff")); - let last_messages_file_path = example_output_dir.join("last.messages.md"); - let request_markdown = RequestMarkdown::new(request); - let response_events_markdown = response_events_to_markdown(response_events); - let dialog = ThreadDialog::new(request, response_events); - let dialog_json = serde_json::to_string_pretty(&dialog.to_combined_request()).unwrap_or_default(); - - let messages = format!("{}\n\n{}", request_markdown.messages, response_events_markdown); - fs::write(&messages_file_path, messages.clone()).expect("failed to write messages file"); - fs::write(&last_messages_file_path, messages).expect("failed to write last messages file"); - fs::write(&messages_json_file_path, dialog_json).expect("failed to write last.messages.json"); - - let diff_result = smol::block_on(this.repository_diff()); - match diff_result { - Ok(diff) => { - if diff != previous_diff.borrow().clone() { - fs::write(&diff_file_path, &diff).expect("failed to write diff file"); - fs::write(&last_diff_file_path, &diff).expect("failed to write last diff file"); - *previous_diff.borrow_mut() = diff; - } - } - Err(err) => { - let error_message = format!("{err:?}"); - fs::write(&diff_file_path, &error_message).expect("failed to write diff error to file"); - fs::write(&last_diff_file_path, &error_message).expect("failed to write last diff file"); - } + WorktreeContext { + root_name, + abs_path, + rules_file: None, } + }).collect::>(); + let project_context = cx.new(|_cx| ProjectContext::new(worktrees, vec![])); + let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - if request_count == 1 { - let tools_file_path = example_output_dir.join("tools.md"); - fs::write(tools_file_path, request_markdown.tools).expect("failed to write tools file"); - } + let thread = if let Some(json) = &meta.existing_thread_json { + let session_id = acp::SessionId::new( + rand::rng() + .sample_iter(&distr::Alphanumeric) + .take(7) + .map(char::from) + .collect::(), + ); + + let db_thread = agent::DbThread::from_json(json.as_bytes()).expect("Can't read serialized thread"); + cx.new(|cx| agent::Thread::from_db(session_id, db_thread, project.clone(), project_context, context_server_registry, agent::Templates::new(), cx)) + } else { + cx.new(|cx| agent::Thread::new(project.clone(), project_context, context_server_registry, agent::Templates::new(), None, cx)) + }; + + thread.update(cx, |thread, cx| { + thread.add_default_tools(Rc::new(EvalThreadEnvironment { + project: project.clone(), + }), cx); + thread.set_profile(meta.profile_id.clone(), cx); + thread.set_model( + LanguageModelInterceptor::new( + LanguageModelRegistry::read_global(cx).default_model().expect("Missing model").model.clone(), + this.run_directory.clone(), + last_diff_file_path.clone(), + this.run_directory.join("last.messages.json"), + this.worktree_path(), + this.repo_url(), + ), + cx, + ); }); - })?; + + thread + }).unwrap(); let mut example_cx = ExampleContext::new( meta.clone(), this.log_prefix.clone(), thread.clone(), - model.clone(), cx.clone(), ); let result = this.thread.conversation(&mut example_cx).await; @@ -381,7 +354,7 @@ impl ExampleInstance { println!("{}Stopped", this.log_prefix); println!("{}Getting repository diff", this.log_prefix); - let repository_diff = this.repository_diff().await?; + let repository_diff = Self::repository_diff(this.worktree_path(), &this.repo_url()).await?; std::fs::write(last_diff_file_path, &repository_diff)?; @@ -416,34 +389,28 @@ impl ExampleInstance { } thread.update(cx, |thread, _cx| { - let response_count = thread - .messages() - .filter(|message| message.role == language_model::Role::Assistant) - .count(); RunOutput { repository_diff, diagnostic_summary_before, diagnostic_summary_after, diagnostics_before, diagnostics_after, - response_count, - token_usage: thread.cumulative_token_usage(), + token_usage: thread.latest_request_token_usage().unwrap(), tool_metrics: example_cx.tool_metrics.lock().unwrap().clone(), - all_messages: messages_to_markdown(thread.messages()), + thread_markdown: thread.to_markdown(), programmatic_assertions: example_cx.assertions, } }) }) } - async fn repository_diff(&self) -> Result { - let worktree_path = self.worktree_path(); - run_git(&worktree_path, &["add", "."]).await?; + async fn repository_diff(repository_path: PathBuf, repository_url: &str) -> Result { + run_git(&repository_path, &["add", "."]).await?; let mut diff_args = vec!["diff", "--staged"]; - if self.thread.meta().url == ZED_REPO_URL { + if repository_url == ZED_REPO_URL { diff_args.push(":(exclude).rules"); } - run_git(&worktree_path, &diff_args).await + run_git(&repository_path, &diff_args).await } pub async fn judge( @@ -543,7 +510,7 @@ impl ExampleInstance { hbs.register_template_string(judge_thread_prompt_name, judge_thread_prompt) .unwrap(); - let complete_messages = &run_output.all_messages; + let complete_messages = &run_output.thread_markdown; let to_prompt = |assertion: String| { hbs.render( judge_thread_prompt_name, @@ -585,6 +552,7 @@ impl ExampleInstance { role: Role::User, content: vec![MessageContent::Text(to_prompt(assertion.description))], cache: false, + reasoning_details: None, }], temperature: None, tools: Vec::new(), @@ -635,6 +603,282 @@ impl ExampleInstance { } } +struct EvalThreadEnvironment { + project: Entity, +} + +struct EvalTerminalHandle { + terminal: Entity, +} + +impl agent::TerminalHandle for EvalTerminalHandle { + fn id(&self, cx: &AsyncApp) -> Result { + self.terminal.read_with(cx, |term, _cx| term.id().clone()) + } + + fn wait_for_exit(&self, cx: &AsyncApp) -> Result>> { + self.terminal + .read_with(cx, |term, _cx| term.wait_for_exit()) + } + + fn current_output(&self, cx: &AsyncApp) -> Result { + self.terminal + .read_with(cx, |term, cx| term.current_output(cx)) + } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } +} + +impl agent::ThreadEnvironment for EvalThreadEnvironment { + fn create_terminal( + &self, + command: String, + cwd: Option, + output_byte_limit: Option, + cx: &mut AsyncApp, + ) -> Task>> { + let project = self.project.clone(); + cx.spawn(async move |cx| { + let language_registry = + project.read_with(cx, |project, _cx| project.languages().clone())?; + let id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); + let terminal = + acp_thread::create_terminal_entity(command, &[], vec![], cwd.clone(), &project, cx) + .await?; + let terminal = cx.new(|cx| { + acp_thread::Terminal::new( + id, + "", + cwd, + output_byte_limit.map(|limit| limit as usize), + terminal, + language_registry, + cx, + ) + })?; + Ok(Rc::new(EvalTerminalHandle { terminal }) as Rc) + }) + } +} + +struct LanguageModelInterceptor { + model: Arc, + request_count: Arc>, + previous_diff: Arc>, + example_output_dir: PathBuf, + last_diff_file_path: PathBuf, + messages_json_file_path: PathBuf, + repository_path: PathBuf, + repository_url: String, +} + +impl LanguageModelInterceptor { + fn new( + model: Arc, + example_output_dir: PathBuf, + last_diff_file_path: PathBuf, + messages_json_file_path: PathBuf, + repository_path: PathBuf, + repository_url: String, + ) -> Arc { + Arc::new(Self { + model, + request_count: Arc::new(Mutex::new(0)), + previous_diff: Arc::new(Mutex::new("".to_string())), + example_output_dir, + last_diff_file_path, + messages_json_file_path, + repository_path, + repository_url, + }) + } +} + +impl language_model::LanguageModel for LanguageModelInterceptor { + fn id(&self) -> language_model::LanguageModelId { + self.model.id() + } + + fn name(&self) -> language_model::LanguageModelName { + self.model.name() + } + + fn provider_id(&self) -> language_model::LanguageModelProviderId { + self.model.provider_id() + } + + fn provider_name(&self) -> language_model::LanguageModelProviderName { + self.model.provider_name() + } + + fn telemetry_id(&self) -> String { + self.model.telemetry_id() + } + + fn supports_images(&self) -> bool { + self.model.supports_images() + } + + fn supports_tools(&self) -> bool { + self.model.supports_tools() + } + + fn supports_tool_choice(&self, choice: language_model::LanguageModelToolChoice) -> bool { + self.model.supports_tool_choice(choice) + } + + fn max_token_count(&self) -> u64 { + self.model.max_token_count() + } + + fn count_tokens( + &self, + request: LanguageModelRequest, + cx: &App, + ) -> future::BoxFuture<'static, Result> { + self.model.count_tokens(request, cx) + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> future::BoxFuture< + 'static, + Result< + futures::stream::BoxStream< + 'static, + Result, + >, + language_model::LanguageModelCompletionError, + >, + > { + let stream = self.model.stream_completion(request.clone(), cx); + let request_count = self.request_count.clone(); + let previous_diff = self.previous_diff.clone(); + let example_output_dir = self.example_output_dir.clone(); + let last_diff_file_path = self.last_diff_file_path.clone(); + let messages_json_file_path = self.messages_json_file_path.clone(); + let repository_path = self.repository_path.clone(); + let repository_url = self.repository_url.clone(); + + Box::pin(async move { + let stream = stream.await?; + + let response_events = Arc::new(Mutex::new(Vec::new())); + let request_clone = request.clone(); + + let wrapped_stream = stream.then(move |event| { + let response_events = response_events.clone(); + let request = request_clone.clone(); + let request_count = request_count.clone(); + let previous_diff = previous_diff.clone(); + let example_output_dir = example_output_dir.clone(); + let last_diff_file_path = last_diff_file_path.clone(); + let messages_json_file_path = messages_json_file_path.clone(); + let repository_path = repository_path.clone(); + let repository_url = repository_url.clone(); + + async move { + let event_result = match &event { + Ok(ev) => Ok(ev.clone()), + Err(err) => Err(err.to_string()), + }; + response_events.lock().unwrap().push(event_result); + + let should_execute = matches!( + &event, + Ok(LanguageModelCompletionEvent::Stop { .. }) | Err(_) + ); + + if should_execute { + let current_request_count = { + let mut count = request_count.lock().unwrap(); + *count += 1; + *count + }; + + let messages_file_path = + example_output_dir.join(format!("{current_request_count}.messages.md")); + let diff_file_path = + example_output_dir.join(format!("{current_request_count}.diff")); + let last_messages_file_path = example_output_dir.join("last.messages.md"); + + let collected_events = response_events.lock().unwrap().clone(); + let request_markdown = RequestMarkdown::new(&request); + let response_events_markdown = + response_events_to_markdown(&collected_events); + let dialog = ThreadDialog::new(&request, &collected_events); + let dialog_json = + serde_json::to_string_pretty(&dialog.to_combined_request()) + .unwrap_or_default(); + + let messages = format!( + "{}\n\n{}", + request_markdown.messages, response_events_markdown + ); + fs::write(&messages_file_path, messages.clone()) + .expect("failed to write messages file"); + fs::write(&last_messages_file_path, messages) + .expect("failed to write last messages file"); + fs::write(&messages_json_file_path, dialog_json) + .expect("failed to write last.messages.json"); + + // Get repository diff + let diff_result = + ExampleInstance::repository_diff(repository_path, &repository_url) + .await; + + match diff_result { + Ok(diff) => { + let prev_diff = previous_diff.lock().unwrap().clone(); + if diff != prev_diff { + fs::write(&diff_file_path, &diff) + .expect("failed to write diff file"); + fs::write(&last_diff_file_path, &diff) + .expect("failed to write last diff file"); + *previous_diff.lock().unwrap() = diff; + } + } + Err(err) => { + let error_message = format!("{err:?}"); + fs::write(&diff_file_path, &error_message) + .expect("failed to write diff error to file"); + fs::write(&last_diff_file_path, &error_message) + .expect("failed to write last diff file"); + } + } + + if current_request_count == 1 { + let tools_file_path = example_output_dir.join("tools.md"); + fs::write(tools_file_path, request_markdown.tools) + .expect("failed to write tools file"); + } + } + + event + } + }); + + Ok(Box::pin(wrapped_stream) + as futures::stream::BoxStream< + 'static, + Result< + LanguageModelCompletionEvent, + language_model::LanguageModelCompletionError, + >, + >) + }) + } +} + pub fn wait_for_lang_server( project: &Entity, buffer: &Entity, @@ -657,7 +901,7 @@ pub fn wait_for_lang_server( .update(cx, |buffer, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store - .language_servers_for_local_buffer(buffer, cx) + .running_language_servers_for_local_buffer(buffer, cx) .next() .is_some() }) @@ -826,40 +1070,6 @@ pub async fn run_git(repo_path: &Path, args: &[&str]) -> Result { Ok(String::from_utf8(output.stdout)?.trim().to_string()) } -fn messages_to_markdown<'a>(message_iter: impl IntoIterator) -> String { - let mut messages = String::new(); - let mut assistant_message_number: u32 = 1; - - for message in message_iter { - push_role(&message.role, &mut messages, &mut assistant_message_number); - - for segment in &message.segments { - match segment { - MessageSegment::Text(text) => { - messages.push_str(text); - messages.push_str("\n\n"); - } - MessageSegment::Thinking { text, signature } => { - messages.push_str("**Thinking**:\n\n"); - if let Some(sig) = signature { - messages.push_str(&format!("Signature: {}\n\n", sig)); - } - messages.push_str(text); - messages.push_str("\n"); - } - MessageSegment::RedactedThinking(items) => { - messages.push_str(&format!( - "**Redacted Thinking**: {} item(s)\n\n", - items.len() - )); - } - } - } - } - - messages -} - fn push_role(role: &Role, buf: &mut String, assistant_message_number: &mut u32) { match role { Role::System => buf.push_str("# ⚙️ SYSTEM\n\n"), @@ -1050,8 +1260,12 @@ pub fn response_events_to_markdown( } Ok( LanguageModelCompletionEvent::UsageUpdate(_) + | LanguageModelCompletionEvent::ToolUseLimitReached | LanguageModelCompletionEvent::StartMessage { .. } - | LanguageModelCompletionEvent::StatusUpdate { .. }, + | LanguageModelCompletionEvent::UsageUpdated { .. } + | LanguageModelCompletionEvent::Queued { .. } + | LanguageModelCompletionEvent::Started + | LanguageModelCompletionEvent::ReasoningDetails(_), ) => {} Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { json_parse_error, .. @@ -1136,9 +1350,13 @@ impl ThreadDialog { // Skip these Ok(LanguageModelCompletionEvent::UsageUpdate(_)) | Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) - | Ok(LanguageModelCompletionEvent::StatusUpdate { .. }) | Ok(LanguageModelCompletionEvent::StartMessage { .. }) - | Ok(LanguageModelCompletionEvent::Stop(_)) => {} + | Ok(LanguageModelCompletionEvent::ReasoningDetails(_)) + | Ok(LanguageModelCompletionEvent::Stop(_)) + | Ok(LanguageModelCompletionEvent::Queued { .. }) + | Ok(LanguageModelCompletionEvent::Started) + | Ok(LanguageModelCompletionEvent::UsageUpdated { .. }) + | Ok(LanguageModelCompletionEvent::ToolUseLimitReached) => {} Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { json_parse_error, @@ -1165,6 +1383,7 @@ impl ThreadDialog { role: Role::Assistant, content, cache: false, + reasoning_details: None, }) } else { None diff --git a/crates/eval_utils/Cargo.toml b/crates/eval_utils/Cargo.toml new file mode 100644 index 0000000000..a512035f5d --- /dev/null +++ b/crates/eval_utils/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "eval_utils" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/eval_utils.rs" +doctest = false + +[dependencies] +gpui.workspace = true +serde.workspace = true +smol.workspace = true diff --git a/crates/eval_utils/LICENSE-GPL b/crates/eval_utils/LICENSE-GPL new file mode 120000 index 0000000000..e0f9dbd5d6 --- /dev/null +++ b/crates/eval_utils/LICENSE-GPL @@ -0,0 +1 @@ +LICENSE-GPL \ No newline at end of file diff --git a/crates/eval_utils/README.md b/crates/eval_utils/README.md new file mode 100644 index 0000000000..617077a815 --- /dev/null +++ b/crates/eval_utils/README.md @@ -0,0 +1,3 @@ +# eval_utils + +Utilities for evals of agents. diff --git a/crates/eval_utils/src/eval_utils.rs b/crates/eval_utils/src/eval_utils.rs new file mode 100644 index 0000000000..be3294ed14 --- /dev/null +++ b/crates/eval_utils/src/eval_utils.rs @@ -0,0 +1,146 @@ +//! Utilities for evaluation and benchmarking. + +use std::{ + collections::HashMap, + sync::{Arc, mpsc}, +}; + +fn report_progress(evaluated_count: usize, failed_count: usize, iterations: usize) { + let passed_count = evaluated_count - failed_count; + let passed_ratio = if evaluated_count == 0 { + 0.0 + } else { + passed_count as f64 / evaluated_count as f64 + }; + println!( + "\r\x1b[KEvaluated {}/{} ({:.2}% passed)", + evaluated_count, + iterations, + passed_ratio * 100.0 + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OutcomeKind { + Passed, + Failed, + Error, +} + +pub trait EvalOutputProcessor { + type Metadata: 'static + Send; + fn process(&mut self, output: &EvalOutput); + fn assert(&mut self); +} + +#[derive(Clone, Debug)] +pub struct EvalOutput { + pub outcome: OutcomeKind, + pub data: String, + pub metadata: M, +} + +impl EvalOutput { + pub fn passed(message: impl Into) -> Self { + EvalOutput { + outcome: OutcomeKind::Passed, + data: message.into(), + metadata: M::default(), + } + } + + pub fn failed(message: impl Into) -> Self { + EvalOutput { + outcome: OutcomeKind::Failed, + data: message.into(), + metadata: M::default(), + } + } +} + +pub struct NoProcessor; +impl EvalOutputProcessor for NoProcessor { + type Metadata = (); + + fn process(&mut self, _output: &EvalOutput) {} + + fn assert(&mut self) {} +} + +pub fn eval

( + iterations: usize, + expected_pass_ratio: f32, + mut processor: P, + evalf: impl Fn() -> EvalOutput + Send + Sync + 'static, +) where + P: EvalOutputProcessor, +{ + let mut evaluated_count = 0; + let mut failed_count = 0; + let evalf = Arc::new(evalf); + report_progress(evaluated_count, failed_count, iterations); + + let (tx, rx) = mpsc::channel(); + + let executor = gpui::background_executor(); + let semaphore = Arc::new(smol::lock::Semaphore::new(32)); + let evalf = Arc::new(evalf); + // Warm the cache once + let first_output = evalf(); + tx.send(first_output).ok(); + + for _ in 1..iterations { + let tx = tx.clone(); + let semaphore = semaphore.clone(); + let evalf = evalf.clone(); + executor + .spawn(async move { + let _guard = semaphore.acquire().await; + let output = evalf(); + tx.send(output).ok(); + }) + .detach(); + } + drop(tx); + + let mut failed_evals = Vec::new(); + let mut errored_evals = HashMap::new(); + while let Ok(output) = rx.recv() { + processor.process(&output); + + match output.outcome { + OutcomeKind::Passed => {} + OutcomeKind::Failed => { + failed_count += 1; + failed_evals.push(output); + } + OutcomeKind::Error => { + failed_count += 1; + *errored_evals.entry(output.data).or_insert(0) += 1; + } + } + + evaluated_count += 1; + report_progress(evaluated_count, failed_count, iterations); + } + + let actual_pass_ratio = (iterations - failed_count) as f32 / iterations as f32; + println!("Actual pass ratio: {}\n", actual_pass_ratio); + if actual_pass_ratio < expected_pass_ratio { + for (error, count) in errored_evals { + println!("Eval errored {} times. Error: {}", count, error); + } + + for failed in failed_evals { + println!("Eval failed"); + println!("{}", failed.data); + } + + panic!( + "Actual pass ratio: {}\nExpected pass ratio: {}", + actual_pass_ratio, expected_pass_ratio + ); + } + + processor.assert(); +} diff --git a/crates/explorer_command_injector/Cargo.toml b/crates/explorer_command_injector/Cargo.toml index e929ba6fc8..8530329358 100644 --- a/crates/explorer_command_injector/Cargo.toml +++ b/crates/explorer_command_injector/Cargo.toml @@ -25,4 +25,3 @@ windows-core.workspace = true windows-registry = "0.5" [dependencies] -workspace-hack.workspace = true diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index 42189f20b3..307a3a19bd 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -13,8 +13,6 @@ path = "src/extension.rs" [dependencies] anyhow.workspace = true -async-compression.workspace = true -async-tar.workspace = true async-trait.workspace = true collections.workspace = true dap.workspace = true @@ -27,7 +25,8 @@ language.workspace = true log.workspace = true lsp.workspace = true parking_lot.workspace = true -semantic_version.workspace = true +proto.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true task.workspace = true @@ -36,7 +35,10 @@ url.workspace = true util.workspace = true wasm-encoder.workspace = true wasmparser.workspace = true -workspace-hack.workspace = true [dev-dependencies] +fs = { workspace = true, "features" = ["test-support"] } +gpui = { workspace = true, "features" = ["test-support"] } +indoc.workspace = true pretty_assertions.workspace = true +tempfile.workspace = true diff --git a/crates/extension/src/extension.rs b/crates/extension/src/extension.rs index bd2b37c337..88f2bea0c0 100644 --- a/crates/extension/src/extension.rs +++ b/crates/extension/src/extension.rs @@ -14,7 +14,7 @@ use async_trait::async_trait; use fs::normalize_path; use gpui::{App, Task}; use language::LanguageName; -use semantic_version::SemanticVersion; +use semver::Version; use task::{SpawnInTerminal, ZedDebugConfig}; use util::rel_path::RelPath; @@ -170,10 +170,7 @@ pub trait Extension: Send + Sync + 'static { ) -> Result; } -pub fn parse_wasm_extension_version( - extension_id: &str, - wasm_bytes: &[u8], -) -> Result { +pub fn parse_wasm_extension_version(extension_id: &str, wasm_bytes: &[u8]) -> Result { let mut version = None; for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) { @@ -200,9 +197,9 @@ pub fn parse_wasm_extension_version( version.with_context(|| format!("extension {extension_id} has no zed:api-version section")) } -fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option { +fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option { if data.len() == 6 { - Some(SemanticVersion::new( + Some(Version::new( u16::from_be_bytes([data[0], data[1]]) as _, u16::from_be_bytes([data[2], data[3]]) as _, u16::from_be_bytes([data[4], data[5]]) as _, diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 15ff230ec7..8b9bf994d1 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -2,10 +2,9 @@ use crate::{ ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, build_debug_adapter_schema_path, parse_wasm_extension_version, }; +use ::fs::Fs; use anyhow::{Context as _, Result, bail}; -use async_compression::futures::bufread::GzipDecoder; -use async_tar::Archive; -use futures::io::BufReader; +use futures::{AsyncReadExt, StreamExt}; use heck::ToSnakeCase; use http_client::{self, AsyncBody, HttpClient}; use serde::Deserialize; @@ -79,8 +78,9 @@ impl ExtensionBuilder { extension_dir: &Path, extension_manifest: &mut ExtensionManifest, options: CompileExtensionOptions, + fs: Arc, ) -> Result<()> { - populate_defaults(extension_manifest, extension_dir)?; + populate_defaults(extension_manifest, extension_dir, fs).await?; if extension_dir.is_relative() { bail!( @@ -249,26 +249,34 @@ impl ExtensionBuilder { let parser_path = src_path.join("parser.c"); let scanner_path = src_path.join("scanner.c"); - log::info!("compiling {grammar_name} parser"); - let clang_output = util::command::new_smol_command(&clang_path) - .args(["-fPIC", "-shared", "-Os"]) - .arg(format!("-Wl,--export=tree_sitter_{grammar_name}")) - .arg("-o") - .arg(&grammar_wasm_path) - .arg("-I") - .arg(&src_path) - .arg(&parser_path) - .args(scanner_path.exists().then_some(scanner_path)) - .output() - .await - .context("failed to run clang")?; - - if !clang_output.status.success() { - bail!( - "failed to compile {} parser with clang: {}", - grammar_name, - String::from_utf8_lossy(&clang_output.stderr), + // Skip recompiling if the WASM object is already newer than the source files + if file_newer_than_deps(&grammar_wasm_path, &[&parser_path, &scanner_path]).unwrap_or(false) + { + log::info!( + "skipping compilation of {grammar_name} parser because the existing compiled grammar is up to date" ); + } else { + log::info!("compiling {grammar_name} parser"); + let clang_output = util::command::new_smol_command(&clang_path) + .args(["-fPIC", "-shared", "-Os"]) + .arg(format!("-Wl,--export=tree_sitter_{grammar_name}")) + .arg("-o") + .arg(&grammar_wasm_path) + .arg("-I") + .arg(&src_path) + .arg(&parser_path) + .args(scanner_path.exists().then_some(scanner_path)) + .output() + .await + .context("failed to run clang")?; + + if !clang_output.status.success() { + bail!( + "failed to compile {} parser with clang: {}", + grammar_name, + String::from_utf8_lossy(&clang_output.stderr), + ); + } } Ok(()) @@ -411,25 +419,53 @@ impl ExtensionBuilder { let mut clang_path = wasi_sdk_dir.clone(); clang_path.extend(["bin", &format!("clang{}", env::consts::EXE_SUFFIX)]); + log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display()); + if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) { return Ok(clang_path); } - let mut tar_out_dir = wasi_sdk_dir.clone(); - tar_out_dir.set_extension("archive"); + let tar_out_dir = self.cache_dir.join("wasi-sdk-temp"); fs::remove_dir_all(&wasi_sdk_dir).ok(); fs::remove_dir_all(&tar_out_dir).ok(); + fs::create_dir_all(&tar_out_dir).context("failed to create extraction directory")?; - log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display()); let mut response = self.http.get(&url, AsyncBody::default(), true).await?; - let body = BufReader::new(response.body_mut()); - let body = GzipDecoder::new(body); - let tar = Archive::new(body); - tar.unpack(&tar_out_dir) + // Write the response to a temporary file + let tar_gz_path = self.cache_dir.join("wasi-sdk.tar.gz"); + let mut tar_gz_file = + fs::File::create(&tar_gz_path).context("failed to create temporary tar.gz file")?; + let response_body = response.body_mut(); + let mut body_bytes = Vec::new(); + response_body.read_to_end(&mut body_bytes).await?; + std::io::Write::write_all(&mut tar_gz_file, &body_bytes)?; + drop(tar_gz_file); + + log::info!("un-tarring wasi-sdk to {}", tar_out_dir.display()); + + // Shell out to tar to extract the archive + let tar_output = util::command::new_smol_command("tar") + .arg("-xzf") + .arg(&tar_gz_path) + .arg("-C") + .arg(&tar_out_dir) + .output() .await - .context("failed to unpack wasi-sdk archive")?; + .context("failed to run tar")?; + + if !tar_output.status.success() { + bail!( + "failed to extract wasi-sdk archive: {}", + String::from_utf8_lossy(&tar_output.stderr) + ); + } + + log::info!("finished downloading wasi-sdk"); + + // Clean up the temporary tar.gz file + fs::remove_file(&tar_gz_path).ok(); let inner_dir = fs::read_dir(&tar_out_dir)? .next() @@ -512,7 +548,11 @@ impl ExtensionBuilder { } } -fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> Result<()> { +async fn populate_defaults( + manifest: &mut ExtensionManifest, + extension_path: &Path, + fs: Arc, +) -> Result<()> { // For legacy extensions on the v0 schema (aka, using `extension.json`), clear out any existing // contents of the computed fields, since we don't care what the existing values are. if manifest.schema_version.is_v0() { @@ -527,12 +567,16 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> } let languages_dir = extension_path.join("languages"); - if languages_dir.exists() { - for entry in fs::read_dir(&languages_dir).context("failed to list languages dir")? { - let entry = entry?; - let language_dir = entry.path(); + if fs.is_dir(&languages_dir).await { + let mut language_dir_entries = fs + .read_dir(&languages_dir) + .await + .context("failed to list languages dir")?; + + while let Some(language_dir) = language_dir_entries.next().await { + let language_dir = language_dir?; let config_path = language_dir.join("config.toml"); - if config_path.exists() { + if fs.is_file(config_path.as_path()).await { let relative_language_dir = language_dir.strip_prefix(extension_path)?.to_path_buf(); if !manifest.languages.contains(&relative_language_dir) { @@ -543,10 +587,14 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> } let themes_dir = extension_path.join("themes"); - if themes_dir.exists() { - for entry in fs::read_dir(&themes_dir).context("failed to list themes dir")? { - let entry = entry?; - let theme_path = entry.path(); + if fs.is_dir(&themes_dir).await { + let mut theme_dir_entries = fs + .read_dir(&themes_dir) + .await + .context("failed to list themes dir")?; + + while let Some(theme_path) = theme_dir_entries.next().await { + let theme_path = theme_path?; if theme_path.extension() == Some("json".as_ref()) { let relative_theme_path = theme_path.strip_prefix(extension_path)?.to_path_buf(); if !manifest.themes.contains(&relative_theme_path) { @@ -557,10 +605,14 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> } let icon_themes_dir = extension_path.join("icon_themes"); - if icon_themes_dir.exists() { - for entry in fs::read_dir(&icon_themes_dir).context("failed to list icon themes dir")? { - let entry = entry?; - let icon_theme_path = entry.path(); + if fs.is_dir(&icon_themes_dir).await { + let mut icon_theme_dir_entries = fs + .read_dir(&icon_themes_dir) + .await + .context("failed to list icon themes dir")?; + + while let Some(icon_theme_path) = icon_theme_dir_entries.next().await { + let icon_theme_path = icon_theme_path?; if icon_theme_path.extension() == Some("json".as_ref()) { let relative_icon_theme_path = icon_theme_path.strip_prefix(extension_path)?.to_path_buf(); @@ -569,21 +621,26 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> } } } - } - - let snippets_json_path = extension_path.join("snippets.json"); - if snippets_json_path.exists() { - manifest.snippets = Some(snippets_json_path); + }; + if manifest.snippets.is_none() + && let snippets_json_path = extension_path.join("snippets.json") + && fs.is_file(&snippets_json_path).await + { + manifest.snippets = Some("snippets.json".into()); } // For legacy extensions on the v0 schema (aka, using `extension.json`), we want to populate the grammars in // the manifest using the contents of the `grammars` directory. if manifest.schema_version.is_v0() { let grammars_dir = extension_path.join("grammars"); - if grammars_dir.exists() { - for entry in fs::read_dir(&grammars_dir).context("failed to list grammars dir")? { - let entry = entry?; - let grammar_path = entry.path(); + if fs.is_dir(&grammars_dir).await { + let mut grammar_dir_entries = fs + .read_dir(&grammars_dir) + .await + .context("failed to list grammars dir")?; + + while let Some(grammar_path) = grammar_dir_entries.next().await { + let grammar_path = grammar_path?; if grammar_path.extension() == Some("toml".as_ref()) { #[derive(Deserialize)] struct GrammarConfigToml { @@ -593,7 +650,7 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> pub path: Option, } - let grammar_config = fs::read_to_string(&grammar_path)?; + let grammar_config = fs.load(&grammar_path).await?; let grammar_config: GrammarConfigToml = toml::from_str(&grammar_config)?; let grammar_name = grammar_path @@ -617,3 +674,153 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> Ok(()) } + +/// Returns `true` if the target exists and its last modified time is greater than that +/// of each dependency which exists (i.e., dependency paths which do not exist are ignored). +/// +/// # Errors +/// +/// Returns `Err` if any of the underlying file I/O operations fail. +fn file_newer_than_deps(target: &Path, dependencies: &[&Path]) -> Result { + if !target.try_exists()? { + return Ok(false); + } + let target_modified = target.metadata()?.modified()?; + for dependency in dependencies { + if !dependency.try_exists()? { + continue; + } + let dep_modified = dependency.metadata()?.modified()?; + if target_modified < dep_modified { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::{ + path::{Path, PathBuf}, + str::FromStr, + thread::sleep, + time::Duration, + }; + + use gpui::TestAppContext; + use indoc::indoc; + + use crate::{ + ExtensionManifest, + extension_builder::{file_newer_than_deps, populate_defaults}, + }; + + #[test] + fn test_file_newer_than_deps() { + // Don't use TempTree because we need to guarantee the order + let tmpdir = tempfile::tempdir().unwrap(); + let target = tmpdir.path().join("target.wasm"); + let dep1 = tmpdir.path().join("parser.c"); + let dep2 = tmpdir.path().join("scanner.c"); + + assert!( + !file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(), + "target doesn't exist" + ); + std::fs::write(&target, "foo").unwrap(); // Create target + assert!( + file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(), + "dependencies don't exist; target is newer" + ); + sleep(Duration::from_secs(1)); + std::fs::write(&dep1, "foo").unwrap(); // Create dep1 (newer than target) + // Dependency is newer + assert!( + !file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(), + "a dependency is newer (target {:?}, dep1 {:?})", + target.metadata().unwrap().modified().unwrap(), + dep1.metadata().unwrap().modified().unwrap(), + ); + sleep(Duration::from_secs(1)); + std::fs::write(&dep2, "foo").unwrap(); // Create dep2 + sleep(Duration::from_secs(1)); + std::fs::write(&target, "foobar").unwrap(); // Update target + assert!( + file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(), + "target is newer than dependencies (target {:?}, dep2 {:?})", + target.metadata().unwrap().modified().unwrap(), + dep2.metadata().unwrap().modified().unwrap(), + ); + } + + #[gpui::test] + async fn test_snippet_location_is_kept(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let extension_path = Path::new("/extension"); + + fs.insert_tree( + extension_path, + serde_json::json!({ + "extension.toml": indoc! {r#" + id = "test-manifest" + name = "Test Manifest" + version = "0.0.1" + schema_version = 1 + + snippets = "./snippets/snippets.json" + "# + }, + "snippets.json": "", + }), + ) + .await; + + let mut manifest = ExtensionManifest::load(fs.clone(), extension_path) + .await + .unwrap(); + + populate_defaults(&mut manifest, extension_path, fs.clone()) + .await + .unwrap(); + + assert_eq!( + manifest.snippets, + Some(PathBuf::from_str("./snippets/snippets.json").unwrap()) + ) + } + + #[gpui::test] + async fn test_automatic_snippet_location_is_relative(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let extension_path = Path::new("/extension"); + + fs.insert_tree( + extension_path, + serde_json::json!({ + "extension.toml": indoc! {r#" + id = "test-manifest" + name = "Test Manifest" + version = "0.0.1" + schema_version = 1 + + "# + }, + "snippets.json": "", + }), + ) + .await; + + let mut manifest = ExtensionManifest::load(fs.clone(), extension_path) + .await + .unwrap(); + + populate_defaults(&mut manifest, extension_path, fs.clone()) + .await + .unwrap(); + + assert_eq!( + manifest.snippets, + Some(PathBuf::from_str("snippets.json").unwrap()) + ) + } +} diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index f5296198b0..4ecdd378ca 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -1,9 +1,9 @@ -use anyhow::{Context as _, Result, bail}; +use anyhow::{Context as _, Result, anyhow, bail}; use collections::{BTreeMap, HashMap}; use fs::Fs; use language::LanguageName; use lsp::LanguageServerName; -use semantic_version::SemanticVersion; +use semver::Version; use serde::{Deserialize, Serialize}; use std::{ ffi::OsStr, @@ -82,6 +82,8 @@ pub struct ExtensionManifest { #[serde(default)] pub context_servers: BTreeMap, ContextServerManifestEntry>, #[serde(default)] + pub agent_servers: BTreeMap, AgentServerManifestEntry>, + #[serde(default)] pub slash_commands: BTreeMap, SlashCommandManifestEntry>, #[serde(default)] pub snippets: Option, @@ -135,7 +137,92 @@ pub fn build_debug_adapter_schema_path( #[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)] pub struct LibManifestEntry { pub kind: Option, - pub version: Option, + pub version: Option, +} + +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct AgentServerManifestEntry { + /// Display name for the agent (shown in menus). + pub name: String, + /// Environment variables to set when launching the agent server. + #[serde(default)] + pub env: HashMap, + /// Optional icon path (relative to extension root, e.g., "ai.svg"). + /// Should be a small SVG icon for display in menus. + #[serde(default)] + pub icon: Option, + /// Per-target configuration for archive-based installation. + /// The key format is "{os}-{arch}" where: + /// - os: "darwin" (macOS), "linux", "windows" + /// - arch: "aarch64" (arm64), "x86_64" + /// + /// Example: + /// ```toml + /// [agent_servers.myagent.targets.darwin-aarch64] + /// archive = "https://example.com/myagent-darwin-arm64.zip" + /// cmd = "./myagent" + /// args = ["--serve"] + /// sha256 = "abc123..." # optional + /// ``` + /// + /// For Node.js-based agents, you can use "node" as the cmd to automatically + /// use Zed's managed Node.js runtime instead of relying on the user's PATH: + /// ```toml + /// [agent_servers.nodeagent.targets.darwin-aarch64] + /// archive = "https://example.com/nodeagent.zip" + /// cmd = "node" + /// args = ["index.js", "--port", "3000"] + /// ``` + /// + /// Note: All commands are executed with the archive extraction directory as the + /// working directory, so relative paths in args (like "index.js") will resolve + /// relative to the extracted archive contents. + pub targets: HashMap, +} + +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct TargetConfig { + /// URL to download the archive from (e.g., "https://github.com/owner/repo/releases/download/v1.0.0/myagent-darwin-arm64.zip") + pub archive: String, + /// Command to run (e.g., "./myagent" or "./myagent.exe") + pub cmd: String, + /// Command-line arguments to pass to the agent server. + #[serde(default)] + pub args: Vec, + /// Optional SHA-256 hash of the archive for verification. + /// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub. + #[serde(default)] + pub sha256: Option, + /// Environment variables to set when launching the agent server. + /// These target-specific env vars will override any env vars set at the agent level. + #[serde(default)] + pub env: HashMap, +} + +impl TargetConfig { + pub fn from_proto(proto: proto::ExternalExtensionAgentTarget) -> Self { + Self { + archive: proto.archive, + cmd: proto.cmd, + args: proto.args, + sha256: proto.sha256, + env: proto.env.into_iter().collect(), + } + } + + pub fn to_proto(&self) -> proto::ExternalExtensionAgentTarget { + proto::ExternalExtensionAgentTarget { + archive: self.archive.clone(), + cmd: self.cmd.clone(), + args: self.args.clone(), + sha256: self.sha256.clone(), + env: self + .env + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + } + } } #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] @@ -208,26 +295,26 @@ impl ExtensionManifest { .and_then(OsStr::to_str) .context("invalid extension name")?; - let mut extension_manifest_path = extension_dir.join("extension.json"); + let extension_manifest_path = extension_dir.join("extension.toml"); if fs.is_file(&extension_manifest_path).await { - let manifest_content = fs - .load(&extension_manifest_path) - .await - .with_context(|| format!("failed to load {extension_name} extension.json"))?; - let manifest_json = serde_json::from_str::(&manifest_content) - .with_context(|| { - format!("invalid extension.json for extension {extension_name}") - })?; + let manifest_content = fs.load(&extension_manifest_path).await.with_context(|| { + format!("loading {extension_name} extension.toml, {extension_manifest_path:?}") + })?; + toml::from_str(&manifest_content).map_err(|err| { + anyhow!("Invalid extension.toml for extension {extension_name}:\n{err}") + }) + } else if let extension_manifest_path = extension_manifest_path.with_extension("json") + && fs.is_file(&extension_manifest_path).await + { + let manifest_content = fs.load(&extension_manifest_path).await.with_context(|| { + format!("loading {extension_name} extension.json, {extension_manifest_path:?}") + })?; - Ok(manifest_from_old_manifest(manifest_json, extension_name)) + serde_json::from_str::(&manifest_content) + .with_context(|| format!("invalid extension.json for extension {extension_name}")) + .map(|manifest_json| manifest_from_old_manifest(manifest_json, extension_name)) } else { - extension_manifest_path.set_extension("toml"); - let manifest_content = fs - .load(&extension_manifest_path) - .await - .with_context(|| format!("failed to load {extension_name} extension.toml"))?; - toml::from_str(&manifest_content) - .with_context(|| format!("invalid extension.toml for extension {extension_name}")) + anyhow::bail!("No extension manifest found for extension {extension_name}") } } } @@ -265,6 +352,7 @@ fn manifest_from_old_manifest( .collect(), language_servers: Default::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -297,6 +385,7 @@ mod tests { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![], @@ -403,4 +492,31 @@ mod tests { ); assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg } + #[test] + fn parse_manifest_with_agent_server_archive_launcher() { + let toml_src = r#" +id = "example.agent-server-ext" +name = "Agent Server Example" +version = "1.0.0" +schema_version = 0 + +[agent_servers.foo] +name = "Foo Agent" + +[agent_servers.foo.targets.linux-x86_64] +archive = "https://example.com/agent-linux-x64.tar.gz" +cmd = "./agent" +args = ["--serve"] +"#; + + let manifest: ExtensionManifest = toml::from_str(toml_src).expect("manifest should parse"); + assert_eq!(manifest.id.as_ref(), "example.agent-server-ext"); + assert!(manifest.agent_servers.contains_key("foo")); + let entry = manifest.agent_servers.get("foo").unwrap(); + assert!(entry.targets.contains_key("linux-x86_64")); + let target = entry.targets.get("linux-x86_64").unwrap(); + assert_eq!(target.archive, "https://example.com/agent-linux-x64.tar.gz"); + assert_eq!(target.cmd, "./agent"); + assert_eq!(target.args, vec!["--serve"]); + } } diff --git a/crates/extension_api/Cargo.toml b/crates/extension_api/Cargo.toml index 318a0024bf..829455e629 100644 --- a/crates/extension_api/Cargo.toml +++ b/crates/extension_api/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "zed_extension_api" -version = "0.7.0" +version = "0.8.0" description = "APIs for creating Zed extensions in Rust" repository = "https://github.com/zed-industries/zed" documentation = "https://docs.rs/zed_extension_api" keywords = ["zed", "extension"] edition.workspace = true -publish = true +# Change back to `true` when we're ready to publish v0.8.0. +publish = false license = "Apache-2.0" [lints] diff --git a/crates/extension_api/src/extension_api.rs b/crates/extension_api/src/extension_api.rs index 723e544209..9418623224 100644 --- a/crates/extension_api/src/extension_api.rs +++ b/crates/extension_api/src/extension_api.rs @@ -334,7 +334,7 @@ mod wit { wit_bindgen::generate!({ skip: ["init-extension"], - path: "./wit/since_v0.6.0", + path: "./wit/since_v0.8.0", }); } diff --git a/crates/extension_api/wit/since_v0.8.0/common.wit b/crates/extension_api/wit/since_v0.8.0/common.wit new file mode 100644 index 0000000000..139e7ba0ca --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/common.wit @@ -0,0 +1,12 @@ +interface common { + /// A (half-open) range (`[start, end)`). + record range { + /// The start of the range (inclusive). + start: u32, + /// The end of the range (exclusive). + end: u32, + } + + /// A list of environment variables. + type env-vars = list>; +} diff --git a/crates/extension_api/wit/since_v0.8.0/context-server.wit b/crates/extension_api/wit/since_v0.8.0/context-server.wit new file mode 100644 index 0000000000..7234e0e6d0 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/context-server.wit @@ -0,0 +1,11 @@ +interface context-server { + /// Configuration for context server setup and installation. + record context-server-configuration { + /// Installation instructions in Markdown format. + installation-instructions: string, + /// JSON schema for settings validation. + settings-schema: string, + /// Default settings template. + default-settings: string, + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/dap.wit b/crates/extension_api/wit/since_v0.8.0/dap.wit new file mode 100644 index 0000000000..693befe02f --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/dap.wit @@ -0,0 +1,123 @@ +interface dap { + use common.{env-vars}; + + /// Resolves a specified TcpArgumentsTemplate into TcpArguments + resolve-tcp-template: func(template: tcp-arguments-template) -> result; + + record launch-request { + program: string, + cwd: option, + args: list, + envs: env-vars, + } + + record attach-request { + process-id: option, + } + + variant debug-request { + launch(launch-request), + attach(attach-request) + } + + record tcp-arguments { + port: u16, + host: u32, + timeout: option, + } + + record tcp-arguments-template { + port: option, + host: option, + timeout: option, + } + + /// Debug Config is the "highest-level" configuration for a debug session. + /// It comes from a new process modal UI; thus, it is essentially debug-adapter-agnostic. + /// It is expected of the extension to translate this generic configuration into something that can be debugged by the adapter (debug scenario). + record debug-config { + /// Name of the debug task + label: string, + /// The debug adapter to use + adapter: string, + request: debug-request, + stop-on-entry: option, + } + + record task-template { + /// Human readable name of the task to display in the UI. + label: string, + /// Executable command to spawn. + command: string, + args: list, + env: env-vars, + cwd: option, + } + + /// A task template with substituted task variables. + type resolved-task = task-template; + + /// A task template for building a debug target. + type build-task-template = task-template; + + variant build-task-definition { + by-name(string), + template(build-task-definition-template-payload ) + } + record build-task-definition-template-payload { + locator-name: option, + template: build-task-template + } + + /// Debug Scenario is the user-facing configuration type (used in debug.json). It is still concerned with what to debug and not necessarily how to do it (except for any + /// debug-adapter-specific configuration options). + record debug-scenario { + /// Unsubstituted label for the task.DebugAdapterBinary + label: string, + /// Name of the Debug Adapter this configuration is intended for. + adapter: string, + /// An optional build step to be ran prior to starting a debug session. Build steps are used by Zed's locators to locate the executable to debug. + build: option, + /// JSON-encoded configuration for a given debug adapter. + config: string, + /// TCP connection parameters (if they were specified by user) + tcp-connection: option, + } + + enum start-debugging-request-arguments-request { + launch, + attach, + } + + record debug-task-definition { + /// Unsubstituted label for the task.DebugAdapterBinary + label: string, + /// Name of the Debug Adapter this configuration is intended for. + adapter: string, + /// JSON-encoded configuration for a given debug adapter. + config: string, + /// TCP connection parameters (if they were specified by user) + tcp-connection: option, + } + + record start-debugging-request-arguments { + /// JSON-encoded configuration for a given debug adapter. It is specific to each debug adapter. + /// `configuration` will have it's Zed variable references substituted prior to being passed to the debug adapter. + configuration: string, + request: start-debugging-request-arguments-request, + } + + /// The lowest-level representation of a debug session, which specifies: + /// - How to start a debug adapter process + /// - How to start a debug session with it (using DAP protocol) + /// for a given debug scenario. + record debug-adapter-binary { + command: option, + arguments: list, + envs: env-vars, + cwd: option, + /// Zed will use TCP transport if `connection` is specified. + connection: option, + request-args: start-debugging-request-arguments + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/extension.wit b/crates/extension_api/wit/since_v0.8.0/extension.wit new file mode 100644 index 0000000000..8195162b89 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/extension.wit @@ -0,0 +1,167 @@ +package zed:extension; + +world extension { + import context-server; + import dap; + import github; + import http-client; + import platform; + import process; + import nodejs; + + use common.{env-vars, range}; + use context-server.{context-server-configuration}; + use dap.{attach-request, build-task-template, debug-config, debug-adapter-binary, debug-task-definition, debug-request, debug-scenario, launch-request, resolved-task, start-debugging-request-arguments-request}; + use lsp.{completion, symbol}; + use process.{command}; + use slash-command.{slash-command, slash-command-argument-completion, slash-command-output}; + + /// Initializes the extension. + export init-extension: func(); + + /// The type of a downloaded file. + enum downloaded-file-type { + /// A gzipped file (`.gz`). + gzip, + /// A gzipped tar archive (`.tar.gz`). + gzip-tar, + /// A ZIP file (`.zip`). + zip, + /// An uncompressed file. + uncompressed, + } + + /// The installation status for a language server. + variant language-server-installation-status { + /// The language server has no installation status. + none, + /// The language server is being downloaded. + downloading, + /// The language server is checking for updates. + checking-for-update, + /// The language server installation failed for specified reason. + failed(string), + } + + record settings-location { + worktree-id: u64, + path: string, + } + + import get-settings: func(path: option, category: string, key: option) -> result; + + /// Downloads a file from the given URL and saves it to the given path within the extension's + /// working directory. + /// + /// The file will be extracted according to the given file type. + import download-file: func(url: string, file-path: string, file-type: downloaded-file-type) -> result<_, string>; + + /// Makes the file at the given path executable. + import make-file-executable: func(filepath: string) -> result<_, string>; + + /// Updates the installation status for the given language server. + import set-language-server-installation-status: func(language-server-name: string, status: language-server-installation-status); + + /// A Zed worktree. + resource worktree { + /// Returns the ID of the worktree. + id: func() -> u64; + /// Returns the root path of the worktree. + root-path: func() -> string; + /// Returns the textual contents of the specified file in the worktree. + read-text-file: func(path: string) -> result; + /// Returns the path to the given binary name, if one is present on the `$PATH`. + which: func(binary-name: string) -> option; + /// Returns the current shell environment. + shell-env: func() -> env-vars; + } + + /// A Zed project. + resource project { + /// Returns the IDs of all of the worktrees in this project. + worktree-ids: func() -> list; + } + + /// A key-value store. + resource key-value-store { + /// Inserts an entry under the specified key. + insert: func(key: string, value: string) -> result<_, string>; + } + + /// Returns the command used to start up the language server. + export language-server-command: func(language-server-id: string, worktree: borrow) -> result; + + /// Returns the initialization options to pass to the language server on startup. + /// + /// The initialization options are represented as a JSON string. + export language-server-initialization-options: func(language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the workspace configuration options to pass to the language server. + export language-server-workspace-configuration: func(language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the initialization options to pass to the other language server. + export language-server-additional-initialization-options: func(language-server-id: string, target-language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the workspace configuration options to pass to the other language server. + export language-server-additional-workspace-configuration: func(language-server-id: string, target-language-server-id: string, worktree: borrow) -> result, string>; + + /// A label containing some code. + record code-label { + /// The source code to parse with Tree-sitter. + code: string, + /// The spans to display in the label. + spans: list, + /// The range of the displayed label to include when filtering. + filter-range: range, + } + + /// A span within a code label. + variant code-label-span { + /// A range into the parsed code. + code-range(range), + /// A span containing a code literal. + literal(code-label-span-literal), + } + + /// A span containing a code literal. + record code-label-span-literal { + /// The literal text. + text: string, + /// The name of the highlight to use for this literal. + highlight-name: option, + } + + export labels-for-completions: func(language-server-id: string, completions: list) -> result>, string>; + export labels-for-symbols: func(language-server-id: string, symbols: list) -> result>, string>; + + + /// Returns the completions that should be shown when completing the provided slash command with the given query. + export complete-slash-command-argument: func(command: slash-command, args: list) -> result, string>; + + /// Returns the output from running the provided slash command. + export run-slash-command: func(command: slash-command, args: list, worktree: option>) -> result; + + /// Returns the command used to start up a context server. + export context-server-command: func(context-server-id: string, project: borrow) -> result; + + /// Returns the configuration for a context server. + export context-server-configuration: func(context-server-id: string, project: borrow) -> result, string>; + + /// Returns a list of packages as suggestions to be included in the `/docs` + /// search results. + /// + /// This can be used to provide completions for known packages (e.g., from the + /// local project or a registry) before a package has been indexed. + export suggest-docs-packages: func(provider-name: string) -> result, string>; + + /// Indexes the docs for the specified package. + export index-docs: func(provider-name: string, package-name: string, database: borrow) -> result<_, string>; + + /// Returns a configured debug adapter binary for a given debug task. + export get-dap-binary: func(adapter-name: string, config: debug-task-definition, user-installed-path: option, worktree: borrow) -> result; + /// Returns the kind of a debug scenario (launch or attach). + export dap-request-kind: func(adapter-name: string, config: string) -> result; + export dap-config-to-scenario: func(config: debug-config) -> result; + export dap-locator-create-scenario: func(locator-name: string, build-config-template: build-task-template, resolved-label: string, debug-adapter-name: string) -> option; + export run-dap-locator: func(locator-name: string, config: resolved-task) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/github.wit b/crates/extension_api/wit/since_v0.8.0/github.wit new file mode 100644 index 0000000000..21cd5d4805 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/github.wit @@ -0,0 +1,35 @@ +interface github { + /// A GitHub release. + record github-release { + /// The version of the release. + version: string, + /// The list of assets attached to the release. + assets: list, + } + + /// An asset from a GitHub release. + record github-release-asset { + /// The name of the asset. + name: string, + /// The download URL for the asset. + download-url: string, + } + + /// The options used to filter down GitHub releases. + record github-release-options { + /// Whether releases without assets should be included. + require-assets: bool, + /// Whether pre-releases should be included. + pre-release: bool, + } + + /// Returns the latest release for the given GitHub repository. + /// + /// Takes repo as a string in the form "/", for example: "zed-industries/zed". + latest-github-release: func(repo: string, options: github-release-options) -> result; + + /// Returns the GitHub release with the specified tag name for the given GitHub repository. + /// + /// Returns an error if a release with the given tag name does not exist. + github-release-by-tag-name: func(repo: string, tag: string) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/http-client.wit b/crates/extension_api/wit/since_v0.8.0/http-client.wit new file mode 100644 index 0000000000..bb0206c17a --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/http-client.wit @@ -0,0 +1,67 @@ +interface http-client { + /// An HTTP request. + record http-request { + /// The HTTP method for the request. + method: http-method, + /// The URL to which the request should be made. + url: string, + /// The headers for the request. + headers: list>, + /// The request body. + body: option>, + /// The policy to use for redirects. + redirect-policy: redirect-policy, + } + + /// HTTP methods. + enum http-method { + /// `GET` + get, + /// `HEAD` + head, + /// `POST` + post, + /// `PUT` + put, + /// `DELETE` + delete, + /// `OPTIONS` + options, + /// `PATCH` + patch, + } + + /// The policy for dealing with redirects received from the server. + variant redirect-policy { + /// Redirects from the server will not be followed. + /// + /// This is the default behavior. + no-follow, + /// Redirects from the server will be followed up to the specified limit. + follow-limit(u32), + /// All redirects from the server will be followed. + follow-all, + } + + /// An HTTP response. + record http-response { + /// The response headers. + headers: list>, + /// The response body. + body: list, + } + + /// Performs an HTTP request and returns the response. + fetch: func(req: http-request) -> result; + + /// An HTTP response stream. + resource http-response-stream { + /// Retrieves the next chunk of data from the response stream. + /// + /// Returns `Ok(None)` if the stream has ended. + next-chunk: func() -> result>, string>; + } + + /// Performs an HTTP request and returns a response stream. + fetch-stream: func(req: http-request) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/lsp.wit b/crates/extension_api/wit/since_v0.8.0/lsp.wit new file mode 100644 index 0000000000..91a36c93a6 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/lsp.wit @@ -0,0 +1,90 @@ +interface lsp { + /// An LSP completion. + record completion { + label: string, + label-details: option, + detail: option, + kind: option, + insert-text-format: option, + } + + /// The kind of an LSP completion. + variant completion-kind { + text, + method, + function, + %constructor, + field, + variable, + class, + %interface, + module, + property, + unit, + value, + %enum, + keyword, + snippet, + color, + file, + reference, + folder, + enum-member, + constant, + struct, + event, + operator, + type-parameter, + other(s32), + } + + /// Label details for an LSP completion. + record completion-label-details { + detail: option, + description: option, + } + + /// Defines how to interpret the insert text in a completion item. + variant insert-text-format { + plain-text, + snippet, + other(s32), + } + + /// An LSP symbol. + record symbol { + kind: symbol-kind, + name: string, + } + + /// The kind of an LSP symbol. + variant symbol-kind { + file, + module, + namespace, + %package, + class, + method, + property, + field, + %constructor, + %enum, + %interface, + function, + variable, + constant, + %string, + number, + boolean, + array, + object, + key, + null, + enum-member, + struct, + event, + operator, + type-parameter, + other(s32), + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/nodejs.wit b/crates/extension_api/wit/since_v0.8.0/nodejs.wit new file mode 100644 index 0000000000..c814548314 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/nodejs.wit @@ -0,0 +1,13 @@ +interface nodejs { + /// Returns the path to the Node binary used by Zed. + node-binary-path: func() -> result; + + /// Returns the latest version of the given NPM package. + npm-package-latest-version: func(package-name: string) -> result; + + /// Returns the installed version of the given NPM package, if it exists. + npm-package-installed-version: func(package-name: string) -> result, string>; + + /// Installs the specified NPM package. + npm-install-package: func(package-name: string, version: string) -> result<_, string>; +} diff --git a/crates/extension_api/wit/since_v0.8.0/platform.wit b/crates/extension_api/wit/since_v0.8.0/platform.wit new file mode 100644 index 0000000000..48472a99bc --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/platform.wit @@ -0,0 +1,24 @@ +interface platform { + /// An operating system. + enum os { + /// macOS. + mac, + /// Linux. + linux, + /// Windows. + windows, + } + + /// A platform architecture. + enum architecture { + /// AArch64 (e.g., Apple Silicon). + aarch64, + /// x86. + x86, + /// x86-64. + x8664, + } + + /// Gets the current operating system and architecture. + current-platform: func() -> tuple; +} diff --git a/crates/extension_api/wit/since_v0.8.0/process.wit b/crates/extension_api/wit/since_v0.8.0/process.wit new file mode 100644 index 0000000000..d9a5728a3d --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/process.wit @@ -0,0 +1,29 @@ +interface process { + use common.{env-vars}; + + /// A command. + record command { + /// The command to execute. + command: string, + /// The arguments to pass to the command. + args: list, + /// The environment variables to set for the command. + env: env-vars, + } + + /// The output of a finished process. + record output { + /// The status (exit code) of the process. + /// + /// On Unix, this will be `None` if the process was terminated by a signal. + status: option, + /// The data that the process wrote to stdout. + stdout: list, + /// The data that the process wrote to stderr. + stderr: list, + } + + /// Executes the given command as a child process, waiting for it to finish + /// and collecting all of its output. + run-command: func(command: command) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/settings.rs b/crates/extension_api/wit/since_v0.8.0/settings.rs new file mode 100644 index 0000000000..19e28c1ba9 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/settings.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, num::NonZeroU32}; + +/// The settings for a particular language. +#[derive(Debug, Serialize, Deserialize)] +pub struct LanguageSettings { + /// How many columns a tab should occupy. + pub tab_size: NonZeroU32, +} + +/// The settings for a particular language server. +#[derive(Default, Debug, Serialize, Deserialize)] +pub struct LspSettings { + /// The settings for the language server binary. + pub binary: Option, + /// The initialization options to pass to the language server. + pub initialization_options: Option, + /// The settings to pass to language server. + pub settings: Option, +} + +/// The settings for a particular context server. +#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextServerSettings { + /// The settings for the context server binary. + pub command: Option, + /// The settings to pass to the context server. + pub settings: Option, +} + +/// The settings for a command. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommandSettings { + /// The path to the command. + pub path: Option, + /// The arguments to pass to the command. + pub arguments: Option>, + /// The environment variables. + pub env: Option>, +} diff --git a/crates/extension_api/wit/since_v0.8.0/slash-command.wit b/crates/extension_api/wit/since_v0.8.0/slash-command.wit new file mode 100644 index 0000000000..f52561c2ef --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/slash-command.wit @@ -0,0 +1,41 @@ +interface slash-command { + use common.{range}; + + /// A slash command for use in the Assistant. + record slash-command { + /// The name of the slash command. + name: string, + /// The description of the slash command. + description: string, + /// The tooltip text to display for the run button. + tooltip-text: string, + /// Whether this slash command requires an argument. + requires-argument: bool, + } + + /// The output of a slash command. + record slash-command-output { + /// The text produced by the slash command. + text: string, + /// The list of sections to show in the slash command placeholder. + sections: list, + } + + /// A section in the slash command output. + record slash-command-output-section { + /// The range this section occupies. + range: range, + /// The label to display in the placeholder for this section. + label: string, + } + + /// A completion for a slash command argument. + record slash-command-argument-completion { + /// The label to display for this completion. + label: string, + /// The new text that should be inserted into the command when this completion is accepted. + new-text: string, + /// Whether the command should be run when accepting this completion. + run-command: bool, + } +} diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml index b2909ec6c9..b2562a8e82 100644 --- a/crates/extension_cli/Cargo.toml +++ b/crates/extension_cli/Cargo.toml @@ -30,4 +30,3 @@ tokio = { workspace = true, features = ["full"] } toml.workspace = true tree-sitter.workspace = true wasmtime.workspace = true -workspace-hack.workspace = true diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 367dba98a3..699a6b0143 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -71,6 +71,7 @@ async fn main() -> Result<()> { &extension_path, &mut manifest, CompileExtensionOptions { release: true }, + fs.clone(), ) .await .context("failed to compile extension")?; @@ -145,6 +146,10 @@ fn extension_provides(manifest: &ExtensionManifest) -> BTreeSet TestAppContext { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); }); cx } -fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest) -> Vec { +fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest, fs: Arc) -> Vec { let extension_builder = extension_builder(); let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -72,6 +77,7 @@ fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest) -> Vec &path, manifest, CompileExtensionOptions { release: true }, + fs, )) .unwrap(); std::fs::read(path.join("extension.wasm")).unwrap() @@ -123,7 +129,7 @@ fn manifest() -> ExtensionManifest { icon_themes: Vec::new(), lib: LibManifestEntry { kind: Some(ExtensionLibraryKind::Rust), - version: Some(SemanticVersion::new(0, 1, 0)), + version: Some(semver::Version::new(0, 1, 0)), }, languages: Vec::new(), grammars: BTreeMap::default(), @@ -131,6 +137,7 @@ fn manifest() -> ExtensionManifest { .into_iter() .collect(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![ExtensionCapability::ProcessExec( diff --git a/crates/extension_host/src/capability_granter.rs b/crates/extension_host/src/capability_granter.rs index 5491967e08..9f27b5e480 100644 --- a/crates/extension_host/src/capability_granter.rs +++ b/crates/extension_host/src/capability_granter.rs @@ -107,6 +107,7 @@ mod tests { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![], diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 3397f770c2..09e8259771 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -11,7 +11,7 @@ use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; use client::ExtensionProvides; use client::{Client, ExtensionMetadata, GetExtensionsResponse, proto, telemetry::Telemetry}; -use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; +use collections::{BTreeMap, BTreeSet, HashSet, btree_map}; pub use extension::ExtensionManifest; use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::{ @@ -43,8 +43,8 @@ use language::{ use node_runtime::NodeRuntime; use project::ContextProviderWithTasks; use release_channel::ReleaseChannel; -use remote::{RemoteClient, RemoteConnectionOptions}; -use semantic_version::SemanticVersion; +use remote::RemoteClient; +use semver::Version; use serde::{Deserialize, Serialize}; use settings::Settings; use std::ops::RangeInclusive; @@ -98,7 +98,7 @@ pub fn is_version_compatible( .manifest .wasm_api_version .as_ref() - .and_then(|wasm_api_version| SemanticVersion::from_str(wasm_api_version).ok()) + .and_then(|wasm_api_version| Version::from_str(wasm_api_version).ok()) && !is_supported_wasm_api_version(release_channel, wasm_api_version) { return false; @@ -123,7 +123,7 @@ pub struct ExtensionStore { pub wasm_host: Arc, pub wasm_extensions: Vec<(Arc, WasmExtension)>, pub tasks: Vec>, - pub remote_clients: HashMap>, + pub remote_clients: Vec>, pub ssh_registered_tx: UnboundedSender<()>, } @@ -200,8 +200,6 @@ pub fn init( node_runtime: NodeRuntime, cx: &mut App, ) { - ExtensionSettings::register(cx); - let store = cx.new(move |cx| { ExtensionStore::new( paths::extensions_dir().clone(), @@ -276,7 +274,7 @@ impl ExtensionStore { reload_tx, tasks: Vec::new(), - remote_clients: HashMap::default(), + remote_clients: Default::default(), ssh_registered_tx: connection_registered_tx, }; @@ -345,12 +343,12 @@ impl ExtensionStore { let index = this .update(cx, |this, cx| this.rebuild_extension_index(cx))? .await; - this.update( cx, |this, cx| this.extensions_updated(index, cx))? + this.update(cx, |this, cx| this.extensions_updated(index, cx))? .await; index_changed = false; } - Self::update_ssh_clients(&this, cx).await?; + Self::update_remote_clients(&this, cx).await?; } _ = connection_registered_rx.next() => { debounce_timer = cx @@ -360,7 +358,7 @@ impl ExtensionStore { } extension_id = reload_rx.next() => { let Some(extension_id) = extension_id else { break; }; - this.update( cx, |this, _| { + this.update(cx, |this, _| { this.modified_extensions.extend(extension_id); })?; index_changed = true; @@ -608,7 +606,7 @@ impl ExtensionStore { .extension_index .extensions .contains_key(extension_id.as_ref()); - !is_already_installed + !is_already_installed && !SUPPRESSED_EXTENSIONS.contains(&extension_id.as_ref()) }) .cloned() .collect::>(); @@ -641,9 +639,8 @@ impl ExtensionStore { this.extension_index.extensions.get(&extension.id) { let installed_version = - SemanticVersion::from_str(&installed_extension.manifest.version).ok()?; - let latest_version = - SemanticVersion::from_str(&extension.manifest.version).ok()?; + Version::from_str(&installed_extension.manifest.version).ok()?; + let latest_version = Version::from_str(&extension.manifest.version).ok()?; if installed_version >= latest_version { return None; @@ -760,29 +757,28 @@ impl ExtensionStore { if let Some(content_length) = content_length { let actual_len = tar_gz_bytes.len(); if content_length != actual_len { - bail!("downloaded extension size {actual_len} does not match content length {content_length}"); + bail!(concat!( + "downloaded extension size {actual_len} ", + "does not match content length {content_length}" + )); } } let decompressed_bytes = GzipDecoder::new(BufReader::new(tar_gz_bytes.as_slice())); let archive = Archive::new(decompressed_bytes); archive.unpack(extension_dir).await?; - this.update( cx, |this, cx| { - this.reload(Some(extension_id.clone()), cx) - })? - .await; + this.update(cx, |this, cx| this.reload(Some(extension_id.clone()), cx))? + .await; if let ExtensionOperation::Install = operation { - this.update( cx, |this, cx| { + this.update(cx, |this, cx| { cx.emit(Event::ExtensionInstalled(extension_id.clone())); if let Some(events) = ExtensionEvents::try_global(cx) - && let Some(manifest) = this.extension_manifest_for_id(&extension_id) { - events.update(cx, |this, cx| { - this.emit( - extension::Event::ExtensionInstalled(manifest.clone()), - cx, - ) - }); - } + && let Some(manifest) = this.extension_manifest_for_id(&extension_id) + { + events.update(cx, |this, cx| { + this.emit(extension::Event::ExtensionInstalled(manifest.clone()), cx) + }); + } }) .ok(); } @@ -984,12 +980,14 @@ impl ExtensionStore { cx.background_spawn({ let extension_source_path = extension_source_path.clone(); + let fs = fs.clone(); async move { builder .compile_extension( &extension_source_path, &mut extension_manifest, CompileExtensionOptions { release: false }, + fs, ) .await } @@ -1046,12 +1044,13 @@ impl ExtensionStore { cx.notify(); let compile = cx.background_spawn(async move { - let mut manifest = ExtensionManifest::load(fs, &path).await?; + let mut manifest = ExtensionManifest::load(fs.clone(), &path).await?; builder .compile_extension( &path, &mut manifest, CompileExtensionOptions { release: true }, + fs, ) .await }); @@ -1131,6 +1130,7 @@ impl ExtensionStore { } if extensions_to_load.is_empty() && extensions_to_unload.is_empty() { + self.reload_complete_senders.clear(); return Task::ready(()); } @@ -1379,7 +1379,11 @@ impl ExtensionStore { wasm_extensions.push((extension.manifest.clone(), wasm_extension)) } Err(e) => { - log::error!("Failed to load extension: {e:#}"); + log::error!( + "Failed to load extension: {}, {:#}", + extension.manifest.id, + e + ); this.update(cx, |_, cx| { cx.emit(Event::ExtensionFailedToLoad(extension.manifest.id.clone())) }) @@ -1728,7 +1732,7 @@ impl ExtensionStore { }) } - async fn sync_extensions_over_ssh( + async fn sync_extensions_to_remotes( this: &WeakEntity, client: WeakEntity, cx: &mut AsyncApp, @@ -1781,7 +1785,11 @@ impl ExtensionStore { })?, path_style, ); - log::info!("Uploading extension {}", missing_extension.clone().id); + log::info!( + "Uploading extension {} to {:?}", + missing_extension.clone().id, + dest_dir + ); client .update(cx, |client, cx| { @@ -1794,27 +1802,35 @@ impl ExtensionStore { missing_extension.clone().id ); - client + let result = client .update(cx, |client, _cx| { client.proto_client().request(proto::InstallExtension { tmp_dir: dest_dir.to_proto(), - extension: Some(missing_extension), + extension: Some(missing_extension.clone()), }) })? - .await?; + .await; + + if let Err(e) = result { + log::error!( + "Failed to install extension {}: {}", + missing_extension.id, + e + ); + } } anyhow::Ok(()) } - pub async fn update_ssh_clients(this: &WeakEntity, cx: &mut AsyncApp) -> Result<()> { + pub async fn update_remote_clients(this: &WeakEntity, cx: &mut AsyncApp) -> Result<()> { let clients = this.update(cx, |this, _cx| { - this.remote_clients.retain(|_k, v| v.upgrade().is_some()); - this.remote_clients.values().cloned().collect::>() + this.remote_clients.retain(|v| v.upgrade().is_some()); + this.remote_clients.clone() })?; for client in clients { - Self::sync_extensions_over_ssh(this, client, cx) + Self::sync_extensions_to_remotes(this, client, cx) .await .log_err(); } @@ -1822,16 +1838,12 @@ impl ExtensionStore { anyhow::Ok(()) } - pub fn register_remote_client(&mut self, client: Entity, cx: &mut Context) { - let options = client.read(cx).connection_options(); - - if let Some(existing_client) = self.remote_clients.get(&options) - && existing_client.upgrade().is_some() - { - return; - } - - self.remote_clients.insert(options, client.downgrade()); + pub fn register_remote_client( + &mut self, + client: Entity, + _cx: &mut Context, + ) { + self.remote_clients.push(client.downgrade()); self.ssh_registered_tx.unbounded_send(()).ok(); } } diff --git a/crates/extension_host/src/extension_settings.rs b/crates/extension_host/src/extension_settings.rs index a4af4a1ba3..736dd6b87a 100644 --- a/crates/extension_host/src/extension_settings.rs +++ b/crates/extension_host/src/extension_settings.rs @@ -2,11 +2,10 @@ use collections::HashMap; use extension::{ DownloadFileCapability, ExtensionCapability, NpmInstallPackageCapability, ProcessExecCapability, }; -use gpui::App; -use settings::Settings; +use settings::{RegisterSetting, Settings}; use std::sync::Arc; -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, RegisterSetting)] pub struct ExtensionSettings { /// The extensions that should be automatically installed by Zed. /// @@ -37,7 +36,7 @@ impl ExtensionSettings { } impl Settings for ExtensionSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { Self { auto_install_extensions: content.extension.auto_install_extensions.clone(), auto_update_extensions: content.extension.auto_update_extensions.clone(), diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 855077bcf8..54b090347f 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -1,14 +1,14 @@ use crate::{ Event, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, - ExtensionIndexThemeEntry, ExtensionManifest, ExtensionSettings, ExtensionStore, - GrammarManifestEntry, RELOAD_DEBOUNCE_DURATION, SchemaVersion, + ExtensionIndexThemeEntry, ExtensionManifest, ExtensionStore, GrammarManifestEntry, + RELOAD_DEBOUNCE_DURATION, SchemaVersion, }; use async_compression::futures::bufread::GzipEncoder; use collections::{BTreeMap, HashSet}; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs, RealFs}; use futures::{AsyncReadExt, StreamExt, io::BufReader}; -use gpui::{AppContext as _, SemanticVersion, TestAppContext}; +use gpui::{AppContext as _, TestAppContext}; use http_client::{FakeHttpClient, Response}; use language::{BinaryStatus, LanguageMatcher, LanguageName, LanguageRegistry}; use language_extension::LspAccess; @@ -19,7 +19,7 @@ use project::{DEFAULT_COMPLETION_CONTEXT, Project}; use release_channel::AppVersion; use reqwest_client::ReqwestClient; use serde_json::json; -use settings::{Settings as _, SettingsStore}; +use settings::SettingsStore; use std::{ ffi::OsString, path::{Path, PathBuf}, @@ -159,6 +159,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { .collect(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -189,6 +190,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -305,9 +307,9 @@ async fn test_extension_store(cx: &mut TestAppContext) { assert_eq!( language_registry.language_names(), [ - LanguageName::new("ERB"), - LanguageName::new("Plain Text"), - LanguageName::new("Ruby"), + LanguageName::new_static("ERB"), + LanguageName::new_static("Plain Text"), + LanguageName::new_static("Ruby"), ] ); assert_eq!( @@ -368,6 +370,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -460,9 +463,9 @@ async fn test_extension_store(cx: &mut TestAppContext) { assert_eq!( language_registry.language_names(), [ - LanguageName::new("ERB"), - LanguageName::new("Plain Text"), - LanguageName::new("Ruby"), + LanguageName::new_static("ERB"), + LanguageName::new_static("Plain Text"), + LanguageName::new_static("Ruby"), ] ); assert_eq!( @@ -520,18 +523,15 @@ async fn test_extension_store(cx: &mut TestAppContext) { assert_eq!( language_registry.language_names(), - [LanguageName::new("Plain Text")] + [LanguageName::new_static("Plain Text")] ); assert_eq!(language_registry.grammar_names(), []); }); } -// todo(windows) -// Disable this test on Windows for now. Because this test hangs at -// `let fake_server = fake_servers.next().await.unwrap();`. -// Reenable this test when we figure out why. #[gpui::test] async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { + log::info!("Initializing test"); init_test(cx); cx.executor().allow_parking(); @@ -556,6 +556,8 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { let extensions_dir = extensions_tree.path().canonicalize().unwrap(); let project_dir = project_dir.path().canonicalize().unwrap(); + log::info!("Setting up test"); + let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await; let proxy = Arc::new(ExtensionHostProxy::new()); @@ -674,6 +676,8 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { ) }); + log::info!("Flushing events"); + // Ensure that debounces fire. let mut events = cx.events(&extension_store); let executor = cx.executor(); @@ -701,7 +705,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { .await .unwrap(); - let mut fake_servers = language_registry.register_fake_language_server( + let mut fake_servers = language_registry.register_fake_lsp_server( LanguageServerName("gleam".into()), lsp::ServerCapabilities { completion_provider: Some(Default::default()), @@ -862,11 +866,9 @@ fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); extension::init(cx); theme::init(theme::LoadThemes::JustBase, cx); - Project::init_settings(cx); - ExtensionSettings::register(cx); - language::init(cx); + gpui_tokio::init(cx); }); } diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index f14bb811a6..c3a290a55a 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -96,7 +96,7 @@ impl HeadlessExtensionStore { for extension in to_load { if let Err(e) = Self::load_extension(this.clone(), extension.clone(), cx).await { - log::info!("failed to load extension: {}, {:?}", extension.id, e); + log::info!("failed to load extension: {}, {:#}", extension.id, e); missing.push(extension) } else if extension.dev { missing.push(extension) @@ -279,7 +279,8 @@ impl HeadlessExtensionStore { } fs.rename(&tmp_path, &path, RenameOptions::default()) - .await?; + .await + .context("Failed to rename {tmp_path:?} to {path:?}")?; Self::load_extension(this, extension, cx).await }) diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index f77258e895..cecaf2039b 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -28,14 +28,16 @@ use lsp::LanguageServerName; use moka::sync::Cache; use node_runtime::NodeRuntime; use release_channel::ReleaseChannel; -use semantic_version::SemanticVersion; +use semver::Version; use settings::Settings; -use std::borrow::Cow; -use std::sync::{LazyLock, OnceLock}; -use std::time::Duration; use std::{ + borrow::Cow, path::{Path, PathBuf}, - sync::Arc, + sync::{ + Arc, LazyLock, OnceLock, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, }; use task::{DebugScenario, SpawnInTerminal, TaskTemplate, ZedDebugConfig}; use util::paths::SanitizedPath; @@ -66,7 +68,8 @@ pub struct WasmExtension { pub manifest: Arc, pub work_dir: Arc, #[allow(unused)] - pub zed_api_version: SemanticVersion, + pub zed_api_version: Version, + _task: Arc>>, } impl Drop for WasmExtension { @@ -495,6 +498,11 @@ pub struct WasmState { pub(crate) capability_granter: CapabilityGranter, } +std::thread_local! { + /// Used by the crash handler to ignore panics in extension-related threads. + pub static IS_WASM_THREAD: AtomicBool = const { AtomicBool::new(false) }; +} + type MainThreadCall = Box FnOnce(&'a mut AsyncApp) -> LocalBoxFuture<'a, ()>>; type ExtensionCall = Box< @@ -591,11 +599,12 @@ impl WasmHost { self: &Arc, wasm_bytes: Vec, manifest: &Arc, - executor: BackgroundExecutor, + cx: &AsyncApp, ) -> Task> { let this = self.clone(); let manifest = manifest.clone(); - executor.clone().spawn(async move { + let executor = cx.background_executor().clone(); + let load_extension_task = async move { let zed_api_version = parse_wasm_extension_version(&manifest.id, &wasm_bytes)?; let component = Component::from_binary(&this.engine, &wasm_bytes) @@ -621,7 +630,7 @@ impl WasmHost { &executor, &mut store, this.release_channel, - zed_api_version, + zed_api_version.clone(), &component, ) .await?; @@ -632,19 +641,39 @@ impl WasmHost { .context("failed to initialize wasm extension")?; let (tx, mut rx) = mpsc::unbounded::(); - executor - .spawn(async move { - while let Some(call) = rx.next().await { - (call)(&mut extension, &mut store).await; - } - }) - .detach(); + let extension_task = async move { + // note: Setting the thread local here will slowly "poison" all tokio threads + // causing us to not record their panics any longer. + // + // This is fine though, the main zed binary only uses tokio for livekit and wasm extensions. + // Livekit seldom (if ever) panics 🤞 so the likelihood of us missing a panic in sentry is very low. + IS_WASM_THREAD.with(|v| v.store(true, Ordering::Release)); + while let Some(call) = rx.next().await { + (call)(&mut extension, &mut store).await; + } + }; - Ok(WasmExtension { - manifest: manifest.clone(), - work_dir: this.work_dir.join(manifest.id.as_ref()).into(), + anyhow::Ok(( + extension_task, + manifest.clone(), + this.work_dir.join(manifest.id.as_ref()).into(), tx, zed_api_version, + )) + }; + cx.spawn(async move |cx| { + let (extension_task, manifest, work_dir, tx, zed_api_version) = + cx.background_executor().spawn(load_extension_task).await?; + // we need to run run the task in a tokio context as wasmtime_wasi may + // call into tokio, accessing its runtime handle when we trigger the `engine.increment_epoch()` above. + let task = Arc::new(gpui_tokio::Tokio::spawn(cx, extension_task)?); + + Ok(WasmExtension { + manifest, + work_dir, + tx, + zed_api_version, + _task: task, }) }) } @@ -684,10 +713,7 @@ impl WasmHost { } } -pub fn parse_wasm_extension_version( - extension_id: &str, - wasm_bytes: &[u8], -) -> Result { +pub fn parse_wasm_extension_version(extension_id: &str, wasm_bytes: &[u8]) -> Result { let mut version = None; for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) { @@ -714,9 +740,9 @@ pub fn parse_wasm_extension_version( version.with_context(|| format!("extension {extension_id} has no zed:api-version section")) } -fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option { +fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option { if data.len() == 6 { - Some(SemanticVersion::new( + Some(Version::new( u16::from_be_bytes([data[0], data[1]]) as _, u16::from_be_bytes([data[2], data[3]]) as _, u16::from_be_bytes([data[4], data[5]]) as _, @@ -739,17 +765,17 @@ impl WasmExtension { .fs .open_sync(&path) .await - .context("failed to open wasm file")?; + .context(format!("opening wasm file, path: {path:?}"))?; let mut wasm_bytes = Vec::new(); wasm_file .read_to_end(&mut wasm_bytes) - .context("failed to read wasm")?; + .context(format!("reading wasm file, path: {path:?}"))?; wasm_host - .load_extension(wasm_bytes, manifest, cx.background_executor().clone()) + .load_extension(wasm_bytes, manifest, cx) .await - .with_context(|| format!("failed to load wasm extension {}", manifest.id)) + .with_context(|| format!("loading wasm extension: {}", manifest.id)) } pub async fn call(&self, f: Fn) -> Result diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index 1f1fa49bd5..5058c63365 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -7,6 +7,7 @@ mod since_v0_3_0; mod since_v0_4_0; mod since_v0_5_0; mod since_v0_6_0; +mod since_v0_8_0; use dap::DebugRequest; use extension::{DebugTaskDefinition, KeyValueStoreDelegate, WorktreeDelegate}; use gpui::BackgroundExecutor; @@ -19,8 +20,8 @@ use crate::wasm_host::wit::since_v0_6_0::dap::StartDebuggingRequestArgumentsRequ use super::{WasmState, wasm_engine}; use anyhow::{Context as _, Result, anyhow}; -use semantic_version::SemanticVersion; -use since_v0_6_0 as latest; +use semver::Version; +use since_v0_8_0 as latest; use std::{ops::RangeInclusive, path::PathBuf, sync::Arc}; use wasmtime::{ Store, @@ -54,22 +55,19 @@ fn wasi_view(state: &mut WasmState) -> &mut WasmState { } /// Returns whether the given Wasm API version is supported by the Wasm host. -pub fn is_supported_wasm_api_version( - release_channel: ReleaseChannel, - version: SemanticVersion, -) -> bool { +pub fn is_supported_wasm_api_version(release_channel: ReleaseChannel, version: Version) -> bool { wasm_api_version_range(release_channel).contains(&version) } /// Returns the Wasm API version range that is supported by the Wasm host. #[inline(always)] -pub fn wasm_api_version_range(release_channel: ReleaseChannel) -> RangeInclusive { +pub fn wasm_api_version_range(release_channel: ReleaseChannel) -> RangeInclusive { // Note: The release channel can be used to stage a new version of the extension API. let _ = release_channel; let max_version = match release_channel { ReleaseChannel::Dev | ReleaseChannel::Nightly => latest::MAX_VERSION, - ReleaseChannel::Stable | ReleaseChannel::Preview => latest::MAX_VERSION, + ReleaseChannel::Stable | ReleaseChannel::Preview => since_v0_6_0::MAX_VERSION, }; since_v0_0_1::MIN_VERSION..=max_version @@ -98,6 +96,7 @@ pub fn authorize_access_to_unreleased_wasm_api_version( } pub enum Extension { + V0_8_0(since_v0_8_0::Extension), V0_6_0(since_v0_6_0::Extension), V0_5_0(since_v0_5_0::Extension), V0_4_0(since_v0_4_0::Extension), @@ -114,17 +113,28 @@ impl Extension { executor: &BackgroundExecutor, store: &mut Store, release_channel: ReleaseChannel, - version: SemanticVersion, + version: Version, component: &Component, ) -> Result { // Note: The release channel can be used to stage a new version of the extension API. let _ = release_channel; if version >= latest::MIN_VERSION { + authorize_access_to_unreleased_wasm_api_version(release_channel)?; + let extension = latest::Extension::instantiate_async(store, component, latest::linker(executor)) .await .context("failed to instantiate wasm extension")?; + Ok(Self::V0_8_0(extension)) + } else if version >= since_v0_6_0::MIN_VERSION { + let extension = since_v0_6_0::Extension::instantiate_async( + store, + component, + since_v0_6_0::linker(executor), + ) + .await + .context("failed to instantiate wasm extension")?; Ok(Self::V0_6_0(extension)) } else if version >= since_v0_5_0::MIN_VERSION { let extension = since_v0_5_0::Extension::instantiate_async( @@ -203,6 +213,7 @@ impl Extension { pub async fn call_init_extension(&self, store: &mut Store) -> Result<()> { match self { + Extension::V0_8_0(ext) => ext.call_init_extension(store).await, Extension::V0_6_0(ext) => ext.call_init_extension(store).await, Extension::V0_5_0(ext) => ext.call_init_extension(store).await, Extension::V0_4_0(ext) => ext.call_init_extension(store).await, @@ -223,6 +234,10 @@ impl Extension { resource: Resource>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_command(store, &language_server_id.0, resource) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_command(store, &language_server_id.0, resource) .await @@ -285,6 +300,14 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_initialization_options( + store, + &language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_initialization_options( store, @@ -374,6 +397,14 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_workspace_configuration( + store, + &language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_workspace_configuration( store, @@ -442,6 +473,15 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_additional_initialization_options( + store, + &language_server_id.0, + &target_language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_additional_initialization_options( store, @@ -486,6 +526,15 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_additional_workspace_configuration( + store, + &language_server_id.0, + &target_language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_additional_workspace_configuration( store, @@ -529,10 +578,23 @@ impl Extension { completions: Vec, ) -> Result>, String>> { match self { - Extension::V0_6_0(ext) => { + Extension::V0_8_0(ext) => { ext.call_labels_for_completions(store, &language_server_id.0, &completions) .await } + Extension::V0_6_0(ext) => Ok(ext + .call_labels_for_completions( + store, + &language_server_id.0, + &completions.into_iter().collect::>(), + ) + .await? + .map(|labels| { + labels + .into_iter() + .map(|label| label.map(Into::into)) + .collect() + })), Extension::V0_5_0(ext) => Ok(ext .call_labels_for_completions( store, @@ -622,10 +684,23 @@ impl Extension { symbols: Vec, ) -> Result>, String>> { match self { - Extension::V0_6_0(ext) => { + Extension::V0_8_0(ext) => { ext.call_labels_for_symbols(store, &language_server_id.0, &symbols) .await } + Extension::V0_6_0(ext) => Ok(ext + .call_labels_for_symbols( + store, + &language_server_id.0, + &symbols.into_iter().collect::>(), + ) + .await? + .map(|labels| { + labels + .into_iter() + .map(|label| label.map(Into::into)) + .collect() + })), Extension::V0_5_0(ext) => Ok(ext .call_labels_for_symbols( store, @@ -715,6 +790,10 @@ impl Extension { arguments: &[String], ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_complete_slash_command_argument(store, command, arguments) + .await + } Extension::V0_6_0(ext) => { ext.call_complete_slash_command_argument(store, command, arguments) .await @@ -753,6 +832,10 @@ impl Extension { resource: Option>>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_run_slash_command(store, command, arguments, resource) + .await + } Extension::V0_6_0(ext) => { ext.call_run_slash_command(store, command, arguments, resource) .await @@ -790,6 +873,10 @@ impl Extension { project: Resource, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_context_server_command(store, &context_server_id, project) + .await + } Extension::V0_6_0(ext) => { ext.call_context_server_command(store, &context_server_id, project) .await @@ -826,6 +913,10 @@ impl Extension { project: Resource, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_context_server_configuration(store, &context_server_id, project) + .await + } Extension::V0_6_0(ext) => { ext.call_context_server_configuration(store, &context_server_id, project) .await @@ -852,6 +943,7 @@ impl Extension { provider: &str, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_6_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_5_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_4_0(ext) => ext.call_suggest_docs_packages(store, provider).await, @@ -872,6 +964,10 @@ impl Extension { kv_store: Resource>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_index_docs(store, provider, package_name, kv_store) + .await + } Extension::V0_6_0(ext) => { ext.call_index_docs(store, provider, package_name, kv_store) .await @@ -901,6 +997,7 @@ impl Extension { } } } + pub async fn call_get_dap_binary( &self, store: &mut Store, @@ -927,6 +1024,7 @@ impl Extension { _ => anyhow::bail!("`get_dap_binary` not available prior to v0.6.0"), } } + pub async fn call_dap_request_kind( &self, store: &mut Store, @@ -947,6 +1045,7 @@ impl Extension { _ => anyhow::bail!("`dap_request_kind` not available prior to v0.6.0"), } } + pub async fn call_dap_config_to_scenario( &self, store: &mut Store, @@ -965,6 +1064,7 @@ impl Extension { _ => anyhow::bail!("`dap_config_to_scenario` not available prior to v0.6.0"), } } + pub async fn call_dap_locator_create_scenario( &self, store: &mut Store, @@ -991,6 +1091,7 @@ impl Extension { _ => anyhow::bail!("`dap_locator_create_scenario` not available prior to v0.6.0"), } } + pub async fn call_run_dap_locator( &self, store: &mut Store, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs index 168dea4a22..17d5c00a9a 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs @@ -5,11 +5,11 @@ use anyhow::Result; use extension::{ExtensionLanguageServerProxy, WorktreeDelegate}; use gpui::BackgroundExecutor; use language::BinaryStatus; -use semantic_version::SemanticVersion; +use semver::Version; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 0, 1); +pub const MIN_VERSION: Version = Version::new(0, 0, 1); wasmtime::component::bindgen!({ async: true, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs index 31f752080b..11b2e9f661 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs @@ -3,11 +3,11 @@ use crate::wasm_host::WasmState; use anyhow::Result; use extension::WorktreeDelegate; use gpui::BackgroundExecutor; -use semantic_version::SemanticVersion; +use semver::Version; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 0, 4); +pub const MIN_VERSION: Version = Version::new(0, 0, 4); wasmtime::component::bindgen!({ async: true, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs index 2fc29abadb..835a2b30fb 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs @@ -3,11 +3,11 @@ use crate::wasm_host::WasmState; use anyhow::Result; use extension::WorktreeDelegate; use gpui::BackgroundExecutor; -use semantic_version::SemanticVersion; +use semver::Version; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 0, 6); +pub const MIN_VERSION: Version = Version::new(0, 0, 6); wasmtime::component::bindgen!({ async: true, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index 26347ec442..a7a20f6dc7 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -11,7 +11,7 @@ use gpui::BackgroundExecutor; use language::LanguageName; use language::{BinaryStatus, language_settings::AllLanguageSettings}; use project::project_settings::ProjectSettings; -use semantic_version::SemanticVersion; +use semver::Version; use std::{ path::{Path, PathBuf}, sync::{Arc, OnceLock}, @@ -23,7 +23,7 @@ use wasmtime::component::{Linker, Resource}; use super::latest; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 1, 0); +pub const MIN_VERSION: Version = Version::new(0, 1, 0); wasmtime::component::bindgen!({ async: true, @@ -520,7 +520,7 @@ impl ExtensionImports for WasmState { anyhow::ensure!( response.status().is_success(), "download failed with status {}", - response.status().to_string() + response.status() ); let body = BufReader::new(response.body_mut()); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs index 9475438b66..05e3f5a4e7 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs @@ -2,13 +2,13 @@ use crate::wasm_host::WasmState; use anyhow::Result; use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate}; use gpui::BackgroundExecutor; -use semantic_version::SemanticVersion; +use semver::Version; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; use super::latest; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 2, 0); +pub const MIN_VERSION: Version = Version::new(0, 2, 0); wasmtime::component::bindgen!({ async: true, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs index b6a75ba7dd..08393934fe 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs @@ -2,13 +2,13 @@ use crate::wasm_host::WasmState; use anyhow::Result; use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate}; use gpui::BackgroundExecutor; -use semantic_version::SemanticVersion; +use semver::Version; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; use super::latest; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 3, 0); +pub const MIN_VERSION: Version = Version::new(0, 3, 0); wasmtime::component::bindgen!({ async: true, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs index 7c8be1322f..1b2a95023b 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs @@ -2,13 +2,13 @@ use crate::wasm_host::WasmState; use anyhow::Result; use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate}; use gpui::BackgroundExecutor; -use semantic_version::SemanticVersion; +use semver::Version; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; use super::latest; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 4, 0); +pub const MIN_VERSION: Version = Version::new(0, 4, 0); wasmtime::component::bindgen!({ async: true, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs index 6d04663de7..23701c9d03 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs @@ -2,13 +2,13 @@ use crate::wasm_host::WasmState; use anyhow::Result; use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate}; use gpui::BackgroundExecutor; -use semantic_version::SemanticVersion; +use semver::Version; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; use super::latest; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 5, 0); +pub const MIN_VERSION: Version = Version::new(0, 5, 0); wasmtime::component::bindgen!({ async: true, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index 9942f8aeea..8595c278b9 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -1,53 +1,34 @@ -use crate::wasm_host::wit::since_v0_6_0::{ - dap::{ - AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest, - StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate, - }, - slash_command::SlashCommandOutputSection, -}; -use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind}; -use crate::wasm_host::{WasmState, wit::ToWasmtimeResult}; -use ::http_client::{AsyncBody, HttpRequestExt}; -use ::settings::{Settings, WorktreeId}; -use anyhow::{Context as _, Result, bail}; -use async_compression::futures::bufread::GzipDecoder; -use async_tar::Archive; -use async_trait::async_trait; -use extension::{ - ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate, -}; -use futures::{AsyncReadExt, lock::Mutex}; -use futures::{FutureExt as _, io::BufReader}; -use gpui::{BackgroundExecutor, SharedString}; -use language::{BinaryStatus, LanguageName, language_settings::AllLanguageSettings}; -use project::project_settings::ProjectSettings; -use semantic_version::SemanticVersion; -use std::{ - env, - net::Ipv4Addr, - path::{Path, PathBuf}, - str::FromStr, - sync::{Arc, OnceLock}, -}; -use task::{SpawnInTerminal, ZedDebugConfig}; -use url::Url; -use util::{ - archive::extract_zip, fs::make_file_executable, maybe, paths::PathStyle, rel_path::RelPath, -}; +use crate::wasm_host::WasmState; +use anyhow::Result; +use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate}; +use gpui::BackgroundExecutor; +use semver::Version; +use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; -pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 6, 0); -pub const MAX_VERSION: SemanticVersion = SemanticVersion::new(0, 7, 0); +use super::latest; + +pub const MIN_VERSION: Version = Version::new(0, 6, 0); +pub const MAX_VERSION: Version = Version::new(0, 7, 0); wasmtime::component::bindgen!({ async: true, trappable_imports: true, path: "../extension_api/wit/since_v0.6.0", with: { - "worktree": ExtensionWorktree, - "project": ExtensionProject, - "key-value-store": ExtensionKeyValueStore, - "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream + "worktree": ExtensionWorktree, + "project": ExtensionProject, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/common": latest::zed::extension::common, + "zed:extension/github": latest::zed::extension::github, + "zed:extension/http-client": latest::zed::extension::http_client, + "zed:extension/lsp": latest::zed::extension::lsp, + "zed:extension/nodejs": latest::zed::extension::nodejs, + "zed:extension/platform": latest::zed::extension::platform, + "zed:extension/process": latest::zed::extension::process, + "zed:extension/slash-command": latest::zed::extension::slash_command, + "zed:extension/context-server": latest::zed::extension::context_server, + "zed:extension/dap": latest::zed::extension::dap, }, }); @@ -61,289 +42,32 @@ mod settings { pub type ExtensionWorktree = Arc; pub type ExtensionProject = Arc; pub type ExtensionKeyValueStore = Arc; -pub type ExtensionHttpResponseStream = Arc>>; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) } -impl From for std::ops::Range { - fn from(range: Range) -> Self { - let start = range.start as usize; - let end = range.end as usize; - start..end - } -} - -impl From for extension::Command { - fn from(value: Command) -> Self { - Self { - command: value.command.into(), - args: value.args, - env: value.env, - } - } -} - -impl From - for extension::StartDebuggingRequestArgumentsRequest -{ - fn from(value: StartDebuggingRequestArgumentsRequest) -> Self { - match value { - StartDebuggingRequestArgumentsRequest::Launch => Self::Launch, - StartDebuggingRequestArgumentsRequest::Attach => Self::Attach, - } - } -} -impl TryFrom for extension::StartDebuggingRequestArguments { - type Error = anyhow::Error; - - fn try_from(value: StartDebuggingRequestArguments) -> Result { - Ok(Self { - configuration: serde_json::from_str(&value.configuration)?, - request: value.request.into(), - }) - } -} -impl From for extension::TcpArguments { - fn from(value: TcpArguments) -> Self { - Self { - host: value.host.into(), - port: value.port, - timeout: value.timeout, - } - } -} - -impl From for TcpArgumentsTemplate { - fn from(value: extension::TcpArgumentsTemplate) -> Self { - Self { - host: value.host.map(Ipv4Addr::to_bits), - port: value.port, - timeout: value.timeout, - } - } -} - -impl From for extension::TcpArgumentsTemplate { - fn from(value: TcpArgumentsTemplate) -> Self { - Self { - host: value.host.map(Ipv4Addr::from_bits), - port: value.port, - timeout: value.timeout, - } - } -} - -impl TryFrom for DebugTaskDefinition { - type Error = anyhow::Error; - fn try_from(value: extension::DebugTaskDefinition) -> Result { - Ok(Self { - label: value.label.to_string(), - adapter: value.adapter.to_string(), - config: value.config.to_string(), - tcp_connection: value.tcp_connection.map(Into::into), - }) - } -} - -impl From for DebugRequest { - fn from(value: task::DebugRequest) -> Self { - match value { - task::DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), - task::DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), - } - } -} - -impl From for task::DebugRequest { - fn from(value: DebugRequest) -> Self { - match value { - DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), - DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), - } - } -} - -impl From for LaunchRequest { - fn from(value: task::LaunchRequest) -> Self { - Self { - program: value.program, - cwd: value.cwd.map(|p| p.to_string_lossy().into_owned()), - args: value.args, - envs: value.env.into_iter().collect(), - } - } -} - -impl From for AttachRequest { - fn from(value: task::AttachRequest) -> Self { - Self { - process_id: value.process_id, - } - } -} - -impl From for task::LaunchRequest { - fn from(value: LaunchRequest) -> Self { - Self { - program: value.program, - cwd: value.cwd.map(|p| p.into()), - args: value.args, - env: value.envs.into_iter().collect(), - } - } -} -impl From for task::AttachRequest { - fn from(value: AttachRequest) -> Self { - Self { - process_id: value.process_id, - } - } -} - -impl From for DebugConfig { - fn from(value: ZedDebugConfig) -> Self { - Self { - label: value.label.into(), - adapter: value.adapter.into(), - request: value.request.into(), - stop_on_entry: value.stop_on_entry, - } - } -} -impl TryFrom for extension::DebugAdapterBinary { - type Error = anyhow::Error; - fn try_from(value: DebugAdapterBinary) -> Result { - Ok(Self { - command: value.command, - arguments: value.arguments, - envs: value.envs.into_iter().collect(), - cwd: value.cwd.map(|s| s.into()), - connection: value.connection.map(Into::into), - request_args: value.request_args.try_into()?, - }) - } -} - -impl From for extension::BuildTaskDefinition { - fn from(value: BuildTaskDefinition) -> Self { - match value { - BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), - BuildTaskDefinition::Template(build_task_template) => Self::Template { - task_template: build_task_template.template.into(), - locator_name: build_task_template.locator_name.map(SharedString::from), - }, - } - } -} - -impl From for BuildTaskDefinition { - fn from(value: extension::BuildTaskDefinition) -> Self { - match value { - extension::BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), - extension::BuildTaskDefinition::Template { - task_template, - locator_name, - } => Self::Template(BuildTaskDefinitionTemplatePayload { - template: task_template.into(), - locator_name: locator_name.map(String::from), - }), - } - } -} -impl From for extension::BuildTaskTemplate { - fn from(value: BuildTaskTemplate) -> Self { - Self { - label: value.label, - command: value.command, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd, - ..Default::default() - } - } -} -impl From for BuildTaskTemplate { - fn from(value: extension::BuildTaskTemplate) -> Self { - Self { - label: value.label, - command: value.command, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd, - } - } -} - -impl TryFrom for extension::DebugScenario { - type Error = anyhow::Error; - - fn try_from(value: DebugScenario) -> std::result::Result { - Ok(Self { - adapter: value.adapter.into(), - label: value.label.into(), - build: value.build.map(Into::into), - config: serde_json::Value::from_str(&value.config)?, - tcp_connection: value.tcp_connection.map(Into::into), - }) - } -} - -impl From for DebugScenario { - fn from(value: extension::DebugScenario) -> Self { - Self { - adapter: value.adapter.into(), - label: value.label.into(), - build: value.build.map(Into::into), - config: value.config.to_string(), - tcp_connection: value.tcp_connection.map(Into::into), - } - } -} - -impl TryFrom for ResolvedTask { - type Error = anyhow::Error; - - fn try_from(value: SpawnInTerminal) -> Result { - Ok(Self { - label: value.label, - command: value.command.context("missing command")?, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd.map(|s| { - let s = s.to_string_lossy(); - if cfg!(target_os = "windows") { - s.replace('\\', "/") - } else { - s.into_owned() - } - }), - }) - } -} - -impl From for extension::CodeLabel { +impl From for latest::CodeLabel { fn from(value: CodeLabel) -> Self { Self { code: value.code, spans: value.spans.into_iter().map(Into::into).collect(), - filter_range: value.filter_range.into(), + filter_range: value.filter_range, } } } -impl From for extension::CodeLabelSpan { +impl From for latest::CodeLabelSpan { fn from(value: CodeLabelSpan) -> Self { match value { - CodeLabelSpan::CodeRange(range) => Self::CodeRange(range.into()), + CodeLabelSpan::CodeRange(range) => Self::CodeRange(range), CodeLabelSpan::Literal(literal) => Self::Literal(literal.into()), } } } -impl From for extension::CodeLabelSpanLiteral { +impl From for latest::CodeLabelSpanLiteral { fn from(value: CodeLabelSpanLiteral) -> Self { Self { text: value.text, @@ -352,167 +76,37 @@ impl From for extension::CodeLabelSpanLiteral { } } -impl From for Completion { - fn from(value: extension::Completion) -> Self { +impl From for latest::SettingsLocation { + fn from(value: SettingsLocation) -> Self { Self { - label: value.label, - label_details: value.label_details.map(Into::into), - detail: value.detail, - kind: value.kind.map(Into::into), - insert_text_format: value.insert_text_format.map(Into::into), + worktree_id: value.worktree_id, + path: value.path, } } } -impl From for CompletionLabelDetails { - fn from(value: extension::CompletionLabelDetails) -> Self { - Self { - detail: value.detail, - description: value.description, - } - } -} - -impl From for CompletionKind { - fn from(value: extension::CompletionKind) -> Self { +impl From for latest::LanguageServerInstallationStatus { + fn from(value: LanguageServerInstallationStatus) -> Self { match value { - extension::CompletionKind::Text => Self::Text, - extension::CompletionKind::Method => Self::Method, - extension::CompletionKind::Function => Self::Function, - extension::CompletionKind::Constructor => Self::Constructor, - extension::CompletionKind::Field => Self::Field, - extension::CompletionKind::Variable => Self::Variable, - extension::CompletionKind::Class => Self::Class, - extension::CompletionKind::Interface => Self::Interface, - extension::CompletionKind::Module => Self::Module, - extension::CompletionKind::Property => Self::Property, - extension::CompletionKind::Unit => Self::Unit, - extension::CompletionKind::Value => Self::Value, - extension::CompletionKind::Enum => Self::Enum, - extension::CompletionKind::Keyword => Self::Keyword, - extension::CompletionKind::Snippet => Self::Snippet, - extension::CompletionKind::Color => Self::Color, - extension::CompletionKind::File => Self::File, - extension::CompletionKind::Reference => Self::Reference, - extension::CompletionKind::Folder => Self::Folder, - extension::CompletionKind::EnumMember => Self::EnumMember, - extension::CompletionKind::Constant => Self::Constant, - extension::CompletionKind::Struct => Self::Struct, - extension::CompletionKind::Event => Self::Event, - extension::CompletionKind::Operator => Self::Operator, - extension::CompletionKind::TypeParameter => Self::TypeParameter, - extension::CompletionKind::Other(value) => Self::Other(value), + LanguageServerInstallationStatus::None => Self::None, + LanguageServerInstallationStatus::Downloading => Self::Downloading, + LanguageServerInstallationStatus::CheckingForUpdate => Self::CheckingForUpdate, + LanguageServerInstallationStatus::Failed(message) => Self::Failed(message), } } } -impl From for InsertTextFormat { - fn from(value: extension::InsertTextFormat) -> Self { +impl From for latest::DownloadedFileType { + fn from(value: DownloadedFileType) -> Self { match value { - extension::InsertTextFormat::PlainText => Self::PlainText, - extension::InsertTextFormat::Snippet => Self::Snippet, - extension::InsertTextFormat::Other(value) => Self::Other(value), + DownloadedFileType::Gzip => Self::Gzip, + DownloadedFileType::GzipTar => Self::GzipTar, + DownloadedFileType::Zip => Self::Zip, + DownloadedFileType::Uncompressed => Self::Uncompressed, } } } -impl From for Symbol { - fn from(value: extension::Symbol) -> Self { - Self { - kind: value.kind.into(), - name: value.name, - } - } -} - -impl From for SymbolKind { - fn from(value: extension::SymbolKind) -> Self { - match value { - extension::SymbolKind::File => Self::File, - extension::SymbolKind::Module => Self::Module, - extension::SymbolKind::Namespace => Self::Namespace, - extension::SymbolKind::Package => Self::Package, - extension::SymbolKind::Class => Self::Class, - extension::SymbolKind::Method => Self::Method, - extension::SymbolKind::Property => Self::Property, - extension::SymbolKind::Field => Self::Field, - extension::SymbolKind::Constructor => Self::Constructor, - extension::SymbolKind::Enum => Self::Enum, - extension::SymbolKind::Interface => Self::Interface, - extension::SymbolKind::Function => Self::Function, - extension::SymbolKind::Variable => Self::Variable, - extension::SymbolKind::Constant => Self::Constant, - extension::SymbolKind::String => Self::String, - extension::SymbolKind::Number => Self::Number, - extension::SymbolKind::Boolean => Self::Boolean, - extension::SymbolKind::Array => Self::Array, - extension::SymbolKind::Object => Self::Object, - extension::SymbolKind::Key => Self::Key, - extension::SymbolKind::Null => Self::Null, - extension::SymbolKind::EnumMember => Self::EnumMember, - extension::SymbolKind::Struct => Self::Struct, - extension::SymbolKind::Event => Self::Event, - extension::SymbolKind::Operator => Self::Operator, - extension::SymbolKind::TypeParameter => Self::TypeParameter, - extension::SymbolKind::Other(value) => Self::Other(value), - } - } -} - -impl From for SlashCommand { - fn from(value: extension::SlashCommand) -> Self { - Self { - name: value.name, - description: value.description, - tooltip_text: value.tooltip_text, - requires_argument: value.requires_argument, - } - } -} - -impl From for extension::SlashCommandOutput { - fn from(value: SlashCommandOutput) -> Self { - Self { - text: value.text, - sections: value.sections.into_iter().map(Into::into).collect(), - } - } -} - -impl From for extension::SlashCommandOutputSection { - fn from(value: SlashCommandOutputSection) -> Self { - Self { - range: value.range.start as usize..value.range.end as usize, - label: value.label, - } - } -} - -impl From for extension::SlashCommandArgumentCompletion { - fn from(value: SlashCommandArgumentCompletion) -> Self { - Self { - label: value.label, - new_text: value.new_text, - run_command: value.run_command, - } - } -} - -impl TryFrom for extension::ContextServerConfiguration { - type Error = anyhow::Error; - - fn try_from(value: ContextServerConfiguration) -> Result { - let settings_schema: serde_json::Value = serde_json::from_str(&value.settings_schema) - .context("Failed to parse settings_schema")?; - - Ok(Self { - installation_instructions: value.installation_instructions, - default_settings: value.default_settings, - settings_schema, - }) - } -} - impl HostKeyValueStore for WasmState { async fn insert( &mut self, @@ -520,8 +114,7 @@ impl HostKeyValueStore for WasmState { key: String, value: String, ) -> wasmtime::Result> { - let kv_store = self.table.get(&kv_store)?; - kv_store.insert(key, value).await.to_wasmtime_result() + latest::HostKeyValueStore::insert(self, kv_store, key, value).await } async fn drop(&mut self, _worktree: Resource) -> Result<()> { @@ -535,8 +128,7 @@ impl HostProject for WasmState { &mut self, project: Resource, ) -> wasmtime::Result> { - let project = self.table.get(&project)?; - Ok(project.worktree_ids()) + latest::HostProject::worktree_ids(self, project).await } async fn drop(&mut self, _project: Resource) -> Result<()> { @@ -547,16 +139,14 @@ impl HostProject for WasmState { impl HostWorktree for WasmState { async fn id(&mut self, delegate: Resource>) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.id()) + latest::HostWorktree::id(self, delegate).await } async fn root_path( &mut self, delegate: Resource>, ) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.root_path()) + latest::HostWorktree::root_path(self, delegate).await } async fn read_text_file( @@ -564,19 +154,14 @@ impl HostWorktree for WasmState { delegate: Resource>, path: String, ) -> wasmtime::Result> { - let delegate = self.table.get(&delegate)?; - Ok(delegate - .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?) - .await - .map_err(|error| error.to_string())) + latest::HostWorktree::read_text_file(self, delegate, path).await } async fn shell_env( &mut self, delegate: Resource>, ) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.shell_env().await.into_iter().collect()) + latest::HostWorktree::shell_env(self, delegate).await } async fn which( @@ -584,8 +169,7 @@ impl HostWorktree for WasmState { delegate: Resource>, binary_name: String, ) -> wasmtime::Result> { - let delegate = self.table.get(&delegate)?; - Ok(delegate.which(binary_name).await) + latest::HostWorktree::which(self, delegate, binary_name).await } async fn drop(&mut self, _worktree: Resource) -> Result<()> { @@ -594,319 +178,6 @@ impl HostWorktree for WasmState { } } -impl common::Host for WasmState {} - -impl http_client::Host for WasmState { - async fn fetch( - &mut self, - request: http_client::HttpRequest, - ) -> wasmtime::Result> { - maybe!(async { - let url = &request.url; - let request = convert_request(&request)?; - let mut response = self.host.http_client.send(request).await?; - - if response.status().is_client_error() || response.status().is_server_error() { - bail!("failed to fetch '{url}': status code {}", response.status()) - } - convert_response(&mut response).await - }) - .await - .to_wasmtime_result() - } - - async fn fetch_stream( - &mut self, - request: http_client::HttpRequest, - ) -> wasmtime::Result, String>> { - let request = convert_request(&request)?; - let response = self.host.http_client.send(request); - maybe!(async { - let response = response.await?; - let stream = Arc::new(Mutex::new(response)); - let resource = self.table.push(stream)?; - Ok(resource) - }) - .await - .to_wasmtime_result() - } -} - -impl http_client::HostHttpResponseStream for WasmState { - async fn next_chunk( - &mut self, - resource: Resource, - ) -> wasmtime::Result>, String>> { - let stream = self.table.get(&resource)?.clone(); - maybe!(async move { - let mut response = stream.lock().await; - let mut buffer = vec![0; 8192]; // 8KB buffer - let bytes_read = response.body_mut().read(&mut buffer).await?; - if bytes_read == 0 { - Ok(None) - } else { - buffer.truncate(bytes_read); - Ok(Some(buffer)) - } - }) - .await - .to_wasmtime_result() - } - - async fn drop(&mut self, _resource: Resource) -> Result<()> { - Ok(()) - } -} - -impl From for ::http_client::Method { - fn from(value: http_client::HttpMethod) -> Self { - match value { - http_client::HttpMethod::Get => Self::GET, - http_client::HttpMethod::Post => Self::POST, - http_client::HttpMethod::Put => Self::PUT, - http_client::HttpMethod::Delete => Self::DELETE, - http_client::HttpMethod::Head => Self::HEAD, - http_client::HttpMethod::Options => Self::OPTIONS, - http_client::HttpMethod::Patch => Self::PATCH, - } - } -} - -fn convert_request( - extension_request: &http_client::HttpRequest, -) -> anyhow::Result<::http_client::Request> { - let mut request = ::http_client::Request::builder() - .method(::http_client::Method::from(extension_request.method)) - .uri(&extension_request.url) - .follow_redirects(match extension_request.redirect_policy { - http_client::RedirectPolicy::NoFollow => ::http_client::RedirectPolicy::NoFollow, - http_client::RedirectPolicy::FollowLimit(limit) => { - ::http_client::RedirectPolicy::FollowLimit(limit) - } - http_client::RedirectPolicy::FollowAll => ::http_client::RedirectPolicy::FollowAll, - }); - for (key, value) in &extension_request.headers { - request = request.header(key, value); - } - let body = extension_request - .body - .clone() - .map(AsyncBody::from) - .unwrap_or_default(); - request.body(body).map_err(anyhow::Error::from) -} - -async fn convert_response( - response: &mut ::http_client::Response, -) -> anyhow::Result { - let mut extension_response = http_client::HttpResponse { - body: Vec::new(), - headers: Vec::new(), - }; - - for (key, value) in response.headers() { - extension_response - .headers - .push((key.to_string(), value.to_str().unwrap_or("").to_string())); - } - - response - .body_mut() - .read_to_end(&mut extension_response.body) - .await?; - - Ok(extension_response) -} - -impl nodejs::Host for WasmState { - async fn node_binary_path(&mut self) -> wasmtime::Result> { - self.host - .node_runtime - .binary_path() - .await - .map(|path| path.to_string_lossy().into_owned()) - .to_wasmtime_result() - } - - async fn npm_package_latest_version( - &mut self, - package_name: String, - ) -> wasmtime::Result> { - self.host - .node_runtime - .npm_package_latest_version(&package_name) - .await - .to_wasmtime_result() - } - - async fn npm_package_installed_version( - &mut self, - package_name: String, - ) -> wasmtime::Result, String>> { - self.host - .node_runtime - .npm_package_installed_version(&self.work_dir(), &package_name) - .await - .to_wasmtime_result() - } - - async fn npm_install_package( - &mut self, - package_name: String, - version: String, - ) -> wasmtime::Result> { - self.capability_granter - .grant_npm_install_package(&package_name)?; - - self.host - .node_runtime - .npm_install_packages(&self.work_dir(), &[(&package_name, &version)]) - .await - .to_wasmtime_result() - } -} - -#[async_trait] -impl lsp::Host for WasmState {} - -impl From<::http_client::github::GithubRelease> for github::GithubRelease { - fn from(value: ::http_client::github::GithubRelease) -> Self { - Self { - version: value.tag_name, - assets: value.assets.into_iter().map(Into::into).collect(), - } - } -} - -impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAsset { - fn from(value: ::http_client::github::GithubReleaseAsset) -> Self { - Self { - name: value.name, - download_url: value.browser_download_url, - } - } -} - -impl github::Host for WasmState { - async fn latest_github_release( - &mut self, - repo: String, - options: github::GithubReleaseOptions, - ) -> wasmtime::Result> { - maybe!(async { - let release = ::http_client::github::latest_github_release( - &repo, - options.require_assets, - options.pre_release, - self.host.http_client.clone(), - ) - .await?; - Ok(release.into()) - }) - .await - .to_wasmtime_result() - } - - async fn github_release_by_tag_name( - &mut self, - repo: String, - tag: String, - ) -> wasmtime::Result> { - maybe!(async { - let release = ::http_client::github::get_release_by_tag_name( - &repo, - &tag, - self.host.http_client.clone(), - ) - .await?; - Ok(release.into()) - }) - .await - .to_wasmtime_result() - } -} - -impl platform::Host for WasmState { - async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> { - Ok(( - match env::consts::OS { - "macos" => platform::Os::Mac, - "linux" => platform::Os::Linux, - "windows" => platform::Os::Windows, - _ => panic!("unsupported os"), - }, - match env::consts::ARCH { - "aarch64" => platform::Architecture::Aarch64, - "x86" => platform::Architecture::X86, - "x86_64" => platform::Architecture::X8664, - _ => panic!("unsupported architecture"), - }, - )) - } -} - -impl From for process::Output { - fn from(output: std::process::Output) -> Self { - Self { - status: output.status.code(), - stdout: output.stdout, - stderr: output.stderr, - } - } -} - -impl process::Host for WasmState { - async fn run_command( - &mut self, - command: process::Command, - ) -> wasmtime::Result> { - maybe!(async { - self.capability_granter - .grant_exec(&command.command, &command.args)?; - - let output = util::command::new_smol_command(command.command.as_str()) - .args(&command.args) - .envs(command.env) - .output() - .await?; - - Ok(output.into()) - }) - .await - .to_wasmtime_result() - } -} - -#[async_trait] -impl slash_command::Host for WasmState {} - -#[async_trait] -impl context_server::Host for WasmState {} - -impl dap::Host for WasmState { - async fn resolve_tcp_template( - &mut self, - template: TcpArgumentsTemplate, - ) -> wasmtime::Result> { - maybe!(async { - let (host, port, timeout) = - ::dap::configure_tcp_connection(task::TcpArgumentsTemplate { - port: template.port, - host: template.host.map(Ipv4Addr::from_bits), - timeout: template.timeout, - }) - .await?; - Ok(TcpArguments { - port, - host: host.to_bits(), - timeout, - }) - }) - .await - .to_wasmtime_result() - } -} - impl ExtensionImports for WasmState { async fn get_settings( &mut self, @@ -914,93 +185,13 @@ impl ExtensionImports for WasmState { category: String, key: Option, ) -> wasmtime::Result> { - self.on_main_thread(|cx| { - async move { - let path = location.as_ref().and_then(|location| { - RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() - }); - let location = path - .as_ref() - .zip(location.as_ref()) - .map(|(path, location)| ::settings::SettingsLocation { - worktree_id: WorktreeId::from_proto(location.worktree_id), - path, - }); - - cx.update(|cx| match category.as_str() { - "language" => { - let key = key.map(|k| LanguageName::new(&k)); - let settings = AllLanguageSettings::get(location, cx).language( - location, - key.as_ref(), - cx, - ); - Ok(serde_json::to_string(&settings::LanguageSettings { - tab_size: settings.tab_size, - })?) - } - "lsp" => { - let settings = key - .and_then(|key| { - ProjectSettings::get(location, cx) - .lsp - .get(&::lsp::LanguageServerName::from_proto(key)) - }) - .cloned() - .unwrap_or_default(); - Ok(serde_json::to_string(&settings::LspSettings { - binary: settings.binary.map(|binary| settings::CommandSettings { - path: binary.path, - arguments: binary.arguments, - env: binary.env.map(|env| env.into_iter().collect()), - }), - settings: settings.settings, - initialization_options: settings.initialization_options, - })?) - } - "context_servers" => { - let settings = key - .and_then(|key| { - ProjectSettings::get(location, cx) - .context_servers - .get(key.as_str()) - }) - .cloned() - .unwrap_or_else(|| { - project::project_settings::ContextServerSettings::default_extension( - ) - }); - - match settings { - project::project_settings::ContextServerSettings::Custom { - enabled: _, - command, - } => Ok(serde_json::to_string(&settings::ContextServerSettings { - command: Some(settings::CommandSettings { - path: command.path.to_str().map(|path| path.to_string()), - arguments: Some(command.args), - env: command.env.map(|env| env.into_iter().collect()), - }), - settings: None, - })?), - project::project_settings::ContextServerSettings::Extension { - enabled: _, - settings, - } => Ok(serde_json::to_string(&settings::ContextServerSettings { - command: None, - settings: Some(settings), - })?), - } - } - _ => { - bail!("Unknown settings category: {}", category); - } - }) - } - .boxed_local() - }) - .await? - .to_wasmtime_result() + latest::ExtensionImports::get_settings( + self, + location.map(|location| location.into()), + category, + key, + ) + .await } async fn set_language_server_installation_status( @@ -1008,18 +199,12 @@ impl ExtensionImports for WasmState { server_name: String, status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { - let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, - LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, - LanguageServerInstallationStatus::None => BinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, - }; - - self.host - .proxy - .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status); - - Ok(()) + latest::ExtensionImports::set_language_server_installation_status( + self, + server_name, + status.into(), + ) + .await } async fn download_file( @@ -1028,79 +213,10 @@ impl ExtensionImports for WasmState { path: String, file_type: DownloadedFileType, ) -> wasmtime::Result> { - maybe!(async { - let parsed_url = Url::parse(&url)?; - self.capability_granter.grant_download_file(&parsed_url)?; - - let path = PathBuf::from(path); - let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref()); - - self.host.fs.create_dir(&extension_work_dir).await?; - - let destination_path = self - .host - .writeable_path_from_extension(&self.manifest.id, &path)?; - - let mut response = self - .host - .http_client - .get(&url, Default::default(), true) - .await - .context("downloading release")?; - - anyhow::ensure!( - response.status().is_success(), - "download failed with status {}", - response.status().to_string() - ); - let body = BufReader::new(response.body_mut()); - - match file_type { - DownloadedFileType::Uncompressed => { - futures::pin_mut!(body); - self.host - .fs - .create_file_with(&destination_path, body) - .await?; - } - DownloadedFileType::Gzip => { - let body = GzipDecoder::new(body); - futures::pin_mut!(body); - self.host - .fs - .create_file_with(&destination_path, body) - .await?; - } - DownloadedFileType::GzipTar => { - let body = GzipDecoder::new(body); - futures::pin_mut!(body); - self.host - .fs - .extract_tar_file(&destination_path, Archive::new(body)) - .await?; - } - DownloadedFileType::Zip => { - futures::pin_mut!(body); - extract_zip(&destination_path, body) - .await - .with_context(|| format!("unzipping {path:?} archive"))?; - } - } - - Ok(()) - }) - .await - .to_wasmtime_result() + latest::ExtensionImports::download_file(self, url, path, file_type.into()).await } async fn make_file_executable(&mut self, path: String) -> wasmtime::Result> { - let path = self - .host - .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?; - - make_file_executable(&path) - .await - .with_context(|| format!("setting permissions for path {path:?}")) - .to_wasmtime_result() + latest::ExtensionImports::make_file_executable(self, path).await } } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs new file mode 100644 index 0000000000..a2776f9f3b --- /dev/null +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -0,0 +1,1109 @@ +use crate::wasm_host::wit::since_v0_6_0::{ + dap::{ + AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest, + StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate, + }, + slash_command::SlashCommandOutputSection, +}; +use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind}; +use crate::wasm_host::{WasmState, wit::ToWasmtimeResult}; +use ::http_client::{AsyncBody, HttpRequestExt}; +use ::settings::{Settings, WorktreeId}; +use anyhow::{Context as _, Result, bail}; +use async_compression::futures::bufread::GzipDecoder; +use async_tar::Archive; +use async_trait::async_trait; +use extension::{ + ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate, +}; +use futures::{AsyncReadExt, lock::Mutex}; +use futures::{FutureExt as _, io::BufReader}; +use gpui::{BackgroundExecutor, SharedString}; +use language::{BinaryStatus, LanguageName, language_settings::AllLanguageSettings}; +use project::project_settings::ProjectSettings; +use semver::Version; +use std::{ + env, + net::Ipv4Addr, + path::{Path, PathBuf}, + str::FromStr, + sync::{Arc, OnceLock}, +}; +use task::{SpawnInTerminal, ZedDebugConfig}; +use url::Url; +use util::{ + archive::extract_zip, fs::make_file_executable, maybe, paths::PathStyle, rel_path::RelPath, +}; +use wasmtime::component::{Linker, Resource}; + +pub const MIN_VERSION: Version = Version::new(0, 8, 0); +pub const MAX_VERSION: Version = Version::new(0, 8, 0); + +wasmtime::component::bindgen!({ + async: true, + trappable_imports: true, + path: "../extension_api/wit/since_v0.8.0", + with: { + "worktree": ExtensionWorktree, + "project": ExtensionProject, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream + }, +}); + +pub use self::zed::extension::*; + +mod settings { + #![allow(dead_code)] + include!(concat!(env!("OUT_DIR"), "/since_v0.8.0/settings.rs")); +} + +pub type ExtensionWorktree = Arc; +pub type ExtensionProject = Arc; +pub type ExtensionKeyValueStore = Arc; +pub type ExtensionHttpResponseStream = Arc>>; + +pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { + static LINKER: OnceLock> = OnceLock::new(); + LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) +} + +impl From for std::ops::Range { + fn from(range: Range) -> Self { + let start = range.start as usize; + let end = range.end as usize; + start..end + } +} + +impl From for extension::Command { + fn from(value: Command) -> Self { + Self { + command: value.command.into(), + args: value.args, + env: value.env, + } + } +} + +impl From + for extension::StartDebuggingRequestArgumentsRequest +{ + fn from(value: StartDebuggingRequestArgumentsRequest) -> Self { + match value { + StartDebuggingRequestArgumentsRequest::Launch => Self::Launch, + StartDebuggingRequestArgumentsRequest::Attach => Self::Attach, + } + } +} +impl TryFrom for extension::StartDebuggingRequestArguments { + type Error = anyhow::Error; + + fn try_from(value: StartDebuggingRequestArguments) -> Result { + Ok(Self { + configuration: serde_json::from_str(&value.configuration)?, + request: value.request.into(), + }) + } +} +impl From for extension::TcpArguments { + fn from(value: TcpArguments) -> Self { + Self { + host: value.host.into(), + port: value.port, + timeout: value.timeout, + } + } +} + +impl From for TcpArgumentsTemplate { + fn from(value: extension::TcpArgumentsTemplate) -> Self { + Self { + host: value.host.map(Ipv4Addr::to_bits), + port: value.port, + timeout: value.timeout, + } + } +} + +impl From for extension::TcpArgumentsTemplate { + fn from(value: TcpArgumentsTemplate) -> Self { + Self { + host: value.host.map(Ipv4Addr::from_bits), + port: value.port, + timeout: value.timeout, + } + } +} + +impl TryFrom for DebugTaskDefinition { + type Error = anyhow::Error; + fn try_from(value: extension::DebugTaskDefinition) -> Result { + Ok(Self { + label: value.label.to_string(), + adapter: value.adapter.to_string(), + config: value.config.to_string(), + tcp_connection: value.tcp_connection.map(Into::into), + }) + } +} + +impl From for DebugRequest { + fn from(value: task::DebugRequest) -> Self { + match value { + task::DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), + task::DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), + } + } +} + +impl From for task::DebugRequest { + fn from(value: DebugRequest) -> Self { + match value { + DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), + DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), + } + } +} + +impl From for LaunchRequest { + fn from(value: task::LaunchRequest) -> Self { + Self { + program: value.program, + cwd: value.cwd.map(|p| p.to_string_lossy().into_owned()), + args: value.args, + envs: value.env.into_iter().collect(), + } + } +} + +impl From for AttachRequest { + fn from(value: task::AttachRequest) -> Self { + Self { + process_id: value.process_id, + } + } +} + +impl From for task::LaunchRequest { + fn from(value: LaunchRequest) -> Self { + Self { + program: value.program, + cwd: value.cwd.map(|p| p.into()), + args: value.args, + env: value.envs.into_iter().collect(), + } + } +} +impl From for task::AttachRequest { + fn from(value: AttachRequest) -> Self { + Self { + process_id: value.process_id, + } + } +} + +impl From for DebugConfig { + fn from(value: ZedDebugConfig) -> Self { + Self { + label: value.label.into(), + adapter: value.adapter.into(), + request: value.request.into(), + stop_on_entry: value.stop_on_entry, + } + } +} +impl TryFrom for extension::DebugAdapterBinary { + type Error = anyhow::Error; + fn try_from(value: DebugAdapterBinary) -> Result { + Ok(Self { + command: value.command, + arguments: value.arguments, + envs: value.envs.into_iter().collect(), + cwd: value.cwd.map(|s| s.into()), + connection: value.connection.map(Into::into), + request_args: value.request_args.try_into()?, + }) + } +} + +impl From for extension::BuildTaskDefinition { + fn from(value: BuildTaskDefinition) -> Self { + match value { + BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), + BuildTaskDefinition::Template(build_task_template) => Self::Template { + task_template: build_task_template.template.into(), + locator_name: build_task_template.locator_name.map(SharedString::from), + }, + } + } +} + +impl From for BuildTaskDefinition { + fn from(value: extension::BuildTaskDefinition) -> Self { + match value { + extension::BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), + extension::BuildTaskDefinition::Template { + task_template, + locator_name, + } => Self::Template(BuildTaskDefinitionTemplatePayload { + template: task_template.into(), + locator_name: locator_name.map(String::from), + }), + } + } +} +impl From for extension::BuildTaskTemplate { + fn from(value: BuildTaskTemplate) -> Self { + Self { + label: value.label, + command: value.command, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd, + ..Default::default() + } + } +} +impl From for BuildTaskTemplate { + fn from(value: extension::BuildTaskTemplate) -> Self { + Self { + label: value.label, + command: value.command, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd, + } + } +} + +impl TryFrom for extension::DebugScenario { + type Error = anyhow::Error; + + fn try_from(value: DebugScenario) -> std::result::Result { + Ok(Self { + adapter: value.adapter.into(), + label: value.label.into(), + build: value.build.map(Into::into), + config: serde_json::Value::from_str(&value.config)?, + tcp_connection: value.tcp_connection.map(Into::into), + }) + } +} + +impl From for DebugScenario { + fn from(value: extension::DebugScenario) -> Self { + Self { + adapter: value.adapter.into(), + label: value.label.into(), + build: value.build.map(Into::into), + config: value.config.to_string(), + tcp_connection: value.tcp_connection.map(Into::into), + } + } +} + +impl TryFrom for ResolvedTask { + type Error = anyhow::Error; + + fn try_from(value: SpawnInTerminal) -> Result { + Ok(Self { + label: value.label, + command: value.command.context("missing command")?, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd.map(|s| { + let s = s.to_string_lossy(); + if cfg!(target_os = "windows") { + s.replace('\\', "/") + } else { + s.into_owned() + } + }), + }) + } +} + +impl From for extension::CodeLabel { + fn from(value: CodeLabel) -> Self { + Self { + code: value.code, + spans: value.spans.into_iter().map(Into::into).collect(), + filter_range: value.filter_range.into(), + } + } +} + +impl From for extension::CodeLabelSpan { + fn from(value: CodeLabelSpan) -> Self { + match value { + CodeLabelSpan::CodeRange(range) => Self::CodeRange(range.into()), + CodeLabelSpan::Literal(literal) => Self::Literal(literal.into()), + } + } +} + +impl From for extension::CodeLabelSpanLiteral { + fn from(value: CodeLabelSpanLiteral) -> Self { + Self { + text: value.text, + highlight_name: value.highlight_name, + } + } +} + +impl From for Completion { + fn from(value: extension::Completion) -> Self { + Self { + label: value.label, + label_details: value.label_details.map(Into::into), + detail: value.detail, + kind: value.kind.map(Into::into), + insert_text_format: value.insert_text_format.map(Into::into), + } + } +} + +impl From for CompletionLabelDetails { + fn from(value: extension::CompletionLabelDetails) -> Self { + Self { + detail: value.detail, + description: value.description, + } + } +} + +impl From for CompletionKind { + fn from(value: extension::CompletionKind) -> Self { + match value { + extension::CompletionKind::Text => Self::Text, + extension::CompletionKind::Method => Self::Method, + extension::CompletionKind::Function => Self::Function, + extension::CompletionKind::Constructor => Self::Constructor, + extension::CompletionKind::Field => Self::Field, + extension::CompletionKind::Variable => Self::Variable, + extension::CompletionKind::Class => Self::Class, + extension::CompletionKind::Interface => Self::Interface, + extension::CompletionKind::Module => Self::Module, + extension::CompletionKind::Property => Self::Property, + extension::CompletionKind::Unit => Self::Unit, + extension::CompletionKind::Value => Self::Value, + extension::CompletionKind::Enum => Self::Enum, + extension::CompletionKind::Keyword => Self::Keyword, + extension::CompletionKind::Snippet => Self::Snippet, + extension::CompletionKind::Color => Self::Color, + extension::CompletionKind::File => Self::File, + extension::CompletionKind::Reference => Self::Reference, + extension::CompletionKind::Folder => Self::Folder, + extension::CompletionKind::EnumMember => Self::EnumMember, + extension::CompletionKind::Constant => Self::Constant, + extension::CompletionKind::Struct => Self::Struct, + extension::CompletionKind::Event => Self::Event, + extension::CompletionKind::Operator => Self::Operator, + extension::CompletionKind::TypeParameter => Self::TypeParameter, + extension::CompletionKind::Other(value) => Self::Other(value), + } + } +} + +impl From for InsertTextFormat { + fn from(value: extension::InsertTextFormat) -> Self { + match value { + extension::InsertTextFormat::PlainText => Self::PlainText, + extension::InsertTextFormat::Snippet => Self::Snippet, + extension::InsertTextFormat::Other(value) => Self::Other(value), + } + } +} + +impl From for Symbol { + fn from(value: extension::Symbol) -> Self { + Self { + kind: value.kind.into(), + name: value.name, + } + } +} + +impl From for SymbolKind { + fn from(value: extension::SymbolKind) -> Self { + match value { + extension::SymbolKind::File => Self::File, + extension::SymbolKind::Module => Self::Module, + extension::SymbolKind::Namespace => Self::Namespace, + extension::SymbolKind::Package => Self::Package, + extension::SymbolKind::Class => Self::Class, + extension::SymbolKind::Method => Self::Method, + extension::SymbolKind::Property => Self::Property, + extension::SymbolKind::Field => Self::Field, + extension::SymbolKind::Constructor => Self::Constructor, + extension::SymbolKind::Enum => Self::Enum, + extension::SymbolKind::Interface => Self::Interface, + extension::SymbolKind::Function => Self::Function, + extension::SymbolKind::Variable => Self::Variable, + extension::SymbolKind::Constant => Self::Constant, + extension::SymbolKind::String => Self::String, + extension::SymbolKind::Number => Self::Number, + extension::SymbolKind::Boolean => Self::Boolean, + extension::SymbolKind::Array => Self::Array, + extension::SymbolKind::Object => Self::Object, + extension::SymbolKind::Key => Self::Key, + extension::SymbolKind::Null => Self::Null, + extension::SymbolKind::EnumMember => Self::EnumMember, + extension::SymbolKind::Struct => Self::Struct, + extension::SymbolKind::Event => Self::Event, + extension::SymbolKind::Operator => Self::Operator, + extension::SymbolKind::TypeParameter => Self::TypeParameter, + extension::SymbolKind::Other(value) => Self::Other(value), + } + } +} + +impl From for SlashCommand { + fn from(value: extension::SlashCommand) -> Self { + Self { + name: value.name, + description: value.description, + tooltip_text: value.tooltip_text, + requires_argument: value.requires_argument, + } + } +} + +impl From for extension::SlashCommandOutput { + fn from(value: SlashCommandOutput) -> Self { + Self { + text: value.text, + sections: value.sections.into_iter().map(Into::into).collect(), + } + } +} + +impl From for extension::SlashCommandOutputSection { + fn from(value: SlashCommandOutputSection) -> Self { + Self { + range: value.range.start as usize..value.range.end as usize, + label: value.label, + } + } +} + +impl From for extension::SlashCommandArgumentCompletion { + fn from(value: SlashCommandArgumentCompletion) -> Self { + Self { + label: value.label, + new_text: value.new_text, + run_command: value.run_command, + } + } +} + +impl TryFrom for extension::ContextServerConfiguration { + type Error = anyhow::Error; + + fn try_from(value: ContextServerConfiguration) -> Result { + let settings_schema: serde_json::Value = serde_json::from_str(&value.settings_schema) + .context("Failed to parse settings_schema")?; + + Ok(Self { + installation_instructions: value.installation_instructions, + default_settings: value.default_settings, + settings_schema, + }) + } +} + +impl HostKeyValueStore for WasmState { + async fn insert( + &mut self, + kv_store: Resource, + key: String, + value: String, + ) -> wasmtime::Result> { + let kv_store = self.table.get(&kv_store)?; + kv_store.insert(key, value).await.to_wasmtime_result() + } + + async fn drop(&mut self, _worktree: Resource) -> Result<()> { + // We only ever hand out borrows of key-value stores. + Ok(()) + } +} + +impl HostProject for WasmState { + async fn worktree_ids( + &mut self, + project: Resource, + ) -> wasmtime::Result> { + let project = self.table.get(&project)?; + Ok(project.worktree_ids()) + } + + async fn drop(&mut self, _project: Resource) -> Result<()> { + // We only ever hand out borrows of projects. + Ok(()) + } +} + +impl HostWorktree for WasmState { + async fn id(&mut self, delegate: Resource>) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.id()) + } + + async fn root_path( + &mut self, + delegate: Resource>, + ) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.root_path()) + } + + async fn read_text_file( + &mut self, + delegate: Resource>, + path: String, + ) -> wasmtime::Result> { + let delegate = self.table.get(&delegate)?; + Ok(delegate + .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?) + .await + .map_err(|error| error.to_string())) + } + + async fn shell_env( + &mut self, + delegate: Resource>, + ) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.shell_env().await.into_iter().collect()) + } + + async fn which( + &mut self, + delegate: Resource>, + binary_name: String, + ) -> wasmtime::Result> { + let delegate = self.table.get(&delegate)?; + Ok(delegate.which(binary_name).await) + } + + async fn drop(&mut self, _worktree: Resource) -> Result<()> { + // We only ever hand out borrows of worktrees. + Ok(()) + } +} + +impl common::Host for WasmState {} + +impl http_client::Host for WasmState { + async fn fetch( + &mut self, + request: http_client::HttpRequest, + ) -> wasmtime::Result> { + maybe!(async { + let url = &request.url; + let request = convert_request(&request)?; + let mut response = self.host.http_client.send(request).await?; + + if response.status().is_client_error() || response.status().is_server_error() { + bail!("failed to fetch '{url}': status code {}", response.status()) + } + convert_response(&mut response).await + }) + .await + .to_wasmtime_result() + } + + async fn fetch_stream( + &mut self, + request: http_client::HttpRequest, + ) -> wasmtime::Result, String>> { + let request = convert_request(&request)?; + let response = self.host.http_client.send(request); + maybe!(async { + let response = response.await?; + let stream = Arc::new(Mutex::new(response)); + let resource = self.table.push(stream)?; + Ok(resource) + }) + .await + .to_wasmtime_result() + } +} + +impl http_client::HostHttpResponseStream for WasmState { + async fn next_chunk( + &mut self, + resource: Resource, + ) -> wasmtime::Result>, String>> { + let stream = self.table.get(&resource)?.clone(); + maybe!(async move { + let mut response = stream.lock().await; + let mut buffer = vec![0; 8192]; // 8KB buffer + let bytes_read = response.body_mut().read(&mut buffer).await?; + if bytes_read == 0 { + Ok(None) + } else { + buffer.truncate(bytes_read); + Ok(Some(buffer)) + } + }) + .await + .to_wasmtime_result() + } + + async fn drop(&mut self, _resource: Resource) -> Result<()> { + Ok(()) + } +} + +impl From for ::http_client::Method { + fn from(value: http_client::HttpMethod) -> Self { + match value { + http_client::HttpMethod::Get => Self::GET, + http_client::HttpMethod::Post => Self::POST, + http_client::HttpMethod::Put => Self::PUT, + http_client::HttpMethod::Delete => Self::DELETE, + http_client::HttpMethod::Head => Self::HEAD, + http_client::HttpMethod::Options => Self::OPTIONS, + http_client::HttpMethod::Patch => Self::PATCH, + } + } +} + +fn convert_request( + extension_request: &http_client::HttpRequest, +) -> anyhow::Result<::http_client::Request> { + let mut request = ::http_client::Request::builder() + .method(::http_client::Method::from(extension_request.method)) + .uri(&extension_request.url) + .follow_redirects(match extension_request.redirect_policy { + http_client::RedirectPolicy::NoFollow => ::http_client::RedirectPolicy::NoFollow, + http_client::RedirectPolicy::FollowLimit(limit) => { + ::http_client::RedirectPolicy::FollowLimit(limit) + } + http_client::RedirectPolicy::FollowAll => ::http_client::RedirectPolicy::FollowAll, + }); + for (key, value) in &extension_request.headers { + request = request.header(key, value); + } + let body = extension_request + .body + .clone() + .map(AsyncBody::from) + .unwrap_or_default(); + request.body(body).map_err(anyhow::Error::from) +} + +async fn convert_response( + response: &mut ::http_client::Response, +) -> anyhow::Result { + let mut extension_response = http_client::HttpResponse { + body: Vec::new(), + headers: Vec::new(), + }; + + for (key, value) in response.headers() { + extension_response + .headers + .push((key.to_string(), value.to_str().unwrap_or("").to_string())); + } + + response + .body_mut() + .read_to_end(&mut extension_response.body) + .await?; + + Ok(extension_response) +} + +impl nodejs::Host for WasmState { + async fn node_binary_path(&mut self) -> wasmtime::Result> { + self.host + .node_runtime + .binary_path() + .await + .map(|path| path.to_string_lossy().into_owned()) + .to_wasmtime_result() + } + + async fn npm_package_latest_version( + &mut self, + package_name: String, + ) -> wasmtime::Result> { + self.host + .node_runtime + .npm_package_latest_version(&package_name) + .await + .to_wasmtime_result() + } + + async fn npm_package_installed_version( + &mut self, + package_name: String, + ) -> wasmtime::Result, String>> { + self.host + .node_runtime + .npm_package_installed_version(&self.work_dir(), &package_name) + .await + .to_wasmtime_result() + } + + async fn npm_install_package( + &mut self, + package_name: String, + version: String, + ) -> wasmtime::Result> { + self.capability_granter + .grant_npm_install_package(&package_name)?; + + self.host + .node_runtime + .npm_install_packages(&self.work_dir(), &[(&package_name, &version)]) + .await + .to_wasmtime_result() + } +} + +#[async_trait] +impl lsp::Host for WasmState {} + +impl From<::http_client::github::GithubRelease> for github::GithubRelease { + fn from(value: ::http_client::github::GithubRelease) -> Self { + Self { + version: value.tag_name, + assets: value.assets.into_iter().map(Into::into).collect(), + } + } +} + +impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAsset { + fn from(value: ::http_client::github::GithubReleaseAsset) -> Self { + Self { + name: value.name, + download_url: value.browser_download_url, + } + } +} + +impl github::Host for WasmState { + async fn latest_github_release( + &mut self, + repo: String, + options: github::GithubReleaseOptions, + ) -> wasmtime::Result> { + maybe!(async { + let release = ::http_client::github::latest_github_release( + &repo, + options.require_assets, + options.pre_release, + self.host.http_client.clone(), + ) + .await?; + Ok(release.into()) + }) + .await + .to_wasmtime_result() + } + + async fn github_release_by_tag_name( + &mut self, + repo: String, + tag: String, + ) -> wasmtime::Result> { + maybe!(async { + let release = ::http_client::github::get_release_by_tag_name( + &repo, + &tag, + self.host.http_client.clone(), + ) + .await?; + Ok(release.into()) + }) + .await + .to_wasmtime_result() + } +} + +impl platform::Host for WasmState { + async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> { + Ok(( + match env::consts::OS { + "macos" => platform::Os::Mac, + "linux" => platform::Os::Linux, + "windows" => platform::Os::Windows, + _ => panic!("unsupported os"), + }, + match env::consts::ARCH { + "aarch64" => platform::Architecture::Aarch64, + "x86" => platform::Architecture::X86, + "x86_64" => platform::Architecture::X8664, + _ => panic!("unsupported architecture"), + }, + )) + } +} + +impl From for process::Output { + fn from(output: std::process::Output) -> Self { + Self { + status: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + } + } +} + +impl process::Host for WasmState { + async fn run_command( + &mut self, + command: process::Command, + ) -> wasmtime::Result> { + maybe!(async { + self.capability_granter + .grant_exec(&command.command, &command.args)?; + + let output = util::command::new_smol_command(command.command.as_str()) + .args(&command.args) + .envs(command.env) + .output() + .await?; + + Ok(output.into()) + }) + .await + .to_wasmtime_result() + } +} + +#[async_trait] +impl slash_command::Host for WasmState {} + +#[async_trait] +impl context_server::Host for WasmState {} + +impl dap::Host for WasmState { + async fn resolve_tcp_template( + &mut self, + template: TcpArgumentsTemplate, + ) -> wasmtime::Result> { + maybe!(async { + let (host, port, timeout) = + ::dap::configure_tcp_connection(task::TcpArgumentsTemplate { + port: template.port, + host: template.host.map(Ipv4Addr::from_bits), + timeout: template.timeout, + }) + .await?; + Ok(TcpArguments { + port, + host: host.to_bits(), + timeout, + }) + }) + .await + .to_wasmtime_result() + } +} + +impl ExtensionImports for WasmState { + async fn get_settings( + &mut self, + location: Option, + category: String, + key: Option, + ) -> wasmtime::Result> { + self.on_main_thread(|cx| { + async move { + let path = location.as_ref().and_then(|location| { + RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() + }); + let location = path + .as_ref() + .zip(location.as_ref()) + .map(|(path, location)| ::settings::SettingsLocation { + worktree_id: WorktreeId::from_proto(location.worktree_id), + path, + }); + + cx.update(|cx| match category.as_str() { + "language" => { + let key = key.map(|k| LanguageName::new(&k)); + let settings = AllLanguageSettings::get(location, cx).language( + location, + key.as_ref(), + cx, + ); + Ok(serde_json::to_string(&settings::LanguageSettings { + tab_size: settings.tab_size, + })?) + } + "lsp" => { + let settings = key + .and_then(|key| { + ProjectSettings::get(location, cx) + .lsp + .get(&::lsp::LanguageServerName::from_proto(key)) + }) + .cloned() + .unwrap_or_default(); + Ok(serde_json::to_string(&settings::LspSettings { + binary: settings.binary.map(|binary| settings::CommandSettings { + path: binary.path, + arguments: binary.arguments, + env: binary.env.map(|env| env.into_iter().collect()), + }), + settings: settings.settings, + initialization_options: settings.initialization_options, + })?) + } + "context_servers" => { + let settings = key + .and_then(|key| { + ProjectSettings::get(location, cx) + .context_servers + .get(key.as_str()) + }) + .cloned() + .unwrap_or_else(|| { + project::project_settings::ContextServerSettings::default_extension( + ) + }); + + match settings { + project::project_settings::ContextServerSettings::Stdio { + enabled: _, + command, + } => Ok(serde_json::to_string(&settings::ContextServerSettings { + command: Some(settings::CommandSettings { + path: command.path.to_str().map(|path| path.to_string()), + arguments: Some(command.args), + env: command.env.map(|env| env.into_iter().collect()), + }), + settings: None, + })?), + project::project_settings::ContextServerSettings::Extension { + enabled: _, + settings, + } => Ok(serde_json::to_string(&settings::ContextServerSettings { + command: None, + settings: Some(settings), + })?), + project::project_settings::ContextServerSettings::Http { .. } => { + bail!("remote context server settings not supported in 0.6.0") + } + } + } + _ => { + bail!("Unknown settings category: {}", category); + } + }) + } + .boxed_local() + }) + .await? + .to_wasmtime_result() + } + + async fn set_language_server_installation_status( + &mut self, + server_name: String, + status: LanguageServerInstallationStatus, + ) -> wasmtime::Result<()> { + let status = match status { + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, + LanguageServerInstallationStatus::None => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, + }; + + self.host + .proxy + .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status); + + Ok(()) + } + + async fn download_file( + &mut self, + url: String, + path: String, + file_type: DownloadedFileType, + ) -> wasmtime::Result> { + maybe!(async { + let parsed_url = Url::parse(&url)?; + self.capability_granter.grant_download_file(&parsed_url)?; + + let path = PathBuf::from(path); + let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref()); + + self.host.fs.create_dir(&extension_work_dir).await?; + + let destination_path = self + .host + .writeable_path_from_extension(&self.manifest.id, &path)?; + + let mut response = self + .host + .http_client + .get(&url, Default::default(), true) + .await + .context("downloading release")?; + + anyhow::ensure!( + response.status().is_success(), + "download failed with status {}", + response.status() + ); + let body = BufReader::new(response.body_mut()); + + match file_type { + DownloadedFileType::Uncompressed => { + futures::pin_mut!(body); + self.host + .fs + .create_file_with(&destination_path, body) + .await?; + } + DownloadedFileType::Gzip => { + let body = GzipDecoder::new(body); + futures::pin_mut!(body); + self.host + .fs + .create_file_with(&destination_path, body) + .await?; + } + DownloadedFileType::GzipTar => { + let body = GzipDecoder::new(body); + futures::pin_mut!(body); + self.host + .fs + .extract_tar_file(&destination_path, Archive::new(body)) + .await?; + } + DownloadedFileType::Zip => { + futures::pin_mut!(body); + extract_zip(&destination_path, body) + .await + .with_context(|| format!("unzipping {path:?} archive"))?; + } + } + + Ok(()) + }) + .await + .to_wasmtime_result() + } + + async fn make_file_executable(&mut self, path: String) -> wasmtime::Result> { + let path = self + .host + .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?; + + make_file_executable(&path) + .await + .with_context(|| format!("setting permissions for path {path:?}")) + .to_wasmtime_result() + } +} diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index c31483d763..707938a9eb 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -28,7 +28,7 @@ num-format.workspace = true picker.workspace = true project.workspace = true release_channel.workspace = true -semantic_version.workspace = true +semver.workspace = true serde.workspace = true settings.workspace = true smallvec.workspace = true @@ -38,7 +38,6 @@ theme.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/extensions_ui/src/components.rs b/crates/extensions_ui/src/components.rs index 957980e49f..bf11abd679 100644 --- a/crates/extensions_ui/src/components.rs +++ b/crates/extensions_ui/src/components.rs @@ -1,5 +1,3 @@ mod extension_card; -mod feature_upsell; pub use extension_card::*; -pub use feature_upsell::*; diff --git a/crates/extensions_ui/src/components/extension_card.rs b/crates/extensions_ui/src/components/extension_card.rs index abdd32fee9..524f90c7f0 100644 --- a/crates/extensions_ui/src/components/extension_card.rs +++ b/crates/extensions_ui/src/components/extension_card.rs @@ -32,14 +32,14 @@ impl RenderOnce for ExtensionCard { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { div().w_full().child( v_flex() - .w_full() - .h(rems(7.)) - .p_3() .mt_4() + .w_full() + .h(rems_from_px(110.)) + .p_3() .gap_2() - .bg(cx.theme().colors().elevated_surface_background) + .bg(cx.theme().colors().elevated_surface_background.opacity(0.5)) .border_1() - .border_color(cx.theme().colors().border) + .border_color(cx.theme().colors().border_variant) .rounded_md() .children(self.children) .when(self.overridden_by_dev_extension, |card| { @@ -51,7 +51,6 @@ impl RenderOnce for ExtensionCard { .block_mouse_except_scroll() .cursor_default() .size_full() - .items_center() .justify_center() .bg(cx.theme().colors().elevated_surface_background.alpha(0.8)) .child(Label::new("Overridden by dev extension.")), diff --git a/crates/extensions_ui/src/components/feature_upsell.rs b/crates/extensions_ui/src/components/feature_upsell.rs deleted file mode 100644 index 0515dd46d3..0000000000 --- a/crates/extensions_ui/src/components/feature_upsell.rs +++ /dev/null @@ -1,77 +0,0 @@ -use gpui::{AnyElement, Div, StyleRefinement}; -use smallvec::SmallVec; -use ui::prelude::*; - -#[derive(IntoElement)] -pub struct FeatureUpsell { - base: Div, - text: SharedString, - docs_url: Option, - children: SmallVec<[AnyElement; 2]>, -} - -impl FeatureUpsell { - pub fn new(text: impl Into) -> Self { - Self { - base: h_flex(), - text: text.into(), - docs_url: None, - children: SmallVec::new(), - } - } - - pub fn docs_url(mut self, docs_url: impl Into) -> Self { - self.docs_url = Some(docs_url.into()); - self - } -} - -impl ParentElement for FeatureUpsell { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements) - } -} - -// Style methods. -impl FeatureUpsell { - fn style(&mut self) -> &mut StyleRefinement { - self.base.style() - } - - gpui::border_style_methods!({ - visibility: pub - }); -} - -impl RenderOnce for FeatureUpsell { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - self.base - .py_2() - .px_4() - .justify_between() - .flex_wrap() - .border_color(cx.theme().colors().border_variant) - .child(Label::new(self.text)) - .child(h_flex().gap_2().children(self.children).when_some( - self.docs_url, - |el, docs_url| { - el.child( - Button::new("open_docs", "View Documentation") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) - .on_click({ - move |_event, _window, cx| { - telemetry::event!( - "Documentation Viewed", - source = "Feature Upsell", - url = docs_url, - ); - cx.open_url(&docs_url) - } - }), - ) - }, - )) - } -} diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index 5dcd1e2105..7ad4c1540a 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -75,6 +75,7 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("vue", &["vue"]), ("wgsl", &["wgsl"]), ("wit", &["wit"]), + ("xml", &["xml"]), ("zig", &["zig"]), ]; diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index d38c27375f..17d293da76 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -8,7 +8,7 @@ use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{App, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, prelude::*}; use picker::{Picker, PickerDelegate}; use release_channel::ReleaseChannel; -use semantic_version::SemanticVersion; +use semver::Version; use settings::update_settings_file; use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; @@ -60,8 +60,8 @@ impl ExtensionVersionSelectorDelegate { mut extension_versions: Vec, ) -> Self { extension_versions.sort_unstable_by(|a, b| { - let a_version = SemanticVersion::from_str(&a.manifest.version); - let b_version = SemanticVersion::from_str(&b.manifest.version); + let a_version = Version::from_str(&a.manifest.version); + let b_version = Version::from_str(&b.manifest.version); match (a_version, b_version) { (Ok(a_version), Ok(b_version)) => b_version.cmp(&a_version), diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index dc40bad4e0..3dd4803ce1 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -13,8 +13,8 @@ use editor::{Editor, EditorElement, EditorStyle}; use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{ - Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten, Focusable, - InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle, + Action, App, ClipboardItem, Context, Corner, Entity, EventEmitter, Flatten, Focusable, + InteractiveElement, KeyContext, ParentElement, Point, Render, Styled, Task, TextStyle, UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list, }; use num_format::{Locale, ToFormattedString}; @@ -24,8 +24,9 @@ use settings::{Settings, SettingsContent}; use strum::IntoEnumIterator as _; use theme::ThemeSettings; use ui::{ - CheckboxWithLabel, Chip, ContextMenu, PopoverMenu, ScrollableHandle, ToggleButton, Tooltip, - WithScrollbar, prelude::*, + Banner, Chip, ContextMenu, Divider, PopoverMenu, ScrollableHandle, Switch, ToggleButtonGroup, + ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, + prelude::*, }; use vim_mode_setting::VimModeSetting; use workspace::{ @@ -34,7 +35,7 @@ use workspace::{ }; use zed_actions::ExtensionCategoryFilter; -use crate::components::{ExtensionCard, FeatureUpsell}; +use crate::components::ExtensionCard; use crate::extension_version_selector::{ ExtensionVersionSelector, ExtensionVersionSelectorDelegate, }; @@ -66,6 +67,7 @@ pub fn init(cx: &mut App) { ExtensionCategoryFilter::ContextServers => { ExtensionProvides::ContextServers } + ExtensionCategoryFilter::AgentServers => ExtensionProvides::AgentServers, ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands, ExtensionCategoryFilter::IndexedDocsProviders => { ExtensionProvides::IndexedDocsProviders @@ -189,6 +191,7 @@ fn extension_provides_label(provides: ExtensionProvides) -> &'static str { ExtensionProvides::Grammars => "Grammars", ExtensionProvides::LanguageServers => "Language Servers", ExtensionProvides::ContextServers => "MCP Servers", + ExtensionProvides::AgentServers => "Agent Servers", ExtensionProvides::SlashCommands => "Slash Commands", ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers", ExtensionProvides::Snippets => "Snippets", @@ -223,9 +226,14 @@ impl ExtensionFilter { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] enum Feature { + AgentClaude, + AgentCodex, + AgentGemini, + ExtensionBasedpyright, + ExtensionRuff, + ExtensionTailwind, + ExtensionTy, Git, - OpenIn, - Vim, LanguageBash, LanguageC, LanguageCpp, @@ -234,13 +242,36 @@ enum Feature { LanguageReact, LanguageRust, LanguageTypescript, + OpenIn, + Vim, } fn keywords_by_feature() -> &'static BTreeMap> { static KEYWORDS_BY_FEATURE: OnceLock>> = OnceLock::new(); KEYWORDS_BY_FEATURE.get_or_init(|| { BTreeMap::from_iter([ + (Feature::AgentClaude, vec!["claude", "claude code"]), + (Feature::AgentCodex, vec!["codex", "codex cli"]), + (Feature::AgentGemini, vec!["gemini", "gemini cli"]), + ( + Feature::ExtensionBasedpyright, + vec!["basedpyright", "pyright"], + ), + (Feature::ExtensionRuff, vec!["ruff"]), + (Feature::ExtensionTailwind, vec!["tail", "tailwind"]), + (Feature::ExtensionTy, vec!["ty"]), (Feature::Git, vec!["git"]), + (Feature::LanguageBash, vec!["sh", "bash"]), + (Feature::LanguageC, vec!["c", "clang"]), + (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]), + (Feature::LanguageGo, vec!["go", "golang"]), + (Feature::LanguagePython, vec!["python", "py"]), + (Feature::LanguageReact, vec!["react"]), + (Feature::LanguageRust, vec!["rust", "rs"]), + ( + Feature::LanguageTypescript, + vec!["type", "typescript", "ts"], + ), ( Feature::OpenIn, vec![ @@ -255,17 +286,6 @@ fn keywords_by_feature() -> &'static BTreeMap> { ], ), (Feature::Vim, vec!["vim"]), - (Feature::LanguageBash, vec!["sh", "bash"]), - (Feature::LanguageC, vec!["c", "clang"]), - (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]), - (Feature::LanguageGo, vec!["go", "golang"]), - (Feature::LanguagePython, vec!["python", "py"]), - (Feature::LanguageReact, vec!["react"]), - (Feature::LanguageRust, vec!["rust", "rs"]), - ( - Feature::LanguageTypescript, - vec!["type", "typescript", "ts"], - ), ]) }) } @@ -280,6 +300,7 @@ pub struct ExtensionsPage { workspace: WeakEntity, list: UniformListScrollHandle, is_fetching_extensions: bool, + fetch_failed: bool, filter: ExtensionFilter, remote_extension_entries: Vec, dev_extension_entries: Vec>, @@ -340,6 +361,7 @@ impl ExtensionsPage { workspace: workspace.weak_handle(), list: scroll_handle, is_fetching_extensions: false, + fetch_failed: false, filter: ExtensionFilter::All, dev_extension_entries: Vec::new(), filtered_remote_extension_indices: Vec::new(), @@ -466,6 +488,7 @@ impl ExtensionsPage { cx: &mut Context, ) { self.is_fetching_extensions = true; + self.fetch_failed = false; cx.notify(); let extension_store = ExtensionStore::global(cx); @@ -521,17 +544,31 @@ impl ExtensionsPage { }; let fetch_result = remote_extensions.await; - this.update(cx, |this, cx| { + + let result = this.update(cx, |this, cx| { cx.notify(); this.dev_extension_entries = dev_extensions; this.is_fetching_extensions = false; - this.remote_extension_entries = fetch_result?; - this.filter_extension_entries(cx); - if let Some(callback) = on_complete { - callback(this, cx); + + match fetch_result { + Ok(extensions) => { + this.fetch_failed = false; + this.remote_extension_entries = extensions; + this.filter_extension_entries(cx); + if let Some(callback) = on_complete { + callback(this, cx); + } + Ok(()) + } + Err(err) => { + this.fetch_failed = true; + this.filter_extension_entries(cx); + Err(err) + } } - anyhow::Ok(()) - })? + }); + + result? }) .detach_and_log_err(cx); } @@ -702,7 +739,7 @@ impl ExtensionsPage { extension: &ExtensionMetadata, cx: &mut Context, ) -> ExtensionCard { - let this = cx.entity(); + let this = cx.weak_entity(); let status = Self::extension_status(&extension.id, cx); let has_dev_extension = Self::dev_extension_exists(&extension.id, cx); @@ -727,7 +764,7 @@ impl ExtensionsPage { .gap_2() .child( Headline::new(extension.manifest.name.clone()) - .size(HeadlineSize::Medium), + .size(HeadlineSize::Small), ) .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall)) .children( @@ -777,20 +814,12 @@ impl ExtensionsPage { h_flex() .gap_2() .justify_between() - .child( - Label::new(format!( - "{}: {}", - if extension.manifest.authors.len() > 1 { - "Authors" - } else { - "Author" - }, - extension.manifest.authors.join(", ") - )) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ) + .children(extension.manifest.description.as_ref().map(|description| { + Label::new(description.clone()) + .size(LabelSize::Small) + .color(Color::Default) + .truncate() + })) .child( Label::new(format!( "Downloads: {}", @@ -801,32 +830,47 @@ impl ExtensionsPage { ) .child( h_flex() - .gap_2() .justify_between() - .children(extension.manifest.description.as_ref().map(|description| { - Label::new(description.clone()) - .size(LabelSize::Small) - .color(Color::Default) - .truncate() - })) .child( h_flex() - .gap_2() + .gap_1() .child( + Icon::new(IconName::Person) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(extension.manifest.authors.join(", ")) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .child( + h_flex() + .gap_1() + .child({ + let repo_url_for_tooltip = repository_url.clone(); + IconButton::new( SharedString::from(format!("repository-{}", extension.id)), IconName::Github, ) - .icon_color(Color::Accent) .icon_size(IconSize::Small) - .on_click(cx.listener({ - let repository_url = repository_url.clone(); + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Visit Extension Repository", + None, + repo_url_for_tooltip.clone(), + cx, + ) + }) + .on_click(cx.listener( move |_, _, _, cx| { cx.open_url(&repository_url); - } - })) - .tooltip(Tooltip::text(repository_url)), - ) + }, + )) + }) .child( PopoverMenu::new(SharedString::from(format!( "more-{}", @@ -837,17 +881,23 @@ impl ExtensionsPage { SharedString::from(format!("more-{}", extension.id)), IconName::Ellipsis, ) - .icon_color(Color::Accent) .icon_size(IconSize::Small), ) + .anchor(Corner::TopRight) + .offset(Point { + x: px(0.0), + y: px(2.0), + }) .menu(move |window, cx| { - Some(Self::render_remote_extension_context_menu( - &this, - extension_id.clone(), - authors.clone(), - window, - cx, - )) + this.upgrade().map(|this| { + Self::render_remote_extension_context_menu( + &this, + extension_id.clone(), + authors.clone(), + window, + cx, + ) + }) }), ), ), @@ -961,6 +1011,11 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Install", ) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .icon(IconName::Download) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) .on_click({ let extension_id = extension.id.clone(); move |_, _, cx| { @@ -978,6 +1033,11 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Install", ) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .icon(IconName::Download) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) .disabled(true), configure: None, upgrade: None, @@ -987,6 +1047,7 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Uninstall", ) + .style(ButtonStyle::OutlinedGhost) .disabled(true), configure: is_configurable.then(|| { Button::new( @@ -1004,6 +1065,7 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Uninstall", ) + .style(ButtonStyle::OutlinedGhost) .on_click({ let extension_id = extension.id.clone(); move |_, _, cx| { @@ -1020,6 +1082,7 @@ impl ExtensionsPage { SharedString::from(format!("configure-{}", extension.id)), "Configure", ) + .style(ButtonStyle::OutlinedGhost) .on_click({ let extension_id = extension.id.clone(); move |_, _, cx| { @@ -1044,6 +1107,7 @@ impl ExtensionsPage { } else { Some( Button::new(SharedString::from(extension.id.clone()), "Upgrade") + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .when(!is_compatible, |upgrade_button| { upgrade_button.disabled(true).tooltip({ let version = extension.manifest.version.clone(); @@ -1082,6 +1146,7 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Uninstall", ) + .style(ButtonStyle::OutlinedGhost) .disabled(true), configure: is_configurable.then(|| { Button::new( @@ -1108,15 +1173,14 @@ impl ExtensionsPage { h_flex() .key_context(key_context) .h_8() - .flex_1() .min_w(rems_from_px(384.)) + .flex_1() .pl_1p5() .pr_2() - .py_1() .gap_2() .border_1() .border_color(editor_border) - .rounded_lg() + .rounded_md() .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted)) .child(self.render_text_input(&self.query_editor, cx)) } @@ -1239,7 +1303,9 @@ impl ExtensionsPage { let has_search = self.search_query(cx).is_some(); let message = if self.is_fetching_extensions { - "Loading extensions..." + "Loading extensions…" + } else if self.fetch_failed { + "Failed to load extensions. Please check your connection and try again." } else { match self.filter { ExtensionFilter::All => { @@ -1266,7 +1332,17 @@ impl ExtensionsPage { } }; - Label::new(message) + h_flex() + .py_4() + .gap_1p5() + .when(self.fetch_failed, |this| { + this.child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + }) + .child(Label::new(message)) } fn update_settings( @@ -1297,6 +1373,23 @@ impl ExtensionsPage { return; }; + if let Some(id) = search.strip_prefix("id:") { + self.upsells.clear(); + + let upsell = match id.to_lowercase().as_str() { + "ruff" => Some(Feature::ExtensionRuff), + "basedpyright" => Some(Feature::ExtensionBasedpyright), + "ty" => Some(Feature::ExtensionTy), + _ => None, + }; + + if let Some(upsell) = upsell { + self.upsells.insert(upsell); + } + + return; + } + let search = search.to_lowercase(); let search_terms = search .split_whitespace() @@ -1315,58 +1408,201 @@ impl ExtensionsPage { } } - fn render_feature_upsells(&self, cx: &mut Context) -> impl IntoElement { - let upsells_count = self.upsells.len(); - - v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| { - let upsell = match feature { - Feature::Git => FeatureUpsell::new( - "Zed comes with basic Git support. More Git features are coming in the future.", - ) - .docs_url("https://zed.dev/docs/git"), - Feature::OpenIn => FeatureUpsell::new( - "Zed supports linking to a source line on GitHub and others.", - ) - .docs_url("https://zed.dev/docs/git#git-integrations"), - Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!") - .docs_url("https://zed.dev/docs/vim") - .child(CheckboxWithLabel::new( - "enable-vim", - Label::new("Enable vim mode"), - if VimModeSetting::get_global(cx).0 { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - cx.listener(move |this, selection, _, cx| { - telemetry::event!("Vim Mode Toggled", source = "Feature Upsell"); - this.update_settings(selection, cx, |setting, value| { - setting.vim_mode = Some(value) - }); - }), - )), - Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/bash"), - Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/c"), - Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/cpp"), - Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/go"), - Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/python"), - Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/typescript"), - Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/rust"), - Feature::LanguageTypescript => { - FeatureUpsell::new("Typescript support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/typescript") + fn render_feature_upsell_banner( + &self, + label: SharedString, + docs_url: SharedString, + vim: bool, + cx: &mut Context, + ) -> impl IntoElement { + let docs_url_button = Button::new("open_docs", "View Documentation") + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Small) + .icon_position(IconPosition::End) + .on_click({ + move |_event, _window, cx| { + telemetry::event!( + "Documentation Viewed", + source = "Feature Upsell", + url = docs_url, + ); + cx.open_url(&docs_url) } - }; + }); - upsell.when(ix < upsells_count, |upsell| upsell.border_b_1()) - })) + div() + .pt_4() + .px_4() + .child( + Banner::new() + .severity(Severity::Success) + .child(Label::new(label).mt_0p5()) + .map(|this| { + if vim { + this.action_slot( + h_flex() + .gap_1() + .child(docs_url_button) + .child(Divider::vertical().color(ui::DividerColor::Border)) + .child( + h_flex() + .pl_1() + .gap_1() + .child(Label::new("Enable Vim mode")) + .child( + Switch::new( + "enable-vim", + if VimModeSetting::get_global(cx).0 { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + ) + .on_click(cx.listener( + move |this, selection, _, cx| { + telemetry::event!( + "Vim Mode Toggled", + source = "Feature Upsell" + ); + this.update_settings( + selection, + cx, + |setting, value| { + setting.vim_mode = Some(value) + }, + ); + }, + )), + ), + ), + ) + } else { + this.action_slot(docs_url_button) + } + }), + ) + .into_any_element() + } + + fn render_feature_upsells(&self, cx: &mut Context) -> impl IntoElement { + let mut container = v_flex(); + + for feature in &self.upsells { + let banner = match feature { + Feature::AgentClaude => self.render_feature_upsell_banner( + "Claude Code support is built-in to Zed!".into(), + "https://zed.dev/docs/ai/external-agents#claude-code".into(), + false, + cx, + ), + Feature::AgentCodex => self.render_feature_upsell_banner( + "Codex CLI support is built-in to Zed!".into(), + "https://zed.dev/docs/ai/external-agents#codex-cli".into(), + false, + cx, + ), + Feature::AgentGemini => self.render_feature_upsell_banner( + "Gemini CLI support is built-in to Zed!".into(), + "https://zed.dev/docs/ai/external-agents#gemini-cli".into(), + false, + cx, + ), + Feature::ExtensionBasedpyright => self.render_feature_upsell_banner( + "Basedpyright (Python language server) support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python#basedpyright".into(), + false, + cx, + ), + Feature::ExtensionRuff => self.render_feature_upsell_banner( + "Ruff (linter for Python) support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python#code-formatting--linting".into(), + false, + cx, + ), + Feature::ExtensionTailwind => self.render_feature_upsell_banner( + "Tailwind CSS support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/tailwindcss".into(), + false, + cx, + ), + Feature::ExtensionTy => self.render_feature_upsell_banner( + "Ty (Python language server) support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python".into(), + false, + cx, + ), + Feature::Git => self.render_feature_upsell_banner( + "Zed comes with basic Git support—more features are coming in the future." + .into(), + "https://zed.dev/docs/git".into(), + false, + cx, + ), + Feature::LanguageBash => self.render_feature_upsell_banner( + "Shell support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/bash".into(), + false, + cx, + ), + Feature::LanguageC => self.render_feature_upsell_banner( + "C support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/c".into(), + false, + cx, + ), + Feature::LanguageCpp => self.render_feature_upsell_banner( + "C++ support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/cpp".into(), + false, + cx, + ), + Feature::LanguageGo => self.render_feature_upsell_banner( + "Go support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/go".into(), + false, + cx, + ), + Feature::LanguagePython => self.render_feature_upsell_banner( + "Python support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python".into(), + false, + cx, + ), + Feature::LanguageReact => self.render_feature_upsell_banner( + "React support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/typescript".into(), + false, + cx, + ), + Feature::LanguageRust => self.render_feature_upsell_banner( + "Rust support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/rust".into(), + false, + cx, + ), + Feature::LanguageTypescript => self.render_feature_upsell_banner( + "Typescript support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/typescript".into(), + false, + cx, + ), + Feature::OpenIn => self.render_feature_upsell_banner( + "Zed supports linking to a source line on GitHub and others.".into(), + "https://zed.dev/docs/git#git-integrations".into(), + false, + cx, + ), + Feature::Vim => self.render_feature_upsell_banner( + "Vim support is built-in to Zed!".into(), + "https://zed.dev/docs/vim".into(), + true, + cx, + ), + }; + container = container.child(banner); + } + + container } } @@ -1384,13 +1620,13 @@ impl Render for ExtensionsPage { .child( h_flex() .w_full() - .gap_2() + .gap_1p5() .justify_between() .child(Headline::new("Extensions").size(HeadlineSize::XLarge)) .child( Button::new("install-dev-extension", "Install Dev Extension") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .on_click(|_event, window, cx| { window.dispatch_action(Box::new(InstallDevExtension), cx) }), @@ -1399,58 +1635,51 @@ impl Render for ExtensionsPage { .child( h_flex() .w_full() - .gap_4() .flex_wrap() + .gap_2() .child(self.render_search(cx)) .child( - h_flex() - .child( - ToggleButton::new("filter-all", "All") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .toggle_state(self.filter == ExtensionFilter::All) - .on_click(cx.listener(|this, _event, _, cx| { - this.filter = ExtensionFilter::All; - this.filter_extension_entries(cx); - this.scroll_to_top(cx); - })) - .tooltip(move |_, cx| { - Tooltip::simple("Show all extensions", cx) - }) - .first(), + div().child( + ToggleButtonGroup::single_row( + "filter-buttons", + [ + ToggleButtonSimple::new( + "All", + cx.listener(|this, _event, _, cx| { + this.filter = ExtensionFilter::All; + this.filter_extension_entries(cx); + this.scroll_to_top(cx); + }), + ), + ToggleButtonSimple::new( + "Installed", + cx.listener(|this, _event, _, cx| { + this.filter = ExtensionFilter::Installed; + this.filter_extension_entries(cx); + this.scroll_to_top(cx); + }), + ), + ToggleButtonSimple::new( + "Not Installed", + cx.listener(|this, _event, _, cx| { + this.filter = ExtensionFilter::NotInstalled; + this.filter_extension_entries(cx); + this.scroll_to_top(cx); + }), + ), + ], ) - .child( - ToggleButton::new("filter-installed", "Installed") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .toggle_state(self.filter == ExtensionFilter::Installed) - .on_click(cx.listener(|this, _event, _, cx| { - this.filter = ExtensionFilter::Installed; - this.filter_extension_entries(cx); - this.scroll_to_top(cx); - })) - .tooltip(move |_, cx| { - Tooltip::simple("Show installed extensions", cx) - }) - .middle(), - ) - .child( - ToggleButton::new("filter-not-installed", "Not Installed") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .toggle_state( - self.filter == ExtensionFilter::NotInstalled, - ) - .on_click(cx.listener(|this, _event, _, cx| { - this.filter = ExtensionFilter::NotInstalled; - this.filter_extension_entries(cx); - this.scroll_to_top(cx); - })) - .tooltip(move |_, cx| { - Tooltip::simple("Show not installed extensions", cx) - }) - .last(), - ), + .style(ToggleButtonGroupStyle::Outlined) + .size(ToggleButtonGroupSize::Custom(rems_from_px(30.))) // Perfectly matches the input + .label_size(LabelSize::Default) + .auto_width() + .selected_index(match self.filter { + ExtensionFilter::All => 0, + ExtensionFilter::Installed => 1, + ExtensionFilter::NotInstalled => 2, + }) + .into_any_element(), + ), ), ), ) @@ -1510,16 +1739,14 @@ impl Render for ExtensionsPage { } if count == 0 { - this.py_4() - .child(self.render_empty_state(cx)) - .into_any_element() + this.child(self.render_empty_state(cx)).into_any_element() } else { - let scroll_handle = self.list.clone(); + let scroll_handle = &self.list; this.child( uniform_list("entries", count, cx.processor(Self::render_extensions)) .flex_grow() .pb_4() - .track_scroll(scroll_handle.clone()), + .track_scroll(scroll_handle), ) .vertical_scrollbar_for(scroll_handle, window, cx) .into_any_element() diff --git a/crates/feature_flags/Cargo.toml b/crates/feature_flags/Cargo.toml index e4cc1e9330..65d6942d50 100644 --- a/crates/feature_flags/Cargo.toml +++ b/crates/feature_flags/Cargo.toml @@ -15,4 +15,3 @@ path = "src/feature_flags.rs" futures.workspace = true gpui.workspace = true smol.workspace = true -workspace-hack.workspace = true diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 981abc0fa6..1768e43d1d 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -1,11 +1,5 @@ use crate::FeatureFlag; -pub struct PredictEditsRateCompletionsFeatureFlag; - -impl FeatureFlag for PredictEditsRateCompletionsFeatureFlag { - const NAME: &'static str = "predict-edits-rate-completions"; -} - pub struct NotebookFeatureFlag; impl FeatureFlag for NotebookFeatureFlag { @@ -18,8 +12,14 @@ impl FeatureFlag for PanicFeatureFlag { const NAME: &'static str = "panic"; } -pub struct CodexAcpFeatureFlag; +pub struct InlineAssistantUseToolFeatureFlag; -impl FeatureFlag for CodexAcpFeatureFlag { - const NAME: &'static str = "codex-acp"; +impl FeatureFlag for InlineAssistantUseToolFeatureFlag { + const NAME: &'static str = "inline-assistant-use-tool"; +} + +pub struct AgentV2FeatureFlag; + +impl FeatureFlag for AgentV2FeatureFlag { + const NAME: &'static str = "agent-v2"; } diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml index db872f7a15..0a53a1b6f3 100644 --- a/crates/feedback/Cargo.toml +++ b/crates/feedback/Cargo.toml @@ -16,14 +16,11 @@ test-support = [] [dependencies] gpui.workspace = true -menu.workspace = true system_specs.workspace = true -ui.workspace = true urlencoding.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/feedback/src/feedback.rs b/crates/feedback/src/feedback.rs index 3822dd7ba3..57bddb6ae7 100644 --- a/crates/feedback/src/feedback.rs +++ b/crates/feedback/src/feedback.rs @@ -2,19 +2,13 @@ use gpui::{App, ClipboardItem, PromptLevel, actions}; use system_specs::{CopySystemSpecsIntoClipboard, SystemSpecs}; use util::ResultExt; use workspace::Workspace; -use zed_actions::feedback::FileBugReport; - -pub mod feedback_modal; +use zed_actions::feedback::{EmailZed, FileBugReport, RequestFeature}; actions!( zed, [ - /// Opens email client to send feedback to Zed support. - EmailZed, /// Opens the Zed repository on GitHub. OpenZedRepo, - /// Opens the feature request form. - RequestFeature, ] ); @@ -48,11 +42,7 @@ fn email_body(specs: &SystemSpecs) -> String { } pub fn init(cx: &mut App) { - cx.observe_new(|workspace: &mut Workspace, window, cx| { - let Some(window) = window else { - return; - }; - feedback_modal::FeedbackModal::register(workspace, window, cx); + cx.observe_new(|workspace: &mut Workspace, _, _| { workspace .register_action(|_, _: &CopySystemSpecsIntoClipboard, window, cx| { let specs = SystemSpecs::new(window, cx); diff --git a/crates/feedback/src/feedback_modal.rs b/crates/feedback/src/feedback_modal.rs deleted file mode 100644 index beb879efe7..0000000000 --- a/crates/feedback/src/feedback_modal.rs +++ /dev/null @@ -1,113 +0,0 @@ -use gpui::{App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, Render, Window}; -use ui::{IconPosition, prelude::*}; -use workspace::{ModalView, Workspace}; -use zed_actions::feedback::GiveFeedback; - -use crate::{EmailZed, FileBugReport, OpenZedRepo, RequestFeature}; - -pub struct FeedbackModal { - focus_handle: FocusHandle, -} - -impl Focusable for FeedbackModal { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} -impl EventEmitter for FeedbackModal {} - -impl ModalView for FeedbackModal {} - -impl FeedbackModal { - pub fn register(workspace: &mut Workspace, _: &mut Window, cx: &mut Context) { - let _handle = cx.entity().downgrade(); - workspace.register_action(move |workspace, _: &GiveFeedback, window, cx| { - workspace.toggle_modal(window, cx, move |_, cx| FeedbackModal::new(cx)); - }); - } - - pub fn new(cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - } - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent) - } -} - -impl Render for FeedbackModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let open_zed_repo = - cx.listener(|_, _, window, cx| window.dispatch_action(Box::new(OpenZedRepo), cx)); - - v_flex() - .key_context("GiveFeedback") - .on_action(cx.listener(Self::cancel)) - .elevation_3(cx) - .w_96() - .h_auto() - .p_4() - .gap_2() - .child( - h_flex() - .w_full() - .justify_between() - .child(Headline::new("Give Feedback")) - .child( - IconButton::new("close-btn", IconName::Close) - .icon_color(Color::Muted) - .on_click(cx.listener(move |_, _, window, cx| { - cx.spawn_in(window, async move |this, cx| { - this.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); - }) - .detach(); - })), - ), - ) - .child(Label::new("Thanks for using Zed! To share your experience with us, reach for the channel that's the most appropriate:")) - .child( - Button::new("file-a-bug-report", "File a Bug Report") - .full_width() - .icon(IconName::Debug) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(FileBugReport), cx); - })), - ) - .child( - Button::new("request-a-feature", "Request a Feature") - .full_width() - .icon(IconName::Sparkle) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(RequestFeature), cx); - })), - ) - .child( - Button::new("send-us_an-email", "Send an Email") - .full_width() - .icon(IconName::Envelope) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(EmailZed), cx); - })), - ) - .child( - Button::new("zed_repository", "GitHub Repository") - .full_width() - .icon(IconName::Github) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(open_zed_repo), - ) - } -} diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index edb7031f93..46257b1f49 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -32,7 +32,6 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 61a3e469c1..050d7a45a1 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -21,7 +21,9 @@ use gpui::{ }; use open_path_prompt::OpenPathPrompt; use picker::{Picker, PickerDelegate}; -use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; +use project::{ + PathMatchCandidateSet, Project, ProjectPath, WorktreeId, worktree_store::WorktreeStore, +}; use search::ToggleIncludeIgnored; use settings::Settings; use std::{ @@ -88,12 +90,7 @@ pub struct FileFinder { init_modifiers: Option, } -pub fn init_settings(cx: &mut App) { - FileFinderSettings::register(cx); -} - pub fn init(cx: &mut App) { - init_settings(cx); cx.observe_new(FileFinder::register).detach(); cx.observe_new(OpenPathPrompt::register).detach(); cx.observe_new(OpenPathPrompt::register_new_path).detach(); @@ -538,11 +535,14 @@ impl Matches { fn push_new_matches<'a>( &'a mut self, + worktree_store: Entity, + cx: &'a App, history_items: impl IntoIterator + Clone, currently_opened: Option<&'a FoundPath>, query: Option<&FileSearchQuery>, new_search_matches: impl Iterator, extend_old_matches: bool, + path_style: PathStyle, ) { let Some(query) = query else { // assuming that if there's no query, then there's no search matches. @@ -556,8 +556,25 @@ impl Matches { .extend(history_items.into_iter().map(path_to_entry)); return; }; - - let new_history_matches = matching_history_items(history_items, currently_opened, query); + // If several worktress are open we have to set the worktree root names in path prefix + let several_worktrees = worktree_store.read(cx).worktrees().count() > 1; + let worktree_name_by_id = several_worktrees.then(|| { + worktree_store + .read(cx) + .worktrees() + .map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + (snapshot.id(), snapshot.root_name().into()) + }) + .collect() + }); + let new_history_matches = matching_history_items( + history_items, + currently_opened, + worktree_name_by_id, + query, + path_style, + ); let new_search_matches: Vec = new_search_matches .filter(|path_match| { !new_history_matches.contains_key(&ProjectPath { @@ -694,7 +711,9 @@ impl Matches { fn matching_history_items<'a>( history_items: impl IntoIterator, currently_opened: Option<&'a FoundPath>, + worktree_name_by_id: Option>>, query: &FileSearchQuery, + path_style: PathStyle, ) -> HashMap { let mut candidates_paths = HashMap::default(); @@ -734,13 +753,18 @@ fn matching_history_items<'a>( let mut matching_history_paths = HashMap::default(); for (worktree, candidates) in history_items_by_worktrees { let max_results = candidates.len() + 1; + let worktree_root_name = worktree_name_by_id + .as_ref() + .and_then(|w| w.get(&worktree).cloned()); matching_history_paths.extend( fuzzy::match_fixed_path_set( candidates, worktree.to_usize(), + worktree_root_name, query.path_query(), false, max_results, + path_style, ) .into_iter() .filter_map(|path_match| { @@ -866,7 +890,9 @@ impl FileFinderDelegate { let worktrees = self .project .read(cx) - .visible_worktrees(cx) + .worktree_store() + .read(cx) + .visible_worktrees_and_single_files(cx) .collect::>(); let include_root_name = worktrees.len() > 1; let candidate_sets = worktrees @@ -935,15 +961,18 @@ impl FileFinderDelegate { self.matches.get(self.selected_index).cloned() }; + let path_style = self.project.read(cx).path_style(cx); self.matches.push_new_matches( + self.project.read(cx).worktree_store(), + cx, &self.history_items, self.currently_opened_path.as_ref(), Some(&query), matches.into_iter(), extend_old_matches, + path_style, ); - let path_style = self.project.read(cx).path_style(cx); let query_path = query.raw_query.as_str(); if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) { let available_worktree = self @@ -1031,7 +1060,7 @@ impl FileFinderDelegate { ( filename.to_string(), Vec::new(), - prefix.display(path_style).to_string() + path_style.separator(), + prefix.display(path_style).to_string() + path_style.primary_separator(), Vec::new(), ) } else { @@ -1042,7 +1071,7 @@ impl FileFinderDelegate { .map_or(String::new(), |f| f.to_string_lossy().into_owned()), Vec::new(), entry_path.absolute.parent().map_or(String::new(), |path| { - path.to_string_lossy().into_owned() + path_style.separator() + path.to_string_lossy().into_owned() + path_style.primary_separator() }), Vec::new(), ) @@ -1172,18 +1201,25 @@ impl FileFinderDelegate { ) } + /// Attempts to resolve an absolute file path and update the search matches if found. + /// + /// If the query path resolves to an absolute file that exists in the project, + /// this method will find the corresponding worktree and relative path, create a + /// match for it, and update the picker's search results. + /// + /// Returns `true` if the absolute path exists, otherwise returns `false`. fn lookup_absolute_path( &self, query: FileSearchQuery, window: &mut Window, cx: &mut Context>, - ) -> Task<()> { + ) -> Task { cx.spawn_in(window, async move |picker, cx| { let Some(project) = picker .read_with(cx, |picker, _| picker.delegate.project.clone()) .log_err() else { - return; + return false; }; let query_path = Path::new(query.path_query()); @@ -1216,7 +1252,7 @@ impl FileFinderDelegate { }) .log_err(); if update_result.is_none() { - return; + return abs_file_exists; } } @@ -1229,6 +1265,7 @@ impl FileFinderDelegate { anyhow::Ok(()) }) .log_err(); + abs_file_exists }) } @@ -1355,7 +1392,11 @@ impl PickerDelegate for FileFinderDelegate { separate_history: self.separate_history, ..Matches::default() }; + let path_style = self.project.read(cx).path_style(cx); + self.matches.push_new_matches( + project.worktree_store(), + cx, self.history_items.iter().filter(|history_item| { project .worktree_for_id(history_item.project.worktree_id, cx) @@ -1367,6 +1408,7 @@ impl PickerDelegate for FileFinderDelegate { None, None.into_iter(), false, + path_style, ); self.first_update = false; @@ -1377,13 +1419,14 @@ impl PickerDelegate for FileFinderDelegate { } else { let path_position = PathWithPosition::parse_str(raw_query); let raw_query = raw_query.trim().trim_end_matches(':').to_owned(); - let path = path_position.path.to_str(); - let path_trimmed = path.unwrap_or(&raw_query).trim_end_matches(':'); + let path = path_position.path.clone(); + let path_str = path_position.path.to_str(); + let path_trimmed = path_str.unwrap_or(&raw_query).trim_end_matches(':'); let file_query_end = if path_trimmed == raw_query { None } else { // Safe to unwrap as we won't get here when the unwrap in if fails - Some(path.unwrap().len()) + Some(path_str.unwrap().len()) }; let query = FileSearchQuery { @@ -1392,11 +1435,29 @@ impl PickerDelegate for FileFinderDelegate { path_position, }; - if Path::new(query.path_query()).is_absolute() { - self.lookup_absolute_path(query, window, cx) - } else { - self.spawn_search(query, window, cx) - } + cx.spawn_in(window, async move |this, cx| { + let _ = maybe!(async move { + let is_absolute_path = path.is_absolute(); + let did_resolve_abs_path = is_absolute_path + && this + .update_in(cx, |this, window, cx| { + this.delegate + .lookup_absolute_path(query.clone(), window, cx) + })? + .await; + + // Only check for relative paths if no absolute paths were + // found. + if !did_resolve_abs_path { + this.update_in(cx, |this, window, cx| { + this.delegate.spawn_search(query, window, cx) + })? + .await; + } + anyhow::Ok(()) + }) + .await; + }) } } @@ -1597,11 +1658,7 @@ impl PickerDelegate for FileFinderDelegate { ) } - fn render_footer( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> Option { + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { let focus_handle = self.focus_handle.clone(); Some( @@ -1630,12 +1687,11 @@ impl PickerDelegate for FileFinderDelegate { }), { let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Filter Options", &ToggleFilterMenu, &focus_handle, - window, cx, ) } @@ -1685,14 +1741,13 @@ impl PickerDelegate for FileFinderDelegate { ButtonLike::new("split-trigger") .child(Label::new("Split…")) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .children( + .child( KeyBinding::for_action_in( &ToggleSplitMenu, &focus_handle, - window, cx, ) - .map(|kb| kb.size(rems_from_px(12.))), + .size(rems_from_px(12.)), ), ) .menu({ @@ -1724,13 +1779,8 @@ impl PickerDelegate for FileFinderDelegate { .child( Button::new("open-selection", "Open") .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::Confirm.boxed_clone(), cx) diff --git a/crates/file_finder/src/file_finder_settings.rs b/crates/file_finder/src/file_finder_settings.rs index cf2b4f4bfb..36f05e89bd 100644 --- a/crates/file_finder/src/file_finder_settings.rs +++ b/crates/file_finder/src/file_finder_settings.rs @@ -1,8 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::Settings; +use settings::{RegisterSetting, Settings}; -#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, RegisterSetting)] pub struct FileFinderSettings { pub file_icons: bool, pub modal_max_width: FileFinderWidth, @@ -11,14 +11,18 @@ pub struct FileFinderSettings { } impl Settings for FileFinderSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut ui::App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let file_finder = content.file_finder.as_ref().unwrap(); Self { file_icons: file_finder.file_icons.unwrap(), modal_max_width: file_finder.modal_max_width.unwrap().into(), skip_focus_for_active_in_search: file_finder.skip_focus_for_active_in_search.unwrap(), - include_ignored: file_finder.include_ignored, + include_ignored: match file_finder.include_ignored.unwrap() { + settings::IncludeIgnoredContent::All => Some(true), + settings::IncludeIgnoredContent::Indexed => Some(false), + settings::IncludeIgnoredContent::Smart => None, + }, } } } diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 6df23c9dfc..aeb9d794c2 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -7,8 +7,9 @@ use menu::{Confirm, SelectNext, SelectPrevious}; use pretty_assertions::{assert_eq, assert_matches}; use project::{FS_WATCH_LATENCY, RemoveOptions}; use serde_json::json; +use settings::SettingsStore; use util::{path, rel_path::rel_path}; -use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace}; +use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace, open_paths}; #[ctor::ctor] fn init_logger() { @@ -490,7 +491,7 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) { cx.executor().advance_clock(Duration::from_secs(2)); editor.update(cx, |editor, cx| { - let all_selections = editor.selections.all_adjusted(cx); + let all_selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); assert_eq!( all_selections.len(), 1, @@ -565,7 +566,7 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) { cx.executor().advance_clock(Duration::from_secs(2)); editor.update(cx, |editor, cx| { - let all_selections = editor.selections.all_adjusted(cx); + let all_selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); assert_eq!( all_selections.len(), 1, @@ -658,6 +659,147 @@ async fn test_matching_cancellation(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_ignored_root_with_file_inclusions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_inclusions = Some(vec![ + "height_demo/**/hi_bonjour".to_string(), + "**/height_1".to_string(), + ]); + }); + }) + }); + app_state + .fs + .as_fake() + .insert_tree( + "/ancestor", + json!({ + ".gitignore": "ignored-root", + "ignored-root": { + "happiness": "", + "height": "", + "hi": "", + "hiccup": "", + }, + "tracked-root": { + ".gitignore": "height*", + "happiness": "", + "height": "", + "heights": { + "height_1": "", + "height_2": "", + }, + "height_demo": { + "test_1": { + "hi_bonjour": "hi_bonjour", + "hi": "hello", + }, + "hihi": "bye", + "test_2": { + "hoi": "nl" + } + }, + "height_include": { + "height_1_include": "", + "height_2_include": "", + }, + "hi": "", + "hiccup": "", + }, + }), + ) + .await; + + let project = Project::test( + app_state.fs.clone(), + [ + Path::new(path!("/ancestor/tracked-root")), + Path::new(path!("/ancestor/ignored-root")), + ], + cx, + ) + .await; + let (picker, _workspace, cx) = build_find_picker(project, cx); + + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .spawn_search(test_path_position("hi"), window, cx) + }) + .await; + picker.update(cx, |picker, _| { + let matches = collect_search_matches(picker); + assert_eq!(matches.history.len(), 0); + assert_eq!( + matches.search, + vec![ + rel_path("ignored-root/hi").into(), + rel_path("tracked-root/hi").into(), + rel_path("ignored-root/hiccup").into(), + rel_path("tracked-root/hiccup").into(), + rel_path("tracked-root/height_demo/test_1/hi_bonjour").into(), + rel_path("ignored-root/height").into(), + rel_path("tracked-root/heights/height_1").into(), + rel_path("ignored-root/happiness").into(), + rel_path("tracked-root/happiness").into(), + ], + "All ignored files that were indexed are found for default ignored mode" + ); + }); +} + +#[gpui::test] +async fn test_ignored_root_with_file_inclusions_repro(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_inclusions = Some(vec!["**/.env".to_string()]); + }); + }) + }); + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + ".gitignore": "node_modules", + "node_modules": { + "package.json": "// package.json", + ".env": "BAR=FOO" + }, + ".env": "FOO=BAR" + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [Path::new(path!("/src"))], cx).await; + let (picker, _workspace, cx) = build_find_picker(project, cx); + + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .spawn_search(test_path_position("json"), window, cx) + }) + .await; + picker.update(cx, |picker, _| { + let matches = collect_search_matches(picker); + assert_eq!(matches.history.len(), 0); + assert_eq!( + matches.search, + vec![], + "All ignored files that were indexed are found for default ignored mode" + ); + }); +} + #[gpui::test] async fn test_ignored_root(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -1456,7 +1598,7 @@ async fn test_history_match_positions(cx: &mut gpui::TestAppContext) { assert_eq!(file_label.highlight_indices(), &[0, 1, 2]); assert_eq!( path_label.text(), - format!("test{}", PathStyle::local().separator()) + format!("test{}", PathStyle::local().primary_separator()) ); assert_eq!(path_label.highlight_indices(), &[] as &[usize]); }); @@ -2337,7 +2479,6 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp assert_match_at_position(finder, 1, "main.rs"); assert_match_at_position(finder, 2, "rs"); }); - // Delete main.rs app_state .fs @@ -2370,6 +2511,64 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp }); } +#[gpui::test] +async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "lib.rs": "// Lib file", + "main.rs": "// Bar file", + "read.me": "// Readme file", + }), + ) + .await; + app_state + .fs + .as_fake() + .insert_tree( + "/test", + json!({ + "new.rs": "// New file", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + cx.update(|_, cx| { + open_paths( + &[PathBuf::from(path!("/test/new.rs"))], + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|_, cx| cx.windows().len()), 1); + + let initial_history = open_close_queried_buffer("new", 1, "new.rs", &workspace, cx).await; + assert_eq!( + initial_history.first().unwrap().absolute, + PathBuf::from(path!("/test/new.rs")), + "Should show 1st opened item in the history when opening the 2nd item" + ); + + let history_after_first = open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await; + assert_eq!( + history_after_first.first().unwrap().absolute, + PathBuf::from(path!("/test/new.rs")), + "Should show 1st opened item in the history when opening the 2nd item" + ); +} + #[gpui::test] async fn test_search_results_refreshed_on_adding_and_removing_worktrees( cx: &mut gpui::TestAppContext, @@ -2446,6 +2645,147 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees( }); } +#[gpui::test] +async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files( + cx: &mut TestAppContext, +) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/repo1"), + json!({ + "package.json": r#"{"name": "repo1"}"#, + "src": { + "index.js": "// Repo 1 index", + } + }), + ) + .await; + + app_state + .fs + .as_fake() + .insert_tree( + path!("/repo2"), + json!({ + "package.json": r#"{"name": "repo2"}"#, + "src": { + "index.js": "// Repo 2 index", + } + }), + ) + .await; + + let project = Project::test( + app_state.fs.clone(), + [path!("/repo1").as_ref(), path!("/repo2").as_ref()], + cx, + ) + .await; + + let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (worktree_id1, worktree_id2) = cx.read(|cx| { + let worktrees = workspace.read(cx).worktrees(cx).collect::>(); + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) + }); + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id: worktree_id1, + path: rel_path("package.json").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .unwrap(); + + cx.dispatch_action(workspace::CloseActiveItem { + save_intent: None, + close_pinned: false, + }); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id: worktree_id2, + path: rel_path("package.json").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .unwrap(); + + cx.dispatch_action(workspace::CloseActiveItem { + save_intent: None, + close_pinned: false, + }); + + let picker = open_file_picker(&workspace, cx); + cx.simulate_input("package.json"); + + picker.update(cx, |finder, _| { + let matches = &finder.delegate.matches.matches; + + assert_eq!( + matches.len(), + 2, + "Expected 1 history match + 1 search matches, but got {} matches: {:?}", + matches.len(), + matches + ); + + assert_matches!(matches[0], Match::History { .. }); + + let search_matches = collect_search_matches(finder); + assert_eq!( + search_matches.history.len(), + 2, + "Should have exactly 2 history match" + ); + assert_eq!( + search_matches.search.len(), + 0, + "Should have exactly 0 search match (because we already opened the 2 package.json)" + ); + + if let Match::History { path, panel_match } = &matches[0] { + assert_eq!(path.project.worktree_id, worktree_id2); + assert_eq!(path.project.path.as_ref(), rel_path("package.json")); + let panel_match = panel_match.as_ref().unwrap(); + assert_eq!(panel_match.0.path_prefix, rel_path("repo2").into()); + assert_eq!(panel_match.0.path, rel_path("package.json").into()); + assert_eq!( + panel_match.0.positions, + vec![6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17] + ); + } + + if let Match::History { path, panel_match } = &matches[1] { + assert_eq!(path.project.worktree_id, worktree_id1); + assert_eq!(path.project.path.as_ref(), rel_path("package.json")); + let panel_match = panel_match.as_ref().unwrap(); + assert_eq!(panel_match.0.path_prefix, rel_path("repo1").into()); + assert_eq!(panel_match.0.path, rel_path("package.json").into()); + assert_eq!( + panel_match.0.positions, + vec![6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17] + ); + } + }); +} + #[gpui::test] async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); @@ -2866,11 +3206,8 @@ fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); super::init(cx); editor::init(cx); - workspace::init_settings(cx); - Project::init_settings(cx); state }) } @@ -3069,3 +3406,145 @@ async fn test_filename_precedence(cx: &mut TestAppContext) { ); }); } + +#[gpui::test] +async fn test_paths_with_starting_slash(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "file1.txt": "", + "b": { + "file2.txt": "", + }, + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + + let (picker, workspace, cx) = build_find_picker(project, cx); + + let matching_abs_path = "/file1.txt".to_string(); + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .update_matches(matching_abs_path, window, cx) + }) + .await; + picker.update(cx, |picker, _| { + assert_eq!( + collect_search_matches(picker).search_paths_only(), + vec![rel_path("a/file1.txt").into()], + "Relative path starting with slash should match" + ) + }); + cx.dispatch_action(SelectNext); + cx.dispatch_action(Confirm); + cx.read(|cx| { + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + assert_eq!(active_editor.read(cx).title(cx), "file1.txt"); + }); +} + +#[gpui::test] +async fn test_clear_navigation_history(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/src"), + json!({ + "test": { + "first.rs": "// First file", + "second.rs": "// Second file", + "third.rs": "// Third file", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; + let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + workspace.update_in(cx, |_workspace, window, cx| window.focused(cx)); + + // Open some files to generate navigation history + open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; + open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; + let history_before_clear = + open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await; + + assert_eq!( + history_before_clear.len(), + 2, + "Should have history items before clearing" + ); + + // Verify that file finder shows history items + let picker = open_file_picker(&workspace, cx); + cx.simulate_input("fir"); + picker.update(cx, |finder, _| { + let matches = collect_search_matches(finder); + assert!( + !matches.history.is_empty(), + "File finder should show history items before clearing" + ); + }); + workspace.update_in(cx, |_, window, cx| { + window.dispatch_action(menu::Cancel.boxed_clone(), cx); + }); + + // Verify navigation state before clear + workspace.update(cx, |workspace, cx| { + let pane = workspace.active_pane(); + pane.read(cx).can_navigate_backward() + }); + + // Clear navigation history + cx.dispatch_action(workspace::ClearNavigationHistory); + + // Verify that navigation is disabled immediately after clear + workspace.update(cx, |workspace, cx| { + let pane = workspace.active_pane(); + assert!( + !pane.read(cx).can_navigate_backward(), + "Should not be able to navigate backward after clearing history" + ); + assert!( + !pane.read(cx).can_navigate_forward(), + "Should not be able to navigate forward after clearing history" + ); + }); + + // Verify that file finder no longer shows history items + let picker = open_file_picker(&workspace, cx); + cx.simulate_input("fir"); + picker.update(cx, |finder, _| { + let matches = collect_search_matches(finder); + assert!( + matches.history.is_empty(), + "File finder should not show history items after clearing" + ); + }); + workspace.update_in(cx, |_, window, cx| { + window.dispatch_action(menu::Cancel.boxed_clone(), cx); + }); + + // Verify history is empty by opening a new file + // (this should not show any previous history) + let history_after_clear = + open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; + assert_eq!( + history_after_clear.len(), + 0, + "Should have no history items after clearing" + ); +} diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index b0417b1d13..f75d0ee99d 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -44,8 +44,9 @@ impl OpenPathDelegate { tx: oneshot::Sender>>, lister: DirectoryLister, creating_path: bool, - path_style: PathStyle, + cx: &App, ) -> Self { + let path_style = lister.path_style(cx); Self { tx: Some(tx), lister, @@ -216,8 +217,7 @@ impl OpenPathPrompt { cx: &mut Context, ) { workspace.toggle_modal(window, cx, |window, cx| { - let delegate = - OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::local()); + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); let query = lister.default_query(cx); picker.set_query(query, window, cx); @@ -399,7 +399,12 @@ impl PickerDelegate for OpenPathDelegate { } }) .unwrap_or(false); - if should_prepend_with_current_dir { + + let current_dir_in_new_entries = new_entries + .iter() + .any(|entry| &entry.path.string == current_dir); + + if should_prepend_with_current_dir && !current_dir_in_new_entries { new_entries.insert( 0, CandidateInfo { @@ -554,7 +559,7 @@ impl PickerDelegate for OpenPathDelegate { parent_path, candidate.path.string, if candidate.is_dir { - path_style.separator() + path_style.primary_separator() } else { "" } @@ -564,7 +569,7 @@ impl PickerDelegate for OpenPathDelegate { parent_path, candidate.path.string, if candidate.is_dir { - path_style.separator() + path_style.primary_separator() } else { "" } @@ -669,7 +674,7 @@ impl PickerDelegate for OpenPathDelegate { ) -> Option { let settings = FileFinderSettings::get_global(cx); let candidate = self.get_entry(ix)?; - let match_positions = match &self.directory_state { + let mut match_positions = match &self.directory_state { DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(), DirectoryState::Create { user_input, .. } => { if let Some(user_input) = user_input { @@ -710,29 +715,38 @@ impl PickerDelegate for OpenPathDelegate { }); match &self.directory_state { - DirectoryState::List { parent_path, .. } => Some( - ListItem::new(ix) - .spacing(ListItemSpacing::Sparse) - .start_slot::(file_icon) - .inset(true) - .toggle_state(selected) - .child(HighlightedLabel::new( - if parent_path == &self.prompt_root { - format!("{}{}", self.prompt_root, candidate.path.string) - } else if is_current_dir_candidate { - "open this directory".to_string() - } else { - candidate.path.string - }, + DirectoryState::List { parent_path, .. } => { + let (label, indices) = if is_current_dir_candidate { + ("open this directory".to_string(), vec![]) + } else if *parent_path == self.prompt_root { + match_positions.iter_mut().for_each(|position| { + *position += self.prompt_root.len(); + }); + ( + format!("{}{}", self.prompt_root, candidate.path.string), match_positions, - )), - ), + ) + } else { + (candidate.path.string, match_positions) + }; + Some( + ListItem::new(ix) + .spacing(ListItemSpacing::Sparse) + .start_slot::(file_icon) + .inset(true) + .toggle_state(selected) + .child(HighlightedLabel::new(label, indices)), + ) + } DirectoryState::Create { parent_path, user_input, .. } => { - let (label, delta) = if parent_path == &self.prompt_root { + let (label, delta) = if *parent_path == self.prompt_root { + match_positions.iter_mut().for_each(|position| { + *position += self.prompt_root.len(); + }); ( format!("{}{}", self.prompt_root, candidate.path.string), self.prompt_root.len(), @@ -740,10 +754,10 @@ impl PickerDelegate for OpenPathDelegate { } else { (candidate.path.string.clone(), 0) }; - let label_len = label.len(); let label_with_highlights = match user_input { Some(user_input) => { + let label_len = label.len(); if user_input.file.string == candidate.path.string { if user_input.exists { let label = if user_input.is_dir { @@ -755,7 +769,7 @@ impl PickerDelegate for OpenPathDelegate { .with_default_highlights( &window.text_style(), vec![( - delta..delta + label_len, + delta..label_len, HighlightStyle::color(Color::Conflict.color(cx)), )], ) @@ -765,27 +779,17 @@ impl PickerDelegate for OpenPathDelegate { .with_default_highlights( &window.text_style(), vec![( - delta..delta + label_len, + delta..label_len, HighlightStyle::color(Color::Created.color(cx)), )], ) .into_any_element() } } else { - let mut highlight_positions = match_positions; - highlight_positions.iter_mut().for_each(|position| { - *position += delta; - }); - HighlightedLabel::new(label, highlight_positions).into_any_element() + HighlightedLabel::new(label, match_positions).into_any_element() } } - None => { - let mut highlight_positions = match_positions; - highlight_positions.iter_mut().for_each(|position| { - *position += delta; - }); - HighlightedLabel::new(label, highlight_positions).into_any_element() - } + None => HighlightedLabel::new(label, match_positions).into_any_element(), }; Some( @@ -822,7 +826,13 @@ impl PickerDelegate for OpenPathDelegate { } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - Arc::from(format!("[directory{}]filename.ext", self.path_style.separator()).as_str()) + Arc::from( + format!( + "[directory{}]filename.ext", + self.path_style.primary_separator() + ) + .as_str(), + ) } fn separators_after_indices(&self) -> Vec { diff --git a/crates/file_finder/src/open_path_prompt_tests.rs b/crates/file_finder/src/open_path_prompt_tests.rs index 5e8874cd01..9af18c8a6b 100644 --- a/crates/file_finder/src/open_path_prompt_tests.rs +++ b/crates/file_finder/src/open_path_prompt_tests.rs @@ -5,7 +5,7 @@ use picker::{Picker, PickerDelegate}; use project::Project; use serde_json::json; use ui::rems; -use util::{path, paths::PathStyle}; +use util::path; use workspace::{AppState, Workspace}; use crate::OpenPathDelegate; @@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); insert_query(path!("sadjaoislkdjasldj"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), Vec::::new()); @@ -119,7 +119,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash. let query = path!("/root"); @@ -227,7 +227,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); // Support both forward and backward slashes. let query = "C:/root/"; @@ -295,56 +295,6 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { ); } -#[gpui::test] -#[cfg_attr(not(target_os = "windows"), ignore)] -async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) { - let app_state = init_test(cx); - app_state - .fs - .as_fake() - .insert_tree( - "/root", - json!({ - "a": "A", - "dir1": {}, - "dir2": {} - }), - ) - .await; - - let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::Posix, cx); - - let query = "/root/"; - insert_query(query, &picker, cx).await; - assert_eq!( - collect_match_candidates(&picker, cx), - vec!["./", "a", "dir1", "dir2"] - ); - assert_eq!( - confirm_completion(query, 1, &picker, cx).unwrap(), - "/root/a" - ); - - // Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash. - let query = "/root/d"; - insert_query(query, &picker, cx).await; - assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!( - confirm_completion(query, 1, &picker, cx).unwrap(), - "/root/dir2/" - ); - - let query = "/root/d"; - insert_query(query, &picker, cx).await; - assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!( - confirm_completion(query, 0, &picker, cx).unwrap(), - "/root/dir1/" - ); -} - #[gpui::test] async fn test_new_path_prompt(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -372,7 +322,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, true, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, true, cx); insert_query(path!("/root"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); @@ -397,11 +347,8 @@ fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); super::init(cx); editor::init(cx); - workspace::init_settings(cx); - Project::init_settings(cx); state }) } @@ -409,16 +356,15 @@ fn init_test(cx: &mut TestAppContext) -> Arc { fn build_open_path_prompt( project: Entity, creating_path: bool, - path_style: PathStyle, cx: &mut TestAppContext, ) -> (Entity>, &mut VisualTestContext) { let (tx, _) = futures::channel::oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, path_style); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); ( workspace.update_in(cx, |_, window, cx| { + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) .width(rems(34.)) diff --git a/crates/file_icons/Cargo.toml b/crates/file_icons/Cargo.toml index 1c271f4132..d45b606e5a 100644 --- a/crates/file_icons/Cargo.toml +++ b/crates/file_icons/Cargo.toml @@ -15,7 +15,5 @@ doctest = false [dependencies] gpui.workspace = true serde.workspace = true -settings.workspace = true theme.workspace = true util.workspace = true -workspace-hack.workspace = true diff --git a/crates/file_icons/src/file_icons.rs b/crates/file_icons/src/file_icons.rs index b7322a717d..e8650a83b9 100644 --- a/crates/file_icons/src/file_icons.rs +++ b/crates/file_icons/src/file_icons.rs @@ -2,8 +2,7 @@ use std::sync::Arc; use std::{path::Path, str}; use gpui::{App, SharedString}; -use settings::Settings; -use theme::{IconTheme, ThemeRegistry, ThemeSettings}; +use theme::{GlobalTheme, IconTheme, ThemeRegistry}; use util::paths::PathExt; #[derive(Debug)] @@ -13,10 +12,8 @@ pub struct FileIcons { impl FileIcons { pub fn get(cx: &App) -> Self { - let theme_settings = ThemeSettings::get_global(cx); - Self { - icon_theme: theme_settings.active_icon_theme.clone(), + icon_theme: GlobalTheme::icon_theme(cx).clone(), } } @@ -97,7 +94,7 @@ impl FileIcons { .map(|icon_definition| icon_definition.path.clone()) } - get_icon_for_type(&ThemeSettings::get_global(cx).active_icon_theme, typ).or_else(|| { + get_icon_for_type(GlobalTheme::icon_theme(cx), typ).or_else(|| { Self::default_icon_theme(cx).and_then(|icon_theme| get_icon_for_type(&icon_theme, typ)) }) } @@ -122,20 +119,16 @@ impl FileIcons { } } - get_folder_icon( - &ThemeSettings::get_global(cx).active_icon_theme, - path, - expanded, - ) - .or_else(|| { - Self::default_icon_theme(cx) - .and_then(|icon_theme| get_folder_icon(&icon_theme, path, expanded)) - }) - .or_else(|| { - // If we can't find a specific folder icon for the folder at the given path, fall back to the generic folder - // icon. - Self::get_generic_folder_icon(expanded, cx) - }) + get_folder_icon(GlobalTheme::icon_theme(cx), path, expanded) + .or_else(|| { + Self::default_icon_theme(cx) + .and_then(|icon_theme| get_folder_icon(&icon_theme, path, expanded)) + }) + .or_else(|| { + // If we can't find a specific folder icon for the folder at the given path, fall back to the generic folder + // icon. + Self::get_generic_folder_icon(expanded, cx) + }) } fn get_generic_folder_icon(expanded: bool, cx: &App) -> Option { @@ -150,12 +143,10 @@ impl FileIcons { } } - get_generic_folder_icon(&ThemeSettings::get_global(cx).active_icon_theme, expanded).or_else( - || { - Self::default_icon_theme(cx) - .and_then(|icon_theme| get_generic_folder_icon(&icon_theme, expanded)) - }, - ) + get_generic_folder_icon(GlobalTheme::icon_theme(cx), expanded).or_else(|| { + Self::default_icon_theme(cx) + .and_then(|icon_theme| get_generic_folder_icon(&icon_theme, expanded)) + }) } pub fn get_chevron_icon(expanded: bool, cx: &App) -> Option { @@ -167,7 +158,7 @@ impl FileIcons { } } - get_chevron_icon(&ThemeSettings::get_global(cx).active_icon_theme, expanded).or_else(|| { + get_chevron_icon(GlobalTheme::icon_theme(cx), expanded).or_else(|| { Self::default_icon_theme(cx) .and_then(|icon_theme| get_chevron_icon(&icon_theme, expanded)) }) diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 1d4161134e..52063eeddc 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -33,7 +33,7 @@ tempfile.workspace = true text.workspace = true time.workspace = true util.workspace = true -workspace-hack.workspace = true +is_executable = "1.0.5" [target.'cfg(target_os = "macos")'.dependencies] fsevent.workspace = true @@ -41,7 +41,7 @@ objc.workspace = true cocoa = "0.26" [target.'cfg(not(target_os = "macos"))'.dependencies] -notify = "8.0.0" +notify = "8.2.0" [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 2c6db5b539..be9b84ff6a 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -3,22 +3,32 @@ use anyhow::{Context as _, Result, bail}; use collections::{HashMap, HashSet}; use futures::future::{self, BoxFuture, join_all}; use git::{ - Oid, + Oid, RunHook, blame::Blame, repository::{ AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository, - GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, + GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, Worktree, + }, + status::{ + DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, + UnmergedStatus, }, - status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; -use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task}; +use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task, TaskLabel}; use ignore::gitignore::GitignoreBuilder; use parking_lot::Mutex; use rope::Rope; use smol::future::FutureExt as _; -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::PathBuf, + sync::{Arc, LazyLock}, +}; +use text::LineEnding; use util::{paths::PathStyle, rel_path::RelPath}; +pub static LOAD_INDEX_TEXT_TASK: LazyLock = LazyLock::new(TaskLabel::new); +pub static LOAD_HEAD_TEXT_TASK: LazyLock = LazyLock::new(TaskLabel::new); + #[derive(Clone)] pub struct FakeGitRepository { pub(crate) fs: Arc, @@ -35,9 +45,14 @@ pub struct FakeGitRepositoryState { pub unmerged_paths: HashMap, pub head_contents: HashMap, pub index_contents: HashMap, + // everything in commit contents is in oids + pub merge_base_contents: HashMap, + pub oids: HashMap, pub blames: HashMap, pub current_branch_name: Option, pub branches: HashSet, + /// List of remotes, keys are names and values are URLs + pub remotes: HashMap, pub simulated_index_write_error_message: Option, pub refs: HashMap, } @@ -54,6 +69,9 @@ impl FakeGitRepositoryState { branches: Default::default(), simulated_index_write_error_message: Default::default(), refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), + merge_base_contents: Default::default(), + oids: Default::default(), + remotes: HashMap::default(), } } } @@ -79,32 +97,35 @@ impl GitRepository for FakeGitRepository { fn reload_index(&self) {} fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - async { - self.with_state_async(false, move |state| { - state - .index_contents - .get(&path) - .context("not present in index") - .cloned() - }) - .await - .ok() - } - .boxed() + let fut = self.with_state_async(false, move |state| { + state + .index_contents + .get(&path) + .context("not present in index") + .cloned() + }); + self.executor + .spawn_labeled(*LOAD_INDEX_TEXT_TASK, async move { fut.await.ok() }) + .boxed() } fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - async { - self.with_state_async(false, move |state| { - state - .head_contents - .get(&path) - .context("not present in HEAD") - .cloned() - }) - .await - .ok() - } + let fut = self.with_state_async(false, move |state| { + state + .head_contents + .get(&path) + .context("not present in HEAD") + .cloned() + }); + self.executor + .spawn_labeled(*LOAD_HEAD_TEXT_TASK, async move { fut.await.ok() }) + .boxed() + } + + fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result> { + self.with_state_async(false, move |state| { + state.oids.get(&oid).cloned().context("oid does not exist") + }) .boxed() } @@ -121,6 +142,7 @@ impl GitRepository for FakeGitRepository { path: RepoPath, content: Option, _env: Arc>, + _is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { self.with_state_async(true, move |state| { if let Some(message) = &state.simulated_index_write_error_message { @@ -134,8 +156,36 @@ impl GitRepository for FakeGitRepository { }) } - fn remote_url(&self, _name: &str) -> Option { - None + fn remote_url(&self, _name: &str) -> BoxFuture<'_, Option> { + async move { None }.boxed() + } + + fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result> { + let mut entries = HashMap::default(); + self.with_state_async(false, |state| { + for (path, content) in &state.head_contents { + let status = if let Some((oid, original)) = state + .merge_base_contents + .get(path) + .map(|oid| (oid, &state.oids[oid])) + { + if original == content { + continue; + } + TreeDiffStatus::Modified { old: *oid } + } else { + TreeDiffStatus::Added + }; + entries.insert(path.clone(), status); + } + for (path, oid) in &state.merge_base_contents { + if !entries.contains_key(path) { + entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid }); + } + } + Ok(TreeDiff { entries }) + }) + .boxed() } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { @@ -151,6 +201,7 @@ impl GitRepository for FakeGitRepository { async { Ok(CommitDetails { sha: commit.into(), + message: "initial commit".into(), ..Default::default() }) } @@ -227,7 +278,7 @@ impl GitRepository for FakeGitRepository { .ok() .map(|content| String::from_utf8(content).unwrap())?; let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; - Some((repo_path.into(), (content, is_ignored))) + Some((RepoPath::from_rel_path(&repo_path), (content, is_ignored))) }) .collect(); @@ -332,16 +383,36 @@ impl GitRepository for FakeGitRepository { Ok(state .branches .iter() - .map(|branch_name| Branch { - is_head: Some(branch_name) == current_branch.as_ref(), - ref_name: branch_name.into(), - most_recent_commit: None, - upstream: None, + .map(|branch_name| { + let ref_name = if branch_name.starts_with("refs/") { + branch_name.into() + } else { + format!("refs/heads/{branch_name}").into() + }; + Branch { + is_head: Some(branch_name) == current_branch.as_ref(), + ref_name, + most_recent_commit: None, + upstream: None, + } }) .collect()) }) } + fn worktrees(&self) -> BoxFuture<'_, Result>> { + unimplemented!() + } + + fn create_worktree( + &self, + _: String, + _: PathBuf, + _: Option, + ) -> BoxFuture<'_, Result<()>> { + unimplemented!() + } + fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, |state| { state.current_branch_name = Some(name); @@ -349,7 +420,11 @@ impl GitRepository for FakeGitRepository { }) } - fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { + fn create_branch( + &self, + name: String, + _base_branch: Option, + ) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { state.branches.insert(name); Ok(()) @@ -369,16 +444,49 @@ impl GitRepository for FakeGitRepository { }) } - fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result> { + fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + if !state.branches.remove(&name) { + bail!("no such branch: {name}"); + } + Ok(()) + }) + } + + fn blame( + &self, + path: RepoPath, + _content: Rope, + _line_ending: LineEnding, + ) -> BoxFuture<'_, Result> { self.with_state_async(false, move |state| { state .blames .get(&path) - .with_context(|| format!("failed to get blame for {:?}", path.0)) + .with_context(|| format!("failed to get blame for {:?}", path)) .cloned() }) } + fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { + self.file_history_paginated(path, 0, None) + } + + fn file_history_paginated( + &self, + path: RepoPath, + _skip: usize, + _limit: Option, + ) -> BoxFuture<'_, Result> { + async move { + Ok(git::repository::FileHistory { + entries: Vec::new(), + path, + }) + } + .boxed() + } + fn stage_paths( &self, paths: Vec, @@ -464,9 +572,18 @@ impl GitRepository for FakeGitRepository { _message: gpui::SharedString, _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>, _options: CommitOptions, + _askpass: AskPassDelegate, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { - unimplemented!() + async { Ok(()) }.boxed() + } + + fn run_hook( + &self, + _hook: RunHook, + _env: Arc>, + ) -> BoxFuture<'_, Result<()>> { + async { Ok(()) }.boxed() } fn push( @@ -483,8 +600,9 @@ impl GitRepository for FakeGitRepository { fn pull( &self, - _branch: String, + _branch: Option, _remote: String, + _rebase: bool, _askpass: AskPassDelegate, _env: Arc>, _cx: AsyncApp, @@ -502,7 +620,24 @@ impl GitRepository for FakeGitRepository { unimplemented!() } - fn get_remotes(&self, _branch: Option) -> BoxFuture<'_, Result>> { + fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { + self.with_state_async(false, move |state| { + let remotes = state + .remotes + .keys() + .map(|r| Remote { + name: r.clone().into(), + }) + .collect::>(); + Ok(remotes) + }) + } + + fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { + unimplemented!() + } + + fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } @@ -521,7 +656,7 @@ impl GitRepository for FakeGitRepository { let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf(); async move { executor.simulate_random_delay().await; - let oid = Oid::random(&mut executor.rng()); + let oid = git::Oid::random(&mut executor.rng()); let entry = fs.entry(&repository_dir_path)?; checkpoints.lock().insert(oid, entry); Ok(GitRepositoryCheckpoint { commit_sha: oid }) @@ -577,7 +712,21 @@ impl GitRepository for FakeGitRepository { } fn default_branch(&self) -> BoxFuture<'_, Result>> { - unimplemented!() + async { Ok(Some("main".into())) }.boxed() + } + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.remotes.insert(name, url); + Ok(()) + }) + } + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.remotes.remove(&name); + Ok(()) + }) } } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 03cf78d74e..e6f69a1459 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -4,13 +4,19 @@ mod mac_watcher; #[cfg(not(target_os = "macos"))] pub mod fs_watcher; +use parking_lot::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + use anyhow::{Context as _, Result, anyhow}; #[cfg(any(target_os = "linux", target_os = "freebsd"))] use ashpd::desktop::trash; +use futures::stream::iter; use gpui::App; use gpui::BackgroundExecutor; use gpui::Global; use gpui::ReadGlobal as _; +use gpui::SharedString; use std::borrow::Cow; use util::command::new_smol_command; @@ -26,6 +32,7 @@ use std::mem::MaybeUninit; use async_tar::Archive; use futures::{AsyncRead, Stream, StreamExt, future::BoxFuture}; use git::repository::{GitRepository, RealGitRepository}; +use is_executable::IsExecutable; use rope::Rope; use serde::{Deserialize, Serialize}; use smol::io::AsyncWriteExt; @@ -50,13 +57,15 @@ use git::{ repository::{RepoPath, repo_path}, status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; -#[cfg(any(test, feature = "test-support"))] -use parking_lot::Mutex; + #[cfg(any(test, feature = "test-support"))] use smol::io::AsyncReadExt; #[cfg(any(test, feature = "test-support"))] use std::ffi::OsStr; +#[cfg(any(test, feature = "test-support"))] +pub use fake_git_repo::{LOAD_HEAD_TEXT_TASK, LOAD_INDEX_TEXT_TASK}; + pub trait Watcher: Send + Sync { fn add(&self, path: &Path) -> Result<()>; fn remove(&self, path: &Path) -> Result<()>; @@ -144,6 +153,7 @@ pub trait Fs: Send + Sync { async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>; fn is_fake(&self) -> bool; async fn is_case_sensitive(&self) -> Result; + fn subscribe_to_jobs(&self) -> JobEventReceiver; #[cfg(any(test, feature = "test-support"))] fn as_fake(&self) -> Arc { @@ -183,6 +193,8 @@ pub struct CopyOptions { pub struct RenameOptions { pub overwrite: bool, pub ignore_if_exists: bool, + /// Whether to create parent directories if they do not exist. + pub create_parents: bool, } #[derive(Copy, Clone, Default)] @@ -199,6 +211,7 @@ pub struct Metadata { pub is_dir: bool, pub len: u64, pub is_fifo: bool, + pub is_executable: bool, } /// Filesystem modification time. The purpose of this newtype is to discourage use of operations @@ -211,6 +224,55 @@ pub struct Metadata { #[serde(transparent)] pub struct MTime(SystemTime); +pub type JobId = usize; + +#[derive(Clone, Debug)] +pub struct JobInfo { + pub start: Instant, + pub message: SharedString, + pub id: JobId, +} + +#[derive(Debug, Clone)] +pub enum JobEvent { + Started { info: JobInfo }, + Completed { id: JobId }, +} + +pub type JobEventSender = futures::channel::mpsc::UnboundedSender; +pub type JobEventReceiver = futures::channel::mpsc::UnboundedReceiver; + +struct JobTracker { + id: JobId, + subscribers: Arc>>, +} + +impl JobTracker { + fn new(info: JobInfo, subscribers: Arc>>) -> Self { + let id = info.id; + { + let mut subs = subscribers.lock(); + subs.retain(|sender| { + sender + .unbounded_send(JobEvent::Started { info: info.clone() }) + .is_ok() + }); + } + Self { id, subscribers } + } +} + +impl Drop for JobTracker { + fn drop(&mut self) { + let mut subs = self.subscribers.lock(); + subs.retain(|sender| { + sender + .unbounded_send(JobEvent::Completed { id: self.id }) + .is_ok() + }); + } +} + impl MTime { /// Conversion intended for persistence and testing. pub fn from_seconds_and_nanos(secs: u64, nanos: u32) -> Self { @@ -253,6 +315,8 @@ impl From for proto::Timestamp { pub struct RealFs { bundled_git_binary_path: Option, executor: BackgroundExecutor, + next_job_id: Arc, + job_event_subscribers: Arc>>, } pub trait FileHandle: Send + Sync + std::fmt::Debug { @@ -320,7 +384,33 @@ impl FileHandle for std::fs::File { #[cfg(target_os = "windows")] fn current_path(&self, _: &Arc) -> Result { - anyhow::bail!("unimplemented") + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use std::os::windows::io::AsRawHandle; + + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, + }; + + let handle = HANDLE(self.as_raw_handle() as _); + + // Query required buffer size (in wide chars) + let required_len = + unsafe { GetFinalPathNameByHandleW(handle, &mut [], FILE_NAME_NORMALIZED) }; + if required_len == 0 { + anyhow::bail!("GetFinalPathNameByHandleW returned 0 length"); + } + + // Allocate buffer and retrieve the path + let mut buf: Vec = vec![0u16; required_len as usize + 1]; + let written = unsafe { GetFinalPathNameByHandleW(handle, &mut buf, FILE_NAME_NORMALIZED) }; + if written == 0 { + anyhow::bail!("GetFinalPathNameByHandleW failed to write path"); + } + + let os_str: OsString = OsString::from_wide(&buf[..written as usize]); + Ok(PathBuf::from(os_str)) } } @@ -331,8 +421,79 @@ impl RealFs { Self { bundled_git_binary_path: git_binary_path, executor, + next_job_id: Arc::new(AtomicUsize::new(0)), + job_event_subscribers: Arc::new(Mutex::new(Vec::new())), } } + + #[cfg(target_os = "windows")] + fn canonicalize(path: &Path) -> Result { + let mut strip_prefix = None; + + let mut new_path = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::Prefix(_) => { + let canonicalized = std::fs::canonicalize(component)?; + + let mut strip = PathBuf::new(); + for component in canonicalized.components() { + match component { + Component::Prefix(prefix_component) => { + match prefix_component.kind() { + std::path::Prefix::Verbatim(os_str) => { + strip.push(os_str); + } + std::path::Prefix::VerbatimUNC(host, share) => { + strip.push("\\\\"); + strip.push(host); + strip.push(share); + } + std::path::Prefix::VerbatimDisk(disk) => { + strip.push(format!("{}:", disk as char)); + } + _ => strip.push(component), + }; + } + _ => strip.push(component), + } + } + strip_prefix = Some(strip); + new_path.push(component); + } + std::path::Component::RootDir => { + new_path.push(component); + } + std::path::Component::CurDir => { + if strip_prefix.is_none() { + // unrooted path + new_path.push(component); + } + } + std::path::Component::ParentDir => { + if strip_prefix.is_some() { + // rooted path + new_path.pop(); + } else { + new_path.push(component); + } + } + std::path::Component::Normal(_) => { + if let Ok(link) = std::fs::read_link(new_path.join(component)) { + let link = match &strip_prefix { + Some(e) => link.strip_prefix(e).unwrap_or(&link), + None => &link, + }; + new_path.extend(link); + } else { + new_path.push(component); + } + } + } + } + + Ok(new_path) + } } #[async_trait::async_trait] @@ -347,7 +508,7 @@ impl Fs for RealFs { #[cfg(windows)] if smol::fs::metadata(&target).await?.is_dir() { - let status = smol::process::Command::new("cmd") + let status = new_smol_command("cmd") .args(["/C", "mklink", "/J"]) .args([path, target.as_path()]) .status() @@ -420,6 +581,12 @@ impl Fs for RealFs { } } + if options.create_parents { + if let Some(parent) = target.parent() { + self.create_dir(parent).await?; + } + } + smol::fs::rename(source, target).await?; Ok(()) } @@ -474,6 +641,8 @@ impl Fs for RealFs { use objc::{class, msg_send, sel, sel_impl}; unsafe { + /// Allow NSString::alloc use here because it sets autorelease + #[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } @@ -557,17 +726,29 @@ impl Fs for RealFs { } async fn open_handle(&self, path: &Path) -> Result> { - Ok(Arc::new(std::fs::File::open(path)?)) + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(windows::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS.0); + } + Ok(Arc::new(options.open(path)?)) } async fn load(&self, path: &Path) -> Result { let path = path.to_path_buf(); - let text = smol::unblock(|| std::fs::read_to_string(path)).await?; - Ok(text) + self.executor + .spawn(async move { Ok(std::fs::read_to_string(path)?) }) + .await } + async fn load_bytes(&self, path: &Path) -> Result> { let path = path.to_path_buf(); - let bytes = smol::unblock(|| std::fs::read(path)).await?; + let bytes = self + .executor + .spawn(async move { std::fs::read(path) }) + .await?; Ok(bytes) } @@ -624,7 +805,7 @@ impl Fs for RealFs { } let file = smol::fs::File::create(path).await?; let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file); - for chunk in chunks(text, line_ending) { + for chunk in text::chunks_with_line_ending(text, line_ending) { writer.write_all(chunk.as_bytes()).await?; } writer.flush().await?; @@ -635,53 +816,83 @@ impl Fs for RealFs { if let Some(path) = path.parent() { self.create_dir(path).await?; } - smol::fs::write(path, content).await?; - Ok(()) + let path = path.to_owned(); + let contents = content.to_owned(); + self.executor + .spawn(async move { + std::fs::write(path, contents)?; + Ok(()) + }) + .await } async fn canonicalize(&self, path: &Path) -> Result { - Ok(smol::fs::canonicalize(path) + let path = path.to_owned(); + self.executor + .spawn(async move { + #[cfg(target_os = "windows")] + let result = Self::canonicalize(&path); + + #[cfg(not(target_os = "windows"))] + let result = std::fs::canonicalize(&path); + + result.with_context(|| format!("canonicalizing {path:?}")) + }) .await - .with_context(|| format!("canonicalizing {path:?}"))?) } async fn is_file(&self, path: &Path) -> bool { - smol::fs::metadata(path) + let path = path.to_owned(); + self.executor + .spawn(async move { std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) }) .await - .is_ok_and(|metadata| metadata.is_file()) } async fn is_dir(&self, path: &Path) -> bool { - smol::fs::metadata(path) + let path = path.to_owned(); + self.executor + .spawn(async move { std::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) }) .await - .is_ok_and(|metadata| metadata.is_dir()) } async fn metadata(&self, path: &Path) -> Result> { - let symlink_metadata = match smol::fs::symlink_metadata(path).await { + let path_buf = path.to_owned(); + let symlink_metadata = match self + .executor + .spawn(async move { std::fs::symlink_metadata(&path_buf) }) + .await + { Ok(metadata) => metadata, Err(err) => { - return match (err.kind(), err.raw_os_error()) { - (io::ErrorKind::NotFound, _) => Ok(None), - (io::ErrorKind::Other, Some(libc::ENOTDIR)) => Ok(None), + return match err.kind() { + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => Ok(None), _ => Err(anyhow::Error::new(err)), }; } }; - let path_buf = path.to_path_buf(); - let path_exists = smol::unblock(move || { - path_buf - .try_exists() - .with_context(|| format!("checking existence for path {path_buf:?}")) - }) - .await?; let is_symlink = symlink_metadata.file_type().is_symlink(); - let metadata = match (is_symlink, path_exists) { - (true, true) => smol::fs::metadata(path) - .await - .with_context(|| "accessing symlink for path {path}")?, - _ => symlink_metadata, + let metadata = if is_symlink { + let path_buf = path.to_path_buf(); + let path_exists = self + .executor + .spawn(async move { + path_buf + .try_exists() + .with_context(|| format!("checking existence for path {path_buf:?}")) + }) + .await?; + if path_exists { + let path_buf = path.to_path_buf(); + self.executor + .spawn(async move { std::fs::metadata(path_buf) }) + .await + .with_context(|| "accessing symlink for path {path}")? + } else { + symlink_metadata + } + } else { + symlink_metadata }; #[cfg(unix)] @@ -696,6 +907,12 @@ impl Fs for RealFs { #[cfg(unix)] let is_fifo = metadata.file_type().is_fifo(); + let path_buf = path.to_path_buf(); + let is_executable = self + .executor + .spawn(async move { path_buf.is_executable() }) + .await; + Ok(Some(Metadata { inode, mtime: MTime(metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH)), @@ -703,11 +920,16 @@ impl Fs for RealFs { is_symlink, is_dir: metadata.file_type().is_dir(), is_fifo, + is_executable, })) } async fn read_link(&self, path: &Path) -> Result { - let path = smol::fs::read_link(path).await?; + let path = path.to_owned(); + let path = self + .executor + .spawn(async move { std::fs::read_link(&path) }) + .await?; Ok(path) } @@ -715,7 +937,13 @@ impl Fs for RealFs { &self, path: &Path, ) -> Result>>>> { - let result = smol::fs::read_dir(path).await?.map(|entry| match entry { + let path = path.to_owned(); + let result = iter( + self.executor + .spawn(async move { std::fs::read_dir(path) }) + .await?, + ) + .map(|entry| match entry { Ok(entry) => Ok(entry.path()), Err(error) => Err(anyhow!("failed to read dir entry {error:?}")), }); @@ -749,6 +977,7 @@ impl Fs for RealFs { events .into_iter() .map(|event| { + log::trace!("fs path event: {event:?}"); let kind = if event.flags.contains(StreamFlags::ITEM_REMOVED) { Some(PathEventKind::Removed) } else if event.flags.contains(StreamFlags::ITEM_CREATED) { @@ -785,7 +1014,6 @@ impl Fs for RealFs { Pin>>>, Arc, ) { - use parking_lot::Mutex; use util::{ResultExt as _, paths::SanitizedPath}; let (tx, rx) = smol::channel::unbounded(); @@ -806,6 +1034,7 @@ impl Fs for RealFs { // Check if path is a symlink and follow the target parent if let Some(mut target) = self.read_link(path).await.ok() { + log::trace!("watch symlink {path:?} -> {target:?}"); // Check if symlink target is relative path, if so make it absolute if target.is_relative() && let Some(parent) = path.parent() @@ -881,6 +1110,15 @@ impl Fs for RealFs { } async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()> { + let job_id = self.next_job_id.fetch_add(1, Ordering::SeqCst); + let job_info = JobInfo { + id: job_id, + start: Instant::now(), + message: SharedString::from(format!("Cloning {}", repo_url)), + }; + + let _job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone()); + let output = new_smol_command("git") .current_dir(abs_work_directory) .args(&["clone", repo_url]) @@ -901,6 +1139,12 @@ impl Fs for RealFs { false } + fn subscribe_to_jobs(&self) -> JobEventReceiver { + let (sender, receiver) = futures::channel::mpsc::unbounded(); + self.job_event_subscribers.lock().push(sender); + receiver + } + /// Checks whether the file system is case sensitive by attempting to create two files /// that have the same name except for the casing. /// @@ -971,6 +1215,7 @@ struct FakeFsState { read_dir_call_count: usize, path_write_counts: std::collections::HashMap, moves: std::collections::HashMap, + job_event_subscribers: Arc>>, } #[cfg(any(test, feature = "test-support"))] @@ -1255,6 +1500,7 @@ impl FakeFs { metadata_call_count: 0, path_write_counts: Default::default(), moves: Default::default(), + job_event_subscribers: Arc::new(Mutex::new(Vec::new())), })), }); @@ -1673,6 +1919,26 @@ impl FakeFs { .unwrap(); } + pub fn set_merge_base_content_for_repo( + &self, + dot_git: &Path, + contents_by_path: &[(&str, String)], + ) { + self.with_git_state(dot_git, true, |state| { + use git::Oid; + + state.merge_base_contents.clear(); + let oids = (1..) + .map(|n| n.to_string()) + .map(|n| Oid::from_bytes(n.repeat(20).as_bytes()).unwrap()); + for ((path, content), oid) in contents_by_path.iter().zip(oids) { + state.merge_base_contents.insert(repo_path(path), oid); + state.oids.insert(oid, content.clone()); + } + }) + .unwrap(); + } + pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(RepoPath, git::blame::Blame)>) { self.with_git_state(dot_git, true, |state| { state.blames.clear(); @@ -1693,7 +1959,8 @@ impl FakeFs { for (path, content) in workdir_contents { use util::{paths::PathStyle, rel_path::RelPath}; - let repo_path: RepoPath = RelPath::new(path.strip_prefix(&workdir_path).unwrap(), PathStyle::local()).unwrap().into(); + let repo_path = RelPath::new(path.strip_prefix(&workdir_path).unwrap(), PathStyle::local()).unwrap(); + let repo_path = RepoPath::from_rel_path(&repo_path); let status = statuses .iter() .find_map(|(p, status)| (*p == repo_path.as_unix_str()).then_some(status)); @@ -2100,6 +2367,12 @@ impl Fs for FakeFs { let old_path = normalize_path(old_path); let new_path = normalize_path(new_path); + if options.create_parents { + if let Some(parent) = new_path.parent() { + self.create_dir(parent).await?; + } + } + let mut state = self.state.lock(); let moved_entry = state.write_path(&old_path, |e| { if let btree_map::Entry::Occupied(e) = e { @@ -2284,7 +2557,7 @@ impl Fs for FakeFs { async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path); - let content = chunks(text, line_ending).collect::(); + let content = text::chunks_with_line_ending(text, line_ending).collect::(); if let Some(path) = path.parent() { self.create_dir(path).await?; } @@ -2354,6 +2627,7 @@ impl Fs for FakeFs { is_dir: false, is_symlink, is_fifo: false, + is_executable: false, }, FakeFsEntry::Dir { inode, mtime, len, .. @@ -2364,6 +2638,7 @@ impl Fs for FakeFs { is_dir: true, is_symlink, is_fifo: false, + is_executable: false, }, FakeFsEntry::Symlink { .. } => unreachable!(), })) @@ -2488,31 +2763,18 @@ impl Fs for FakeFs { Ok(true) } + fn subscribe_to_jobs(&self) -> JobEventReceiver { + let (sender, receiver) = futures::channel::mpsc::unbounded(); + self.state.lock().job_event_subscribers.lock().push(sender); + receiver + } + #[cfg(any(test, feature = "test-support"))] fn as_fake(&self) -> Arc { self.this.upgrade().unwrap() } } -fn chunks(rope: &Rope, line_ending: LineEnding) -> impl Iterator { - rope.chunks().flat_map(move |chunk| { - let mut newline = false; - let end_with_newline = chunk.ends_with('\n').then_some(line_ending.as_str()); - chunk - .lines() - .flat_map(move |line| { - let ending = if newline { - Some(line_ending.as_str()) - } else { - None - }; - newline = true; - ending.into_iter().chain([line]) - }) - .chain(end_with_newline) - }) -} - pub fn normalize_path(path: &Path) -> PathBuf { let mut components = path.components().peekable(); let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { @@ -3102,6 +3364,8 @@ mod tests { let fs = RealFs { bundled_git_binary_path: None, executor, + next_job_id: Arc::new(AtomicUsize::new(0)), + job_event_subscribers: Arc::new(Mutex::new(Vec::new())), }; let temp_dir = TempDir::new().unwrap(); let file_to_be_replaced = temp_dir.path().join("file.txt"); @@ -3120,6 +3384,8 @@ mod tests { let fs = RealFs { bundled_git_binary_path: None, executor, + next_job_id: Arc::new(AtomicUsize::new(0)), + job_event_subscribers: Arc::new(Mutex::new(Vec::new())), }; let temp_dir = TempDir::new().unwrap(); let file_to_be_replaced = temp_dir.path().join("file.txt"); @@ -3127,4 +3393,63 @@ mod tests { let content = std::fs::read_to_string(&file_to_be_replaced).unwrap(); assert_eq!(content, "Hello"); } + + #[gpui::test] + async fn test_rename(executor: BackgroundExecutor) { + let fs = FakeFs::new(executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "file_a.txt": "content a", + "file_b.txt": "content b" + } + }), + ) + .await; + + fs.rename( + Path::new(path!("/root/src/file_a.txt")), + Path::new(path!("/root/src/new/renamed_a.txt")), + RenameOptions { + create_parents: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Assert that the `file_a.txt` file was being renamed and moved to a + // different directory that did not exist before. + assert_eq!( + fs.files(), + vec![ + PathBuf::from(path!("/root/src/file_b.txt")), + PathBuf::from(path!("/root/src/new/renamed_a.txt")), + ] + ); + + let result = fs + .rename( + Path::new(path!("/root/src/file_b.txt")), + Path::new(path!("/root/src/old/renamed_b.txt")), + RenameOptions { + create_parents: false, + ..Default::default() + }, + ) + .await; + + // Assert that the `file_b.txt` file was not renamed nor moved, as + // `create_parents` was set to `false`. + // different directory that did not exist before. + assert!(result.is_err()); + assert_eq!( + fs.files(), + vec![ + PathBuf::from(path!("/root/src/file_b.txt")), + PathBuf::from(path!("/root/src/new/renamed_a.txt")), + ] + ); + } } diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 0d6d914ae4..18d5dbeeb9 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -46,6 +46,7 @@ impl Drop for FsWatcher { impl Watcher for FsWatcher { fn add(&self, path: &std::path::Path) -> anyhow::Result<()> { + log::trace!("watcher add: {path:?}"); let tx = self.tx.clone(); let pending_paths = self.pending_path_events.clone(); @@ -63,12 +64,16 @@ impl Watcher for FsWatcher { .next_back() && path.starts_with(watched_path.as_ref()) { + log::trace!( + "path to watch is covered by existing registration: {path:?}, {watched_path:?}" + ); return Ok(()); } } #[cfg(target_os = "linux")] { if self.registrations.lock().contains_key(path) { + log::trace!("path to watch is already watched: {path:?}"); return Ok(()); } } @@ -85,6 +90,7 @@ impl Watcher for FsWatcher { let path = path.clone(); |g| { g.add(path, mode, move |event: ¬ify::Event| { + log::trace!("watcher received event: {event:?}"); let kind = match event.kind { EventKind::Create(_) => Some(PathEventKind::Created), EventKind::Modify(_) => Some(PathEventKind::Changed), @@ -126,6 +132,7 @@ impl Watcher for FsWatcher { } fn remove(&self, path: &std::path::Path) -> anyhow::Result<()> { + log::trace!("remove watched path: {path:?}"); let Some(registration) = self.registrations.lock().remove(path) else { return Ok(()); }; @@ -215,6 +222,7 @@ static FS_WATCHER_INSTANCE: OnceLock) { + log::trace!("global handle event: {event:?}"); // Filter out access events, which could lead to a weird bug on Linux after upgrading notify // https://github.com/zed-industries/zed/actions/runs/14085230504/job/39449448832 let Some(event) = event diff --git a/crates/fs/src/mac_watcher.rs b/crates/fs/src/mac_watcher.rs index 698014de97..b781a231ba 100644 --- a/crates/fs/src/mac_watcher.rs +++ b/crates/fs/src/mac_watcher.rs @@ -32,6 +32,7 @@ impl MacWatcher { impl Watcher for MacWatcher { fn add(&self, path: &Path) -> Result<()> { + log::trace!("mac watcher add: {:?}", path); let handles = self .handles .upgrade() @@ -44,6 +45,9 @@ impl Watcher for MacWatcher { .next_back() && path.starts_with(watched_path) { + log::trace!( + "mac watched path starts with existing watched path: {watched_path:?}, {path:?}" + ); return Ok(()); } diff --git a/crates/fs_benchmarks/Cargo.toml b/crates/fs_benchmarks/Cargo.toml new file mode 100644 index 0000000000..f207a2db3b --- /dev/null +++ b/crates/fs_benchmarks/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fs_benchmarks" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[dependencies] +fs.workspace = true +gpui = {workspace = true, features = ["windows-manifest"]} + +[lints] +workspace = true diff --git a/crates/edit_prediction_button/LICENSE-GPL b/crates/fs_benchmarks/LICENSE-GPL similarity index 100% rename from crates/edit_prediction_button/LICENSE-GPL rename to crates/fs_benchmarks/LICENSE-GPL diff --git a/crates/fs_benchmarks/src/main.rs b/crates/fs_benchmarks/src/main.rs new file mode 100644 index 0000000000..12df32f076 --- /dev/null +++ b/crates/fs_benchmarks/src/main.rs @@ -0,0 +1,32 @@ +use fs::Fs; +use gpui::{AppContext, Application}; +fn main() { + let Some(path_to_read) = std::env::args().nth(1) else { + println!("Expected path to read as 1st argument."); + return; + }; + + let _ = Application::headless().run(|cx| { + let fs = fs::RealFs::new(None, cx.background_executor().clone()); + cx.background_spawn(async move { + let timer = std::time::Instant::now(); + let result = fs.load_bytes(path_to_read.as_ref()).await; + let elapsed = timer.elapsed(); + if let Err(e) = result { + println!("Failed `load_bytes` after {elapsed:?} with error `{e}`"); + } else { + println!("Took {elapsed:?} to read {} bytes", result.unwrap().len()); + }; + let timer = std::time::Instant::now(); + let result = fs.metadata(path_to_read.as_ref()).await; + let elapsed = timer.elapsed(); + if let Err(e) = result { + println!("Failed `metadata` after {elapsed:?} with error `{e}`"); + } else { + println!("Took {elapsed:?} to query metadata"); + }; + std::process::exit(0); + }) + .detach(); + }); +} diff --git a/crates/fsevent/Cargo.toml b/crates/fsevent/Cargo.toml index a421294785..635b36ebe1 100644 --- a/crates/fsevent/Cargo.toml +++ b/crates/fsevent/Cargo.toml @@ -16,7 +16,6 @@ doctest = false bitflags.workspace = true parking_lot.workspace = true log.workspace = true -workspace-hack.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation.workspace = true diff --git a/crates/fsevent/src/fsevent.rs b/crates/fsevent/src/fsevent.rs index e4060f3ae0..8af57c19ee 100644 --- a/crates/fsevent/src/fsevent.rs +++ b/crates/fsevent/src/fsevent.rs @@ -372,7 +372,9 @@ unsafe extern "C" { pub fn FSEventsGetCurrentEventId() -> u64; } -#[cfg(test)] +// These tests are disabled by default because they seem to be unresolvably flaky. +// Feel free to bring them back to help test this code +#[cfg(false)] mod tests { use super::*; use std::{fs, sync::mpsc, thread, time::Duration}; @@ -395,19 +397,19 @@ mod tests { thread::spawn(move || stream.run(move |events| tx.send(events.to_vec()).is_ok())); fs::write(path.join("new-file"), "").unwrap(); - let events = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let events = rx.recv_timeout(timeout()).unwrap(); let event = events.last().unwrap(); assert_eq!(event.path, path.join("new-file")); assert!(event.flags.contains(StreamFlags::ITEM_CREATED)); fs::remove_file(path.join("existing-file-5")).unwrap(); - let mut events = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let mut events = rx.recv_timeout(timeout()).unwrap(); let mut event = events.last().unwrap(); // we see this duplicate about 1/100 test runs. if event.path == path.join("new-file") && event.flags.contains(StreamFlags::ITEM_CREATED) { - events = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + events = rx.recv_timeout(timeout()).unwrap(); event = events.last().unwrap(); } assert_eq!(event.path, path.join("existing-file-5")); @@ -440,13 +442,13 @@ mod tests { }); fs::write(path.join("new-file"), "").unwrap(); - let events = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let events = rx.recv_timeout(timeout()).unwrap(); let event = events.last().unwrap(); assert_eq!(event.path, path.join("new-file")); assert!(event.flags.contains(StreamFlags::ITEM_CREATED)); fs::remove_file(path.join("existing-file-5")).unwrap(); - let events = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let events = rx.recv_timeout(timeout()).unwrap(); let event = events.last().unwrap(); assert_eq!(event.path, path.join("existing-file-5")); assert!(event.flags.contains(StreamFlags::ITEM_REMOVED)); @@ -477,11 +479,11 @@ mod tests { }); fs::write(path.join("new-file"), "").unwrap(); - assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "running"); + assert_eq!(rx.recv_timeout(timeout()).unwrap(), "running"); // Dropping the handle causes `EventStream::run` to return. drop(handle); - assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "stopped"); + assert_eq!(rx.recv_timeout(timeout()).unwrap(), "stopped"); } #[test] @@ -500,11 +502,14 @@ mod tests { } fn flush_historical_events() { - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(2) + thread::sleep(timeout()); + } + + fn timeout() -> Duration { + if std::env::var("CI").is_ok() { + Duration::from_secs(4) } else { Duration::from_millis(500) - }; - thread::sleep(duration); + } } } diff --git a/crates/fuzzy/Cargo.toml b/crates/fuzzy/Cargo.toml index 35e134236d..7df2142fa1 100644 --- a/crates/fuzzy/Cargo.toml +++ b/crates/fuzzy/Cargo.toml @@ -16,7 +16,6 @@ doctest = false gpui.workspace = true util.workspace = true log.workspace = true -workspace-hack.workspace = true [dev-dependencies] util = {workspace = true, features = ["test-support"]} diff --git a/crates/fuzzy/src/matcher.rs b/crates/fuzzy/src/matcher.rs index eb844e3498..782c9caca8 100644 --- a/crates/fuzzy/src/matcher.rs +++ b/crates/fuzzy/src/matcher.rs @@ -96,7 +96,8 @@ impl<'a> Matcher<'a> { continue; } - let matrix_len = self.query.len() * (prefix.len() + candidate_chars.len()); + let matrix_len = + self.query.len() * (lowercase_prefix.len() + lowercase_candidate_chars.len()); self.score_matrix.clear(); self.score_matrix.resize(matrix_len, None); self.best_position_matrix.clear(); @@ -596,4 +597,15 @@ mod tests { }) .collect() } + + /// Test for https://github.com/zed-industries/zed/issues/44324 + #[test] + fn test_recursive_score_match_index_out_of_bounds() { + let paths = vec!["İ/İ/İ/İ"]; + let query = "İ/İ"; + + // This panicked with "index out of bounds: the len is 21 but the index is 22" + let result = match_single_path_query(query, false, &paths); + let _ = result; + } } diff --git a/crates/fuzzy/src/paths.rs b/crates/fuzzy/src/paths.rs index 6fc52361e3..cce0e08284 100644 --- a/crates/fuzzy/src/paths.rs +++ b/crates/fuzzy/src/paths.rs @@ -88,9 +88,11 @@ impl Ord for PathMatch { pub fn match_fixed_path_set( candidates: Vec, worktree_id: usize, + worktree_root_name: Option>, query: &str, smart_case: bool, max_results: usize, + path_style: PathStyle, ) -> Vec { let lowercase_query = query.to_lowercase().chars().collect::>(); let query = query.chars().collect::>(); @@ -98,10 +100,31 @@ pub fn match_fixed_path_set( let mut matcher = Matcher::new(&query, &lowercase_query, query_char_bag, smart_case, true); - let mut results = Vec::new(); + let mut results = Vec::with_capacity(candidates.len()); + let (path_prefix, path_prefix_chars, lowercase_prefix) = match worktree_root_name { + Some(worktree_root_name) => { + let mut path_prefix_chars = worktree_root_name + .display(path_style) + .chars() + .collect::>(); + path_prefix_chars.extend(path_style.primary_separator().chars()); + let lowercase_pfx = path_prefix_chars + .iter() + .map(|c| c.to_ascii_lowercase()) + .collect::>(); + + (worktree_root_name, path_prefix_chars, lowercase_pfx) + } + None => ( + RelPath::empty().into(), + Default::default(), + Default::default(), + ), + }; + matcher.match_candidates( - &[], - &[], + &path_prefix_chars, + &lowercase_prefix, candidates.into_iter(), &mut results, &AtomicBool::new(false), @@ -111,7 +134,7 @@ pub fn match_fixed_path_set( positions: positions.clone(), is_dir: candidate.is_dir, path: candidate.path.into(), - path_prefix: RelPath::empty().into(), + path_prefix: path_prefix.clone(), distance_to_relative_ancestor: usize::MAX, }, ); diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 74656f1d4c..0a99b0ad27 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -23,6 +23,7 @@ derive_more.workspace = true git2.workspace = true gpui.workspace = true http_client.workspace = true +itertools.workspace = true log.workspace = true parking_lot.workspace = true regex.workspace = true @@ -36,10 +37,10 @@ text.workspace = true thiserror.workspace = true time.workspace = true url.workspace = true +urlencoding.workspace = true util.workspace = true uuid.workspace = true futures.workspace = true -workspace-hack.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index e58b9cb7e0..c3bbeff3f7 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -8,7 +8,7 @@ use gpui::SharedString; use serde::{Deserialize, Serialize}; use std::process::Stdio; use std::{ops::Range, path::Path}; -use text::Rope; +use text::{LineEnding, Rope}; use time::OffsetDateTime; use time::UtcOffset; use time::macros::format_description; @@ -19,7 +19,6 @@ pub use git2 as libgit; pub struct Blame { pub entries: Vec, pub messages: HashMap, - pub remote_url: Option, } #[derive(Clone, Debug, Default)] @@ -36,9 +35,10 @@ impl Blame { working_directory: &Path, path: &RepoPath, content: &Rope, - remote_url: Option, + line_ending: LineEnding, ) -> Result { - let output = run_git_blame(git_binary, working_directory, path, content).await?; + let output = + run_git_blame(git_binary, working_directory, path, content, line_ending).await?; let mut entries = parse_git_blame(&output)?; entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); @@ -53,11 +53,7 @@ impl Blame { .await .context("failed to get commit messages")?; - Ok(Self { - entries, - messages, - remote_url, - }) + Ok(Self { entries, messages }) } } @@ -69,12 +65,12 @@ async fn run_git_blame( working_directory: &Path, path: &RepoPath, contents: &Rope, + line_ending: LineEnding, ) -> Result { let mut child = util::command::new_smol_command(git_binary) .current_dir(working_directory) .arg("blame") .arg("--incremental") - .arg("-w") .arg("--contents") .arg("-") .arg(path.as_unix_str()) @@ -89,7 +85,7 @@ async fn run_git_blame( .as_mut() .context("failed to get pipe to stdin of git blame command")?; - for chunk in contents.chunks() { + for chunk in text::chunks_with_line_ending(contents, line_ending) { stdin.write_all(chunk.as_bytes()).await?; } stdin.flush().await?; diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 354614e32c..8b8f88ef65 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -43,6 +43,8 @@ actions!( /// Shows git blame information for the current file. #[action(deprecated_aliases = ["editor::ToggleGitBlame"])] Blame, + /// Shows the git history for the current file. + FileHistory, /// Stages the current file. StageFile, /// Unstages the current file. @@ -72,6 +74,8 @@ actions!( ForcePush, /// Pulls changes from the remote repository. Pull, + /// Pulls changes from the remote repository with rebase. + PullRebase, /// Fetches changes from the remote repository. Fetch, /// Fetches changes from a specific remote. @@ -94,6 +98,8 @@ actions!( OpenModifiedFiles, /// Clones a repository. Clone, + /// Adds a file to .gitignore. + AddToGitignore, ] ); @@ -221,3 +227,28 @@ impl From for usize { u64::from_ne_bytes(u64_bytes) as usize } } + +#[repr(i32)] +#[derive(Copy, Clone, Debug)] +pub enum RunHook { + PreCommit, +} + +impl RunHook { + pub fn as_str(&self) -> &str { + match self { + Self::PreCommit => "pre-commit", + } + } + + pub fn to_proto(&self) -> i32 { + *self as i32 + } + + pub fn from_proto(value: i32) -> Option { + match value { + 0 => Some(Self::PreCommit), + _ => None, + } + } +} diff --git a/crates/git/src/hosting_provider.rs b/crates/git/src/hosting_provider.rs index 51cdcda211..225d4a3e23 100644 --- a/crates/git/src/hosting_provider.rs +++ b/crates/git/src/hosting_provider.rs @@ -5,9 +5,12 @@ use async_trait::async_trait; use derive_more::{Deref, DerefMut}; use gpui::{App, Global, SharedString}; use http_client::HttpClient; +use itertools::Itertools; use parking_lot::RwLock; use url::Url; +use crate::repository::RepoPath; + #[derive(Debug, PartialEq, Eq, Clone)] pub struct PullRequest { pub number: u32, @@ -55,10 +58,21 @@ pub struct BuildCommitPermalinkParams<'a> { pub struct BuildPermalinkParams<'a> { pub sha: &'a str, - pub path: &'a str, + /// URL-escaped path using unescaped `/` as the directory separator. + pub path: String, pub selection: Option>, } +impl<'a> BuildPermalinkParams<'a> { + pub fn new(sha: &'a str, path: &RepoPath, selection: Option>) -> Self { + Self { + sha, + path: path.components().map(urlencoding::encode).join("/"), + selection, + } + } +} + /// A Git hosting provider. #[async_trait] pub trait GitHostingProvider { diff --git a/crates/git/src/remote.rs b/crates/git/src/remote.rs index e9814afc51..8fb4483984 100644 --- a/crates/git/src/remote.rs +++ b/crates/git/src/remote.rs @@ -1,3 +1,4 @@ +use std::str::FromStr; use std::sync::LazyLock; use derive_more::Deref; @@ -11,7 +12,7 @@ pub struct RemoteUrl(Url); static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z\-_]+@").expect("Failed to create USERNAME_REGEX")); -impl std::str::FromStr for RemoteUrl { +impl FromStr for RemoteUrl { type Err = url::ParseError; fn from_str(input: &str) -> Result { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 2e132d4eac..c3dd0995ff 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,20 +1,22 @@ use crate::commit::parse_git_diff_name_status; use crate::stash::GitStash; -use crate::status::{GitStatus, StatusCode}; -use crate::{Oid, SHORT_SHA_LENGTH}; +use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff}; +use crate::{Oid, RunHook, SHORT_SHA_LENGTH}; use anyhow::{Context as _, Result, anyhow, bail}; use collections::HashMap; use futures::future::BoxFuture; use futures::io::BufWriter; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; -use git2::BranchType; +use git2::{BranchType, ErrorCode}; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task}; use parking_lot::Mutex; use rope::Rope; use schemars::JsonSchema; use serde::Deserialize; use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; -use std::borrow::Cow; +use text::LineEnding; + +use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::process::{ExitStatus, Stdio}; use std::{ @@ -56,6 +58,12 @@ impl Branch { self.ref_name.starts_with("refs/remotes/") } + pub fn remote_name(&self) -> Option<&str> { + self.ref_name + .strip_prefix("refs/remotes/") + .and_then(|stripped| stripped.split("/").next()) + } + pub fn tracking_status(&self) -> Option { self.upstream .as_ref() @@ -72,6 +80,50 @@ impl Branch { } } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Worktree { + pub path: PathBuf, + pub ref_name: SharedString, + pub sha: SharedString, +} + +impl Worktree { + pub fn branch(&self) -> &str { + self.ref_name + .as_ref() + .strip_prefix("refs/heads/") + .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/")) + .unwrap_or(self.ref_name.as_ref()) + } +} + +pub fn parse_worktrees_from_str>(raw_worktrees: T) -> Vec { + let mut worktrees = Vec::new(); + let entries = raw_worktrees.as_ref().split("\n\n"); + for entry in entries { + let mut parts = entry.splitn(3, '\n'); + let path = parts + .next() + .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string())); + let sha = parts + .next() + .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string())); + let ref_name = parts + .next() + .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string())); + + if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) { + worktrees.push(Worktree { + path: PathBuf::from(path), + ref_name: ref_name.into(), + sha: sha.into(), + }) + } + } + + worktrees +} + #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Upstream { pub ref_name: SharedString, @@ -164,6 +216,22 @@ pub struct CommitDetails { pub author_name: SharedString, } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct FileHistoryEntry { + pub sha: SharedString, + pub subject: SharedString, + pub message: SharedString, + pub commit_timestamp: i64, + pub author_name: SharedString, + pub author_email: SharedString, +} + +#[derive(Debug, Clone)] +pub struct FileHistory { + pub entries: Vec, + pub path: RepoPath, +} + #[derive(Debug)] pub struct CommitDiff { pub files: Vec, @@ -350,16 +418,18 @@ pub trait GitRepository: Send + Sync { /// /// Also returns `None` for symlinks. fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option>; + fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result>; fn set_index_text( &self, path: RepoPath, content: Option, env: Arc>, + is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>>; /// Returns the URL of the remote with the given name. - fn remote_url(&self, name: &str) -> Option; + fn remote_url(&self, name: &str) -> BoxFuture<'_, Option>; /// Resolve a list of refs to SHAs. fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>>; @@ -379,15 +449,28 @@ pub trait GitRepository: Send + Sync { fn merge_message(&self) -> BoxFuture<'_, Option>; fn status(&self, path_prefixes: &[RepoPath]) -> Task>; + fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result>; fn stash_entries(&self) -> BoxFuture<'_, Result>; fn branches(&self) -> BoxFuture<'_, Result>>; fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; - fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; + fn create_branch(&self, name: String, base_branch: Option) + -> BoxFuture<'_, Result<()>>; fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>; + fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; + + fn worktrees(&self) -> BoxFuture<'_, Result>>; + + fn create_worktree( + &self, + name: String, + directory: PathBuf, + from_commit: Option, + ) -> BoxFuture<'_, Result<()>>; + fn reset( &self, commit: String, @@ -405,7 +488,19 @@ pub trait GitRepository: Send + Sync { fn show(&self, commit: String) -> BoxFuture<'_, Result>; fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result>; - fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result>; + fn blame( + &self, + path: RepoPath, + content: Rope, + line_ending: LineEnding, + ) -> BoxFuture<'_, Result>; + fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result>; + fn file_history_paginated( + &self, + path: RepoPath, + skip: usize, + limit: Option, + ) -> BoxFuture<'_, Result>; /// Returns the absolute path to the repository. For worktrees, this will be the path to the /// worktree's gitdir within the main repository (typically `.git/worktrees/`). @@ -430,11 +525,18 @@ pub trait GitRepository: Send + Sync { env: Arc>, ) -> BoxFuture<'_, Result<()>>; + fn run_hook( + &self, + hook: RunHook, + env: Arc>, + ) -> BoxFuture<'_, Result<()>>; + fn commit( &self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, options: CommitOptions, + askpass: AskPassDelegate, env: Arc>, ) -> BoxFuture<'_, Result<()>>; @@ -476,8 +578,9 @@ pub trait GitRepository: Send + Sync { fn pull( &self, - branch_name: String, + branch_name: Option, upstream_name: String, + rebase: bool, askpass: AskPassDelegate, env: Arc>, // This method takes an AsyncApp to ensure it's invoked on the main thread, @@ -495,7 +598,15 @@ pub trait GitRepository: Send + Sync { cx: AsyncApp, ) -> BoxFuture<'_, Result>; - fn get_remotes(&self, branch_name: Option) -> BoxFuture<'_, Result>>; + fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result>>; + + fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result>>; + + fn get_all_remotes(&self) -> BoxFuture<'_, Result>>; + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>; + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>; /// returns a list of remote branches that contain HEAD fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>>; @@ -547,6 +658,7 @@ pub struct RealGitRepository { pub repository: Arc>, pub system_git_binary_path: Option, pub any_git_binary_path: PathBuf, + any_git_binary_help_output: Arc>>, executor: BackgroundExecutor, } @@ -565,6 +677,7 @@ impl RealGitRepository { system_git_binary_path, any_git_binary_path, executor, + any_git_binary_help_output: Arc::new(Mutex::new(None)), }) } @@ -575,6 +688,27 @@ impl RealGitRepository { .context("failed to read git work directory") .map(Path::to_path_buf) } + + async fn any_git_binary_help_output(&self) -> SharedString { + if let Some(output) = self.any_git_binary_help_output.lock().clone() { + return output; + } + let git_binary_path = self.any_git_binary_path.clone(); + let executor = self.executor.clone(); + let working_directory = self.working_directory(); + let output: SharedString = self + .executor + .spawn(async move { + GitBinary::new(git_binary_path, working_directory?, executor) + .run(["help", "-a"]) + .await + }) + .await + .unwrap_or_default() + .into(); + *self.any_git_binary_help_output.lock() = Some(output.clone()); + output + } } #[derive(Clone, Debug)] @@ -693,10 +827,11 @@ impl GitRepository for RealGitRepository { .args([ "--no-optional-locks", "show", - "--format=%P", + "--format=", "-z", "--no-renames", "--name-status", + "--first-parent", ]) .arg(&commit) .stdin(Stdio::null()) @@ -707,9 +842,8 @@ impl GitRepository for RealGitRepository { .context("starting git show process")?; let show_stdout = String::from_utf8_lossy(&show_output.stdout); - let mut lines = show_stdout.split('\n'); - let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0'); - let changes = parse_git_diff_name_status(lines.next().unwrap_or("")); + let changes = parse_git_diff_name_status(&show_stdout); + let parent_sha = format!("{}^", commit); let mut cat_file_process = util::command::new_smol_command(&git_binary_path) .current_dir(&working_directory) @@ -790,7 +924,7 @@ impl GitRepository for RealGitRepository { } files.push(CommitFile { - path: rel_path.into(), + path: RepoPath(Arc::from(rel_path)), old_text, new_text, }) @@ -874,7 +1008,15 @@ impl GitRepository for RealGitRepository { index.read(false)?; const STAGE_NORMAL: i32 = 0; - let oid = match index.get_path(path.as_std_path(), STAGE_NORMAL) { + let path = path.as_std_path(); + // `RepoPath` contains a `RelPath` which normalizes `.` into an empty path + // `get_path` unwraps on empty paths though, so undo that normalization here + let path = if path.components().next().is_none() { + ".".as_ref() + } else { + path + }; + let oid = match index.get_path(path, STAGE_NORMAL) { Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id, _ => return Ok(None), }; @@ -908,17 +1050,31 @@ impl GitRepository for RealGitRepository { .boxed() } + fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + let content = repo.find_blob(oid.0)?.content().to_owned(); + Ok(String::from_utf8(content)?) + }) + .boxed() + } + fn set_index_text( &self, path: RepoPath, content: Option, env: Arc>, + is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let working_directory = working_directory?; + let mode = if is_executable { "100755" } else { "100644" }; + if let Some(content) = content { let mut child = new_smol_command(&git_binary_path) .current_dir(&working_directory) @@ -939,7 +1095,7 @@ impl GitRepository for RealGitRepository { let output = new_smol_command(&git_binary_path) .current_dir(&working_directory) .envs(env.iter()) - .args(["update-index", "--add", "--cacheinfo", "100644", sha]) + .args(["update-index", "--add", "--cacheinfo", mode, sha]) .arg(path.as_unix_str()) .output() .await?; @@ -970,10 +1126,16 @@ impl GitRepository for RealGitRepository { .boxed() } - fn remote_url(&self, name: &str) -> Option { - let repo = self.repository.lock(); - let remote = repo.find_remote(name).ok()?; - remote.url().map(|url| url.to_string()) + fn remote_url(&self, name: &str) -> BoxFuture<'_, Option> { + let repo = self.repository.clone(); + let name = name.to_owned(); + self.executor + .spawn(async move { + let repo = repo.lock(); + let remote = repo.find_remote(&name).ok()?; + remote.url().map(|url| url.to_string()) + }) + .boxed() } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { @@ -1060,6 +1222,50 @@ impl GitRepository for RealGitRepository { }) } + fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = match self.working_directory() { + Ok(working_directory) => working_directory, + Err(e) => return Task::ready(Err(e)).boxed(), + }; + + let mut args = vec![ + OsString::from("--no-optional-locks"), + OsString::from("diff-tree"), + OsString::from("-r"), + OsString::from("-z"), + OsString::from("--no-renames"), + ]; + match request { + DiffTreeType::MergeBase { base, head } => { + args.push("--merge-base".into()); + args.push(OsString::from(base.as_str())); + args.push(OsString::from(head.as_str())); + } + DiffTreeType::Since { base, head } => { + args.push(OsString::from(base.as_str())); + args.push(OsString::from(head.as_str())); + } + } + + self.executor + .spawn(async move { + let output = new_smol_command(&git_binary_path) + .current_dir(working_directory) + .args(args) + .output() + .await?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.parse() + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git status failed: {stderr}"); + } + }) + .boxed() + } + fn stash_entries(&self) -> BoxFuture<'_, Result> { let git_binary_path = self.any_git_binary_path.clone(); let working_directory = self.working_directory(); @@ -1149,6 +1355,66 @@ impl GitRepository for RealGitRepository { .boxed() } + fn worktrees(&self) -> BoxFuture<'_, Result>> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self.working_directory(); + self.executor + .spawn(async move { + let output = new_smol_command(&git_binary_path) + .current_dir(working_directory?) + .args(&["--no-optional-locks", "worktree", "list", "--porcelain"]) + .output() + .await?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_worktrees_from_str(&stdout)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git worktree list failed: {stderr}"); + } + }) + .boxed() + } + + fn create_worktree( + &self, + name: String, + directory: PathBuf, + from_commit: Option, + ) -> BoxFuture<'_, Result<()>> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self.working_directory(); + let final_path = directory.join(&name); + let mut args = vec![ + OsString::from("--no-optional-locks"), + OsString::from("worktree"), + OsString::from("add"), + OsString::from(final_path.as_os_str()), + ]; + if let Some(from_commit) = from_commit { + args.extend([ + OsString::from("-b"), + OsString::from(name.as_str()), + OsString::from(from_commit), + ]); + } + self.executor + .spawn(async move { + let output = new_smol_command(&git_binary_path) + .current_dir(working_directory?) + .args(args) + .output() + .await?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git worktree list failed: {stderr}"); + } + }) + .boxed() + } + fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { let repo = self.repository.clone(); let working_directory = self.working_directory(); @@ -1160,9 +1426,19 @@ impl GitRepository for RealGitRepository { branch } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) { let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?; + let revision = revision.get(); let branch_commit = revision.peel_to_commit()?; - let mut branch = repo.branch(&branch_name, &branch_commit, false)?; + let mut branch = match repo.branch(&branch_name, &branch_commit, false) { + Ok(branch) => branch, + Err(err) if err.code() == ErrorCode::Exists => { + repo.find_branch(&branch_name, BranchType::Local)? + } + Err(err) => { + return Err(err.into()); + } + }; + branch.set_upstream(Some(&name))?; branch } else { @@ -1178,7 +1454,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let branch = branch.await?; - GitBinary::new(git_binary_path, working_directory?, executor) .run(&["checkout", &branch]) .await?; @@ -1187,14 +1462,28 @@ impl GitRepository for RealGitRepository { .boxed() } - fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { - let repo = self.repository.clone(); + fn create_branch( + &self, + name: String, + base_branch: Option, + ) -> BoxFuture<'_, Result<()>> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self.working_directory(); + let executor = self.executor.clone(); + self.executor .spawn(async move { - let repo = repo.lock(); - let current_commit = repo.head()?.peel_to_commit()?; - repo.branch(&name, ¤t_commit, false)?; - Ok(()) + let mut args = vec!["switch", "-c", &name]; + let base_branch_str; + if let Some(ref base) = base_branch { + base_branch_str = base.clone(); + args.push(&base_branch_str); + } + + GitBinary::new(git_binary_path, working_directory?, executor) + .run(&args) + .await?; + anyhow::Ok(()) }) .boxed() } @@ -1214,28 +1503,133 @@ impl GitRepository for RealGitRepository { .boxed() } - fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); + fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { let git_binary_path = self.any_git_binary_path.clone(); - - let remote_url = self - .remote_url("upstream") - .or_else(|| self.remote_url("origin")); + let working_directory = self.working_directory(); + let executor = self.executor.clone(); self.executor + .spawn(async move { + GitBinary::new(git_binary_path, working_directory?, executor) + .run(&["branch", "-d", &name]) + .await?; + anyhow::Ok(()) + }) + .boxed() + } + + fn blame( + &self, + path: RepoPath, + content: Rope, + line_ending: LineEnding, + ) -> BoxFuture<'_, Result> { + let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); + let executor = self.executor.clone(); + + executor .spawn(async move { crate::blame::Blame::for_path( &git_binary_path, &working_directory?, &path, &content, - remote_url, + line_ending, ) .await }) .boxed() } + fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { + self.file_history_paginated(path, 0, None) + } + + fn file_history_paginated( + &self, + path: RepoPath, + skip: usize, + limit: Option, + ) -> BoxFuture<'_, Result> { + let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); + self.executor + .spawn(async move { + let working_directory = working_directory?; + // Use a unique delimiter with a hardcoded UUID to separate commits + // This essentially eliminates any chance of encountering the delimiter in actual commit data + let commit_delimiter = + concat!("<>",); + + let format_string = format!( + "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}", + commit_delimiter + ); + + let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string]; + + let skip_str; + let limit_str; + if skip > 0 { + skip_str = skip.to_string(); + args.push("--skip"); + args.push(&skip_str); + } + if let Some(n) = limit { + limit_str = n.to_string(); + args.push("-n"); + args.push(&limit_str); + } + + args.push("--"); + + let output = new_smol_command(&git_binary_path) + .current_dir(&working_directory) + .args(&args) + .arg(path.as_unix_str()) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("git log failed: {stderr}"); + } + + let stdout = std::str::from_utf8(&output.stdout)?; + let mut entries = Vec::new(); + + for commit_block in stdout.split(commit_delimiter) { + let commit_block = commit_block.trim(); + if commit_block.is_empty() { + continue; + } + + let fields: Vec<&str> = commit_block.split('\0').collect(); + if fields.len() >= 6 { + let sha = fields[0].trim().to_string().into(); + let subject = fields[1].trim().to_string().into(); + let message = fields[2].trim().to_string().into(); + let commit_timestamp = fields[3].trim().parse().unwrap_or(0); + let author_name = fields[4].trim().to_string().into(); + let author_email = fields[5].trim().to_string().into(); + + entries.push(FileHistoryEntry { + sha, + subject, + message, + commit_timestamp, + author_name, + author_email, + }); + } + } + + Ok(FileHistory { entries, path }) + }) + .boxed() + } + fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); @@ -1445,41 +1839,42 @@ impl GitRepository for RealGitRepository { message: SharedString, name_and_email: Option<(SharedString, SharedString)>, options: CommitOptions, + ask_pass: AskPassDelegate, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); - self.executor - .spawn(async move { - let mut cmd = new_smol_command(git_binary_path); - cmd.current_dir(&working_directory?) - .envs(env.iter()) - .args(["commit", "--quiet", "-m"]) - .arg(&message.to_string()) - .arg("--cleanup=strip"); + let executor = self.executor.clone(); + // Note: Do not spawn this command on the background thread, it might pop open the credential helper + // which we want to block on. + async move { + let mut cmd = new_smol_command(git_binary_path); + cmd.current_dir(&working_directory?) + .envs(env.iter()) + .args(["commit", "--quiet", "-m"]) + .arg(&message.to_string()) + .arg("--cleanup=strip") + .arg("--no-verify") + .stdout(smol::process::Stdio::piped()) + .stderr(smol::process::Stdio::piped()); - if options.amend { - cmd.arg("--amend"); - } + if options.amend { + cmd.arg("--amend"); + } - if options.signoff { - cmd.arg("--signoff"); - } + if options.signoff { + cmd.arg("--signoff"); + } - if let Some((name, email)) = name_and_email { - cmd.arg("--author").arg(&format!("{name} <{email}>")); - } + if let Some((name, email)) = name_and_email { + cmd.arg("--author").arg(&format!("{name} <{email}>")); + } - let output = cmd.output().await?; + run_git_command(env, ask_pass, cmd, &executor).await?; - anyhow::ensure!( - output.status.success(), - "Failed to commit:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - Ok(()) - }) - .boxed() + Ok(()) + } + .boxed() } fn push( @@ -1494,6 +1889,8 @@ impl GitRepository for RealGitRepository { let working_directory = self.working_directory(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); + // Note: Do not spawn this command on the background thread, it might pop open the credential helper + // which we want to block on. async move { let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?; let working_directory = working_directory?; @@ -1519,8 +1916,9 @@ impl GitRepository for RealGitRepository { fn pull( &self, - branch_name: String, + branch_name: Option, remote_name: String, + rebase: bool, ask_pass: AskPassDelegate, env: Arc>, cx: AsyncApp, @@ -1528,15 +1926,23 @@ impl GitRepository for RealGitRepository { let working_directory = self.working_directory(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); + // Note: Do not spawn this command on the background thread, it might pop open the credential helper + // which we want to block on. async move { let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?; let mut command = new_smol_command(git_binary_path); command .envs(env.iter()) .current_dir(&working_directory?) - .args(["pull"]) + .arg("pull"); + + if rebase { + command.arg("--rebase"); + } + + command .arg(remote_name) - .arg(branch_name) + .args(branch_name) .stdout(smol::process::Stdio::piped()) .stderr(smol::process::Stdio::piped()); @@ -1556,6 +1962,8 @@ impl GitRepository for RealGitRepository { let remote_name = format!("{}", fetch_options); let git_binary_path = self.system_git_binary_path.clone(); let executor = cx.background_executor().clone(); + // Note: Do not spawn this command on the background thread, it might pop open the credential helper + // which we want to block on. async move { let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?; let mut command = new_smol_command(git_binary_path); @@ -1571,48 +1979,111 @@ impl GitRepository for RealGitRepository { .boxed() } - fn get_remotes(&self, branch_name: Option) -> BoxFuture<'_, Result>> { + fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result>> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let working_directory = working_directory?; - if let Some(branch_name) = branch_name { - let output = new_smol_command(&git_binary_path) - .current_dir(&working_directory) - .args(["config", "--get"]) - .arg(format!("branch.{}.remote", branch_name)) - .output() - .await?; - - if output.status.success() { - let remote_name = String::from_utf8_lossy(&output.stdout); - - return Ok(vec![Remote { - name: remote_name.trim().to_string().into(), - }]); - } - } - let output = new_smol_command(&git_binary_path) .current_dir(&working_directory) - .args(["remote"]) + .args(["rev-parse", "--abbrev-ref"]) + .arg(format!("{branch}@{{push}}")) + .output() + .await?; + if !output.status.success() { + return Ok(None); + } + let remote_name = String::from_utf8_lossy(&output.stdout) + .split('/') + .next() + .map(|name| Remote { + name: name.trim().to_string().into(), + }); + + Ok(remote_name) + }) + .boxed() + } + + fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result>> { + let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); + self.executor + .spawn(async move { + let working_directory = working_directory?; + let output = new_smol_command(&git_binary_path) + .current_dir(&working_directory) + .args(["config", "--get"]) + .arg(format!("branch.{branch}.remote")) + .output() + .await?; + if !output.status.success() { + return Ok(None); + } + + let remote_name = String::from_utf8_lossy(&output.stdout); + return Ok(Some(Remote { + name: remote_name.trim().to_string().into(), + })); + }) + .boxed() + } + + fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { + let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); + self.executor + .spawn(async move { + let working_directory = working_directory?; + let output = new_smol_command(&git_binary_path) + .current_dir(&working_directory) + .args(["remote", "-v"]) .output() .await?; anyhow::ensure!( output.status.success(), - "Failed to get remotes:\n{}", + "Failed to get all remotes:\n{}", String::from_utf8_lossy(&output.stderr) ); - let remote_names = String::from_utf8_lossy(&output.stdout) - .split('\n') - .filter(|name| !name.is_empty()) - .map(|name| Remote { - name: name.trim().to_string().into(), + let remote_names: HashSet = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let mut split_line = line.split_whitespace(); + let remote_name = split_line.next()?; + + Some(Remote { + name: remote_name.trim().to_string().into(), + }) }) .collect(); - Ok(remote_names) + + Ok(remote_names.into_iter().collect()) + }) + .boxed() + } + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + repo.remote_delete(&name)?; + + Ok(()) + }) + .boxed() + } + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + repo.remote(&name, url.as_ref())?; + Ok(()) }) .boxed() } @@ -1827,16 +2298,75 @@ impl GitRepository for RealGitRepository { return Ok(output); } - let output = git - .run(&["symbolic-ref", "refs/remotes/origin/HEAD"]) - .await?; + if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await { + return Ok(output + .strip_prefix("refs/remotes/origin/") + .map(|s| SharedString::from(s.to_owned()))); + } - Ok(output - .strip_prefix("refs/remotes/origin/") - .map(|s| SharedString::from(s.to_owned()))) + if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await { + if git.run(&["rev-parse", &default_branch]).await.is_ok() { + return Ok(Some(default_branch.into())); + } + } + + if git.run(&["rev-parse", "master"]).await.is_ok() { + return Ok(Some("master".into())); + } + + Ok(None) }) .boxed() } + + fn run_hook( + &self, + hook: RunHook, + env: Arc>, + ) -> BoxFuture<'_, Result<()>> { + let working_directory = self.working_directory(); + let repository = self.repository.clone(); + let git_binary_path = self.any_git_binary_path.clone(); + let executor = self.executor.clone(); + let help_output = self.any_git_binary_help_output(); + + // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. + async move { + let working_directory = working_directory?; + if !help_output + .await + .lines() + .any(|line| line.trim().starts_with("hook ")) + { + let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str()); + if hook_abs_path.is_file() { + let output = new_smol_command(&hook_abs_path) + .envs(env.iter()) + .current_dir(&working_directory) + .output() + .await?; + + if !output.status.success() { + return Err(GitBinaryCommandError { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + status: output.status, + } + .into()); + } + } + + return Ok(()); + } + + let git = GitBinary::new(git_binary_path, working_directory, executor) + .envs(HashMap::clone(&env)); + git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) + .await?; + Ok(()) + } + .boxed() + } } fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { @@ -1848,6 +2378,11 @@ fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { OsString::from("--no-renames"), OsString::from("-z"), ]; + args.extend( + path_prefixes + .iter() + .map(|path_prefix| path_prefix.as_std_path().into()), + ); args.extend(path_prefixes.iter().map(|path_prefix| { if path_prefix.is_empty() { Path::new(".").into() @@ -2103,23 +2638,43 @@ async fn run_askpass_command( } } -#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)] -pub struct RepoPath(pub Arc); +#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)] +pub struct RepoPath(Arc); + +impl std::fmt::Debug for RepoPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} impl RepoPath { pub fn new + ?Sized>(s: &S) -> Result { let rel_path = RelPath::unix(s.as_ref())?; - Ok(rel_path.into()) - } - - pub fn from_proto(proto: &str) -> Result { - let rel_path = RelPath::from_proto(proto)?; - Ok(rel_path.into()) + Ok(Self::from_rel_path(rel_path)) } pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result { let rel_path = RelPath::new(path, path_style)?; - Ok(Self(rel_path.as_ref().into())) + Ok(Self::from_rel_path(&rel_path)) + } + + pub fn from_proto(proto: &str) -> Result { + let rel_path = RelPath::from_proto(proto)?; + Ok(Self(rel_path)) + } + + pub fn from_rel_path(path: &RelPath) -> RepoPath { + Self(Arc::from(path)) + } + + pub fn as_std_path(&self) -> &Path { + // git2 does not like empty paths and our RelPath infra turns `.` into `` + // so undo that here + if self.is_empty() { + Path::new(".") + } else { + self.0.as_std_path() + } } } @@ -2128,27 +2683,9 @@ pub fn repo_path + ?Sized>(s: &S) -> RepoPath { RepoPath(RelPath::unix(s.as_ref()).unwrap().into()) } -impl From<&RelPath> for RepoPath { - fn from(value: &RelPath) -> Self { - RepoPath(value.into()) - } -} - -impl<'a> From> for RepoPath { - fn from(value: Cow<'a, RelPath>) -> Self { - value.as_ref().into() - } -} - -impl From> for RepoPath { - fn from(value: Arc) -> Self { - RepoPath(value) - } -} - -impl Default for RepoPath { - fn default() -> Self { - RepoPath(RelPath::empty().into()) +impl AsRef> for RepoPath { + fn as_ref(&self) -> &Arc { + &self.0 } } @@ -2160,12 +2697,6 @@ impl std::ops::Deref for RepoPath { } } -// impl AsRef for RepoPath { -// fn as_ref(&self) -> &Path { -// RelPath::as_ref(&self.0) -// } -// } - #[derive(Debug)] pub struct RepoPathDescendants<'a>(pub &'a RepoPath); @@ -2186,22 +2717,37 @@ fn parse_branch_input(input: &str) -> Result> { continue; } let mut fields = line.split('\x00'); - let is_current_branch = fields.next().context("no HEAD")? == "*"; - let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into(); - let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into(); - let ref_name = fields.next().context("no refname")?.to_string().into(); - let upstream_name = fields.next().context("no upstream")?.to_string(); - let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?; - let commiterdate = fields.next().context("no committerdate")?.parse::()?; - let author_name = fields.next().context("no authorname")?.to_string().into(); - let subject: SharedString = fields - .next() - .context("no contents:subject")? - .to_string() - .into(); + let Some(head) = fields.next() else { + continue; + }; + let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else { + continue; + }; + let Some(parent_sha) = fields.next().map(|f| f.to_string()) else { + continue; + }; + let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else { + continue; + }; + let Some(upstream_name) = fields.next().map(|f| f.to_string()) else { + continue; + }; + let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok()) + else { + continue; + }; + let Some(commiterdate) = fields.next().and_then(|f| f.parse::().ok()) else { + continue; + }; + let Some(author_name) = fields.next().map(|f| f.to_string().into()) else { + continue; + }; + let Some(subject) = fields.next().map(|f| f.to_string().into()) else { + continue; + }; branches.push(Branch { - is_head: is_current_branch, + is_head: head == "*", ref_name, most_recent_commit: Some(CommitSummary { sha: head_sha, @@ -2267,8 +2813,17 @@ mod tests { use super::*; use gpui::TestAppContext; + fn disable_git_global_config() { + unsafe { + std::env::set_var("GIT_CONFIG_GLOBAL", ""); + std::env::set_var("GIT_CONFIG_SYSTEM", ""); + } + } + #[gpui::test] async fn test_checkpoint_basic(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); @@ -2284,6 +2839,7 @@ mod tests { cx.executor(), ) .unwrap(); + repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default())) .await .unwrap(); @@ -2291,6 +2847,7 @@ mod tests { "Initial commit".into(), None, CommitOptions::default(), + AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await @@ -2317,6 +2874,7 @@ mod tests { "Commit after checkpoint".into(), None, CommitOptions::default(), + AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await @@ -2354,6 +2912,8 @@ mod tests { #[gpui::test] async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); @@ -2398,6 +2958,8 @@ mod tests { #[gpui::test] async fn test_compare_checkpoints(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); @@ -2437,6 +2999,8 @@ mod tests { #[gpui::test] async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); @@ -2467,6 +3031,7 @@ mod tests { "Initial commit".into(), None, CommitOptions::default(), + AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await @@ -2524,6 +3089,44 @@ mod tests { ) } + #[test] + fn test_branches_parsing_containing_refs_with_missing_fields() { + #[allow(clippy::octal_escapes)] + let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n"; + + let branches = parse_branch_input(input).unwrap(); + assert_eq!(branches.len(), 2); + assert_eq!( + branches, + vec![ + Branch { + is_head: false, + ref_name: "refs/heads/dev".into(), + upstream: None, + most_recent_commit: Some(CommitSummary { + sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(), + subject: "Add feature".into(), + commit_timestamp: 1762948725, + author_name: SharedString::new("Zed"), + has_parent: true, + }) + }, + Branch { + is_head: true, + ref_name: "refs/heads/main".into(), + upstream: None, + most_recent_commit: Some(CommitSummary { + sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(), + subject: "Initial commit".into(), + commit_timestamp: 1762948695, + author_name: SharedString::new("Zed"), + has_parent: false, + }) + } + ] + ) + } + impl RealGitRepository { /// Force a Git garbage collection on the repository. fn gc(&self) -> BoxFuture<'_, Result<()>> { diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index c3f28aa204..2cf7cc7c18 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -1,5 +1,7 @@ -use crate::repository::RepoPath; -use anyhow::Result; +use crate::{Oid, repository::RepoPath}; +use anyhow::{Result, anyhow}; +use collections::HashMap; +use gpui::SharedString; use serde::{Deserialize, Serialize}; use std::{str::FromStr, sync::Arc}; use util::{ResultExt, rel_path::RelPath}; @@ -62,23 +64,23 @@ pub enum StageStatus { } impl StageStatus { - pub fn is_fully_staged(&self) -> bool { + pub const fn is_fully_staged(&self) -> bool { matches!(self, StageStatus::Staged) } - pub fn is_fully_unstaged(&self) -> bool { + pub const fn is_fully_unstaged(&self) -> bool { matches!(self, StageStatus::Unstaged) } - pub fn has_staged(&self) -> bool { + pub const fn has_staged(&self) -> bool { matches!(self, StageStatus::Staged | StageStatus::PartiallyStaged) } - pub fn has_unstaged(&self) -> bool { + pub const fn has_unstaged(&self) -> bool { matches!(self, StageStatus::Unstaged | StageStatus::PartiallyStaged) } - pub fn as_bool(self) -> Option { + pub const fn as_bool(self) -> Option { match self { StageStatus::Staged => Some(true), StageStatus::Unstaged => Some(false), @@ -190,7 +192,11 @@ impl FileStatus { } pub fn is_deleted(self) -> bool { - matches!(self, FileStatus::Tracked(tracked) if matches!((tracked.index_status, tracked.worktree_status), (StatusCode::Deleted, _) | (_, StatusCode::Deleted))) + let FileStatus::Tracked(tracked) = self else { + return false; + }; + tracked.index_status == StatusCode::Deleted && tracked.worktree_status != StatusCode::Added + || tracked.worktree_status == StatusCode::Deleted } pub fn is_untracked(self) -> bool { @@ -448,7 +454,7 @@ impl FromStr for GitStatus { let status = entry.as_bytes()[0..2].try_into().unwrap(); let status = FileStatus::from_bytes(status).log_err()?; // git-status outputs `/`-delimited repo paths, even on Windows. - let path = RepoPath(RelPath::unix(path).log_err()?.into()); + let path = RepoPath::from_rel_path(RelPath::unix(path).log_err()?); Some((path, status)) }) .collect::>(); @@ -486,3 +492,128 @@ impl Default for GitStatus { } } } + +pub enum DiffTreeType { + MergeBase { + base: SharedString, + head: SharedString, + }, + Since { + base: SharedString, + head: SharedString, + }, +} + +impl DiffTreeType { + pub fn base(&self) -> &SharedString { + match self { + DiffTreeType::MergeBase { base, .. } => base, + DiffTreeType::Since { base, .. } => base, + } + } + + pub fn head(&self) -> &SharedString { + match self { + DiffTreeType::MergeBase { head, .. } => head, + DiffTreeType::Since { head, .. } => head, + } + } +} + +#[derive(Debug, PartialEq)] +pub struct TreeDiff { + pub entries: HashMap, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TreeDiffStatus { + Added, + Modified { old: Oid }, + Deleted { old: Oid }, +} + +impl FromStr for TreeDiff { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let mut fields = s.split('\0'); + let mut parsed = HashMap::default(); + while let Some((status, path)) = fields.next().zip(fields.next()) { + let path = RepoPath::from_rel_path(RelPath::unix(path)?); + + let mut fields = status.split(" ").skip(2); + let old_sha = fields + .next() + .ok_or_else(|| anyhow!("expected to find old_sha"))? + .to_owned() + .parse()?; + let _new_sha = fields + .next() + .ok_or_else(|| anyhow!("expected to find new_sha"))?; + let status = fields + .next() + .and_then(|s| { + if s.len() == 1 { + s.as_bytes().first() + } else { + None + } + }) + .ok_or_else(|| anyhow!("expected to find status"))?; + + let result = match StatusCode::from_byte(*status)? { + StatusCode::Modified => TreeDiffStatus::Modified { old: old_sha }, + StatusCode::Added => TreeDiffStatus::Added, + StatusCode::Deleted => TreeDiffStatus::Deleted { old: old_sha }, + _status => continue, + }; + + parsed.insert(path, result); + } + + Ok(Self { entries: parsed }) + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + repository::RepoPath, + status::{TreeDiff, TreeDiffStatus}, + }; + + #[test] + fn test_tree_diff_parsing() { + let input = ":000000 100644 0000000000000000000000000000000000000000 0062c311b8727c3a2e3cd7a41bc9904feacf8f98 A\x00.zed/settings.json\x00".to_owned() + + ":100644 000000 bb3e9ed2e97a8c02545bae243264d342c069afb3 0000000000000000000000000000000000000000 D\x00README.md\x00" + + ":100644 100644 42f097005a1f21eb2260fad02ec8c991282beee8 a437d85f63bb8c62bd78f83f40c506631fabf005 M\x00parallel.go\x00"; + + let output: TreeDiff = input.parse().unwrap(); + assert_eq!( + output, + TreeDiff { + entries: [ + ( + RepoPath::new(".zed/settings.json").unwrap(), + TreeDiffStatus::Added, + ), + ( + RepoPath::new("README.md").unwrap(), + TreeDiffStatus::Deleted { + old: "bb3e9ed2e97a8c02545bae243264d342c069afb3".parse().unwrap() + } + ), + ( + RepoPath::new("parallel.go").unwrap(), + TreeDiffStatus::Modified { + old: "42f097005a1f21eb2260fad02ec8c991282beee8".parse().unwrap(), + } + ), + ] + .into_iter() + .collect() + } + ) + } +} diff --git a/crates/git_hosting_providers/Cargo.toml b/crates/git_hosting_providers/Cargo.toml index 64c7e701a4..9480e0ec28 100644 --- a/crates/git_hosting_providers/Cargo.toml +++ b/crates/git_hosting_providers/Cargo.toml @@ -18,15 +18,17 @@ futures.workspace = true git.workspace = true gpui.workspace = true http_client.workspace = true +itertools.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true url.workspace = true +urlencoding.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] indoc.workspace = true serde_json.workspace = true pretty_assertions.workspace = true +git = { workspace = true, features = ["test-support"] } diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index 1d88c47f2e..37cf588205 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/crates/git_hosting_providers/src/git_hosting_providers.rs @@ -21,22 +21,23 @@ pub fn init(cx: &mut App) { let provider_registry = GitHostingProviderRegistry::global(cx); provider_registry.register_hosting_provider(Arc::new(Bitbucket::public_instance())); provider_registry.register_hosting_provider(Arc::new(Chromium)); - provider_registry.register_hosting_provider(Arc::new(Codeberg)); + provider_registry.register_hosting_provider(Arc::new(Forgejo::public_instance())); + provider_registry.register_hosting_provider(Arc::new(Gitea::public_instance())); provider_registry.register_hosting_provider(Arc::new(Gitee)); provider_registry.register_hosting_provider(Arc::new(Github::public_instance())); provider_registry.register_hosting_provider(Arc::new(Gitlab::public_instance())); - provider_registry.register_hosting_provider(Arc::new(Sourcehut)); + provider_registry.register_hosting_provider(Arc::new(SourceHut::public_instance())); } /// Registers additional Git hosting providers. /// /// These require information from the Git repository to construct, so their /// registration is deferred until we have a Git repository initialized. -pub fn register_additional_providers( +pub async fn register_additional_providers( provider_registry: Arc, repository: Arc, ) { - let Some(origin_url) = repository.remote_url("origin") else { + let Some(origin_url) = repository.remote_url("origin").await else { return; }; @@ -44,6 +45,14 @@ pub fn register_additional_providers( provider_registry.register_hosting_provider(Arc::new(gitlab_self_hosted)); } else if let Ok(github_self_hosted) = Github::from_remote_url(&origin_url) { provider_registry.register_hosting_provider(Arc::new(github_self_hosted)); + } else if let Ok(forgejo_self_hosted) = Forgejo::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(forgejo_self_hosted)); + } else if let Ok(gitea_self_hosted) = Gitea::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(gitea_self_hosted)); + } else if let Ok(bitbucket_self_hosted) = Bitbucket::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(bitbucket_self_hosted)); + } else if let Ok(sourcehut_self_hosted) = SourceHut::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(sourcehut_self_hosted)); } } diff --git a/crates/git_hosting_providers/src/providers.rs b/crates/git_hosting_providers/src/providers.rs index c94b830f58..f3b2fe4794 100644 --- a/crates/git_hosting_providers/src/providers.rs +++ b/crates/git_hosting_providers/src/providers.rs @@ -1,6 +1,7 @@ mod bitbucket; mod chromium; -mod codeberg; +mod forgejo; +mod gitea; mod gitee; mod github; mod gitlab; @@ -8,7 +9,8 @@ mod sourcehut; pub use bitbucket::*; pub use chromium::*; -pub use codeberg::*; +pub use forgejo::*; +pub use gitea::*; pub use gitee::*; pub use github::*; pub use gitlab::*; diff --git a/crates/git_hosting_providers/src/providers/bitbucket.rs b/crates/git_hosting_providers/src/providers/bitbucket.rs index 26df7b567a..07c6898d4e 100644 --- a/crates/git_hosting_providers/src/providers/bitbucket.rs +++ b/crates/git_hosting_providers/src/providers/bitbucket.rs @@ -1,7 +1,14 @@ -use std::str::FromStr; use std::sync::LazyLock; +use std::{str::FromStr, sync::Arc}; +use anyhow::{Context as _, Result, bail}; +use async_trait::async_trait; +use futures::AsyncReadExt; +use gpui::SharedString; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; +use itertools::Itertools as _; use regex::Regex; +use serde::Deserialize; use url::Url; use git::{ @@ -9,6 +16,8 @@ use git::{ PullRequest, RemoteUrl, }; +use crate::get_host_from_git_remote_url; + fn pull_request_regex() -> &'static Regex { static PULL_REQUEST_REGEX: LazyLock = LazyLock::new(|| { // This matches Bitbucket PR reference pattern: (pull request #xxx) @@ -17,6 +26,42 @@ fn pull_request_regex() -> &'static Regex { &PULL_REQUEST_REGEX } +#[derive(Debug, Deserialize)] +struct CommitDetails { + author: Author, +} + +#[derive(Debug, Deserialize)] +struct Author { + user: Account, +} + +#[derive(Debug, Deserialize)] +struct Account { + links: AccountLinks, +} + +#[derive(Debug, Deserialize)] +struct AccountLinks { + avatar: Option, +} + +#[derive(Debug, Deserialize)] +struct Link { + href: String, +} + +#[derive(Debug, Deserialize)] +struct CommitDetailsSelfHosted { + author: AuthorSelfHosted, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthorSelfHosted { + avatar_url: Option, +} + pub struct Bitbucket { name: String, base_url: Url, @@ -33,8 +78,85 @@ impl Bitbucket { pub fn public_instance() -> Self { Self::new("Bitbucket", Url::parse("https://bitbucket.org").unwrap()) } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "bitbucket.org" { + bail!("the BitBucket instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "bitbucket" is in the url or not + // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more + // information. + if !host.contains("bitbucket") { + bail!("not a BitBucket URL"); + } + + Ok(Self::new( + "BitBucket Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } + + fn is_self_hosted(&self) -> bool { + self.base_url + .host_str() + .is_some_and(|host| host != "bitbucket.org") + } + + async fn fetch_bitbucket_commit_author( + &self, + repo_owner: &str, + repo: &str, + commit: &str, + client: &Arc, + ) -> Result> { + let Some(host) = self.base_url.host_str() else { + bail!("failed to get host from bitbucket base url"); + }; + let is_self_hosted = self.is_self_hosted(); + let url = if is_self_hosted { + format!( + "https://{host}/rest/api/latest/projects/{repo_owner}/repos/{repo}/commits/{commit}?avatarSize=128" + ) + } else { + format!("https://api.{host}/2.0/repositories/{repo_owner}/{repo}/commit/{commit}") + }; + + let request = Request::get(&url) + .header("Content-Type", "application/json") + .follow_redirects(http_client::RedirectPolicy::FollowAll); + + let mut response = client + .send(request.body(AsyncBody::default())?) + .await + .with_context(|| format!("error fetching BitBucket commit details at {:?}", url))?; + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let body_str = std::str::from_utf8(&body)?; + + if is_self_hosted { + serde_json::from_str::(body_str) + .map(|commit| commit.author.avatar_url) + } else { + serde_json::from_str::(body_str) + .map(|commit| commit.author.user.links.avatar.map(|link| link.href)) + } + .context("failed to deserialize BitBucket commit details") + } } +#[async_trait] impl GitHostingProvider for Bitbucket { fn name(&self) -> String { self.name.clone() @@ -45,14 +167,20 @@ impl GitHostingProvider for Bitbucket { } fn supports_avatars(&self) -> bool { - false + true } fn format_line_number(&self, line: u32) -> String { + if self.is_self_hosted() { + return format!("{line}"); + } format!("lines-{line}") } fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String { + if self.is_self_hosted() { + return format!("{start_line}-{end_line}"); + } format!("lines-{start_line}:{end_line}") } @@ -60,13 +188,20 @@ impl GitHostingProvider for Bitbucket { let url = RemoteUrl::from_str(url).ok()?; let host = url.host_str()?; - if host != "bitbucket.org" { + if host != self.base_url.host_str()? { return None; } - let mut path_segments = url.path_segments()?; - let owner = path_segments.next()?; - let repo = path_segments.next()?.trim_end_matches(".git"); + let mut path_segments = url.path_segments()?.collect::>(); + let repo = path_segments.pop()?.trim_end_matches(".git"); + let owner = if path_segments.get(0).is_some_and(|v| *v == "scm") && path_segments.len() > 1 + { + // Skip the "scm" segment if it's not the only segment + // https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/src/git/remotes/bitbucket-server.ts#L72-L74 + path_segments.into_iter().skip(1).join("/") + } else { + path_segments.into_iter().join("/") + }; Some(ParsedGitRemote { owner: owner.into(), @@ -81,7 +216,12 @@ impl GitHostingProvider for Bitbucket { ) -> Url { let BuildCommitPermalinkParams { sha } = params; let ParsedGitRemote { owner, repo } = remote; - + if self.is_self_hosted() { + return self + .base_url() + .join(&format!("projects/{owner}/repos/{repo}/commits/{sha}")) + .unwrap(); + } self.base_url() .join(&format!("{owner}/{repo}/commits/{sha}")) .unwrap() @@ -95,10 +235,18 @@ impl GitHostingProvider for Bitbucket { selection, } = params; - let mut permalink = self - .base_url() - .join(&format!("{owner}/{repo}/src/{sha}/{path}")) - .unwrap(); + let mut permalink = if self.is_self_hosted() { + self.base_url() + .join(&format!( + "projects/{owner}/repos/{repo}/browse/{path}?at={sha}" + )) + .unwrap() + } else { + self.base_url() + .join(&format!("{owner}/{repo}/src/{sha}/{path}")) + .unwrap() + }; + permalink.set_fragment( selection .map(|selection| self.line_fragment(&selection)) @@ -117,15 +265,39 @@ impl GitHostingProvider for Bitbucket { // Construct the PR URL in Bitbucket format let mut url = self.base_url(); - let path = format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number); + let path = if self.is_self_hosted() { + format!( + "/projects/{}/repos/{}/pull-requests/{}", + remote.owner, remote.repo, number + ) + } else { + format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number) + }; url.set_path(&path); Some(PullRequest { number, url }) } + + async fn commit_author_avatar_url( + &self, + repo_owner: &str, + repo: &str, + commit: SharedString, + http_client: Arc, + ) -> Result> { + let commit = commit.to_string(); + let avatar_url = self + .fetch_bitbucket_commit_author(repo_owner, repo, &commit, &http_client) + .await? + .map(|avatar_url| Url::parse(&avatar_url)) + .transpose()?; + Ok(avatar_url) + } } #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; @@ -175,6 +347,92 @@ mod tests { ); } + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@bitbucket.company.com:zed-industries/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://bitbucket.company.com/zed-industries/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + + // Test with "scm" in the path + let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + + // Test with only "scm" as owner + let remote_url = "https://bitbucket.company.com/scm/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "scm".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url_with_username() { + let remote_url = "https://thorstenballzed@bitbucket.company.com/zed-industries/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + #[test] fn test_build_bitbucket_permalink() { let permalink = Bitbucket::public_instance().build_permalink( @@ -182,17 +440,30 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "f00b4r", - path: "main.rs", - selection: None, - }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None), ); let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_bitbucket_self_hosted_permalink() { + let permalink = + Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None), + ); + + let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_build_bitbucket_permalink_with_single_line_selection() { let permalink = Bitbucket::public_instance().build_permalink( @@ -200,17 +471,30 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "f00b4r", - path: "main.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)), ); let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_bitbucket_self_hosted_permalink_with_single_line_selection() { + let permalink = + Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)), + ); + + let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_build_bitbucket_permalink_with_multi_line_selection() { let permalink = Bitbucket::public_instance().build_permalink( @@ -218,11 +502,7 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "f00b4r", - path: "main.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)), ); let expected_url = @@ -230,6 +510,23 @@ mod tests { assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_bitbucket_self_hosted_permalink_with_multi_line_selection() { + let permalink = + Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)), + ); + + let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#24-48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_bitbucket_pull_requests() { use indoc::indoc; @@ -259,4 +556,36 @@ mod tests { "https://bitbucket.org/zed-industries/zed/pull-requests/123" ); } + + #[test] + fn test_bitbucket_self_hosted_pull_requests() { + use indoc::indoc; + + let remote = ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }; + + let bitbucket = + Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git") + .unwrap(); + + // Test message without PR reference + let message = "This does not contain a pull request"; + assert!(bitbucket.extract_pull_request(&remote, message).is_none()); + + // Pull request number at end of first line + let message = indoc! {r#" + Merged in feature-branch (pull request #123) + + Some detailed description of the changes. + "#}; + + let pr = bitbucket.extract_pull_request(&remote, message).unwrap(); + assert_eq!(pr.number, 123); + assert_eq!( + pr.url.as_str(), + "https://bitbucket.company.com/projects/zed-industries/repos/zed/pull-requests/123" + ); + } } diff --git a/crates/git_hosting_providers/src/providers/chromium.rs b/crates/git_hosting_providers/src/providers/chromium.rs index 5d940fb496..0826e31b30 100644 --- a/crates/git_hosting_providers/src/providers/chromium.rs +++ b/crates/git_hosting_providers/src/providers/chromium.rs @@ -191,6 +191,7 @@ impl GitHostingProvider for Chromium { #[cfg(test)] mod tests { + use git::repository::repo_path; use indoc::indoc; use pretty_assertions::assert_eq; @@ -218,11 +219,11 @@ mod tests { owner: Arc::from(""), repo: "chromium/src".into(), }, - BuildPermalinkParams { - sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", - path: "ui/base/cursor/cursor.h", - selection: None, - }, + BuildPermalinkParams::new( + "fea5080b182fc92e3be0c01c5dece602fe70b588", + &repo_path("ui/base/cursor/cursor.h"), + None, + ), ); let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h"; @@ -236,11 +237,11 @@ mod tests { owner: Arc::from(""), repo: "chromium/src".into(), }, - BuildPermalinkParams { - sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", - path: "ui/base/cursor/cursor.h", - selection: Some(18..18), - }, + BuildPermalinkParams::new( + "fea5080b182fc92e3be0c01c5dece602fe70b588", + &repo_path("ui/base/cursor/cursor.h"), + Some(18..18), + ), ); let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19"; @@ -254,11 +255,11 @@ mod tests { owner: Arc::from(""), repo: "chromium/src".into(), }, - BuildPermalinkParams { - sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", - path: "ui/base/cursor/cursor.h", - selection: Some(18..30), - }, + BuildPermalinkParams::new( + "fea5080b182fc92e3be0c01c5dece602fe70b588", + &repo_path("ui/base/cursor/cursor.h"), + Some(18..30), + ), ); let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19"; diff --git a/crates/git_hosting_providers/src/providers/codeberg.rs b/crates/git_hosting_providers/src/providers/forgejo.rs similarity index 53% rename from crates/git_hosting_providers/src/providers/codeberg.rs rename to crates/git_hosting_providers/src/providers/forgejo.rs index 2cba745f49..3944b7a165 100644 --- a/crates/git_hosting_providers/src/providers/codeberg.rs +++ b/crates/git_hosting_providers/src/providers/forgejo.rs @@ -14,6 +14,8 @@ use git::{ RemoteUrl, }; +use crate::get_host_from_git_remote_url; + #[derive(Debug, Deserialize)] struct CommitDetails { #[expect( @@ -67,31 +69,72 @@ struct User { pub avatar_url: String, } -pub struct Codeberg; +pub struct Forgejo { + name: String, + base_url: Url, +} -impl Codeberg { - async fn fetch_codeberg_commit_author( +impl Forgejo { + pub fn new(name: impl Into, base_url: Url) -> Self { + Self { + name: name.into(), + base_url, + } + } + + pub fn public_instance() -> Self { + Self::new("Codeberg", Url::parse("https://codeberg.org").unwrap()) + } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "codeberg.org" { + bail!("the Forgejo instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "forgejo" is in the url or not + // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more + // information. + if !host.contains("forgejo") { + bail!("not a Forgejo URL"); + } + + Ok(Self::new( + "Forgejo Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } + + async fn fetch_forgejo_commit_author( &self, repo_owner: &str, repo: &str, commit: &str, client: &Arc, ) -> Result> { - let url = - format!("https://codeberg.org/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}"); + let Some(host) = self.base_url.host_str() else { + bail!("failed to get host from forgejo base url"); + }; + let url = format!( + "https://{host}/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}?stat=false&verification=false&files=false" + ); let mut request = Request::get(&url) .header("Content-Type", "application/json") .follow_redirects(http_client::RedirectPolicy::FollowAll); - if let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN") { + // TODO: not renamed yet for compatibility reasons, may require a refactor later + // see https://github.com/zed-industries/zed/issues/11043#issuecomment-3480446231 + if host == "codeberg.org" + && let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN") + { request = request.header("Authorization", format!("Bearer {}", codeberg_token)); } let mut response = client .send(request.body(AsyncBody::default())?) .await - .with_context(|| format!("error fetching Codeberg commit details at {:?}", url))?; + .with_context(|| format!("error fetching Forgejo commit details at {:?}", url))?; let mut body = Vec::new(); response.body_mut().read_to_end(&mut body).await?; @@ -108,18 +151,18 @@ impl Codeberg { serde_json::from_str::(body_str) .map(|commit| commit.author) - .context("failed to deserialize Codeberg commit details") + .context("failed to deserialize Forgejo commit details") } } #[async_trait] -impl GitHostingProvider for Codeberg { +impl GitHostingProvider for Forgejo { fn name(&self) -> String { - "Codeberg".to_string() + self.name.clone() } fn base_url(&self) -> Url { - Url::parse("https://codeberg.org").unwrap() + self.base_url.clone() } fn supports_avatars(&self) -> bool { @@ -138,7 +181,7 @@ impl GitHostingProvider for Codeberg { let url = RemoteUrl::from_str(url).ok()?; let host = url.host_str()?; - if host != "codeberg.org" { + if host != self.base_url.host_str()? { return None; } @@ -194,9 +237,27 @@ impl GitHostingProvider for Codeberg { ) -> Result> { let commit = commit.to_string(); let avatar_url = self - .fetch_codeberg_commit_author(repo_owner, repo, &commit, &http_client) + .fetch_forgejo_commit_author(repo_owner, repo, &commit, &http_client) .await? - .map(|author| Url::parse(&author.avatar_url)) + .map(|author| -> Result { + let mut url = Url::parse(&author.avatar_url)?; + if let Some(host) = url.host_str() { + let size_query = if host.contains("gravatar") || host.contains("libravatar") { + Some("s=128") + } else if self + .base_url + .host_str() + .is_some_and(|base_host| host.contains(base_host)) + { + // This parameter exists on Codeberg but does not seem to take effect. setting it anyway + Some("size=128") + } else { + None + }; + url.set_query(size_query); + } + Ok(url) + }) .transpose()?; Ok(avatar_url) } @@ -204,13 +265,14 @@ impl GitHostingProvider for Codeberg { #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; #[test] fn test_parse_remote_url_given_ssh_url() { - let parsed_remote = Codeberg + let parsed_remote = Forgejo::public_instance() .parse_remote_url("git@codeberg.org:zed-industries/zed.git") .unwrap(); @@ -225,7 +287,7 @@ mod tests { #[test] fn test_parse_remote_url_given_https_url() { - let parsed_remote = Codeberg + let parsed_remote = Forgejo::public_instance() .parse_remote_url("https://codeberg.org/zed-industries/zed.git") .unwrap(); @@ -238,18 +300,53 @@ mod tests { ); } + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@forgejo.my-enterprise.com:zed-industries/zed.git"; + + let parsed_remote = Forgejo::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://forgejo.my-enterprise.com/zed-industries/zed.git"; + let parsed_remote = Forgejo::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + #[test] fn test_build_codeberg_permalink() { - let permalink = Codeberg.build_permalink( + let permalink = Forgejo::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs"; @@ -258,16 +355,16 @@ mod tests { #[test] fn test_build_codeberg_permalink_with_single_line_selection() { - let permalink = Codeberg.build_permalink( + let permalink = Forgejo::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7"; @@ -276,19 +373,61 @@ mod tests { #[test] fn test_build_codeberg_permalink_with_multi_line_selection() { - let permalink = Codeberg.build_permalink( + let permalink = Forgejo::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + + #[test] + fn test_build_forgejo_self_hosted_permalink_from_ssh_url() { + let forgejo = + Forgejo::from_remote_url("git@forgejo.some-enterprise.com:zed-industries/zed.git") + .unwrap(); + let permalink = forgejo.build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://forgejo.some-enterprise.com/zed-industries/zed/src/commit/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_forgejo_self_hosted_permalink_from_https_url() { + let forgejo = + Forgejo::from_remote_url("https://forgejo-instance.big-co.com/zed-industries/zed.git") + .unwrap(); + let permalink = forgejo.build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + &repo_path("crates/zed/src/main.rs"), + None, + ), + ); + + let expected_url = "https://forgejo-instance.big-co.com/zed-industries/zed/src/commit/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } } diff --git a/crates/git_hosting_providers/src/providers/gitea.rs b/crates/git_hosting_providers/src/providers/gitea.rs new file mode 100644 index 0000000000..d3e62fe6a8 --- /dev/null +++ b/crates/git_hosting_providers/src/providers/gitea.rs @@ -0,0 +1,380 @@ +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Context as _, Result, bail}; +use async_trait::async_trait; +use futures::AsyncReadExt; +use gpui::SharedString; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; +use serde::Deserialize; +use url::Url; + +use git::{ + BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote, + RemoteUrl, +}; + +use crate::get_host_from_git_remote_url; + +#[derive(Debug, Deserialize)] +struct CommitDetails { + author: Option, +} + +#[derive(Debug, Deserialize)] +struct User { + pub avatar_url: String, +} + +pub struct Gitea { + name: String, + base_url: Url, +} + +impl Gitea { + pub fn new(name: impl Into, base_url: Url) -> Self { + Self { + name: name.into(), + base_url, + } + } + + pub fn public_instance() -> Self { + Self::new("Gitea", Url::parse("https://gitea.com").unwrap()) + } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "gitea.com" { + bail!("the Gitea instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "gitea" is in the url or not + // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more + // information. + if !host.contains("gitea") { + bail!("not a Gitea URL"); + } + + Ok(Self::new( + "Gitea Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } + + async fn fetch_gitea_commit_author( + &self, + repo_owner: &str, + repo: &str, + commit: &str, + client: &Arc, + ) -> Result> { + let Some(host) = self.base_url.host_str() else { + bail!("failed to get host from gitea base url"); + }; + let url = format!( + "https://{host}/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}?stat=false&verification=false&files=false" + ); + + let request = Request::get(&url) + .header("Content-Type", "application/json") + .follow_redirects(http_client::RedirectPolicy::FollowAll); + + let mut response = client + .send(request.body(AsyncBody::default())?) + .await + .with_context(|| format!("error fetching Gitea commit details at {:?}", url))?; + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let body_str = std::str::from_utf8(&body)?; + + serde_json::from_str::(body_str) + .map(|commit| commit.author) + .context("failed to deserialize Gitea commit details") + } +} + +#[async_trait] +impl GitHostingProvider for Gitea { + fn name(&self) -> String { + self.name.clone() + } + + fn base_url(&self) -> Url { + self.base_url.clone() + } + + fn supports_avatars(&self) -> bool { + true + } + + fn format_line_number(&self, line: u32) -> String { + format!("L{line}") + } + + fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String { + format!("L{start_line}-L{end_line}") + } + + fn parse_remote_url(&self, url: &str) -> Option { + let url = RemoteUrl::from_str(url).ok()?; + + let host = url.host_str()?; + if host != self.base_url.host_str()? { + return None; + } + + let mut path_segments = url.path_segments()?; + let owner = path_segments.next()?; + let repo = path_segments.next()?.trim_end_matches(".git"); + + Some(ParsedGitRemote { + owner: owner.into(), + repo: repo.into(), + }) + } + + fn build_commit_permalink( + &self, + remote: &ParsedGitRemote, + params: BuildCommitPermalinkParams, + ) -> Url { + let BuildCommitPermalinkParams { sha } = params; + let ParsedGitRemote { owner, repo } = remote; + + self.base_url() + .join(&format!("{owner}/{repo}/commit/{sha}")) + .unwrap() + } + + fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url { + let ParsedGitRemote { owner, repo } = remote; + let BuildPermalinkParams { + sha, + path, + selection, + } = params; + + let mut permalink = self + .base_url() + .join(&format!("{owner}/{repo}/src/commit/{sha}/{path}")) + .unwrap(); + permalink.set_fragment( + selection + .map(|selection| self.line_fragment(&selection)) + .as_deref(), + ); + permalink + } + + async fn commit_author_avatar_url( + &self, + repo_owner: &str, + repo: &str, + commit: SharedString, + http_client: Arc, + ) -> Result> { + let commit = commit.to_string(); + let avatar_url = self + .fetch_gitea_commit_author(repo_owner, repo, &commit, &http_client) + .await? + .map(|author| -> Result { + let mut url = Url::parse(&author.avatar_url)?; + if let Some(host) = url.host_str() { + let size_query = if host.contains("gravatar") || host.contains("libravatar") { + Some("s=128") + } else if self + .base_url + .host_str() + .is_some_and(|base_host| host.contains(base_host)) + { + Some("size=128") + } else { + None + }; + url.set_query(size_query); + } + Ok(url) + }) + .transpose()?; + Ok(avatar_url) + } +} + +#[cfg(test)] +mod tests { + use git::repository::repo_path; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_remote_url_given_ssh_url() { + let parsed_remote = Gitea::public_instance() + .parse_remote_url("git@gitea.com:zed-industries/zed.git") + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_https_url() { + let parsed_remote = Gitea::public_instance() + .parse_remote_url("https://gitea.com/zed-industries/zed.git") + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@gitea.my-enterprise.com:zed-industries/zed.git"; + + let parsed_remote = Gitea::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://gitea.my-enterprise.com/zed-industries/zed.git"; + let parsed_remote = Gitea::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_build_codeberg_permalink() { + let permalink = Gitea::public_instance().build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_codeberg_permalink_with_single_line_selection() { + let permalink = Gitea::public_instance().build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), + ); + + let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_codeberg_permalink_with_multi_line_selection() { + let permalink = Gitea::public_instance().build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), + ); + + let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitea_self_hosted_permalink_from_ssh_url() { + let gitea = + Gitea::from_remote_url("git@gitea.some-enterprise.com:zed-industries/zed.git").unwrap(); + let permalink = gitea.build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://gitea.some-enterprise.com/zed-industries/zed/src/commit/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitea_self_hosted_permalink_from_https_url() { + let gitea = + Gitea::from_remote_url("https://gitea-instance.big-co.com/zed-industries/zed.git") + .unwrap(); + let permalink = gitea.build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + &repo_path("crates/zed/src/main.rs"), + None, + ), + ); + + let expected_url = "https://gitea-instance.big-co.com/zed-industries/zed/src/commit/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } +} diff --git a/crates/git_hosting_providers/src/providers/gitee.rs b/crates/git_hosting_providers/src/providers/gitee.rs index 5090cd0d74..120a360cb1 100644 --- a/crates/git_hosting_providers/src/providers/gitee.rs +++ b/crates/git_hosting_providers/src/providers/gitee.rs @@ -1,5 +1,11 @@ -use std::str::FromStr; +use std::{str::FromStr, sync::Arc}; +use anyhow::{Context as _, Result, bail}; +use async_trait::async_trait; +use futures::AsyncReadExt; +use gpui::SharedString; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; +use serde::Deserialize; use url::Url; use git::{ @@ -9,6 +15,55 @@ use git::{ pub struct Gitee; +#[derive(Debug, Deserialize)] +struct CommitDetails { + author: Option, +} + +#[derive(Debug, Deserialize)] +struct Author { + avatar_url: String, +} + +impl Gitee { + async fn fetch_gitee_commit_author( + &self, + repo_owner: &str, + repo: &str, + commit: &str, + client: &Arc, + ) -> Result> { + let url = format!("https://gitee.com/api/v5/repos/{repo_owner}/{repo}/commits/{commit}"); + + let request = Request::get(&url) + .header("Content-Type", "application/json") + .follow_redirects(http_client::RedirectPolicy::FollowAll); + + let mut response = client + .send(request.body(AsyncBody::default())?) + .await + .with_context(|| format!("error fetching Gitee commit details at {:?}", url))?; + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let body_str = std::str::from_utf8(&body)?; + + serde_json::from_str::(body_str) + .map(|commit| commit.author) + .context("failed to deserialize Gitee commit details") + } +} + +#[async_trait] impl GitHostingProvider for Gitee { fn name(&self) -> String { "Gitee".to_string() @@ -19,7 +74,7 @@ impl GitHostingProvider for Gitee { } fn supports_avatars(&self) -> bool { - false + true } fn format_line_number(&self, line: u32) -> String { @@ -80,10 +135,31 @@ impl GitHostingProvider for Gitee { ); permalink } + + async fn commit_author_avatar_url( + &self, + repo_owner: &str, + repo: &str, + commit: SharedString, + http_client: Arc, + ) -> Result> { + let commit = commit.to_string(); + let avatar_url = self + .fetch_gitee_commit_author(repo_owner, repo, &commit, &http_client) + .await? + .map(|author| -> Result { + let mut url = Url::parse(&author.avatar_url)?; + url.set_query(Some("width=128")); + Ok(url) + }) + .transpose()?; + Ok(avatar_url) + } } #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; @@ -125,11 +201,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs"; @@ -143,11 +219,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7"; @@ -161,11 +237,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48"; diff --git a/crates/git_hosting_providers/src/providers/github.rs b/crates/git_hosting_providers/src/providers/github.rs index 28c8f7973b..4f5c71830d 100644 --- a/crates/git_hosting_providers/src/providers/github.rs +++ b/crates/git_hosting_providers/src/providers/github.rs @@ -259,6 +259,7 @@ impl GitHostingProvider for Github { #[cfg(test)] mod tests { + use git::repository::repo_path; use indoc::indoc; use pretty_assertions::assert_eq; @@ -400,11 +401,11 @@ mod tests { }; let permalink = Github::public_instance().build_permalink( remote, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; @@ -418,11 +419,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", - path: "crates/zed/src/main.rs", - selection: None, - }, + BuildPermalinkParams::new( + "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + &repo_path("crates/zed/src/main.rs"), + None, + ), ); let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; @@ -436,11 +437,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7"; @@ -454,11 +455,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48"; @@ -506,4 +507,23 @@ mod tests { }; assert_eq!(github.extract_pull_request(&remote, message), None); } + + /// Regression test for issue #39875 + #[test] + fn test_git_permalink_url_escaping() { + let permalink = Github::public_instance().build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "nonexistent".into(), + }, + BuildPermalinkParams::new( + "3ef1539900037dd3601be7149b2b39ed6d0ce3db", + &repo_path("app/blog/[slug]/page.tsx"), + Some(7..7), + ), + ); + + let expected_url = "https://github.com/zed-industries/nonexistent/blob/3ef1539900037dd3601be7149b2b39ed6d0ce3db/app/blog/%5Bslug%5D/page.tsx#L8"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } } diff --git a/crates/git_hosting_providers/src/providers/gitlab.rs b/crates/git_hosting_providers/src/providers/gitlab.rs index 969a2ff1d5..af3bb17494 100644 --- a/crates/git_hosting_providers/src/providers/gitlab.rs +++ b/crates/git_hosting_providers/src/providers/gitlab.rs @@ -1,6 +1,11 @@ -use std::str::FromStr; +use std::{str::FromStr, sync::Arc}; -use anyhow::{Result, bail}; +use anyhow::{Context as _, Result, bail}; +use async_trait::async_trait; +use futures::AsyncReadExt; +use gpui::SharedString; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; +use serde::Deserialize; use url::Url; use git::{ @@ -10,6 +15,16 @@ use git::{ use crate::get_host_from_git_remote_url; +#[derive(Debug, Deserialize)] +struct CommitDetails { + author_email: String, +} + +#[derive(Debug, Deserialize)] +struct AvatarInfo { + avatar_url: String, +} + #[derive(Debug)] pub struct Gitlab { name: String, @@ -46,8 +61,79 @@ impl Gitlab { Url::parse(&format!("https://{}", host))?, )) } + + async fn fetch_gitlab_commit_author( + &self, + repo_owner: &str, + repo: &str, + commit: &str, + client: &Arc, + ) -> Result> { + let Some(host) = self.base_url.host_str() else { + bail!("failed to get host from gitlab base url"); + }; + let project_path = format!("{}/{}", repo_owner, repo); + let project_path_encoded = urlencoding::encode(&project_path); + let url = format!( + "https://{host}/api/v4/projects/{project_path_encoded}/repository/commits/{commit}" + ); + + let request = Request::get(&url) + .header("Content-Type", "application/json") + .follow_redirects(http_client::RedirectPolicy::FollowAll); + + let mut response = client + .send(request.body(AsyncBody::default())?) + .await + .with_context(|| format!("error fetching GitLab commit details at {:?}", url))?; + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let body_str = std::str::from_utf8(&body)?; + + let author_email = serde_json::from_str::(body_str) + .map(|commit| commit.author_email) + .context("failed to deserialize GitLab commit details")?; + + let avatar_info_url = format!("https://{host}/api/v4/avatar?email={author_email}"); + + let request = Request::get(&avatar_info_url) + .header("Content-Type", "application/json") + .follow_redirects(http_client::RedirectPolicy::FollowAll); + + let mut response = client + .send(request.body(AsyncBody::default())?) + .await + .with_context(|| format!("error fetching GitLab avatar info at {:?}", url))?; + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let body_str = std::str::from_utf8(&body)?; + + serde_json::from_str::>(body_str) + .context("failed to deserialize GitLab avatar info") + } } +#[async_trait] impl GitHostingProvider for Gitlab { fn name(&self) -> String { self.name.clone() @@ -58,7 +144,7 @@ impl GitHostingProvider for Gitlab { } fn supports_avatars(&self) -> bool { - false + true } fn format_line_number(&self, line: u32) -> String { @@ -122,10 +208,44 @@ impl GitHostingProvider for Gitlab { ); permalink } + + async fn commit_author_avatar_url( + &self, + repo_owner: &str, + repo: &str, + commit: SharedString, + http_client: Arc, + ) -> Result> { + let commit = commit.to_string(); + let avatar_url = self + .fetch_gitlab_commit_author(repo_owner, repo, &commit, &http_client) + .await? + .map(|author| -> Result { + let mut url = Url::parse(&author.avatar_url)?; + if let Some(host) = url.host_str() { + let size_query = if host.contains("gravatar") || host.contains("libravatar") { + Some("s=128") + } else if self + .base_url + .host_str() + .is_some_and(|base_host| host.contains(base_host)) + { + Some("width=128") + } else { + None + }; + url.set_query(size_query); + } + Ok(url) + }) + .transpose()?; + Ok(avatar_url) + } } #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; @@ -133,8 +253,8 @@ mod tests { #[test] fn test_invalid_self_hosted_remote_url() { let remote_url = "https://gitlab.com/zed-industries/zed.git"; - let github = Gitlab::from_remote_url(remote_url); - assert!(github.is_err()); + let gitlab = Gitlab::from_remote_url(remote_url); + assert!(gitlab.is_err()); } #[test] @@ -209,11 +329,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; @@ -227,11 +347,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7"; @@ -245,11 +365,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-48"; @@ -266,11 +386,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://gitlab.some-enterprise.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; @@ -287,11 +407,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", - path: "crates/zed/src/main.rs", - selection: None, - }, + BuildPermalinkParams::new( + "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + &repo_path("crates/zed/src/main.rs"), + None, + ), ); let expected_url = "https://gitlab-instance.big-co.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; diff --git a/crates/git_hosting_providers/src/providers/sourcehut.rs b/crates/git_hosting_providers/src/providers/sourcehut.rs index c64f72193d..41011b023b 100644 --- a/crates/git_hosting_providers/src/providers/sourcehut.rs +++ b/crates/git_hosting_providers/src/providers/sourcehut.rs @@ -1,5 +1,6 @@ use std::str::FromStr; +use anyhow::{Result, bail}; use url::Url; use git::{ @@ -7,15 +8,52 @@ use git::{ RemoteUrl, }; -pub struct Sourcehut; +use crate::get_host_from_git_remote_url; -impl GitHostingProvider for Sourcehut { +pub struct SourceHut { + name: String, + base_url: Url, +} + +impl SourceHut { + pub fn new(name: &str, base_url: Url) -> Self { + Self { + name: name.to_string(), + base_url, + } + } + + pub fn public_instance() -> Self { + Self::new("SourceHut", Url::parse("https://git.sr.ht").unwrap()) + } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "git.sr.ht" { + bail!("the SourceHut instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "sourcehut" is in the url or not + // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more + // information. + if !host.contains("sourcehut") { + bail!("not a SourceHut URL"); + } + + Ok(Self::new( + "SourceHut Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } +} + +impl GitHostingProvider for SourceHut { fn name(&self) -> String { - "SourceHut".to_string() + self.name.clone() } fn base_url(&self) -> Url { - Url::parse("https://git.sr.ht").unwrap() + self.base_url.clone() } fn supports_avatars(&self) -> bool { @@ -34,7 +72,7 @@ impl GitHostingProvider for Sourcehut { let url = RemoteUrl::from_str(url).ok()?; let host = url.host_str()?; - if host != "git.sr.ht" { + if host != self.base_url.host_str()? { return None; } @@ -89,13 +127,14 @@ impl GitHostingProvider for Sourcehut { #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; #[test] fn test_parse_remote_url_given_ssh_url() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("git@git.sr.ht:~zed-industries/zed") .unwrap(); @@ -110,7 +149,7 @@ mod tests { #[test] fn test_parse_remote_url_given_ssh_url_with_git_suffix() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("git@git.sr.ht:~zed-industries/zed.git") .unwrap(); @@ -125,7 +164,7 @@ mod tests { #[test] fn test_parse_remote_url_given_https_url() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("https://git.sr.ht/~zed-industries/zed") .unwrap(); @@ -138,18 +177,72 @@ mod tests { ); } + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@sourcehut.org:~zed-industries/zed"; + + let parsed_remote = SourceHut::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url_with_git_suffix() { + let remote_url = "git@sourcehut.org:~zed-industries/zed.git"; + + let parsed_remote = SourceHut::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed.git".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://sourcehut.org/~zed-industries/zed"; + + let parsed_remote = SourceHut::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + #[test] fn test_build_sourcehut_permalink() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; @@ -158,34 +251,74 @@ mod tests { #[test] fn test_build_sourcehut_permalink_with_git_suffix() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed.git".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://git.sr.ht/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_sourcehut_self_hosted_permalink() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_git_suffix() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed.git".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_build_sourcehut_permalink_with_single_line_selection() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7"; @@ -194,19 +327,59 @@ mod tests { #[test] fn test_build_sourcehut_permalink_with_multi_line_selection() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_single_line_selection() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_multi_line_selection() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } } diff --git a/crates/git_hosting_providers/src/settings.rs b/crates/git_hosting_providers/src/settings.rs index e045fae08b..95243cbe4e 100644 --- a/crates/git_hosting_providers/src/settings.rs +++ b/crates/git_hosting_providers/src/settings.rs @@ -2,15 +2,15 @@ use std::sync::Arc; use git::GitHostingProviderRegistry; use gpui::App; -use settings::{GitHostingProviderConfig, GitHostingProviderKind, Settings, SettingsStore}; +use settings::{ + GitHostingProviderConfig, GitHostingProviderKind, RegisterSetting, Settings, SettingsStore, +}; use url::Url; use util::ResultExt as _; -use crate::{Bitbucket, Github, Gitlab}; +use crate::{Bitbucket, Forgejo, Gitea, Github, Gitlab, SourceHut}; pub(crate) fn init(cx: &mut App) { - GitHostingProviderSettings::register(cx); - init_git_hosting_provider_settings(cx); } @@ -46,19 +46,24 @@ fn update_git_hosting_providers_from_settings(cx: &mut App) { } GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _, GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _, + GitHostingProviderKind::Gitea => Arc::new(Gitea::new(&provider.name, url)) as _, + GitHostingProviderKind::Forgejo => Arc::new(Forgejo::new(&provider.name, url)) as _, + GitHostingProviderKind::SourceHut => { + Arc::new(SourceHut::new(&provider.name, url)) as _ + } }) }); provider_registry.set_setting_providers(iter); } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, RegisterSetting)] pub struct GitHostingProviderSettings { pub git_hosting_providers: Vec, } impl Settings for GitHostingProviderSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { Self { git_hosting_providers: content .project diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 6905b3eb89..c88244a036 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -13,7 +13,6 @@ name = "git_ui" path = "src/git_ui.rs" [features] -default = [] test-support = ["multi_buffer/test-support"] [dependencies] @@ -22,7 +21,6 @@ anyhow.workspace = true askpass.workspace = true buffer_diff.workspace = true call.workspace = true -chrono.workspace = true cloud_llm_client.workspace = true collections.workspace = true command_palette_hooks.workspace = true @@ -44,12 +42,15 @@ multi_buffer.workspace = true notifications.workspace = true panel.workspace = true picker.workspace = true -postage.workspace = true project.workspace = true +prompt_store.workspace = true +recent_projects.workspace = true +remote.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +smol.workspace = true strum.workspace = true telemetry.workspace = true theme.workspace = true @@ -58,22 +59,27 @@ time_format.workspace = true ui.workspace = true util.workspace = true watch.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true zeroize.workspace = true - +ztracing.workspace = true +tracing.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true [dev-dependencies] ctor.workspace = true editor = { workspace = true, features = ["test-support"] } +git_hosting_providers.workspace = true gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } +rand.workspace = true settings = { workspace = true, features = ["test-support"] } unindent.workspace = true workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true + +[package.metadata.cargo-machete] +ignored = ["tracing"] diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index b1dca28876..09ab3229bc 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -8,16 +8,15 @@ use git::{ repository::CommitSummary, }; use gpui::{ - ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle, WeakEntity, - prelude::*, + ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle, + TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use markdown::{Markdown, MarkdownElement}; use project::{git_store::Repository, project_settings::ProjectSettings}; use settings::Settings as _; use theme::ThemeSettings; use time::OffsetDateTime; -use time_format::format_local_timestamp; -use ui::{ContextMenu, Divider, IconButtonShape, prelude::*, tooltip_container}; +use ui::{ContextMenu, Divider, prelude::*, tooltip_container}; use workspace::Workspace; const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20; @@ -48,29 +47,31 @@ impl BlameRenderer for GitBlameRenderer { let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED); let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar { - CommitAvatar::new( - &blame_entry.sha.to_string().into(), - details.as_ref().and_then(|it| it.remote.as_ref()), + Some( + CommitAvatar::new( + &blame_entry.sha.to_string().into(), + details.as_ref().and_then(|it| it.remote.as_ref()), + ) + .render(window, cx), ) - .render(window, cx) } else { None }; + Some( div() .mr_2() .child( h_flex() - .w_full() - .justify_between() - .font_family(style.font().family) - .line_height(style.line_height) .id(("blame", ix)) - .text_color(cx.theme().status().hint) + .w_full() .gap_2() + .justify_between() + .font(style.font()) + .line_height(style.line_height) + .text_color(cx.theme().status().hint) .child( h_flex() - .items_center() .gap_2() .child(div().text_color(sha_color).child(short_commit_id)) .children(avatar) @@ -82,7 +83,10 @@ impl BlameRenderer for GitBlameRenderer { .on_mouse_down(MouseButton::Right, { let blame_entry = blame_entry.clone(); let details = details.clone(); + let editor = editor.clone(); move |event, window, cx| { + cx.stop_propagation(); + deploy_blame_entry_context_menu( &blame_entry, details.as_ref(), @@ -99,41 +103,29 @@ impl BlameRenderer for GitBlameRenderer { let workspace = workspace.clone(); move |_, window, cx| { CommitView::open( - CommitSummary { - sha: blame_entry.sha.to_string().into(), - subject: blame_entry - .summary - .clone() - .unwrap_or_default() - .into(), - commit_timestamp: blame_entry - .committer_time - .unwrap_or_default(), - author_name: blame_entry - .committer_name - .clone() - .unwrap_or_default() - .into(), - has_parent: true, - }, + blame_entry.sha.to_string(), repository.downgrade(), workspace.clone(), + None, + None, window, cx, ) } }) - .hoverable_tooltip(move |_window, cx| { - cx.new(|cx| { - CommitTooltip::blame_entry( - &blame_entry, - details.clone(), - repository.clone(), - workspace.clone(), - cx, - ) + .when(!editor.read(cx).has_mouse_context_menu(), |el| { + el.hoverable_tooltip(move |_window, cx| { + cx.new(|cx| { + CommitTooltip::blame_entry( + &blame_entry, + details.clone(), + repository.clone(), + workspace.clone(), + cx, + ) + }) + .into() }) - .into() }), ) .into_any(), @@ -164,7 +156,7 @@ impl BlameRenderer for GitBlameRenderer { h_flex() .id("inline-blame") .w_full() - .font_family(style.font().family) + .font(style.font()) .text_color(cx.theme().status().hint) .line_height(style.line_height) .child(Icon::new(IconName::FileGit).color(Color::Hint)) @@ -204,16 +196,25 @@ impl BlameRenderer for GitBlameRenderer { .get(..8) .map(|sha| sha.to_string().into()) .unwrap_or_else(|| sha.clone()); - let absolute_timestamp = format_local_timestamp( + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let absolute_timestamp = time_format::format_localized_timestamp( commit_time, OffsetDateTime::now_utc(), + local_offset, time_format::TimestampFormat::MediumAbsolute, ); + let link_color = cx.theme().colors().text_accent; let markdown_style = { let mut style = hover_markdown_style(window, cx); - if let Some(code_block) = &style.code_block.text { - style.base_text_style.refine(code_block); - } + style.link.refine(&TextStyleRefinement { + color: Some(link_color), + underline: Some(UnderlineStyle { + color: Some(link_color.opacity(0.4)), + thickness: px(1.0), + ..Default::default() + }), + ..Default::default() + }); style }; @@ -250,21 +251,22 @@ impl BlameRenderer for GitBlameRenderer { }; Some( - tooltip_container(cx, |d, cx| { - d.occlude() + tooltip_container(cx, |this, cx| { + this.occlude() .on_mouse_move(|_, _, cx| cx.stop_propagation()) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child( v_flex() .w(gpui::rems(30.)) - .gap_4() .child( h_flex() - .pb_1p5() - .gap_x_2() + .pb_1() + .gap_2() .overflow_x_hidden() .flex_wrap() - .children(avatar) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(avatar) .child(author) .when(!author_email.is_empty(), |this| { this.child( @@ -272,30 +274,29 @@ impl BlameRenderer for GitBlameRenderer { .text_color(cx.theme().colors().text_muted) .child(author_email.to_owned()), ) - }) - .border_b_1() - .border_color(cx.theme().colors().border_variant), + }), ) .child( div() .id("inline-blame-commit-message") - .child(message) + .track_scroll(&scroll_handle) + .py_1p5() .max_h(message_max_height) .overflow_y_scroll() - .track_scroll(&scroll_handle), + .child(message), ) .child( h_flex() .text_color(cx.theme().colors().text_muted) .w_full() .justify_between() - .pt_1p5() + .pt_1() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(absolute_timestamp) .child( h_flex() - .gap_1p5() + .gap_1() .when_some(pull_request, |this, pr| { this.child( Button::new( @@ -306,29 +307,31 @@ impl BlameRenderer for GitBlameRenderer { .icon(IconName::PullRequest) .icon_color(Color::Muted) .icon_position(IconPosition::Start) - .style(ButtonStyle::Subtle) + .icon_size(IconSize::Small) .on_click(move |_, _, cx| { cx.stop_propagation(); cx.open_url(pr.url.as_str()) }), ) + .child(Divider::vertical()) }) - .child(Divider::vertical()) .child( Button::new( "commit-sha-button", short_commit_id.clone(), ) - .style(ButtonStyle::Subtle) .color(Color::Muted) .icon(IconName::FileGit) .icon_color(Color::Muted) .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) .on_click(move |_, window, cx| { CommitView::open( - commit_summary.clone(), + commit_summary.sha.clone().into(), repository.downgrade(), workspace.clone(), + None, + None, window, cx, ); @@ -337,7 +340,6 @@ impl BlameRenderer for GitBlameRenderer { ) .child( IconButton::new("copy-sha-button", IconName::Copy) - .shape(IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _, cx| { @@ -366,15 +368,11 @@ impl BlameRenderer for GitBlameRenderer { cx: &mut App, ) { CommitView::open( - CommitSummary { - sha: blame_entry.sha.to_string().into(), - subject: blame_entry.summary.clone().unwrap_or_default().into(), - commit_timestamp: blame_entry.committer_time.unwrap_or_default(), - author_name: blame_entry.committer_name.unwrap_or_default().into(), - has_parent: true, - }, + blame_entry.sha.to_string(), repository.downgrade(), workspace, + None, + None, window, cx, ) @@ -406,6 +404,7 @@ fn deploy_blame_entry_context_menu( }); editor.update(cx, move |editor, cx| { + editor.hide_blame_popover(false, cx); editor.deploy_mouse_context_menu(position, context_menu, window, cx); cx.notify(); }); @@ -414,11 +413,12 @@ fn deploy_blame_entry_context_menu( fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String { match blame_entry.author_offset_date_time() { Ok(timestamp) => { - let local = chrono::Local::now().offset().local_minus_utc(); + let local_offset = + time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); time_format::format_localized_timestamp( timestamp, time::OffsetDateTime::now_utc(), - time::UtcOffset::from_whole_seconds(local).unwrap(), + local_offset, time_format::TimestampFormat::Relative, ) } diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index b9a8dfea9e..79cd89d148 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1,12 +1,14 @@ use anyhow::Context as _; +use editor::Editor; use fuzzy::StringMatchCandidate; use collections::HashSet; use git::repository::Branch; +use gpui::http_client::Url; use gpui::{ - App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled, - Subscription, Task, Window, rems, + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, + SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::git_store::Repository; @@ -14,14 +16,30 @@ use project::project_settings::ProjectSettings; use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; -use time_format::format_local_timestamp; -use ui::{HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui::{ + Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, ListItemSpacing, Tooltip, + prelude::*, +}; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; use workspace::{ModalView, Workspace}; +use crate::{branch_picker, git_panel::show_error_toast}; + +actions!( + branch_picker, + [ + /// Deletes the selected git branch or remote. + DeleteBranch, + /// Filter the list of remotes + FilterRemotes + ] +); + pub fn register(workspace: &mut Workspace) { - workspace.register_action(open); + workspace.register_action(|workspace, branch: &zed_actions::git::Branch, window, cx| { + open(workspace, branch, window, cx); + }); workspace.register_action(switch); workspace.register_action(checkout_branch); } @@ -50,10 +68,18 @@ pub fn open( window: &mut Window, cx: &mut Context, ) { + let workspace_handle = workspace.weak_handle(); let repository = workspace.project().read(cx).active_repository(cx); let style = BranchListStyle::Modal; workspace.toggle_modal(window, cx, |window, cx| { - BranchList::new(repository, style, rems(34.), window, cx) + BranchList::new( + Some(workspace_handle), + repository, + style, + rems(34.), + window, + cx, + ) }) } @@ -63,7 +89,14 @@ pub fn popover( cx: &mut App, ) -> Entity { cx.new(|cx| { - let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx); + let list = BranchList::new( + None, + repository, + BranchListStyle::Popover, + rems(20.), + window, + cx, + ); list.focus_handle(cx).focus(window); list }) @@ -78,11 +111,13 @@ enum BranchListStyle { pub struct BranchList { width: Rems, pub picker: Entity>, + picker_focus_handle: FocusHandle, _subscription: Subscription, } impl BranchList { fn new( + workspace: Option>, repository: Option>, style: BranchListStyle, width: Rems, @@ -137,20 +172,24 @@ impl BranchList { }) .await; - this.update_in(cx, |this, window, cx| { + let _ = this.update_in(cx, |this, window, cx| { this.picker.update(cx, |picker, cx| { picker.delegate.default_branch = default_branch; picker.delegate.all_branches = Some(all_branches); picker.refresh(window, cx); }) - })?; + }); anyhow::Ok(()) }) .detach_and_log_err(cx); - let delegate = BranchListDelegate::new(repository, style); + let delegate = BranchListDelegate::new(workspace, repository, style, cx); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + }); let _subscription = cx.subscribe(&picker, |_, _, _, cx| { cx.emit(DismissEvent); @@ -158,6 +197,7 @@ impl BranchList { Self { picker, + picker_focus_handle, width, _subscription, } @@ -172,13 +212,40 @@ impl BranchList { self.picker .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) } + + fn handle_delete( + &mut self, + _: &branch_picker::DeleteBranch, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker + .delegate + .delete_at(picker.delegate.selected_index, window, cx) + }) + } + + fn handle_filter( + &mut self, + _: &branch_picker::FilterRemotes, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker.delegate.branch_filter = picker.delegate.branch_filter.invert(); + picker.update_matches(picker.query(cx), window, cx); + picker.refresh_placeholder(window, cx); + cx.notify(); + }); + } } impl ModalView for BranchList {} impl EventEmitter for BranchList {} impl Focusable for BranchList { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.picker_focus_handle.clone() } } @@ -188,6 +255,8 @@ impl Render for BranchList { .key_context("GitBranchSelector") .w(self.width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) + .on_action(cx.listener(Self::handle_delete)) + .on_action(cx.listener(Self::handle_filter)) .child(self.picker.clone()) .on_mouse_down_out({ cx.listener(move |this, _, window, cx| { @@ -199,15 +268,72 @@ impl Render for BranchList { } } -#[derive(Debug, Clone)] -struct BranchEntry { - branch: Branch, - positions: Vec, - is_new: bool, +#[derive(Debug, Clone, PartialEq)] +enum Entry { + Branch { + branch: Branch, + positions: Vec, + }, + NewUrl { + url: String, + }, + NewBranch { + name: String, + }, + NewRemoteName { + name: String, + url: SharedString, + }, +} + +impl Entry { + fn as_branch(&self) -> Option<&Branch> { + match self { + Entry::Branch { branch, .. } => Some(branch), + _ => None, + } + } + + fn name(&self) -> &str { + match self { + Entry::Branch { branch, .. } => branch.name(), + Entry::NewUrl { url, .. } => url.as_str(), + Entry::NewBranch { name, .. } => name.as_str(), + Entry::NewRemoteName { name, .. } => name.as_str(), + } + } + + #[cfg(test)] + fn is_new_url(&self) -> bool { + matches!(self, Self::NewUrl { .. }) + } + + #[cfg(test)] + fn is_new_branch(&self) -> bool { + matches!(self, Self::NewBranch { .. }) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum BranchFilter { + /// Only show local branches + Local, + /// Only show remote branches + Remote, +} + +impl BranchFilter { + fn invert(&self) -> Self { + match self { + BranchFilter::Local => BranchFilter::Remote, + BranchFilter::Remote => BranchFilter::Local, + } + } } pub struct BranchListDelegate { - matches: Vec, + workspace: Option>, + matches: Vec, all_branches: Option>, default_branch: Option, repo: Option>, @@ -215,11 +341,32 @@ pub struct BranchListDelegate { selected_index: usize, last_query: String, modifiers: Modifiers, + branch_filter: BranchFilter, + state: PickerState, + focus_handle: FocusHandle, +} + +#[derive(Debug)] +enum PickerState { + /// When we display list of branches/remotes + List, + /// When we set an url to create a new remote + NewRemote, + /// When we confirm the new remote url (after NewRemote) + CreateRemote(SharedString), + /// When we set a new branch to create + NewBranch, } impl BranchListDelegate { - fn new(repo: Option>, style: BranchListStyle) -> Self { + fn new( + workspace: Option>, + repo: Option>, + style: BranchListStyle, + cx: &mut Context, + ) -> Self { Self { + workspace, matches: vec![], repo, style, @@ -228,6 +375,9 @@ impl BranchListDelegate { selected_index: 0, last_query: Default::default(), modifiers: Default::default(), + branch_filter: BranchFilter::Local, + state: PickerState::List, + focus_handle: cx.focus_handle(), } } @@ -242,18 +392,10 @@ impl BranchListDelegate { return; }; let new_branch_name = new_branch_name.to_string().replace(' ', "-"); + let base_branch = from_branch.map(|b| b.to_string()); cx.spawn(async move |_, cx| { - if let Some(based_branch) = from_branch { - repo.update(cx, |repo, _| repo.change_branch(based_branch.to_string()))? - .await??; - } - repo.update(cx, |repo, _| { - repo.create_branch(new_branch_name.to_string()) - })? - .await??; - repo.update(cx, |repo, _| { - repo.change_branch(new_branch_name.to_string()) + repo.create_branch(new_branch_name, base_branch) })? .await??; @@ -264,13 +406,189 @@ impl BranchListDelegate { }); cx.emit(DismissEvent); } + + fn create_remote( + &self, + remote_name: String, + remote_url: String, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(repo) = self.repo.clone() else { + return; + }; + + let receiver = repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)); + + cx.background_spawn(async move { receiver.await? }) + .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { + Some(e.to_string()) + }); + cx.emit(DismissEvent); + } + + fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(idx).cloned() else { + return; + }; + let Some(repo) = self.repo.clone() else { + return; + }; + + let workspace = self.workspace.clone(); + + cx.spawn_in(window, async move |picker, cx| { + let mut is_remote = false; + let result = match &entry { + Entry::Branch { branch, .. } => match branch.remote_name() { + Some(remote_name) => { + is_remote = true; + repo.update(cx, |repo, _| repo.remove_remote(remote_name.to_string()))? + .await? + } + None => { + repo.update(cx, |repo, _| repo.delete_branch(branch.name().to_string()))? + .await? + } + }, + _ => { + log::error!("Failed to delete remote: wrong entry to delete"); + return Ok(()); + } + }; + + if let Err(e) = result { + if is_remote { + log::error!("Failed to delete remote: {}", e); + } else { + log::error!("Failed to delete branch: {}", e); + } + + if let Some(workspace) = workspace.and_then(|w| w.upgrade()) { + cx.update(|_window, cx| { + if is_remote { + show_error_toast( + workspace, + format!("remote remove {}", entry.name()), + e, + cx, + ) + } else { + show_error_toast( + workspace, + format!("branch -d {}", entry.name()), + e, + cx, + ) + } + })?; + } + + return Ok(()); + } + + picker.update_in(cx, |picker, _, cx| { + picker.delegate.matches.retain(|e| e != &entry); + + if let Entry::Branch { branch, .. } = &entry { + if let Some(all_branches) = &mut picker.delegate.all_branches { + all_branches.retain(|e| e.ref_name != branch.ref_name); + } + } + + if picker.delegate.matches.is_empty() { + picker.delegate.selected_index = 0; + } else if picker.delegate.selected_index >= picker.delegate.matches.len() { + picker.delegate.selected_index = picker.delegate.matches.len() - 1; + } + + cx.notify(); + })?; + + anyhow::Ok(()) + }) + .detach(); + } } impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select branch…".into() + match self.state { + PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { + match self.branch_filter { + BranchFilter::Local => "Select branch…", + BranchFilter::Remote => "Select remote…", + } + } + PickerState::CreateRemote(_) => "Enter a name for this remote…", + } + .into() + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + match self.state { + PickerState::CreateRemote(_) => { + Some(SharedString::new_static("Remote name can't be empty")) + } + _ => None, + } + } + + fn render_editor( + &self, + editor: &Entity, + _window: &mut Window, + _cx: &mut Context>, + ) -> Div { + let focus_handle = self.focus_handle.clone(); + + v_flex() + .when( + self.editor_position() == PickerEditorPosition::End, + |this| this.child(Divider::horizontal()), + ) + .child( + h_flex() + .overflow_hidden() + .flex_none() + .h_9() + .px_2p5() + .child(editor.clone()) + .when( + self.editor_position() == PickerEditorPosition::End, + |this| { + let tooltip_label = match self.branch_filter { + BranchFilter::Local => "Turn Off Remote Filter", + BranchFilter::Remote => "Filter Remote Branches", + }; + + this.gap_1().justify_between().child({ + IconButton::new("filter-remotes", IconName::Filter) + .toggle_state(self.branch_filter == BranchFilter::Remote) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + tooltip_label, + &branch_picker::FilterRemotes, + &focus_handle, + cx, + ) + }) + .on_click(|_click, window, cx| { + window.dispatch_action( + branch_picker::FilterRemotes.boxed_clone(), + cx, + ); + }) + }) + }, + ), + ) + .when( + self.editor_position() == PickerEditorPosition::Start, + |this| this.child(Divider::horizontal()), + ) } fn editor_position(&self) -> PickerEditorPosition { @@ -307,21 +625,35 @@ impl PickerDelegate for BranchListDelegate { return Task::ready(()); }; - const RECENT_BRANCHES_COUNT: usize = 10; + let display_remotes = self.branch_filter; cx.spawn_in(window, async move |picker, cx| { - let mut matches: Vec = if query.is_empty() { + let mut matches: Vec = if query.is_empty() { all_branches .into_iter() - .filter(|branch| !branch.is_remote()) - .take(RECENT_BRANCHES_COUNT) - .map(|branch| BranchEntry { + .filter(|branch| { + if display_remotes == BranchFilter::Remote { + branch.is_remote() + } else { + !branch.is_remote() + } + }) + .map(|branch| Entry::Branch { branch, positions: Vec::new(), - is_new: false, }) .collect() } else { - let candidates = all_branches + let branches = all_branches + .iter() + .filter(|branch| { + if display_remotes == BranchFilter::Remote { + branch.is_remote() + } else { + !branch.is_remote() + } + }) + .collect::>(); + let candidates = branches .iter() .enumerate() .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name())) @@ -337,31 +669,54 @@ impl PickerDelegate for BranchListDelegate { ) .await .into_iter() - .map(|candidate| BranchEntry { - branch: all_branches[candidate.candidate_id].clone(), + .map(|candidate| Entry::Branch { + branch: branches[candidate.candidate_id].clone(), positions: candidate.positions, - is_new: false, }) .collect() }; picker .update(cx, |picker, _| { + if let PickerState::CreateRemote(url) = &picker.delegate.state { + let query = query.replace(' ', "-"); + if !query.is_empty() { + picker.delegate.matches = vec![Entry::NewRemoteName { + name: query.clone(), + url: url.clone(), + }]; + picker.delegate.selected_index = 0; + } else { + picker.delegate.matches = Vec::new(); + picker.delegate.selected_index = 0; + } + picker.delegate.last_query = query; + return; + } + if !query.is_empty() - && !matches - .first() - .is_some_and(|entry| entry.branch.name() == query) + && !matches.first().is_some_and(|entry| entry.name() == query) { let query = query.replace(' ', "-"); - matches.push(BranchEntry { - branch: Branch { - ref_name: format!("refs/heads/{query}").into(), - is_head: false, - upstream: None, - most_recent_commit: None, - }, - positions: Vec::new(), - is_new: true, - }) + let is_url = query.trim_start_matches("git@").parse::().is_ok(); + let entry = if is_url { + Entry::NewUrl { url: query } + } else { + Entry::NewBranch { name: query } + }; + // Only transition to NewBranch/NewRemote states when we only show their list item + // Otherwise, stay in List state so footer buttons remain visible + picker.delegate.state = if matches.is_empty() { + if is_url { + PickerState::NewRemote + } else { + PickerState::NewBranch + } + } else { + PickerState::List + }; + matches.push(entry); + } else { + picker.delegate.state = PickerState::List; } let delegate = &mut picker.delegate; delegate.matches = matches; @@ -381,69 +736,74 @@ impl PickerDelegate for BranchListDelegate { let Some(entry) = self.matches.get(self.selected_index()) else { return; }; - if entry.is_new { - let from_branch = if secondary { - self.default_branch.clone() - } else { - None - }; - self.create_branch( - from_branch, - entry.branch.name().to_owned().into(), - window, - cx, - ); - return; - } - let current_branch = self.repo.as_ref().map(|repo| { - repo.read_with(cx, |repo, _| { - repo.branch.as_ref().map(|branch| branch.ref_name.clone()) - }) - }); - - if current_branch - .flatten() - .is_some_and(|current_branch| current_branch == entry.branch.ref_name) - { - cx.emit(DismissEvent); - return; - } - - cx.spawn_in(window, { - let branch = entry.branch.clone(); - async move |picker, cx| { - let branch_change_task = picker.update(cx, |this, cx| { - let repo = this - .delegate - .repo - .as_ref() - .context("No active repository")? - .clone(); - - let mut cx = cx.to_async(); - - anyhow::Ok(async move { - repo.update(&mut cx, |repo, _| { - repo.change_branch(branch.name().to_string()) - })? - .await? + match entry { + Entry::Branch { branch, .. } => { + let current_branch = self.repo.as_ref().map(|repo| { + repo.read_with(cx, |repo, _| { + repo.branch.as_ref().map(|branch| branch.ref_name.clone()) }) - })??; + }); - branch_change_task.await?; - - picker.update(cx, |_, cx| { + if current_branch + .flatten() + .is_some_and(|current_branch| current_branch == branch.ref_name) + { cx.emit(DismissEvent); + return; + } + + let Some(repo) = self.repo.clone() else { + return; + }; + + let branch = branch.clone(); + cx.spawn(async move |_, cx| { + repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))? + .await??; anyhow::Ok(()) }) + .detach_and_prompt_err( + "Failed to change branch", + window, + cx, + |_, _, _| None, + ); } - }) - .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None); + Entry::NewUrl { url } => { + self.state = PickerState::CreateRemote(url.clone().into()); + self.matches = Vec::new(); + self.selected_index = 0; + + cx.defer_in(window, |picker, window, cx| { + picker.refresh_placeholder(window, cx); + picker.set_query("", window, cx); + cx.notify(); + }); + + // returning early to prevent dismissing the modal, so a user can enter + // a remote name first. + return; + } + Entry::NewRemoteName { name, url } => { + self.create_remote(name.clone(), url.to_string(), window, cx); + } + Entry::NewBranch { name } => { + let from_branch = if secondary { + self.default_branch.clone() + } else { + None + }; + self.create_branch(from_branch, name.into(), window, cx); + } + } + + cx.emit(DismissEvent); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + self.state = PickerState::List; cx.emit(DismissEvent); } @@ -457,135 +817,1091 @@ impl PickerDelegate for BranchListDelegate { let entry = &self.matches.get(ix)?; let (commit_time, author_name, subject) = entry - .branch - .most_recent_commit - .as_ref() - .map(|commit| { - let subject = commit.subject.clone(); - let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) - .unwrap_or_else(|_| OffsetDateTime::now_utc()); - let formatted_time = format_local_timestamp( - commit_time, - OffsetDateTime::now_utc(), - time_format::TimestampFormat::Relative, - ); - let author = commit.author_name.clone(); - (Some(formatted_time), Some(author), Some(subject)) + .as_branch() + .and_then(|branch| { + branch.most_recent_commit.as_ref().map(|commit| { + let subject = commit.subject.clone(); + let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) + .unwrap_or_else(|_| OffsetDateTime::now_utc()); + let local_offset = + time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let formatted_time = time_format::format_localized_timestamp( + commit_time, + OffsetDateTime::now_utc(), + local_offset, + time_format::TimestampFormat::Relative, + ); + let author = commit.author_name.clone(); + (Some(formatted_time), Some(author), Some(subject)) + }) }) .unwrap_or_else(|| (None, None, None)); - let icon = if let Some(default_branch) = self.default_branch.clone() - && entry.is_new - { - Some( - IconButton::new("branch-from-default", IconName::GitBranchAlt) - .on_click(cx.listener(move |this, _, window, cx| { - this.delegate.set_selected_index(ix, window, cx); - this.delegate.confirm(true, window, cx); - })) - .tooltip(move |window, cx| { - Tooltip::for_action( - format!("Create branch based off default: {default_branch}"), - &menu::SecondaryConfirm, - window, - cx, - ) - }), - ) - } else { - None + let entry_icon = match entry { + Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => { + Icon::new(IconName::Plus).color(Color::Muted) + } + Entry::Branch { .. } => match self.branch_filter { + BranchFilter::Local => Icon::new(IconName::GitBranchAlt).color(Color::Muted), + BranchFilter::Remote => Icon::new(IconName::Screen).color(Color::Muted), + }, }; - let branch_name = if entry.is_new { - h_flex() - .gap_1() - .child( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(format!("Create branch \"{}\"…", entry.branch.name())) - .single_line() - .truncate(), - ) - .into_any_element() - } else { - HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone()) + let entry_title = match entry { + Entry::NewUrl { .. } => Label::new("Create Remote Repository") + .single_line() .truncate() - .into_any_element() + .into_any_element(), + Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\"…")) + .single_line() + .truncate() + .into_any_element(), + Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\"")) + .single_line() + .truncate() + .into_any_element(), + Entry::Branch { branch, positions } => { + HighlightedLabel::new(branch.name().to_string(), positions.clone()) + .single_line() + .truncate() + .into_any_element() + } }; + let focus_handle = self.focus_handle.clone(); + let is_new_items = matches!( + entry, + Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } + ); + + let delete_branch_button = IconButton::new("delete", IconName::Trash) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Delete Branch", + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + let selected_idx = this.delegate.selected_index(); + this.delegate.delete_at(selected_idx, window, cx); + })); + + let create_from_default_button = self.default_branch.as_ref().map(|default_branch| { + let tooltip_label: SharedString = format!("Create New From: {default_branch}").into(); + let focus_handle = self.focus_handle.clone(); + + IconButton::new("create_from_default", IconName::GitBranchPlus) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + tooltip_label.clone(), + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(true, window, cx); + })) + .into_any_element() + }); + Some( - ListItem::new(SharedString::from(format!("vcs-menu-{ix}"))) + ListItem::new(format!("vcs-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .tooltip({ - let branch_name = entry.branch.name().to_string(); - if entry.is_new { - Tooltip::text(format!("Create branch \"{}\"", branch_name)) - } else { - Tooltip::text(branch_name) - } - }) .child( - v_flex() + h_flex() .w_full() - .overflow_hidden() + .gap_3() + .flex_grow() + .child(entry_icon) .child( - h_flex() - .gap_6() - .justify_between() - .overflow_x_hidden() - .child(branch_name) - .when_some(commit_time, |label, commit_time| { - label.child( - Label::new(commit_time) - .size(LabelSize::Small) - .color(Color::Muted) - .into_element(), - ) - }), - ) - .when(self.style == BranchListStyle::Modal, |el| { - el.child(div().max_w_96().child({ - let message = if entry.is_new { - if let Some(current_branch) = - self.repo.as_ref().and_then(|repo| { - repo.read(cx).branch.as_ref().map(|b| b.name()) - }) - { - format!("based off {}", current_branch) - } else { - "based off the current branch".to_string() - } - } else { - let show_author_name = ProjectSettings::get_global(cx) - .git - .branch_picker - .show_author_name; + v_flex() + .id("info_container") + .w_full() + .child(entry_title) + .child( + h_flex() + .w_full() + .justify_between() + .gap_1p5() + .when(self.style == BranchListStyle::Modal, |el| { + el.child(div().max_w_96().child({ + let message = match entry { + Entry::NewUrl { url } => { + format!("Based off {url}") + } + Entry::NewRemoteName { url, .. } => { + format!("Based off {url}") + } + Entry::NewBranch { .. } => { + if let Some(current_branch) = + self.repo.as_ref().and_then(|repo| { + repo.read(cx) + .branch + .as_ref() + .map(|b| b.name()) + }) + { + format!("Based off {}", current_branch) + } else { + "Based off the current branch" + .to_string() + } + } + Entry::Branch { .. } => { + let show_author_name = + ProjectSettings::get_global(cx) + .git + .branch_picker + .show_author_name; - subject.map_or("no commits found".into(), |subject| { - if show_author_name && author_name.is_some() { - format!("{} • {}", author_name.unwrap(), subject) - } else { - subject.to_string() - } - }) - }; - Label::new(message) - .size(LabelSize::Small) - .truncate() - .color(Color::Muted) - })) - }), + subject.map_or( + "No commits found".into(), + |subject| { + if show_author_name + && author_name.is_some() + { + format!( + "{} • {}", + author_name.unwrap(), + subject + ) + } else { + subject.to_string() + } + }, + ) + } + }; + + Label::new(message) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + })) + }) + .when_some(commit_time, |label, commit_time| { + label.child( + Label::new(commit_time) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .when_some( + entry.as_branch().map(|b| b.name().to_string()), + |this, branch_name| this.tooltip(Tooltip::text(branch_name)), + ), + ), ) - .end_slot::(icon), + .when( + self.editor_position() == PickerEditorPosition::End && !is_new_items, + |this| { + this.map(|this| { + if self.selected_index() == ix { + this.end_slot(delete_branch_button) + } else { + this.end_hover_slot(delete_branch_button) + } + }) + }, + ) + .when_some( + if self.editor_position() == PickerEditorPosition::End && is_new_items { + create_from_default_button + } else { + None + }, + |this, create_from_default_button| { + this.map(|this| { + if self.selected_index() == ix { + this.end_slot(create_from_default_button) + } else { + this.end_hover_slot(create_from_default_button) + } + }) + }, + ), ) } - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - None + fn render_header( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + matches!(self.state, PickerState::List).then(|| { + let label = match self.branch_filter { + BranchFilter::Local => "Local", + BranchFilter::Remote => "Remote", + }; + + ListHeader::new(label).inset(true).into_any_element() + }) + } + + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + if self.editor_position() == PickerEditorPosition::End { + return None; + } + let focus_handle = self.focus_handle.clone(); + + let footer_container = || { + h_flex() + .w_full() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }; + + match self.state { + PickerState::List => { + let selected_entry = self.matches.get(self.selected_index); + + let branch_from_default_button = self + .default_branch + .as_ref() + .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. }))) + .map(|default_branch| { + let button_label = format!("Create New From: {default_branch}"); + + Button::new("branch-from-default", button_label) + .key_binding( + KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(true, window, cx); + })) + }); + + let delete_and_select_btns = h_flex() + .gap_1() + .child( + Button::new("delete-branch", "Delete") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window + .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); + }), + ) + .child( + Button::new("select_branch", "Select") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })), + ); + + Some( + footer_container() + .map(|this| { + if branch_from_default_button.is_some() { + this.justify_end().when_some( + branch_from_default_button, + |this, button| { + this.child(button).child( + Button::new("create", "Create") + .key_binding( + KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })), + ) + }, + ) + } else { + this.justify_between() + .child({ + let focus_handle = focus_handle.clone(); + Button::new("filter-remotes", "Filter Remotes") + .toggle_state(matches!( + self.branch_filter, + BranchFilter::Remote + )) + .key_binding( + KeyBinding::for_action_in( + &branch_picker::FilterRemotes, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_click, window, cx| { + window.dispatch_action( + branch_picker::FilterRemotes.boxed_clone(), + cx, + ); + }) + }) + .child(delete_and_select_btns) + } + }) + .into_any_element(), + ) + } + PickerState::NewBranch => { + let branch_from_default_button = + self.default_branch.as_ref().map(|default_branch| { + let button_label = format!("Create New From: {default_branch}"); + + Button::new("branch-from-default", button_label) + .key_binding( + KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(true, window, cx); + })) + }); + + Some( + footer_container() + .gap_1() + .justify_end() + .when_some(branch_from_default_button, |this, button| { + this.child(button) + }) + .child( + Button::new("branch-from-default", "Create") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })), + ) + .into_any_element(), + ) + } + PickerState::CreateRemote(_) => Some( + footer_container() + .justify_end() + .child( + Button::new("branch-from-default", "Confirm") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })) + .disabled(self.last_query.is_empty()), + ) + .into_any_element(), + ), + PickerState::NewRemote => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + use git::repository::{CommitSummary, Remote}; + use gpui::{TestAppContext, VisualTestContext}; + use project::{FakeFs, Project}; + use rand::{Rng, rngs::StdRng}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn create_test_branch( + name: &str, + is_head: bool, + remote_name: Option<&str>, + timestamp: Option, + ) -> Branch { + let ref_name = match remote_name { + Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"), + None => format!("refs/heads/{name}"), + }; + + Branch { + is_head, + ref_name: ref_name.into(), + upstream: None, + most_recent_commit: timestamp.map(|ts| CommitSummary { + sha: "abc123".into(), + commit_timestamp: ts, + author_name: "Test Author".into(), + subject: "Test commit".into(), + has_parent: true, + }), + } + } + + fn create_test_branches() -> Vec { + vec![ + create_test_branch("main", true, None, Some(1000)), + create_test_branch("feature-auth", false, None, Some(900)), + create_test_branch("feature-ui", false, None, Some(800)), + create_test_branch("develop", false, None, Some(700)), + ] + } + + fn init_branch_list_test( + repository: Option>, + branches: Vec, + cx: &mut TestAppContext, + ) -> (Entity, VisualTestContext) { + let window = cx.add_window(|window, cx| { + let mut delegate = + BranchListDelegate::new(None, repository, BranchListStyle::Modal, cx); + delegate.all_branches = Some(branches); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + }); + + let _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + + BranchList { + picker, + picker_focus_handle, + width: rems(34.), + _subscription, + } + }); + + let branch_list = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(*window, cx); + + (branch_list, cx) + } + + async fn init_fake_repository(cx: &mut TestAppContext) -> Entity { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + ".git": {}, + "file.txt": "buffer_text".to_string() + }), + ) + .await; + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", "test".to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", "index_text".to_string())], + ); + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let repository = cx.read(|cx| project.read(cx).active_repository(cx)); + + repository.unwrap() + } + + #[gpui::test] + async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) { + init_test(cx); + + let branches = create_test_branches(); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + let query = "feature".to_string(); + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 2 existing branches + 1 "create new branch" entry = 3 total + assert_eq!(picker.delegate.matches.len(), 3); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "feature-auth") + ); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "feature-ui") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + async fn update_branch_list_matches_with_empty_query( + branch_list: &Entity, + cx: &mut VisualTestContext, + ) { + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.update_matches(String::new(), window, cx) + }) + }) + .await; + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_delete_branch(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + + let branches = create_test_branches(); + + let branch_names = branches + .iter() + .map(|branch| branch.name().to_string()) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in branch_names { + repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) + .unwrap() + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 4); + let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); + picker.delegate.delete_at(1, window, cx); + branch_to_delete + }) + }); + cx.run_until_parked(); + + branch_list.update(cx, move |branch_list, cx| { + branch_list.picker.update(cx, move |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 3); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["main", "feature-auth", "feature-ui", "develop"] + .into_iter() + .filter(|name| name != &branch_to_delete) + .collect::>() + ); + }) + }); + } + + #[gpui::test] + async fn test_delete_remote(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + let branches = vec![ + create_test_branch("main", true, Some("origin"), Some(1000)), + create_test_branch("feature-auth", false, Some("origin"), Some(900)), + create_test_branch("feature-ui", false, Some("fork"), Some(800)), + create_test_branch("develop", false, Some("private"), Some(700)), + ]; + + let remote_names = branches + .iter() + .filter_map(|branch| branch.remote_name().map(|r| r.to_string())) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in remote_names { + repo.update(&mut cx, |repo, _| { + repo.create_remote(branch, String::from("test")) + }) + .unwrap() + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); + let cx = &mut ctx; + // Enable remote filter + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + picker.delegate.branch_filter = BranchFilter::Remote; + }); + }); + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + // Check matches, it should match all existing branches and no option to create new branch + let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 4); + let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); + picker.delegate.delete_at(1, window, cx); + branch_to_delete + }) + }); + cx.run_until_parked(); + + // Check matches, it should match one less branch than before + branch_list.update(cx, move |branch_list, cx| { + branch_list.picker.update(cx, move |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 3); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + [ + "origin/main", + "origin/feature-auth", + "fork/feature-ui", + "private/develop" + ] + .into_iter() + .filter(|name| name != &branch_to_delete) + .collect::>() + ); + }) + }); + } + + #[gpui::test] + async fn test_update_remote_matches_with_query(cx: &mut TestAppContext) { + init_test(cx); + + let branches = vec![ + create_test_branch("main", true, Some("origin"), Some(1000)), + create_test_branch("feature-auth", false, Some("fork"), Some(900)), + create_test_branch("feature-ui", false, None, Some(800)), + create_test_branch("develop", false, None, Some(700)), + ]; + + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + // Check matches, it should match all existing branches and no option to create new branch + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 2); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["feature-ui", "develop"] + .into_iter() + .collect::>() + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_branch()); + assert!(!last_match.is_new_url()); + picker.delegate.branch_filter = BranchFilter::Remote; + picker.delegate.update_matches(String::new(), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 2); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["origin/main", "fork/feature-auth"] + .into_iter() + .collect::>() + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_url()); + picker.delegate.branch_filter = BranchFilter::Remote; + picker + .delegate + .update_matches(String::from("fork"), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 1 existing branch + 1 "create new branch" entry = 2 total + assert_eq!(picker.delegate.matches.len(), 2); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "fork/feature-auth") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + #[gpui::test] + async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) { + const MAIN_BRANCH: &str = "main"; + const FEATURE_BRANCH: &str = "feature"; + const NEW_BRANCH: &str = "new-feature-branch"; + + init_test(test_cx); + let repository = init_fake_repository(test_cx).await; + + let branches = vec![ + create_test_branch(MAIN_BRANCH, true, None, Some(1000)), + create_test_branch(FEATURE_BRANCH, false, None, Some(900)), + ]; + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, test_cx); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .update_matches(NEW_BRANCH.to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + assert_eq!(last_match.name(), NEW_BRANCH); + // State is NewBranch because no existing branches fuzzy-match the query + assert!(matches!(picker.delegate.state, PickerState::NewBranch)); + picker.delegate.confirm(false, window, cx); + }) + }); + cx.run_until_parked(); + + let branches = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.branches()) + }) + }) + .await + .unwrap() + .unwrap(); + + let new_branch = branches + .into_iter() + .find(|branch| branch.name() == NEW_BRANCH) + .expect("new-feature-branch should exist"); + assert_eq!( + new_branch.ref_name.as_ref(), + &format!("refs/heads/{NEW_BRANCH}"), + "branch ref_name should not have duplicate refs/heads/ prefix" + ); + } + + #[gpui::test] + async fn test_remote_url_detection_https(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + let branches = vec![create_test_branch("main", true, None, Some(1000))]; + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "https://github.com/user/repo.git".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_url()); + assert!(matches!(picker.delegate.state, PickerState::NewRemote)); + picker.delegate.confirm(false, window, cx); + assert_eq!(picker.delegate.matches.len(), 0); + if let PickerState::CreateRemote(remote_url) = &picker.delegate.state + && remote_url.as_ref() == "https://github.com/user/repo.git" + { + } else { + panic!("wrong picker state"); + } + picker + .delegate + .update_matches("my_new_remote".to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 1); + assert!(matches!( + picker.delegate.matches.first(), + Some(Entry::NewRemoteName { name, url }) + if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git" + )); + picker.delegate.confirm(false, window, cx); + }) + }); + cx.run_until_parked(); + + // List remotes + let remotes = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.get_remotes(None, false)) + }) + }) + .await + .unwrap() + .unwrap(); + assert_eq!( + remotes, + vec![Remote { + name: SharedString::from("my_new_remote".to_string()) + }] + ); + } + + #[gpui::test] + async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) { + init_test(cx); + + let branches = vec![create_test_branch("main_branch", true, None, Some(1000))]; + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "https://github.com/user/repo.git".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + // Try to create a new remote but cancel in the middle of the process + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = picker.delegate.matches.len() - 1; + picker.delegate.confirm(false, window, cx); + + assert!(matches!( + picker.delegate.state, + PickerState::CreateRemote(_) + )); + if let PickerState::CreateRemote(ref url) = picker.delegate.state { + assert_eq!(url.as_ref(), "https://github.com/user/repo.git"); + } + assert_eq!(picker.delegate.matches.len(), 0); + picker.delegate.dismissed(window, cx); + assert!(matches!(picker.delegate.state, PickerState::List)); + let query = "main".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + // Try to search a branch again to see if the state is restored properly + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 1 existing branch + 1 "create new branch" entry = 2 total + assert_eq!(picker.delegate.matches.len(), 2); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "main_branch") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + #[gpui::test] + async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) { + const REMOTE_URL: &str = "https://github.com/user/repo.git"; + + init_test(cx); + let branches = vec![create_test_branch("main", true, None, Some(1000))]; + + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let cx = &mut ctx; + + let subscription = cx.update(|_, cx| { + cx.subscribe(&branch_list, |_, _: &DismissEvent, _| { + panic!("DismissEvent should not be emitted when confirming a remote URL"); + }) + }); + + branch_list + .update_in(cx, |branch_list, window, cx| { + window.focus(&branch_list.picker_focus_handle); + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .update_matches(REMOTE_URL.to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_url()); + assert!(matches!(picker.delegate.state, PickerState::NewRemote)); + + picker.delegate.confirm(false, window, cx); + + assert!( + matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL), + "State should transition to CreateRemote with the URL" + ); + }); + + assert!( + branch_list.picker_focus_handle.is_focused(window), + "Branch list picker should still be focused after confirming remote URL" + ); + }); + + cx.run_until_parked(); + + drop(subscription); + } + + #[gpui::test(iterations = 10)] + async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) { + init_test(cx); + let branch_count = rng.random_range(13..540); + + let branches: Vec = (0..branch_count) + .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100))) + .collect(); + + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), branch_count as usize); + }) + }); } } diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 6c93e03e4b..822b2c8385 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -139,7 +139,7 @@ impl CommitModal { && !git_panel.amend_pending() { git_panel.set_amend_pending(true, cx); - git_panel.load_last_commit_message_if_empty(cx); + git_panel.load_last_commit_message(cx); } } ForceMode::Commit => { @@ -327,7 +327,7 @@ impl CommitModal { .anchor(Corner::TopRight) } - pub fn render_footer(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + pub fn render_footer(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let ( can_commit, tooltip, @@ -388,7 +388,7 @@ impl CommitModal { }); let focus_handle = self.focus_handle(cx); - let close_kb_hint = ui::KeyBinding::for_action(&menu::Cancel, window, cx).map(|close_kb| { + let close_kb_hint = ui::KeyBinding::for_action(&menu::Cancel, cx).map(|close_kb| { KeybindingHint::new(close_kb, cx.theme().colors().editor_background).suffix("Cancel") }); @@ -423,7 +423,7 @@ impl CommitModal { .flex_none() .px_1() .gap_4() - .children(close_kb_hint) + .child(close_kb_hint) .child(SplitButton::new( ui::ButtonLike::new_rounded_left(ElementId::Name( format!("split-button-left-{}", commit_label).into(), @@ -452,7 +452,7 @@ impl CommitModal { .disabled(!can_commit) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { if can_commit { Tooltip::with_meta_in( tooltip, @@ -467,7 +467,6 @@ impl CommitModal { if is_signoff_enabled { " --signoff" } else { "" } ), &focus_handle.clone(), - window, cx, ) } else { @@ -493,53 +492,20 @@ impl CommitModal { } } - fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { - if self.git_panel.read(cx).amend_pending() { - return; + fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.git_panel.update(cx, |git_panel, cx| { + git_panel.commit(&self.commit_editor.focus_handle(cx), window, cx) + }) { + telemetry::event!("Git Committed", source = "Git Modal"); + cx.emit(DismissEvent); } - telemetry::event!("Git Committed", source = "Git Modal"); - self.git_panel.update(cx, |git_panel, cx| { - git_panel.commit_changes( - CommitOptions { - amend: false, - signoff: git_panel.signoff_enabled(), - }, - window, - cx, - ) - }); - cx.emit(DismissEvent); } - fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { - if self - .git_panel - .read(cx) - .active_repository - .as_ref() - .and_then(|repo| repo.read(cx).head_commit.as_ref()) - .is_none() - { - return; - } - if !self.git_panel.read(cx).amend_pending() { - self.git_panel.update(cx, |git_panel, cx| { - git_panel.set_amend_pending(true, cx); - git_panel.load_last_commit_message_if_empty(cx); - }); - } else { + fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + if self.git_panel.update(cx, |git_panel, cx| { + git_panel.amend(&self.commit_editor.focus_handle(cx), window, cx) + }) { telemetry::event!("Git Amended", source = "Git Modal"); - self.git_panel.update(cx, |git_panel, cx| { - git_panel.set_amend_pending(false, cx); - git_panel.commit_changes( - CommitOptions { - amend: true, - signoff: git_panel.signoff_enabled(), - }, - window, - cx, - ); - }); cx.emit(DismissEvent); } } @@ -565,8 +531,8 @@ impl Render for CommitModal { .id("commit-modal") .key_context("GitCommit") .on_action(cx.listener(Self::dismiss)) - .on_action(cx.listener(Self::commit)) - .on_action(cx.listener(Self::amend)) + .on_action(cx.listener(Self::on_commit)) + .on_action(cx.listener(Self::on_amend)) .when(!DisableAiSettings::get_global(cx).disable_ai, |this| { this.on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| { this.git_panel.update(cx, |panel, cx| { diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 84ecc0b3a9..cf6512b076 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -14,7 +14,6 @@ use settings::Settings; use std::hash::Hash; use theme::ThemeSettings; use time::{OffsetDateTime, UtcOffset}; -use time_format::format_local_timestamp; use ui::{Avatar, Divider, IconButtonShape, prelude::*, tooltip_container}; use workspace::Workspace; @@ -30,11 +29,16 @@ pub struct CommitDetails { pub struct CommitAvatar<'a> { sha: &'a SharedString, remote: Option<&'a GitRemote>, + size: Option, } impl<'a> CommitAvatar<'a> { pub fn new(sha: &'a SharedString, remote: Option<&'a GitRemote>) -> Self { - Self { sha, remote } + Self { + sha, + remote, + size: None, + } } pub fn from_commit_details(details: &'a CommitDetails) -> Self { @@ -44,28 +48,37 @@ impl<'a> CommitAvatar<'a> { .message .as_ref() .and_then(|details| details.remote.as_ref()), + size: None, } } -} -impl<'a> CommitAvatar<'a> { - pub fn render(&'a self, window: &mut Window, cx: &mut App) -> Option> { + pub fn size(mut self, size: IconSize) -> Self { + self.size = Some(size); + self + } + + pub fn render(&'a self, window: &mut Window, cx: &mut App) -> AnyElement { + match self.avatar(window, cx) { + // Loading or no avatar found + None => Icon::new(IconName::Person) + .color(Color::Muted) + .when_some(self.size, |this, size| this.size(size)) + .into_any_element(), + // Found + Some(avatar) => avatar + .when_some(self.size, |this, size| this.size(size.rems())) + .into_any_element(), + } + } + + pub fn avatar(&'a self, window: &mut Window, cx: &mut App) -> Option { let remote = self .remote .filter(|remote| remote.host_supports_avatars())?; - let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha.clone()); - let element = match window.use_asset::(&avatar_url, cx) { - // Loading or no avatar found - None | Some(None) => Icon::new(IconName::Person) - .color(Color::Muted) - .into_element() - .into_any(), - // Found - Some(Some(url)) => Avatar::new(url.to_string()).into_element().into_any(), - }; - Some(element) + let url = window.use_asset::(&avatar_url, cx)??; + Some(Avatar::new(url.to_string())) } } @@ -190,16 +203,15 @@ impl Render for CommitTooltip { .map(|sha| sha.to_string().into()) .unwrap_or_else(|| self.commit.sha.clone()); let full_sha = self.commit.sha.to_string(); - let absolute_timestamp = format_local_timestamp( + let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); + let absolute_timestamp = time_format::format_localized_timestamp( self.commit.commit_time, OffsetDateTime::now_utc(), + local_offset, time_format::TimestampFormat::MediumAbsolute, ); let markdown_style = { - let mut style = hover_markdown_style(window, cx); - if let Some(code_block) = &style.code_block.text { - style.base_text_style.refine(code_block); - } + let style = hover_markdown_style(window, cx); style }; @@ -255,7 +267,7 @@ impl Render for CommitTooltip { .gap_x_2() .overflow_x_hidden() .flex_wrap() - .children(avatar) + .child(avatar) .child(author) .when(!author_email.is_empty(), |this| { this.child( @@ -318,9 +330,11 @@ impl Render for CommitTooltip { .on_click( move |_, window, cx| { CommitView::open( - commit_summary.clone(), + commit_summary.sha.to_string(), repo.downgrade(), workspace.clone(), + None, + None, window, cx, ); @@ -350,11 +364,11 @@ impl Render for CommitTooltip { fn blame_entry_timestamp(blame_entry: &BlameEntry, format: time_format::TimestampFormat) -> String { match blame_entry.author_offset_date_time() { Ok(timestamp) => { - let local = chrono::Local::now().offset().local_minus_utc(); + let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); time_format::format_localized_timestamp( timestamp, time::OffsetDateTime::now_utc(), - UtcOffset::from_whole_seconds(local).unwrap(), + local_offset, format, ) } diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index a87db56c96..8cb9d82826 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -1,71 +1,105 @@ use anyhow::{Context as _, Result}; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects, multibuffer_context_lines}; -use git::repository::{CommitDetails, CommitDiff, CommitSummary, RepoPath}; +use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; +use editor::{Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines}; +use git::repository::{CommitDetails, CommitDiff, RepoPath}; +use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; use gpui::{ - AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, - FocusHandle, Focusable, IntoElement, Render, WeakEntity, Window, + AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, Context, Element, Entity, + EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, + PromptLevel, Render, Styled, Task, WeakEntity, Window, actions, }; use language::{ Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _, - Point, Rope, TextBuffer, + Point, ReplicaId, Rope, TextBuffer, }; use multi_buffer::PathKey; use project::{Project, WorktreeId, git_store::Repository}; use std::{ any::{Any, TypeId}, - fmt::Write as _, path::PathBuf, sync::Arc, }; -use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString}; +use theme::ActiveTheme; +use ui::{DiffStat, Tooltip, prelude::*}; use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; +use workspace::item::TabTooltipContent; use workspace::{ - Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, + Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, item::{BreadcrumbText, ItemEvent, TabContentParams}, + notifications::NotifyTaskExt, + pane::SaveIntent, searchable::SearchableItemHandle, }; +use crate::commit_tooltip::CommitAvatar; +use crate::git_panel::GitPanel; + +actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace.register_action(|workspace, _: &ApplyCurrentStash, window, cx| { + CommitView::apply_stash(workspace, window, cx); + }); + workspace.register_action(|workspace, _: &DropCurrentStash, window, cx| { + CommitView::remove_stash(workspace, window, cx); + }); + workspace.register_action(|workspace, _: &PopCurrentStash, window, cx| { + CommitView::pop_stash(workspace, window, cx); + }); + }) + .detach(); +} + pub struct CommitView { commit: CommitDetails, editor: Entity, + stash: Option, multibuffer: Entity, + repository: Entity, + remote: Option, } struct GitBlob { path: RepoPath, worktree_id: WorktreeId, is_deleted: bool, + display_name: Arc, } -struct CommitMetadataFile { - title: Arc, - worktree_id: WorktreeId, -} - -const COMMIT_METADATA_NAMESPACE: u64 = 0; -const FILE_NAMESPACE: u64 = 1; +const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0; +const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; impl CommitView { pub fn open( - commit: CommitSummary, + commit_sha: String, repo: WeakEntity, workspace: WeakEntity, + stash: Option, + file_filter: Option, window: &mut Window, cx: &mut App, ) { let commit_diff = repo - .update(cx, |repo, _| repo.load_commit_diff(commit.sha.to_string())) + .update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone())) .ok(); let commit_details = repo - .update(cx, |repo, _| repo.show(commit.sha.to_string())) + .update(cx, |repo, _| repo.show(commit_sha.clone())) .ok(); window .spawn(cx, async move |cx| { let (commit_diff, commit_details) = futures::join!(commit_diff?, commit_details?); - let commit_diff = commit_diff.log_err()?.log_err()?; + let mut commit_diff = commit_diff.log_err()?.log_err()?; let commit_details = commit_details.log_err()?.log_err()?; + + // Filter to specific file if requested + if let Some(ref filter_path) = file_filter { + commit_diff.files.retain(|f| &f.path == filter_path); + } + let repo = repo.upgrade()?; workspace @@ -77,6 +111,7 @@ impl CommitView { commit_diff, repo, project.clone(), + stash, window, cx, ) @@ -87,7 +122,7 @@ impl CommitView { let ix = pane.items().position(|item| { let commit_view = item.downcast::(); commit_view - .is_some_and(|view| view.read(cx).commit.sha == commit.sha) + .is_some_and(|view| view.read(cx).commit.sha == commit_sha) }); if let Some(ix) = ix { pane.activate_item(ix, true, true, window, cx); @@ -106,66 +141,93 @@ impl CommitView { commit_diff: CommitDiff, repository: Entity, project: Entity, + stash: Option, window: &mut Window, cx: &mut Context, ) -> Self { let language_registry = project.read(cx).languages().clone(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly)); + + let message_buffer = cx.new(|cx| { + let mut buffer = Buffer::local(commit.message.clone(), cx); + buffer.set_capability(Capability::ReadOnly, cx); + buffer + }); + + multibuffer.update(cx, |multibuffer, cx| { + let snapshot = message_buffer.read(cx).snapshot(); + let full_range = Point::zero()..snapshot.max_point(); + let range = ExcerptRange { + context: full_range.clone(), + primary: full_range, + }; + multibuffer.set_excerpt_ranges_for_path( + PathKey::with_sort_prefix( + COMMIT_MESSAGE_SORT_PREFIX, + RelPath::unix("commit message").unwrap().into(), + ), + message_buffer.clone(), + &snapshot, + vec![range], + cx, + ) + }); + let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); + editor.disable_inline_diagnostics(); + editor.set_show_breakpoints(false, cx); editor.set_expand_all_diff_hunks(cx); + editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx); + editor.disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx); + + editor.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Above(editor::Anchor::min()), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }] + .into_iter() + .chain( + editor + .buffer() + .read(cx) + .buffer_anchor_to_anchor(&message_buffer, Anchor::MAX, cx) + .map(|anchor| BlockProperties { + placement: BlockPlacement::Below(anchor), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }), + ), + None, + cx, + ); + editor }); + let commit_sha = Arc::::from(commit.sha.as_ref()); + let first_worktree_id = project .read(cx) .worktrees(cx) .next() .map(|worktree| worktree.read(cx).id()); - let mut metadata_buffer_id = None; - if let Some(worktree_id) = first_worktree_id { - let file = Arc::new(CommitMetadataFile { - title: RelPath::unix(&format!("commit {}", commit.sha)) - .unwrap() - .into(), - worktree_id, - }); - let buffer = cx.new(|cx| { - let buffer = TextBuffer::new_normalized( - 0, - cx.entity_id().as_non_zero_u64().into(), - LineEnding::default(), - format_commit(&commit).into(), - ); - metadata_buffer_id = Some(buffer.remote_id()); - Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite) - }); - multibuffer.update(cx, |multibuffer, cx| { - multibuffer.set_excerpts_for_path( - PathKey::namespaced(COMMIT_METADATA_NAMESPACE, file.title.clone()), - buffer.clone(), - vec![Point::zero()..buffer.read(cx).max_point()], - 0, - cx, - ); - }); - editor.update(cx, |editor, cx| { - editor.disable_header_for_buffer(metadata_buffer_id.unwrap(), cx); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - selections.select_ranges(vec![0..0]); - }); - }); - } + let repository_clone = repository.clone(); cx.spawn(async move |this, cx| { for file in commit_diff.files { let is_deleted = file.new_text.is_none(); let new_text = file.new_text.unwrap_or_default(); let old_text = file.old_text; - let worktree_id = repository + let worktree_id = repository_clone .update(cx, |repository, cx| { repository .repo_path_to_project_path(&file.path, cx) @@ -173,10 +235,20 @@ impl CommitView { .or(first_worktree_id) })? .context("project has no worktrees")?; + let short_sha = commit_sha.get(0..7).unwrap_or(&commit_sha); + let file_name = file + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_else(|| file.path.display(PathStyle::Posix).to_string()); + let display_name: Arc = + Arc::from(format!("{short_sha} - {file_name}").into_boxed_str()); + let file = Arc::new(GitBlob { path: file.path.clone(), is_deleted, worktree_id, + display_name, }) as Arc; let buffer = build_buffer(new_text, file, &language_registry, cx).await?; @@ -186,16 +258,22 @@ impl CommitView { this.update(cx, |this, cx| { this.multibuffer.update(cx, |multibuffer, cx| { let snapshot = buffer.read(cx).snapshot(); - let diff = buffer_diff.read(cx); - let diff_hunk_ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)) - .collect::>(); let path = snapshot.file().unwrap().path().clone(); + let excerpt_ranges = { + let mut hunks = buffer_diff.read(cx).hunks(&snapshot, cx).peekable(); + if hunks.peek().is_none() { + vec![language::Point::zero()..snapshot.max_point()] + } else { + hunks + .map(|hunk| hunk.buffer_range.to_point(&snapshot)) + .collect::>() + } + }; + let _is_newly_added = multibuffer.set_excerpts_for_path( - PathKey::namespaced(FILE_NAMESPACE, path), + PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path), buffer, - diff_hunk_ranges, + excerpt_ranges, multibuffer_context_lines(cx), cx, ); @@ -203,16 +281,373 @@ impl CommitView { }); })?; } + anyhow::Ok(()) }) .detach(); + let snapshot = repository.read(cx).snapshot(); + let remote_url = snapshot + .remote_upstream_url + .as_ref() + .or(snapshot.remote_origin_url.as_ref()); + + let remote = remote_url.and_then(|url| { + let provider_registry = GitHostingProviderRegistry::default_global(cx); + parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote { + host, + owner: parsed.owner.into(), + repo: parsed.repo.into(), + }) + }); + Self { commit, editor, multibuffer, + stash, + repository, + remote, } } + + fn render_commit_avatar( + &self, + sha: &SharedString, + size: impl Into, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let size = size.into(); + let avatar = CommitAvatar::new(sha, self.remote.as_ref()); + + v_flex() + .w(size) + .h(size) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_full() + .justify_center() + .items_center() + .child( + avatar + .avatar(window, cx) + .map(|a| a.size(size).into_any_element()) + .unwrap_or_else(|| { + Icon::new(IconName::Person) + .color(Color::Muted) + .size(IconSize::Medium) + .into_any_element() + }), + ) + .into_any() + } + + fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { + let snapshot = self.multibuffer.read(cx).snapshot(cx); + let mut total_additions = 0u32; + let mut total_deletions = 0u32; + + let mut seen_buffers = std::collections::HashSet::new(); + for (_, buffer, _) in snapshot.excerpts() { + let buffer_id = buffer.remote_id(); + if !seen_buffers.insert(buffer_id) { + continue; + } + + let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else { + continue; + }; + + let base_text = diff.base_text(); + + for hunk in diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer) { + let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); + total_additions += added_rows; + + let base_start = base_text + .offset_to_point(hunk.diff_base_byte_range.start) + .row; + let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row; + let deleted_rows = base_end.saturating_sub(base_start); + + total_deletions += deleted_rows; + } + } + + (total_additions, total_deletions) + } + + fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let commit = &self.commit; + let author_name = commit.author_name.clone(); + let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let date_string = time_format::format_localized_timestamp( + commit_date, + time::OffsetDateTime::now_utc(), + local_offset, + time_format::TimestampFormat::MediumAbsolute, + ); + + let remote_info = self.remote.as_ref().map(|remote| { + let provider = remote.host.name(); + let url = format!( + "{}/{}/{}/commit/{}", + remote.host.base_url(), + remote.owner, + remote.repo, + commit.sha + ); + (provider, url) + }); + + let (additions, deletions) = self.calculate_changed_lines(cx); + + let commit_diff_stat = if additions > 0 || deletions > 0 { + Some(DiffStat::new( + "commit-diff-stat", + additions as usize, + deletions as usize, + )) + } else { + None + }; + + let gutter_width = self.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let style = editor.style(cx); + let font_id = window.text_system().resolve_font(&style.text.font()); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + snapshot + .gutter_dimensions(font_id, font_size, style, window, cx) + .full_width() + }); + + h_flex() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .w_full() + .child( + h_flex() + .w(gutter_width) + .justify_center() + .child(self.render_commit_avatar(&commit.sha, rems_from_px(48.), window, cx)), + ) + .child( + h_flex() + .py_4() + .pl_1() + .pr_4() + .w_full() + .items_start() + .justify_between() + .flex_wrap() + .child( + v_flex() + .child( + h_flex() + .gap_1() + .child(Label::new(author_name).color(Color::Default)) + .child( + Label::new(format!("Commit:{}", commit.sha)) + .color(Color::Muted) + .size(LabelSize::Small) + .truncate() + .buffer_font(cx), + ), + ) + .child( + h_flex() + .gap_1p5() + .child( + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + Label::new("•") + .color(Color::Ignored) + .size(LabelSize::Small), + ) + .children(commit_diff_stat), + ), + ) + .children(remote_info.map(|(provider_name, url)| { + let icon = match provider_name.as_str() { + "GitHub" => IconName::Github, + _ => IconName::Link, + }; + + Button::new("view_on_provider", format!("View on {}", provider_name)) + .icon(icon) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .on_click(move |_, _, cx| cx.open_url(&url)) + })), + ) + } + + fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { + Self::stash_action( + workspace, + "Apply", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, not applying")); + } + Ok(repo.stash_apply(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await?, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); + } + + fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { + Self::stash_action( + workspace, + "Pop", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, pop aborted")); + } + Ok(repo.stash_pop(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await?, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); + } + + fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { + Self::stash_action( + workspace, + "Drop", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, drop aborted")); + } + Ok(repo.stash_drop(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await??, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); + } + + fn stash_action( + workspace: &mut Workspace, + str_action: &str, + window: &mut Window, + cx: &mut App, + callback: AsyncFn, + ) where + AsyncFn: AsyncFnOnce( + Entity, + &SharedString, + usize, + Entity, + WeakEntity, + &mut AsyncWindowContext, + ) -> anyhow::Result<()> + + 'static, + { + let Some(commit_view) = workspace.active_item_as::(cx) else { + return; + }; + let Some(stash) = commit_view.read(cx).stash else { + return; + }; + let sha = commit_view.read(cx).commit.sha.clone(); + let answer = window.prompt( + PromptLevel::Info, + &format!("{} stash@{{{}}}?", str_action, stash), + None, + &[str_action, "Cancel"], + cx, + ); + + let workspace_weak = workspace.weak_handle(); + let commit_view_entity = commit_view; + + window + .spawn(cx, async move |cx| { + if answer.await != Ok(0) { + return anyhow::Ok(()); + } + + let Some(workspace) = workspace_weak.upgrade() else { + return Ok(()); + }; + + let repo = workspace.update(cx, |workspace, cx| { + workspace + .panel::(cx) + .and_then(|p| p.read(cx).active_repository.clone()) + })?; + + let Some(repo) = repo else { + return Ok(()); + }; + + callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?; + anyhow::Ok(()) + }) + .detach_and_notify_err(window, cx); + } + + async fn close_commit_view( + commit_view: Entity, + workspace: WeakEntity, + cx: &mut AsyncWindowContext, + ) -> anyhow::Result<()> { + workspace + .update_in(cx, |workspace, window, cx| { + let active_pane = workspace.active_pane(); + let commit_view_id = commit_view.entity_id(); + active_pane.update(cx, |pane, cx| { + pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx) + }) + })? + .await?; + anyhow::Ok(()) + } } impl language::File for GitBlob { @@ -233,7 +668,7 @@ impl language::File for GitBlob { } fn path(&self) -> &Arc { - &self.path.0 + self.path.as_ref() } fn full_path(&self, _: &App) -> PathBuf { @@ -241,7 +676,7 @@ impl language::File for GitBlob { } fn file_name<'a>(&'a self, _: &'a App) -> &'a str { - self.path.file_name().unwrap() + self.display_name.as_ref() } fn worktree_id(&self, _: &App) -> WorktreeId { @@ -257,43 +692,44 @@ impl language::File for GitBlob { } } -impl language::File for CommitMetadataFile { - fn as_local(&self) -> Option<&dyn language::LocalFile> { - None - } - - fn disk_state(&self) -> DiskState { - DiskState::New - } - - fn path_style(&self, _: &App) -> PathStyle { - PathStyle::Posix - } - - fn path(&self) -> &Arc { - &self.title - } - - fn full_path(&self, _: &App) -> PathBuf { - PathBuf::from(self.title.as_unix_str().to_owned()) - } - - fn file_name<'a>(&'a self, _: &'a App) -> &'a str { - self.title.file_name().unwrap() - } - - fn worktree_id(&self, _: &App) -> WorktreeId { - self.worktree_id - } - - fn to_proto(&self, _: &App) -> language::proto::File { - unimplemented!() - } - - fn is_private(&self) -> bool { - false - } -} +// No longer needed since metadata buffer is not created +// impl language::File for CommitMetadataFile { +// fn as_local(&self) -> Option<&dyn language::LocalFile> { +// None +// } +// +// fn disk_state(&self) -> DiskState { +// DiskState::New +// } +// +// fn path_style(&self, _: &App) -> PathStyle { +// PathStyle::Posix +// } +// +// fn path(&self) -> &Arc { +// &self.title +// } +// +// fn full_path(&self, _: &App) -> PathBuf { +// self.title.as_std_path().to_path_buf() +// } +// +// fn file_name<'a>(&'a self, _: &'a App) -> &'a str { +// self.title.file_name().unwrap_or("commit") +// } +// +// fn worktree_id(&self, _: &App) -> WorktreeId { +// self.worktree_id +// } +// +// fn to_proto(&self, _cx: &App) -> language::proto::File { +// unimplemented!() +// } +// +// fn is_private(&self) -> bool { +// false +// } +// } async fn build_buffer( mut text: String, @@ -316,13 +752,13 @@ async fn build_buffer( }; let buffer = cx.new(|cx| { let buffer = TextBuffer::new_normalized( - 0, + ReplicaId::LOCAL, cx.entity_id().as_non_zero_u64().into(), line_ending, text, ); let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite); - buffer.set_language(language, cx); + buffer.set_language_async(language, cx); buffer })?; Ok(buffer) @@ -369,39 +805,6 @@ async fn build_buffer_diff( }) } -fn format_commit(commit: &CommitDetails) -> String { - let mut result = String::new(); - writeln!(&mut result, "commit {}", commit.sha).unwrap(); - writeln!( - &mut result, - "Author: {} <{}>", - commit.author_name, commit.author_email - ) - .unwrap(); - writeln!( - &mut result, - "Date: {}", - time_format::format_local_timestamp( - time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).unwrap(), - time::OffsetDateTime::now_utc(), - time_format::TimestampFormat::MediumAbsolute, - ), - ) - .unwrap(); - result.push('\n'); - for line in commit.message.split('\n') { - if line.is_empty() { - result.push('\n'); - } else { - writeln!(&mut result, " {}", line).unwrap(); - } - } - if result.ends_with("\n\n") { - result.pop(); - } - result -} - impl EventEmitter for CommitView {} impl Focusable for CommitView { @@ -430,13 +833,28 @@ impl Item for CommitView { fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha); let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20); - format!("{short_sha} - {subject}").into() + format!("{short_sha} — {subject}").into() } - fn tab_tooltip_text(&self, _: &App) -> Option { + fn tab_tooltip_content(&self, _: &App) -> Option { let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha); let subject = self.commit.message.split('\n').next().unwrap(); - Some(format!("{short_sha} - {subject}").into()) + + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + let subject = subject.to_string(); + let short_sha = short_sha.to_string(); + + move |_, _| { + v_flex() + .child(Label::new(subject.clone())) + .child( + Label::new(short_sha.clone()) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } + })))) } fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { @@ -457,17 +875,17 @@ impl Item for CommitView { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.clone().into()) } else { None } } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } @@ -501,11 +919,11 @@ impl Item for CommitView { } fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + ToolbarItemLocation::Hidden } - fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { - self.editor.breadcrumbs(theme, cx) + fn breadcrumbs(&self, _theme: &theme::Theme, _cx: &App) -> Option> { + None } fn added_to_workspace( @@ -519,16 +937,20 @@ impl Item for CommitView { }); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| { + Task::ready(Some(cx.new(|cx| { let editor = cx.new(|cx| { self.editor .update(cx, |editor, cx| editor.clone(window, cx)) @@ -538,13 +960,76 @@ impl Item for CommitView { editor, multibuffer, commit: self.commit.clone(), + stash: self.stash, + repository: self.repository.clone(), + remote: self.remote.clone(), } - })) + }))) } } impl Render for CommitView { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - self.editor.clone() + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_stash = self.stash.is_some(); + + v_flex() + .key_context(if is_stash { "StashDiff" } else { "CommitDiff" }) + .size_full() + .bg(cx.theme().colors().editor_background) + .child(self.render_header(window, cx)) + .when(!self.editor.read(cx).is_empty(cx), |this| { + this.child(div().flex_grow().child(self.editor.clone())) + }) } } + +pub struct CommitViewToolbar { + commit_view: Option>, +} + +impl CommitViewToolbar { + pub fn new() -> Self { + Self { commit_view: None } + } +} + +impl EventEmitter for CommitViewToolbar {} + +impl Render for CommitViewToolbar { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().hidden() + } +} + +impl ToolbarItemView for CommitViewToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx)) + && entity.read(cx).stash.is_some() + { + self.commit_view = Some(entity.downgrade()); + return ToolbarItemLocation::PrimaryRight; + } + ToolbarItemLocation::Hidden + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool { + repo.stash_entries + .entries + .get(stash_index) + .map(|entry| entry.oid.to_string() == sha) + .unwrap_or(false) +} diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index ee1b82920d..813e63ab8c 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -111,6 +111,7 @@ fn excerpt_for_buffer_updated( ); } +#[ztracing::instrument(skip_all)] fn buffer_added(editor: &mut Editor, buffer: Entity, cx: &mut Context) { let Some(project) = editor.project() else { return; @@ -166,6 +167,7 @@ fn buffers_removed(editor: &mut Editor, removed_buffer_ids: &[BufferId], cx: &mu editor.remove_blocks(removed_block_ids, None, cx); } +#[ztracing::instrument(skip_all)] fn conflicts_updated( editor: &mut Editor, conflict_set: Entity, @@ -234,11 +236,7 @@ fn conflicts_updated( continue; }; let excerpt_id = *excerpt_id; - let Some(range) = snapshot - .anchor_in_excerpt(excerpt_id, conflict_range.start) - .zip(snapshot.anchor_in_excerpt(excerpt_id, conflict_range.end)) - .map(|(start, end)| start..end) - else { + let Some(range) = snapshot.anchor_range_in_excerpt(excerpt_id, conflict_range) else { continue; }; removed_highlighted_ranges.push(range.clone()); @@ -315,33 +313,19 @@ fn conflicts_updated( } } +#[ztracing::instrument(skip_all)] fn update_conflict_highlighting( editor: &mut Editor, conflict: &ConflictRegion, buffer: &editor::MultiBufferSnapshot, excerpt_id: editor::ExcerptId, cx: &mut Context, -) { +) -> Option<()> { log::debug!("update conflict highlighting for {conflict:?}"); - let outer_start = buffer - .anchor_in_excerpt(excerpt_id, conflict.range.start) - .unwrap(); - let outer_end = buffer - .anchor_in_excerpt(excerpt_id, conflict.range.end) - .unwrap(); - let our_start = buffer - .anchor_in_excerpt(excerpt_id, conflict.ours.start) - .unwrap(); - let our_end = buffer - .anchor_in_excerpt(excerpt_id, conflict.ours.end) - .unwrap(); - let their_start = buffer - .anchor_in_excerpt(excerpt_id, conflict.theirs.start) - .unwrap(); - let their_end = buffer - .anchor_in_excerpt(excerpt_id, conflict.theirs.end) - .unwrap(); + let outer = buffer.anchor_range_in_excerpt(excerpt_id, conflict.range.clone())?; + let ours = buffer.anchor_range_in_excerpt(excerpt_id, conflict.ours.clone())?; + let theirs = buffer.anchor_range_in_excerpt(excerpt_id, conflict.theirs.clone())?; let ours_background = cx.theme().colors().version_control_conflict_marker_ours; let theirs_background = cx.theme().colors().version_control_conflict_marker_theirs; @@ -352,32 +336,29 @@ fn update_conflict_highlighting( }; editor.insert_gutter_highlight::( - outer_start..their_end, + outer.start..theirs.end, |cx| cx.theme().colors().editor_background, cx, ); // Prevent diff hunk highlighting within the entire conflict region. - editor.highlight_rows::(outer_start..outer_end, theirs_background, options, cx); - editor.highlight_rows::(our_start..our_end, ours_background, options, cx); + editor.highlight_rows::(outer.clone(), theirs_background, options, cx); + editor.highlight_rows::(ours.clone(), ours_background, options, cx); editor.highlight_rows::( - outer_start..our_start, + outer.start..ours.start, ours_background, options, cx, ); - editor.highlight_rows::( - their_start..their_end, - theirs_background, - options, - cx, - ); + editor.highlight_rows::(theirs.clone(), theirs_background, options, cx); editor.highlight_rows::( - their_end..outer_end, + theirs.end..outer.end, theirs_background, options, cx, ); + + Some(()) } fn render_conflict_buttons( @@ -394,7 +375,7 @@ fn render_conflict_buttons( .gap_1() .bg(cx.theme().colors().editor_background) .child( - Button::new("head", "Use HEAD") + Button::new("head", format!("Use {}", conflict.ours_branch_name)) .label_size(LabelSize::Small) .on_click({ let editor = editor.clone(); @@ -414,7 +395,7 @@ fn render_conflict_buttons( }), ) .child( - Button::new("origin", "Use Origin") + Button::new("origin", format!("Use {}", conflict.theirs_branch_name)) .label_size(LabelSize::Small) .on_click({ let editor = editor.clone(); @@ -488,20 +469,16 @@ pub(crate) fn resolve_conflict( }) .ok()?; let &(_, block_id) = &state.block_ids[ix]; - let start = snapshot - .anchor_in_excerpt(excerpt_id, resolved_conflict.range.start) - .unwrap(); - let end = snapshot - .anchor_in_excerpt(excerpt_id, resolved_conflict.range.end) - .unwrap(); + let range = + snapshot.anchor_range_in_excerpt(excerpt_id, resolved_conflict.range)?; - editor.remove_gutter_highlights::(vec![start..end], cx); + editor.remove_gutter_highlights::(vec![range.clone()], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); + editor.remove_highlighted_rows::(vec![range.clone()], cx); + editor.remove_highlighted_rows::(vec![range.clone()], cx); + editor.remove_highlighted_rows::(vec![range.clone()], cx); + editor.remove_highlighted_rows::(vec![range.clone()], cx); + editor.remove_highlighted_rows::(vec![range], cx); editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); Some((workspace, project, multibuffer, buffer)) }) diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index b13ce28b8a..b020d7a9f3 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -5,8 +5,8 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use editor::{Editor, EditorEvent, MultiBuffer}; use futures::{FutureExt, select_biased}; use gpui::{ - AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, - FocusHandle, Focusable, IntoElement, Render, Task, Window, + AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, Render, Task, Window, }; use language::Buffer; use project::Project; @@ -108,7 +108,7 @@ impl FileDiffView { for buffer in [&old_buffer, &new_buffer] { cx.subscribe(buffer, move |this, _, event, _| match event { language::BufferEvent::Edited - | language::BufferEvent::LanguageChanged + | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); } @@ -268,17 +268,17 @@ impl Item for FileDiffView { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.clone().into()) } else { None } } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } @@ -360,7 +360,7 @@ mod tests { use editor::test::editor_test_context::assert_state_with_diff; use gpui::TestAppContext; use project::{FakeFs, Fs, Project}; - use settings::{Settings, SettingsStore}; + use settings::SettingsStore; use std::path::PathBuf; use unindent::unindent; use util::path; @@ -370,11 +370,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); - editor::init_settings(cx); - theme::ThemeSettings::register(cx) + theme::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs new file mode 100644 index 0000000000..4e91fe7e06 --- /dev/null +++ b/crates/git_ui/src/file_history_view.rs @@ -0,0 +1,669 @@ +use anyhow::Result; +use futures::Future; +use git::repository::{FileHistory, FileHistoryEntry, RepoPath}; +use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; +use gpui::{ + AnyElement, AnyEntity, App, Asset, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window, + actions, uniform_list, +}; +use project::{ + Project, ProjectPath, + git_store::{GitStore, Repository}, +}; +use std::any::{Any, TypeId}; + +use time::OffsetDateTime; +use ui::{Avatar, Chip, Divider, ListItem, WithScrollbar, prelude::*}; +use util::ResultExt; +use workspace::{ + Item, Workspace, + item::{ItemEvent, SaveOptions}, +}; + +use crate::commit_view::CommitView; + +actions!(git, [ViewCommitFromHistory, LoadMoreHistory]); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace.register_action(|_workspace, _: &ViewCommitFromHistory, _window, _cx| {}); + workspace.register_action(|_workspace, _: &LoadMoreHistory, _window, _cx| {}); + }) + .detach(); +} + +const PAGE_SIZE: usize = 50; + +pub struct FileHistoryView { + history: FileHistory, + repository: WeakEntity, + git_store: WeakEntity, + workspace: WeakEntity, + remote: Option, + selected_entry: Option, + scroll_handle: UniformListScrollHandle, + focus_handle: FocusHandle, + loading_more: bool, + has_more: bool, +} + +impl FileHistoryView { + pub fn open( + path: RepoPath, + git_store: WeakEntity, + repo: WeakEntity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + let file_history_task = git_store + .update(cx, |git_store, cx| { + repo.upgrade().map(|repo| { + git_store.file_history_paginated(&repo, path.clone(), 0, Some(PAGE_SIZE), cx) + }) + }) + .ok() + .flatten(); + + window + .spawn(cx, async move |cx| { + let file_history = file_history_task?.await.log_err()?; + let repo = repo.upgrade()?; + + workspace + .update_in(cx, |workspace, window, cx| { + let project = workspace.project(); + let view = cx.new(|cx| { + FileHistoryView::new( + file_history, + git_store.clone(), + repo.clone(), + workspace.weak_handle(), + project.clone(), + window, + cx, + ) + }); + + let pane = workspace.active_pane(); + pane.update(cx, |pane, cx| { + let ix = pane.items().position(|item| { + let view = item.downcast::(); + view.is_some_and(|v| v.read(cx).history.path == path) + }); + if let Some(ix) = ix { + pane.activate_item(ix, true, true, window, cx); + } else { + pane.add_item(Box::new(view), true, true, None, window, cx); + } + }) + }) + .log_err() + }) + .detach(); + } + + fn new( + history: FileHistory, + git_store: WeakEntity, + repository: Entity, + workspace: WeakEntity, + _project: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + let scroll_handle = UniformListScrollHandle::new(); + let has_more = history.entries.len() >= PAGE_SIZE; + + let snapshot = repository.read(cx).snapshot(); + let remote_url = snapshot + .remote_upstream_url + .as_ref() + .or(snapshot.remote_origin_url.as_ref()); + + let remote = remote_url.and_then(|url| { + let provider_registry = GitHostingProviderRegistry::default_global(cx); + parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote { + host, + owner: parsed.owner.into(), + repo: parsed.repo.into(), + }) + }); + + Self { + history, + git_store, + repository: repository.downgrade(), + workspace, + remote, + selected_entry: None, + scroll_handle, + focus_handle, + loading_more: false, + has_more, + } + } + + fn load_more(&mut self, window: &mut Window, cx: &mut Context) { + if self.loading_more || !self.has_more { + return; + } + + self.loading_more = true; + cx.notify(); + + let current_count = self.history.entries.len(); + let path = self.history.path.clone(); + let git_store = self.git_store.clone(); + let repo = self.repository.clone(); + + let this = cx.weak_entity(); + let task = window.spawn(cx, async move |cx| { + let file_history_task = git_store + .update(cx, |git_store, cx| { + repo.upgrade().map(|repo| { + git_store.file_history_paginated( + &repo, + path, + current_count, + Some(PAGE_SIZE), + cx, + ) + }) + }) + .ok() + .flatten(); + + if let Some(task) = file_history_task { + if let Ok(more_history) = task.await { + this.update(cx, |this, cx| { + this.loading_more = false; + this.has_more = more_history.entries.len() >= PAGE_SIZE; + this.history.entries.extend(more_history.entries); + cx.notify(); + }) + .ok(); + } + } + }); + + task.detach(); + } + + fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = match self.selected_entry { + _ if entry_count == 0 => None, + None => Some(0), + Some(ix) => { + if ix == entry_count - 1 { + Some(0) + } else { + Some(ix + 1) + } + } + }; + self.select_ix(ix, cx); + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _: &mut Window, + cx: &mut Context, + ) { + let entry_count = self.history.entries.len(); + let ix = match self.selected_entry { + _ if entry_count == 0 => None, + None => Some(entry_count - 1), + Some(ix) => { + if ix == 0 { + Some(entry_count - 1) + } else { + Some(ix - 1) + } + } + }; + self.select_ix(ix, cx); + } + + fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = if entry_count != 0 { Some(0) } else { None }; + self.select_ix(ix, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = if entry_count != 0 { + Some(entry_count - 1) + } else { + None + }; + self.select_ix(ix, cx); + } + + fn select_ix(&mut self, ix: Option, cx: &mut Context) { + self.selected_entry = ix; + if let Some(ix) = ix { + self.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top); + } + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + self.open_commit_view(window, cx); + } + + fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context) { + let Some(entry) = self + .selected_entry + .and_then(|ix| self.history.entries.get(ix)) + else { + return; + }; + + if let Some(repo) = self.repository.upgrade() { + let sha_str = entry.sha.to_string(); + CommitView::open( + sha_str, + repo.downgrade(), + self.workspace.clone(), + None, + Some(self.history.path.clone()), + window, + cx, + ); + } + } + + fn render_commit_avatar( + &self, + sha: &SharedString, + window: &mut Window, + cx: &mut App, + ) -> impl IntoElement { + let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars()); + let size = rems_from_px(20.); + + if let Some(remote) = remote { + let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone()); + if let Some(Some(url)) = window.use_asset::(&avatar_asset, cx) { + Avatar::new(url.to_string()).size(size) + } else { + Avatar::new("").size(size) + } + } else { + Avatar::new("").size(size) + } + } + + fn render_commit_entry( + &self, + ix: usize, + entry: &FileHistoryEntry, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let pr_number = entry + .subject + .rfind("(#") + .and_then(|start| { + let rest = &entry.subject[start + 2..]; + rest.find(')') + .and_then(|end| rest[..end].parse::().ok()) + }) + .map(|num| format!("#{}", num)) + .unwrap_or_else(|| { + if entry.sha.len() >= 7 { + entry.sha[..7].to_string() + } else { + entry.sha.to_string() + } + }); + + let commit_time = OffsetDateTime::from_unix_timestamp(entry.commit_timestamp) + .unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH); + let relative_timestamp = time_format::format_localized_timestamp( + commit_time, + OffsetDateTime::now_utc(), + time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC), + time_format::TimestampFormat::Relative, + ); + + ListItem::new(("commit", ix)) + .toggle_state(Some(ix) == self.selected_entry) + .child( + h_flex() + .h_8() + .w_full() + .pl_0p5() + .pr_2p5() + .gap_2() + .child( + div() + .w(rems_from_px(52.)) + .flex_none() + .child(Chip::new(pr_number)), + ) + .child(self.render_commit_avatar(&entry.sha, window, cx)) + .child( + h_flex() + .min_w_0() + .w_full() + .justify_between() + .child( + h_flex() + .min_w_0() + .w_full() + .gap_1() + .child( + Label::new(entry.author_name.clone()) + .size(LabelSize::Small) + .color(Color::Default) + .truncate(), + ) + .child( + Label::new(&entry.subject) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .child( + h_flex().flex_none().child( + Label::new(relative_timestamp) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.selected_entry = Some(ix); + cx.notify(); + + this.open_commit_view(window, cx); + })) + .into_any_element() + } +} + +#[derive(Clone, Debug)] +struct CommitAvatarAsset { + sha: SharedString, + remote: GitRemote, +} + +impl std::hash::Hash for CommitAvatarAsset { + fn hash(&self, state: &mut H) { + self.sha.hash(state); + self.remote.host.name().hash(state); + } +} + +impl CommitAvatarAsset { + fn new(remote: GitRemote, sha: SharedString) -> Self { + Self { remote, sha } + } +} + +impl Asset for CommitAvatarAsset { + type Source = Self; + type Output = Option; + + fn load( + source: Self::Source, + cx: &mut App, + ) -> impl Future + Send + 'static { + let client = cx.http_client(); + async move { + match source + .remote + .host + .commit_author_avatar_url( + &source.remote.owner, + &source.remote.repo, + source.sha.clone(), + client, + ) + .await + { + Ok(Some(url)) => Some(SharedString::from(url.to_string())), + Ok(None) => None, + Err(_) => None, + } + } + } +} + +impl EventEmitter for FileHistoryView {} + +impl Focusable for FileHistoryView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for FileHistoryView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let _file_name = self.history.path.file_name().unwrap_or("File"); + let entry_count = self.history.entries.len(); + + v_flex() + .id("file_history_view") + .key_context("FileHistoryView") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .size_full() + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .h(rems_from_px(41.)) + .pl_3() + .pr_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new(self.history.path.as_unix_str().to_string()) + .color(Color::Muted) + .buffer_font(cx), + ) + .child( + h_flex() + .gap_1p5() + .child( + Label::new(format!("{} commits", entry_count)) + .size(LabelSize::Small) + .color(Color::Muted) + .when(self.has_more, |this| this.mr_1()), + ) + .when(self.has_more, |this| { + this.child(Divider::vertical()).child( + Button::new("load-more", "Load More") + .disabled(self.loading_more) + .label_size(LabelSize::Small) + .icon(IconName::ArrowCircle) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .on_click(cx.listener(|this, _, window, cx| { + this.load_more(window, cx); + })), + ) + }), + ), + ) + .child( + v_flex() + .flex_1() + .size_full() + .child({ + let view = cx.weak_entity(); + uniform_list( + "file-history-list", + entry_count, + move |range, window, cx| { + let Some(view) = view.upgrade() else { + return Vec::new(); + }; + view.update(cx, |this, cx| { + let mut items = Vec::with_capacity(range.end - range.start); + for ix in range { + if let Some(entry) = this.history.entries.get(ix) { + items.push( + this.render_commit_entry(ix, entry, window, cx), + ); + } + } + items + }) + }, + ) + .flex_1() + .size_full() + .track_scroll(&self.scroll_handle) + }) + .vertical_scrollbar_for(&self.scroll_handle, window, cx), + ) + } +} + +impl Item for FileHistoryView { + type Event = ItemEvent; + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + f(*event) + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + let file_name = self + .history + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_else(|| "File".to_string()); + format!("History: {}", file_name).into() + } + + fn tab_tooltip_text(&self, _cx: &App) -> Option { + Some(format!("Git history for {}", self.history.path.as_unix_str()).into()) + } + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch)) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("file history") + } + + fn clone_on_split( + &self, + _workspace_id: Option, + _window: &mut Window, + _cx: &mut Context, + ) -> Task>> { + Task::ready(None) + } + + fn navigate(&mut self, _: Box, _window: &mut Window, _: &mut Context) -> bool { + false + } + + fn deactivated(&mut self, _window: &mut Window, _: &mut Context) {} + + fn can_save(&self, _: &App) -> bool { + false + } + + fn save( + &mut self, + _options: SaveOptions, + _project: Entity, + _window: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(())) + } + + fn save_as( + &mut self, + _project: Entity, + _path: ProjectPath, + _window: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(())) + } + + fn reload( + &mut self, + _project: Entity, + _window: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(())) + } + + fn is_dirty(&self, _: &App) -> bool { + false + } + + fn has_conflict(&self, _: &App) -> bool { + false + } + + fn breadcrumbs( + &self, + _theme: &theme::Theme, + _cx: &App, + ) -> Option> { + None + } + + fn added_to_workspace( + &mut self, + _workspace: &mut Workspace, + window: &mut Window, + _cx: &mut Context, + ) { + window.focus(&self.focus_handle); + } + + fn show_toolbar(&self) -> bool { + true + } + + fn pixel_position_of_cursor(&self, _: &App) -> Option> { + None + } + + fn set_nav_history( + &mut self, + _: workspace::ItemNavHistory, + _window: &mut Window, + _: &mut Context, + ) { + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else { + None + } + } +} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 4c76030f5f..362423b79f 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -6,13 +6,19 @@ use crate::project_diff::{self, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ - git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, + file_history_view::FileHistoryView, git_panel_settings::GitPanelSettings, git_status_icon, + repository_selector::RepositorySelector, }; use agent_settings::AgentSettings; use anyhow::Context as _; use askpass::AskPassDelegate; +use cloud_llm_client::CompletionIntent; +use collections::{BTreeMap, HashMap, HashSet}; use db::kvp::KEY_VALUE_STORE; -use editor::{Editor, EditorElement, EditorMode, MultiBuffer}; +use editor::{ + Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, + actions::ExpandAllDiffHunks, +}; use futures::StreamExt as _; use git::blame::ParsedCommitMessage; use git::repository::{ @@ -28,10 +34,11 @@ use git::{ TrashUntrackedFiles, UnstageAll, }; use gpui::{ - Action, AsyncApp, AsyncWindowContext, ClickEvent, Corner, DismissEvent, Entity, EventEmitter, - FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, - MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task, - UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, + Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity, + EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, + ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, + Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, + size, uniform_list, }; use itertools::Itertools; use language::{Buffer, File}; @@ -47,31 +54,31 @@ use panel::{ }; use project::{ Fs, Project, ProjectPath, - git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId}, + git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op}, + project_settings::{GitPathStyle, ProjectSettings}, }; +use prompt_store::RULES_FILE_NAMES; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore, StatusStyle}; use std::future::Future; use std::ops::Range; use std::path::Path; -use std::{collections::HashSet, sync::Arc, time::Duration, usize}; +use std::{sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use time::OffsetDateTime; use ui::{ - Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, - PopoverMenu, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar, prelude::*, + ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors, + PopoverMenu, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar, + prelude::*, }; use util::paths::PathStyle; -use util::{ResultExt, TryFutureExt, maybe}; +use util::{ResultExt, TryFutureExt, maybe, rel_path::RelPath}; use workspace::SERIALIZATION_THROTTLE_TIME; - -use cloud_llm_client::CompletionIntent; use workspace::{ Workspace, dock::{DockPosition, Panel, PanelEvent}, - notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId}, + notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt}, }; - actions!( git_panel, [ @@ -89,6 +96,8 @@ actions!( ToggleFillCoAuthors, /// Toggles sorting entries by path vs status. ToggleSortByPath, + /// Toggles showing entries in tree vs flat view. + ToggleTreeView, ] ); @@ -119,6 +128,7 @@ struct GitMenuState { has_new_changes: bool, sort_by_path: bool, has_stash_items: bool, + tree_view: bool, } fn git_panel_context_menu( @@ -163,20 +173,34 @@ fn git_panel_context_menu( ) .separator() .entry( - if state.sort_by_path { - "Sort by Status" + if state.tree_view { + "Flat View" } else { - "Sort by Path" + "Tree View" }, - Some(Box::new(ToggleSortByPath)), - move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + Some(Box::new(ToggleTreeView)), + move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx), ) + .when(!state.tree_view, |this| { + this.entry( + if state.sort_by_path { + "Sort by Status" + } else { + "Sort by Path" + }, + Some(Box::new(ToggleSortByPath)), + move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + ) + }) }) } const GIT_PANEL_KEY: &str = "GitPanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel +const TREE_INDENT: f32 = 12.0; +const TREE_INDENT_GUIDE_OFFSET: f32 = 16.0; pub fn register(workspace: &mut Workspace) { workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { @@ -201,7 +225,7 @@ struct SerializedGitPanel { signoff_enabled: bool, } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] enum Section { Conflict, Tracked, @@ -237,6 +261,8 @@ impl GitHeaderEntry { #[derive(Debug, PartialEq, Eq, Clone)] enum GitListEntry { Status(GitStatusEntry), + TreeStatus(GitTreeStatusEntry), + Directory(GitTreeDirEntry), Header(GitHeaderEntry), } @@ -244,11 +270,208 @@ impl GitListEntry { fn status_entry(&self) -> Option<&GitStatusEntry> { match self { GitListEntry::Status(entry) => Some(entry), + GitListEntry::TreeStatus(entry) => Some(&entry.entry), _ => None, } } } +enum GitPanelViewMode { + Flat, + Tree(TreeViewState), +} + +impl GitPanelViewMode { + fn from_settings(cx: &App) -> Self { + if GitPanelSettings::get_global(cx).tree_view { + GitPanelViewMode::Tree(TreeViewState::default()) + } else { + GitPanelViewMode::Flat + } + } + + fn tree_state(&self) -> Option<&TreeViewState> { + match self { + GitPanelViewMode::Tree(state) => Some(state), + GitPanelViewMode::Flat => None, + } + } + + fn tree_state_mut(&mut self) -> Option<&mut TreeViewState> { + match self { + GitPanelViewMode::Tree(state) => Some(state), + GitPanelViewMode::Flat => None, + } + } +} + +#[derive(Default)] +struct TreeViewState { + // Maps visible index to actual entry index. + // Length equals the number of visible entries. + // This is needed because some entries (like collapsed directories) may be hidden. + logical_indices: Vec, + expanded_dirs: HashMap, + directory_descendants: HashMap>, +} + +impl TreeViewState { + fn build_tree_entries( + &mut self, + section: Section, + mut entries: Vec, + seen_directories: &mut HashSet, + ) -> Vec<(GitListEntry, bool)> { + if entries.is_empty() { + return Vec::new(); + } + + entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + + let mut root = TreeNode::default(); + for entry in entries { + let components: Vec<&str> = entry.repo_path.components().collect(); + if components.is_empty() { + root.files.push(entry); + continue; + } + + let mut current = &mut root; + let mut current_path = String::new(); + + for (ix, component) in components.iter().enumerate() { + if ix == components.len() - 1 { + current.files.push(entry.clone()); + } else { + if !current_path.is_empty() { + current_path.push('/'); + } + current_path.push_str(component); + let dir_path = RepoPath::new(¤t_path) + .expect("repo path from status entry component"); + + let component = SharedString::from(component.to_string()); + + current = current + .children + .entry(component.clone()) + .or_insert_with(|| TreeNode { + name: component, + path: Some(dir_path), + ..Default::default() + }); + } + } + } + + let (flattened, _) = self.flatten_tree(&root, section, 0, seen_directories); + flattened + } + + fn flatten_tree( + &mut self, + node: &TreeNode, + section: Section, + depth: usize, + seen_directories: &mut HashSet, + ) -> (Vec<(GitListEntry, bool)>, Vec) { + let mut all_statuses = Vec::new(); + let mut flattened = Vec::new(); + + for child in node.children.values() { + let (terminal, name) = Self::compact_directory_chain(child); + let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else { + continue; + }; + let (child_flattened, mut child_statuses) = + self.flatten_tree(terminal, section, depth + 1, seen_directories); + let key = TreeKey { section, path }; + let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); + self.expanded_dirs.entry(key.clone()).or_insert(true); + seen_directories.insert(key.clone()); + + self.directory_descendants + .insert(key.clone(), child_statuses.clone()); + + flattened.push(( + GitListEntry::Directory(GitTreeDirEntry { + key, + name, + depth, + expanded, + }), + true, + )); + + if expanded { + flattened.extend(child_flattened); + } else { + flattened.extend(child_flattened.into_iter().map(|(child, _)| (child, false))); + } + + all_statuses.append(&mut child_statuses); + } + + for file in &node.files { + all_statuses.push(file.clone()); + flattened.push(( + GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: file.clone(), + depth, + }), + true, + )); + } + + (flattened, all_statuses) + } + + fn compact_directory_chain(mut node: &TreeNode) -> (&TreeNode, SharedString) { + let mut parts = vec![node.name.clone()]; + while node.files.is_empty() && node.children.len() == 1 { + let Some(child) = node.children.values().next() else { + continue; + }; + if child.path.is_none() { + break; + } + parts.push(child.name.clone()); + node = child; + } + let name = parts.join("/"); + (node, SharedString::from(name)) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitTreeStatusEntry { + entry: GitStatusEntry, + depth: usize, +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +struct TreeKey { + section: Section, + path: RepoPath, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitTreeDirEntry { + key: TreeKey, + name: SharedString, + depth: usize, + // staged_state: ToggleState, + expanded: bool, +} + +#[derive(Default)] +struct TreeNode { + name: SharedString, + path: Option, + children: BTreeMap, + files: Vec, +} + #[derive(Debug, PartialEq, Eq, Clone)] pub struct GitStatusEntry { pub(crate) repo_path: RepoPath, @@ -271,19 +494,67 @@ impl GitStatusEntry { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum TargetStatus { - Staged, - Unstaged, - Reverted, - Unchanged, +struct TruncatedPatch { + header: String, + hunks: Vec, + hunks_to_keep: usize, } -struct PendingOperation { - finished: bool, - target_status: TargetStatus, - entries: Vec, - op_id: usize, +impl TruncatedPatch { + fn from_unified_diff(patch_str: &str) -> Option { + let lines: Vec<&str> = patch_str.lines().collect(); + if lines.len() < 2 { + return None; + } + let header = format!("{}\n{}\n", lines[0], lines[1]); + let mut hunks = Vec::new(); + let mut current_hunk = String::new(); + for line in &lines[2..] { + if line.starts_with("@@") { + if !current_hunk.is_empty() { + hunks.push(current_hunk); + } + current_hunk = format!("{}\n", line); + } else if !current_hunk.is_empty() { + current_hunk.push_str(line); + current_hunk.push('\n'); + } + } + if !current_hunk.is_empty() { + hunks.push(current_hunk); + } + if hunks.is_empty() { + return None; + } + let hunks_to_keep = hunks.len(); + Some(TruncatedPatch { + header, + hunks, + hunks_to_keep, + }) + } + fn calculate_size(&self) -> usize { + let mut size = self.header.len(); + for (i, hunk) in self.hunks.iter().enumerate() { + if i < self.hunks_to_keep { + size += hunk.len(); + } + } + size + } + fn to_string(&self) -> String { + let mut out = self.header.clone(); + for (i, hunk) in self.hunks.iter().enumerate() { + if i < self.hunks_to_keep { + out.push_str(hunk); + } + } + let skipped_hunks = self.hunks.len() - self.hunks_to_keep; + if skipped_hunks > 0 { + out.push_str(&format!("[...skipped {} hunks...]\n", skipped_hunks)); + } + out + } } pub struct GitPanel { @@ -294,14 +565,16 @@ pub struct GitPanel { add_coauthors: bool, generate_commit_message_task: Option>>, entries: Vec, + view_mode: GitPanelViewMode, + entries_indices: HashMap, single_staged_entry: Option, single_tracked_entry: Option, focus_handle: FocusHandle, fs: Arc, new_count: usize, entry_count: usize, + changes_count: usize, new_staged_count: usize, - pending: Vec, pending_commit: Option>, amend_pending: bool, original_commit_message: Option, @@ -383,13 +656,19 @@ impl GitPanel { cx.on_focus(&focus_handle, window, Self::focus_in).detach(); let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; cx.observe_global_in::(window, move |this, window, cx| { - let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; - if is_sort_by_path != was_sort_by_path { - this.entries.clear(); + let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let tree_view = GitPanelSettings::get_global(cx).tree_view; + if tree_view != was_tree_view { + this.view_mode = GitPanelViewMode::from_settings(cx); + } + if sort_by_path != was_sort_by_path || tree_view != was_tree_view { + this.bulk_staging.take(); this.update_visible_entries(window, cx); } - was_sort_by_path = is_sort_by_path + was_sort_by_path = sort_by_path; + was_tree_view = tree_view; }) .detach(); @@ -421,18 +700,18 @@ impl GitPanel { move |this, _git_store, event, window, cx| match event { GitStoreEvent::ActiveRepositoryChanged(_) => { this.active_repository = this.project.read(cx).active_repository(cx); - this.schedule_update(true, window, cx); + this.schedule_update(window, cx); } GitStoreEvent::RepositoryUpdated( _, - RepositoryEvent::Updated { full_scan, .. }, + RepositoryEvent::StatusesChanged + | RepositoryEvent::BranchChanged + | RepositoryEvent::MergeHeadsChanged, true, - ) => { - this.schedule_update(*full_scan, window, cx); - } - - GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => { - this.schedule_update(false, window, cx); + ) + | GitStoreEvent::RepositoryAdded + | GitStoreEvent::RepositoryRemoved(_) => { + this.schedule_update(window, cx); } GitStoreEvent::IndexWriteError(error) => { this.workspace @@ -455,11 +734,13 @@ impl GitPanel { add_coauthors: true, generate_commit_message_task: None, entries: Vec::new(), + view_mode: GitPanelViewMode::from_settings(cx), + entries_indices: HashMap::default(), focus_handle: cx.focus_handle(), fs, new_count: 0, new_staged_count: 0, - pending: Vec::new(), + changes_count: 0, pending_commit: None, amend_pending: false, original_commit_message: None, @@ -488,56 +769,13 @@ impl GitPanel { _settings_subscription, }; - this.schedule_update(false, window, cx); + this.schedule_update(window, cx); this }) } - pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option { - if GitPanelSettings::get_global(cx).sort_by_path { - return self - .entries - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - .ok(); - } - - if self.conflicted_count > 0 { - let conflicted_start = 1; - if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(conflicted_start + ix); - } - } - if self.tracked_count > 0 { - let tracked_start = if self.conflicted_count > 0 { - 1 + self.conflicted_count - } else { - 0 - } + 1; - if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(tracked_start + ix); - } - } - if self.new_count > 0 { - let untracked_start = if self.conflicted_count > 0 { - 1 + self.conflicted_count - } else { - 0 - } + if self.tracked_count > 0 { - 1 + self.tracked_count - } else { - 0 - } + 1; - if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(untracked_start + ix); - } - } - None + pub fn entry_by_path(&self, path: &RepoPath) -> Option { + self.entries_indices.get(path).copied() } pub fn select_entry_by_path( @@ -552,7 +790,7 @@ impl GitPanel { let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else { return; }; - let Some(ix) = self.entry_by_path(&repo_path, cx) else { + let Some(ix) = self.entry_by_path(&repo_path) else { return; }; self.selected_entry = Some(ix); @@ -652,9 +890,15 @@ impl GitPanel { cx.notify(); } + fn first_status_entry_index(&self) -> Option { + self.entries + .iter() + .position(|entry| entry.status_entry().is_some()) + } + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - if !self.entries.is_empty() { - self.selected_entry = Some(1); + if let Some(first_entry) = self.first_status_entry_index() { + self.selected_entry = Some(first_entry); self.scroll_to_selected_entry(cx); } } @@ -741,7 +985,7 @@ impl GitPanel { .as_ref() .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0); if have_entries && self.selected_entry.is_none() { - self.selected_entry = Some(1); + self.selected_entry = self.first_status_entry_index(); self.scroll_to_selected_entry(cx); cx.notify(); } @@ -755,7 +999,7 @@ impl GitPanel { ) { self.select_first_entry_if_none(cx); - cx.focus_self(window); + self.focus_handle.focus(window); cx.notify(); } @@ -793,6 +1037,26 @@ impl GitPanel { }); } + fn file_history(&mut self, _: &git::FileHistory, window: &mut Window, cx: &mut Context) { + maybe!({ + let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + let active_repo = self.active_repository.as_ref()?; + let repo_path = entry.repo_path.clone(); + let git_store = self.project.read(cx).git_store(); + + FileHistoryView::open( + repo_path, + git_store.downgrade(), + active_repo.downgrade(), + self.workspace.clone(), + window, + cx, + ); + + Some(()) + }); + } + fn open_file( &mut self, _: &menu::SecondaryConfirm, @@ -809,15 +1073,46 @@ impl GitPanel { return None; } - self.workspace + let open_task = self + .workspace .update(cx, |workspace, cx| { - workspace - .open_path_preview(path, None, false, false, true, window, cx) - .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| { - Some(format!("{e}")) - }); + workspace.open_path_preview(path, None, false, false, true, window, cx) }) - .ok() + .ok()?; + + cx.spawn_in(window, async move |_, mut cx| { + let item = open_task + .await + .notify_async_err(&mut cx) + .ok_or_else(|| anyhow::anyhow!("Failed to open file"))?; + if let Some(active_editor) = item.downcast::() { + if let Some(diff_task) = + active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load())? + { + diff_task.await; + } + + cx.update(|window, cx| { + active_editor.update(cx, |editor, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + + let snapshot = editor.snapshot(window, cx); + editor.go_to_hunk_before_or_after_position( + &snapshot, + language::Point::new(0, 0), + Direction::Next, + window, + cx, + ); + }) + })?; + } + + anyhow::Ok(()) + }) + .detach(); + + Some(()) }); } @@ -870,6 +1165,77 @@ impl GitPanel { }); } + fn add_to_gitignore( + &mut self, + _: &git::AddToGitignore, + _window: &mut Window, + cx: &mut Context, + ) { + maybe!({ + let list_entry = self.entries.get(self.selected_entry?)?.clone(); + let entry = list_entry.status_entry()?.to_owned(); + + if !entry.status.is_created() { + return Some(()); + } + + let project = self.project.downgrade(); + let repo_path = entry.repo_path; + let active_repository = self.active_repository.as_ref()?.downgrade(); + + cx.spawn(async move |_, cx| { + let file_path_str = repo_path.as_ref().display(PathStyle::Posix); + + let repo_root = active_repository.read_with(cx, |repository, _| { + repository.snapshot().work_directory_abs_path + })?; + + let gitignore_abs_path = repo_root.join(".gitignore"); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(gitignore_abs_path, cx) + })? + .await?; + + let mut should_save = false; + buffer.update(cx, |buffer, cx| { + let existing_content = buffer.text(); + + if existing_content + .lines() + .any(|line| line.trim() == file_path_str) + { + return; + } + + let insert_position = existing_content.len(); + let new_entry = if existing_content.is_empty() { + format!("{}\n", file_path_str) + } else if existing_content.ends_with('\n') { + format!("{}\n", file_path_str) + } else { + format!("\n{}\n", file_path_str) + }; + + buffer.edit([(insert_position..insert_position, new_entry)], None, cx); + should_save = true; + })?; + + if should_save { + project + .update(cx, |project, cx| project.save_buffer(buffer, cx))? + .await?; + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + Some(()) + }); + } + fn revert_entry( &mut self, entry: &GitStatusEntry, @@ -929,15 +1295,7 @@ impl GitPanel { return; }; - let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1; - self.pending.push(PendingOperation { - op_id, - target_status: TargetStatus::Reverted, - entries: entries.clone(), - finished: false, - }); - self.update_visible_entries(window, cx); - let task = cx.spawn(async move |_, cx| { + let task = cx.spawn_in(window, async move |this, cx| { let tasks: Vec<_> = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { entries @@ -954,8 +1312,8 @@ impl GitPanel { let buffers = futures::future::join_all(tasks).await; - active_repository - .update(cx, |repo, cx| { + this.update_in(cx, |this, window, cx| { + let task = active_repository.update(cx, |repo, cx| { repo.checkout_files( "HEAD", entries @@ -964,10 +1322,14 @@ impl GitPanel { .collect(), cx, ) - })? - .await??; + }); + this.update_visible_entries(window, cx); + cx.notify(); + task + })? + .await?; - let tasks: Vec<_> = cx.update(|cx| { + let tasks: Vec<_> = cx.update(|_, cx| { buffers .iter() .filter_map(|buffer| { @@ -987,21 +1349,10 @@ impl GitPanel { let result = task.await; this.update_in(cx, |this, window, cx| { - for pending in this.pending.iter_mut() { - if pending.op_id == op_id { - pending.finished = true; - if result.is_err() { - pending.target_status = TargetStatus::Unchanged; - this.update_visible_entries(window, cx); - } - break; - } + if let Err(err) = result { + this.update_visible_entries(window, cx); + this.show_error_toast("checkout", err, cx); } - result - .map_err(|e| { - this.show_error_toast("checkout", e, cx); - }) - .ok(); }) .ok(); }) @@ -1028,7 +1379,7 @@ impl GitPanel { } let mut details = entries .iter() - .filter_map(|entry| entry.repo_path.0.file_name()) + .filter_map(|entry| entry.repo_path.as_ref().file_name()) .map(|filename| filename.to_string()) .take(5) .join("\n"); @@ -1083,7 +1434,7 @@ impl GitPanel { .map(|entry| { entry .repo_path - .0 + .as_ref() .file_name() .map(|f| f.to_string()) .unwrap_or_default() @@ -1129,26 +1480,109 @@ impl GitPanel { }); } + fn change_all_files_stage(&mut self, stage: bool, cx: &mut Context) { + let Some(active_repository) = self.active_repository.clone() else { + return; + }; + cx.spawn({ + async move |this, cx| { + let result = this + .update(cx, |this, cx| { + let task = active_repository.update(cx, |repo, cx| { + if stage { + repo.stage_all(cx) + } else { + repo.unstage_all(cx) + } + }); + this.update_counts(active_repository.read(cx)); + cx.notify(); + task + })? + .await; + + this.update(cx, |this, cx| { + if let Err(err) = result { + this.show_error_toast(if stage { "add" } else { "reset" }, err, cx); + } + cx.notify() + }) + } + }) + .detach(); + } + + fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus { + // Checking for current staged/unstaged file status is a chained operation: + // 1. first, we check for any pending operation recorded in repository + // 2. if there are no pending ops either running or finished, we then ask the repository + // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry` + // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on + // the checkbox's state (or flickering) which is undesirable. + // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded + // in `entry` arg. + repo.pending_ops_for_path(&entry.repo_path) + .map(|ops| { + if ops.staging() || ops.staged() { + StageStatus::Staged + } else { + StageStatus::Unstaged + } + }) + .or_else(|| { + repo.status_for_path(&entry.repo_path) + .map(|status| status.status.staging()) + }) + .unwrap_or(entry.staging) + } + + fn stage_status_for_directory( + &self, + entry: &GitTreeDirEntry, + repo: &Repository, + ) -> StageStatus { + let GitPanelViewMode::Tree(tree_state) = &self.view_mode else { + util::debug_panic!("We should never render a directory entry while in flat view mode"); + return StageStatus::Unstaged; + }; + + let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else { + return StageStatus::Unstaged; + }; + + let mut fully_staged_count = 0usize; + let mut any_staged_or_partially_staged = false; + + for descendant in descendants { + match GitPanel::stage_status_for_entry(descendant, repo) { + StageStatus::Staged => { + fully_staged_count += 1; + any_staged_or_partially_staged = true; + } + StageStatus::PartiallyStaged => { + any_staged_or_partially_staged = true; + } + StageStatus::Unstaged => {} + } + } + + if descendants.is_empty() { + StageStatus::Unstaged + } else if fully_staged_count == descendants.len() { + StageStatus::Staged + } else if any_staged_or_partially_staged { + StageStatus::PartiallyStaged + } else { + StageStatus::Unstaged + } + } + pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context) { - let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) - .filter(|status_entry| status_entry.staging.has_unstaged()) - .cloned() - .collect::>(); - self.change_file_stage(true, entries, cx); + self.change_all_files_stage(true, cx); } pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context) { - let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) - .filter(|status_entry| status_entry.staging.has_staged()) - .cloned() - .collect::>(); - self.change_file_stage(false, entries, cx); + self.change_all_files_stage(false, cx); } fn toggle_staged_for_entry( @@ -1157,42 +1591,101 @@ impl GitPanel { _window: &mut Window, cx: &mut Context, ) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; - let (stage, repo_paths) = match entry { - GitListEntry::Status(status_entry) => { - if status_entry.status.staging().is_fully_staged() { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.repo_path - { - self.bulk_staging = None; - } + let mut set_anchor: Option = None; + let mut clear_anchor = None; - (false, vec![status_entry.clone()]) - } else { - self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx); + let (stage, repo_paths) = { + let repo = active_repository.read(cx); + match entry { + GitListEntry::Status(status_entry) => { + let repo_paths = vec![status_entry.clone()]; + let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) { + StageStatus::Staged => { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + set_anchor = Some(status_entry.repo_path.clone()); + true + } + }; + (stage, repo_paths) + } + GitListEntry::TreeStatus(status_entry) => { + let repo_paths = vec![status_entry.entry.clone()]; + let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) { + StageStatus::Staged => { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + set_anchor = Some(status_entry.entry.repo_path.clone()); + true + } + }; + (stage, repo_paths) + } + GitListEntry::Header(section) => { + let goal_staged_state = !self.header_state(section.header).selected(); + let entries = self + .entries + .iter() + .filter_map(|entry| entry.status_entry()) + .filter(|status_entry| { + section.contains(status_entry, &repo) + && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool() + != Some(goal_staged_state) + }) + .cloned() + .collect::>(); - (true, vec![status_entry.clone()]) + (goal_staged_state, entries) + } + GitListEntry::Directory(entry) => { + let goal_staged_state = match self.stage_status_for_directory(entry, repo) { + StageStatus::Staged => StageStatus::Unstaged, + StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged, + }; + let goal_stage = goal_staged_state == StageStatus::Staged; + + let entries = self + .view_mode + .tree_state() + .and_then(|state| state.directory_descendants.get(&entry.key)) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|status_entry| { + GitPanel::stage_status_for_entry(status_entry, &repo) + != goal_staged_state + }) + .collect::>(); + (goal_stage, entries) } } - GitListEntry::Header(section) => { - let goal_staged_state = !self.header_state(section.header).selected(); - let repository = active_repository.read(cx); - let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) - .filter(|status_entry| { - section.contains(status_entry, repository) - && status_entry.staging.as_bool() != Some(goal_staged_state) - }) - .cloned() - .collect::>(); - - (goal_staged_state, entries) - } }; + if let Some(anchor) = clear_anchor { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == anchor + { + self.bulk_staging = None; + } + } + if let Some(anchor) = set_anchor { + self.set_bulk_staging_anchor(anchor, cx); + } + self.change_file_stage(stage, repo_paths, cx); } @@ -1205,56 +1698,31 @@ impl GitPanel { let Some(active_repository) = self.active_repository.clone() else { return; }; - let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1; - self.pending.push(PendingOperation { - op_id, - target_status: if stage { - TargetStatus::Staged - } else { - TargetStatus::Unstaged - }, - entries: entries.clone(), - finished: false, - }); - let repository = active_repository.read(cx); - self.update_counts(repository); - cx.notify(); - cx.spawn({ async move |this, cx| { - let result = cx - .update(|cx| { - if stage { - active_repository.update(cx, |repo, cx| { - let repo_paths = entries - .iter() - .map(|entry| entry.repo_path.clone()) - .collect(); + let result = this + .update(cx, |this, cx| { + let task = active_repository.update(cx, |repo, cx| { + let repo_paths = entries + .iter() + .map(|entry| entry.repo_path.clone()) + .collect(); + if stage { repo.stage_entries(repo_paths, cx) - }) - } else { - active_repository.update(cx, |repo, cx| { - let repo_paths = entries - .iter() - .map(|entry| entry.repo_path.clone()) - .collect(); + } else { repo.unstage_entries(repo_paths, cx) - }) - } + } + }); + this.update_counts(active_repository.read(cx)); + cx.notify(); + task })? .await; this.update(cx, |this, cx| { - for pending in this.pending.iter_mut() { - if pending.op_id == op_id { - pending.finished = true - } + if let Err(err) = result { + this.show_error_toast(if stage { "add" } else { "reset" }, err, cx); } - result - .map_err(|e| { - this.show_error_toast(if stage { "add" } else { "reset" }, e, cx); - }) - .ok(); cx.notify(); }) } @@ -1391,16 +1859,26 @@ impl GitPanel { } } - fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { - if self.amend_pending { - return; - } - if self - .commit_editor - .focus_handle(cx) - .contains_focused(window, cx) - { + fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.commit(&self.commit_editor.focus_handle(cx), window, cx) { telemetry::event!("Git Committed", source = "Git Panel"); + } + } + + /// Commits staged changes with the current commit message. + /// + /// Returns `true` if the commit was executed, `false` otherwise. + pub(crate) fn commit( + &mut self, + commit_editor_focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.amend_pending { + return false; + } + + if commit_editor_focus_handle.contains_focused(window, cx) { self.commit_changes( CommitOptions { amend: false, @@ -1408,24 +1886,39 @@ impl GitPanel { }, window, cx, - ) + ); + true } else { cx.propagate(); + false } } - fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { - if self - .commit_editor - .focus_handle(cx) - .contains_focused(window, cx) - { + fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + if self.amend(&self.commit_editor.focus_handle(cx), window, cx) { + telemetry::event!("Git Amended", source = "Git Panel"); + } + } + + /// Amends the most recent commit with staged changes and/or an updated commit message. + /// + /// Uses a two-stage workflow where the first invocation loads the commit + /// message for editing, second invocation performs the amend. Returns + /// `true` if the amend was executed, `false` otherwise. + pub(crate) fn amend( + &mut self, + commit_editor_focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if commit_editor_focus_handle.contains_focused(window, cx) { if self.head_commit(cx).is_some() { if !self.amend_pending { self.set_amend_pending(true, cx); - self.load_last_commit_message_if_empty(cx); + self.load_last_commit_message(cx); + + return false; } else { - telemetry::event!("Git Amended", source = "Git Panel"); self.commit_changes( CommitOptions { amend: true, @@ -1434,13 +1927,16 @@ impl GitPanel { window, cx, ); + + return true; } } + return false; } else { cx.propagate(); + return false; } } - pub fn head_commit(&self, cx: &App) -> Option { self.active_repository .as_ref() @@ -1448,13 +1944,11 @@ impl GitPanel { .cloned() } - pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context) { - if !self.commit_editor.read(cx).is_empty(cx) { - return; - } + pub fn load_last_commit_message(&mut self, cx: &mut Context) { let Some(head_commit) = self.head_commit(cx) else { return; }; + let recent_sha = head_commit.sha.to_string(); let detail_task = self.load_commit_details(recent_sha, cx); cx.spawn(async move |this, cx| { @@ -1477,7 +1971,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> Option { - let git_commit_language = self.commit_editor.read(cx).language_at(0, cx); + let git_commit_language = self + .commit_editor + .read(cx) + .language_at(MultiBufferOffset(0), cx); let message = self.commit_editor.read(cx).text(cx); if message.is_empty() { return self @@ -1541,6 +2038,7 @@ impl GitPanel { return; } + let askpass = self.askpass_delegate("git commit", window, cx); let commit_message = self.custom_or_suggested_commit_message(window, cx); let Some(mut message) = commit_message else { @@ -1555,7 +2053,7 @@ impl GitPanel { let task = if self.has_staged_changes() { // Repository serializes all git operations, so we can just send a commit immediately let commit_task = active_repository.update(cx, |repo, cx| { - repo.commit(message.into(), None, options, cx) + repo.commit(message.into(), None, options, askpass, cx) }); cx.background_spawn(async move { commit_task.await? }) } else { @@ -1577,7 +2075,7 @@ impl GitPanel { cx.spawn(async move |_, cx| { stage_task.await?; let commit_task = active_repository.update(cx, |repo, cx| { - repo.commit(message.into(), None, options, cx) + repo.commit(message.into(), None, options, askpass, cx) })?; commit_task.await? }) @@ -1586,11 +2084,16 @@ impl GitPanel { let result = task.await; this.update_in(cx, |this, window, cx| { this.pending_commit.take(); + match result { Ok(()) => { - this.commit_editor - .update(cx, |editor, cx| editor.clear(window, cx)); - this.original_commit_message = None; + if options.amend { + this.set_amend_pending(false, cx); + } else { + this.commit_editor + .update(cx, |editor, cx| editor.clear(window, cx)); + this.original_commit_message = None; + } } Err(e) => this.show_error_toast("commit", e, cx), } @@ -1599,9 +2102,6 @@ impl GitPanel { }); self.pending_commit = Some(task); - if options.amend { - self.set_amend_pending(false, cx); - } } pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context) { @@ -1736,6 +2236,146 @@ impl GitPanel { self.generate_commit_message(cx); } + fn split_patch(patch: &str) -> Vec { + let mut result = Vec::new(); + let mut current_patch = String::new(); + + for line in patch.lines() { + if line.starts_with("---") && !current_patch.is_empty() { + result.push(current_patch.trim_end_matches('\n').into()); + current_patch = String::new(); + } + current_patch.push_str(line); + current_patch.push('\n'); + } + + if !current_patch.is_empty() { + result.push(current_patch.trim_end_matches('\n').into()); + } + + result + } + fn truncate_iteratively(patch: &str, max_bytes: usize) -> String { + let mut current_size = patch.len(); + if current_size <= max_bytes { + return patch.to_string(); + } + let file_patches = Self::split_patch(patch); + let mut file_infos: Vec = file_patches + .iter() + .filter_map(|patch| TruncatedPatch::from_unified_diff(patch)) + .collect(); + + if file_infos.is_empty() { + return patch.to_string(); + } + + current_size = file_infos.iter().map(|f| f.calculate_size()).sum::(); + while current_size > max_bytes { + let file_idx = file_infos + .iter() + .enumerate() + .filter(|(_, f)| f.hunks_to_keep > 1) + .max_by_key(|(_, f)| f.hunks_to_keep) + .map(|(idx, _)| idx); + match file_idx { + Some(idx) => { + let file = &mut file_infos[idx]; + let size_before = file.calculate_size(); + file.hunks_to_keep -= 1; + let size_after = file.calculate_size(); + let saved = size_before.saturating_sub(size_after); + current_size = current_size.saturating_sub(saved); + } + None => { + break; + } + } + } + + file_infos + .iter() + .map(|info| info.to_string()) + .collect::>() + .join("\n") + } + + pub fn compress_commit_diff(diff_text: &str, max_bytes: usize) -> String { + if diff_text.len() <= max_bytes { + return diff_text.to_string(); + } + + let mut compressed = diff_text + .lines() + .map(|line| { + if line.len() > 256 { + format!("{}...[truncated]\n", &line[..line.floor_char_boundary(256)]) + } else { + format!("{}\n", line) + } + }) + .collect::>() + .join(""); + + if compressed.len() <= max_bytes { + return compressed; + } + + compressed = Self::truncate_iteratively(&compressed, max_bytes); + + compressed + } + + async fn load_project_rules( + project: &Entity, + repo_work_dir: &Arc, + cx: &mut AsyncApp, + ) -> Option { + let rules_path = cx + .update(|cx| { + for worktree in project.read(cx).worktrees(cx) { + let worktree_abs_path = worktree.read(cx).abs_path(); + if !worktree_abs_path.starts_with(&repo_work_dir) { + continue; + } + + let worktree_snapshot = worktree.read(cx).snapshot(); + for rules_name in RULES_FILE_NAMES { + if let Ok(rel_path) = RelPath::unix(rules_name) { + if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) { + if entry.is_file() { + return Some(ProjectPath { + worktree_id: worktree.read(cx).id(), + path: entry.path.clone(), + }); + } + } + } + } + } + None + }) + .ok()??; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(rules_path, cx)) + .ok()? + .await + .ok()?; + + let content = buffer + .read_with(cx, |buffer, _| buffer.text()) + .ok()? + .trim() + .to_string(); + + if content.is_empty() { + None + } else { + Some(content) + } + } + /// Generates a commit message using an LLM. pub fn generate_commit_message(&mut self, cx: &mut Context) { if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) { @@ -1763,8 +2403,10 @@ impl GitPanel { }); let temperature = AgentSettings::temperature_for_model(&model, cx); + let project = self.project.clone(); + let repo_work_dir = repo.read(cx).work_directory_abs_path.clone(); - self.generate_commit_message_task = Some(cx.spawn(async move |this, cx| { + self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| { async move { let _defer = cx.on_drop(&this, |this, _cx| { this.generate_commit_message_task.take(); @@ -1794,10 +2436,10 @@ impl GitPanel { } }; - const ONE_MB: usize = 1_000_000; - if diff_text.len() > ONE_MB { - diff_text = diff_text.chars().take(ONE_MB).collect() - } + const MAX_DIFF_BYTES: usize = 20_000; + diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES); + + let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await; let subject = this.update(cx, |this, cx| { this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default() @@ -1805,13 +2447,25 @@ impl GitPanel { let text_empty = subject.trim().is_empty(); - let content = if text_empty { - format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}") - } else { - format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n") + const PROMPT: &str = include_str!("commit_message_prompt.txt"); + + let rules_section = match &rules_content { + Some(rules) => format!( + "\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\ + \n{rules}\n\n" + ), + None => String::new(), }; - const PROMPT: &str = include_str!("commit_message_prompt.txt"); + let subject_section = if text_empty { + String::new() + } else { + format!("\nHere is the user's subject line:\n{subject}") + }; + + let content = format!( + "{PROMPT}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" + ); let request = LanguageModelRequest { thread_id: None, @@ -1822,6 +2476,7 @@ impl GitPanel { role: Role::User, content: vec![content.into()], cache: false, + reasoning_details: None, }], tools: Vec::new(), tool_choice: None, @@ -1881,7 +2536,7 @@ impl GitPanel { cx.spawn_in(window, async move |_, cx| { let repo = repo?; let remotes = repo - .update(cx, |repo, _| repo.get_remotes(None)) + .update(cx, |repo, _| repo.get_remotes(None, false)) .ok()? .await .ok()? @@ -2133,7 +2788,7 @@ impl GitPanel { .detach(); } - pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn pull(&mut self, rebase: bool, window: &mut Window, cx: &mut Context) { if !self.can_push_and_pull(cx) { return; } @@ -2145,7 +2800,7 @@ impl GitPanel { }; telemetry::event!("Git Pulled"); let branch = branch.clone(); - let remote = self.get_remote(false, window, cx); + let remote = self.get_remote(false, false, window, cx); cx.spawn_in(window, async move |this, cx| { let remote = match remote.await { Ok(Some(remote)) => remote, @@ -2164,13 +2819,13 @@ impl GitPanel { this.askpass_delegate(format!("git pull {}", remote.name), window, cx) })?; + let branch_name = branch + .upstream + .is_none() + .then(|| branch.name().to_owned().into()); + let pull = repo.update(cx, |repo, cx| { - repo.pull( - branch.name().to_owned().into(), - remote.name.clone(), - askpass, - cx, - ) + repo.pull(branch_name, remote.name.clone(), rebase, askpass, cx) })?; let remote_message = pull.await?; @@ -2221,7 +2876,7 @@ impl GitPanel { _ => None, } }; - let remote = self.get_remote(select_remote, window, cx); + let remote = self.get_remote(select_remote, true, window, cx); cx.spawn_in(window, async move |this, cx| { let remote = match remote.await { @@ -2298,6 +2953,7 @@ impl GitPanel { fn get_remote( &mut self, always_select: bool, + is_push: bool, window: &mut Window, cx: &mut Context, ) -> impl Future>> + use<> { @@ -2315,7 +2971,7 @@ impl GitPanel { let current_branch = repo.branch.as_ref().context("No active branch")?; Some(current_branch.name().to_string()) }; - anyhow::Ok(repo.get_remotes(current_branch)) + anyhow::Ok(repo.get_remotes(current_branch, is_push)) })?? .await??; @@ -2438,6 +3094,29 @@ impl GitPanel { } } + fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) { + let current_setting = GitPanelSettings::get_global(cx).tree_view; + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting); + }); + }) + } + } + + fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context) { + if let Some(state) = self.view_mode.tree_state_mut() { + let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true); + *expanded = !*expanded; + self.update_visible_entries(window, cx); + } else { + util::debug_panic!("Attempted to toggle directory in flat Git Panel state"); + } + } + fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context) { const CO_AUTHOR_PREFIX: &str = "Co-authored-by: "; @@ -2486,12 +3165,7 @@ impl GitPanel { message.push('\n'); } - fn schedule_update( - &mut self, - clear_pending: bool, - window: &mut Window, - cx: &mut Context, - ) { + fn schedule_update(&mut self, window: &mut Window, cx: &mut Context) { let handle = cx.entity().downgrade(); self.reopen_commit_buffer(window, cx); self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| { @@ -2499,9 +3173,6 @@ impl GitPanel { if let Some(git_panel) = handle.upgrade() { git_panel .update_in(cx, |git_panel, window, cx| { - if clear_pending { - git_panel.clear_pending(); - } git_panel.update_visible_entries(window, cx); }) .ok(); @@ -2550,36 +3221,39 @@ impl GitPanel { .detach_and_log_err(cx); } - fn clear_pending(&mut self) { - self.pending.retain(|v| !v.finished) - } - fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context) { let path_style = self.project.read(cx).path_style(cx); let bulk_staging = self.bulk_staging.take(); let last_staged_path_prev_index = bulk_staging .as_ref() - .and_then(|op| self.entry_by_path(&op.anchor, cx)); + .and_then(|op| self.entry_by_path(&op.anchor)); self.entries.clear(); + self.entries_indices.clear(); self.single_staged_entry.take(); self.single_tracked_entry.take(); self.conflicted_count = 0; self.conflicted_staged_count = 0; + self.changes_count = 0; self.new_count = 0; self.tracked_count = 0; self.new_staged_count = 0; self.tracked_staged_count = 0; self.entry_count = 0; + self.max_width_item_index = None; let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); + let group_by_status = is_tree_view || !sort_by_path; let mut changed_entries = Vec::new(); let mut new_entries = Vec::new(); let mut conflict_entries = Vec::new(); let mut single_staged_entry = None; let mut staged_count = 0; - let mut max_width_item: Option<(RepoPath, usize)> = None; + let mut seen_directories = HashSet::default(); + let mut max_width_estimate = 0usize; + let mut max_width_item_index = None; let Some(repo) = self.active_repository.as_ref() else { // Just clear entries if no repository is active. @@ -2592,18 +3266,17 @@ impl GitPanel { self.stash_entries = repo.cached_stash(); for entry in repo.cached_status() { + self.changes_count += 1; let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path); let is_new = entry.status.is_created(); let staging = entry.status.staging(); - if self.pending.iter().any(|pending| { - pending.target_status == TargetStatus::Reverted - && !pending.finished - && pending - .entries - .iter() - .any(|pending| pending.repo_path == entry.repo_path) - }) { + if let Some(pending) = repo.pending_ops_for_path(&entry.repo_path) + && pending + .ops + .iter() + .any(|op| op.git_status == pending_op::GitStatus::Reverted && op.finished()) + { continue; } @@ -2618,108 +3291,154 @@ impl GitPanel { single_staged_entry = Some(entry.clone()); } - let width_estimate = Self::item_width_estimate( - entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), - entry.display_name(path_style).len(), - ); - - match max_width_item.as_mut() { - Some((repo_path, estimate)) => { - if width_estimate > *estimate { - *repo_path = entry.repo_path.clone(); - *estimate = width_estimate; - } - } - None => max_width_item = Some((entry.repo_path.clone(), width_estimate)), - } - - if sort_by_path { - changed_entries.push(entry); - } else if is_conflict { + if group_by_status && is_conflict { conflict_entries.push(entry); - } else if is_new { + } else if group_by_status && is_new { new_entries.push(entry); } else { changed_entries.push(entry); } } - let mut pending_staged_count = 0; - let mut last_pending_staged = None; - let mut pending_status_for_single_staged = None; - for pending in self.pending.iter() { - if pending.target_status == TargetStatus::Staged { - pending_staged_count += pending.entries.len(); - last_pending_staged = pending.entries.first().cloned(); - } - if let Some(single_staged) = &single_staged_entry - && pending - .entries - .iter() - .any(|entry| entry.repo_path == single_staged.repo_path) + if conflict_entries.is_empty() { + if staged_count == 1 + && let Some(entry) = single_staged_entry.as_ref() { - pending_status_for_single_staged = Some(pending.target_status); - } - } - - if conflict_entries.is_empty() && staged_count == 1 && pending_staged_count == 0 { - match pending_status_for_single_staged { - Some(TargetStatus::Staged) | None => { + if let Some(ops) = repo.pending_ops_for_path(&entry.repo_path) { + if ops.staged() { + self.single_staged_entry = single_staged_entry; + } + } else { self.single_staged_entry = single_staged_entry; } - _ => {} + } else if repo.pending_ops_summary().item_summary.staging_count == 1 + && let Some(ops) = repo.pending_ops().find(|ops| ops.staging()) + { + self.single_staged_entry = + repo.status_for_path(&ops.repo_path) + .map(|status| GitStatusEntry { + repo_path: ops.repo_path.clone(), + status: status.status, + staging: StageStatus::Staged, + }); } - } else if conflict_entries.is_empty() && pending_staged_count == 1 { - self.single_staged_entry = last_pending_staged; } if conflict_entries.is_empty() && changed_entries.len() == 1 { self.single_tracked_entry = changed_entries.first().cloned(); } - if !conflict_entries.is_empty() { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::Conflict, - })); - self.entries - .extend(conflict_entries.into_iter().map(GitListEntry::Status)); + let mut push_entry = + |this: &mut Self, + entry: GitListEntry, + is_visible: bool, + logical_indices: Option<&mut Vec>| { + if let Some(estimate) = + this.width_estimate_for_list_entry(is_tree_view, &entry, path_style) + { + if estimate > max_width_estimate { + max_width_estimate = estimate; + max_width_item_index = Some(this.entries.len()); + } + } + + if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone()) + { + this.entries_indices.insert(repo_path, this.entries.len()); + } + + if let (Some(indices), true) = (logical_indices, is_visible) { + indices.push(this.entries.len()); + } + + this.entries.push(entry); + }; + + macro_rules! take_section_entries { + () => { + [ + (Section::Conflict, std::mem::take(&mut conflict_entries)), + (Section::Tracked, std::mem::take(&mut changed_entries)), + (Section::New, std::mem::take(&mut new_entries)), + ] + }; } - if !changed_entries.is_empty() { - if !sort_by_path { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::Tracked, - })); + match &mut self.view_mode { + GitPanelViewMode::Tree(tree_state) => { + tree_state.logical_indices.clear(); + tree_state.directory_descendants.clear(); + + // This is just to get around the borrow checker + // because push_entry mutably borrows self + let mut tree_state = std::mem::take(tree_state); + + for (section, entries) in take_section_entries!() { + if entries.is_empty() { + continue; + } + + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + Some(&mut tree_state.logical_indices), + ); + + for (entry, is_visible) in + tree_state.build_tree_entries(section, entries, &mut seen_directories) + { + push_entry( + self, + entry, + is_visible, + Some(&mut tree_state.logical_indices), + ); + } + } + + tree_state + .expanded_dirs + .retain(|key, _| seen_directories.contains(key)); + self.view_mode = GitPanelViewMode::Tree(tree_state); + } + GitPanelViewMode::Flat => { + for (section, entries) in take_section_entries!() { + if entries.is_empty() { + continue; + } + + if section != Section::Tracked || !sort_by_path { + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + None, + ); + } + + for entry in entries { + push_entry(self, GitListEntry::Status(entry), true, None); + } + } } - self.entries - .extend(changed_entries.into_iter().map(GitListEntry::Status)); - } - if !new_entries.is_empty() { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::New, - })); - self.entries - .extend(new_entries.into_iter().map(GitListEntry::Status)); } - if let Some((repo_path, _)) = max_width_item { - self.max_width_item_index = self.entries.iter().position(|entry| match entry { - GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path, - GitListEntry::Header(_) => false, - }); - } + self.max_width_item_index = max_width_item_index; self.update_counts(repo); let bulk_staging_anchor_new_index = bulk_staging .as_ref() .filter(|op| op.repo_id == repo.id) - .and_then(|op| self.entry_by_path(&op.anchor, cx)); + .and_then(|op| self.entry_by_path(&op.anchor)); if bulk_staging_anchor_new_index == last_staged_path_prev_index && let Some(index) = bulk_staging_anchor_new_index && let Some(entry) = self.entries.get(index) && let Some(entry) = entry.status_entry() - && self.entry_staging(entry) == StageStatus::Staged + && GitPanel::stage_status_for_entry(entry, &repo) + .as_bool() + .unwrap_or(false) { self.bulk_staging = bulk_staging; } @@ -2760,48 +3479,32 @@ impl GitPanel { self.new_staged_count = 0; self.tracked_staged_count = 0; self.entry_count = 0; - for entry in &self.entries { - let Some(status_entry) = entry.status_entry() else { - continue; - }; + + for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { self.entry_count += 1; + let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo) + .as_bool() + .unwrap_or(false); + if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) { self.conflicted_count += 1; - if self.entry_staging(status_entry).has_staged() { + if is_staging_or_staged { self.conflicted_staged_count += 1; } } else if status_entry.status.is_created() { self.new_count += 1; - if self.entry_staging(status_entry).has_staged() { + if is_staging_or_staged { self.new_staged_count += 1; } } else { self.tracked_count += 1; - if self.entry_staging(status_entry).has_staged() { + if is_staging_or_staged { self.tracked_staged_count += 1; } } } } - fn entry_staging(&self, entry: &GitStatusEntry) -> StageStatus { - for pending in self.pending.iter().rev() { - if pending - .entries - .iter() - .any(|pending_entry| pending_entry.repo_path == entry.repo_path) - { - match pending.target_status { - TargetStatus::Staged => return StageStatus::Staged, - TargetStatus::Unstaged => return StageStatus::Unstaged, - TargetStatus::Reverted => continue, - TargetStatus::Unchanged => continue, - } - } - } - entry.staging - } - pub(crate) fn has_staged_changes(&self) -> bool { self.tracked_staged_count > 0 || self.new_staged_count > 0 @@ -2823,35 +3526,10 @@ impl GitPanel { } fn show_error_toast(&self, action: impl Into, e: anyhow::Error, cx: &mut App) { - let action = action.into(); let Some(workspace) = self.workspace.upgrade() else { return; }; - - let message = e.to_string().trim().to_string(); - if message - .matches(git::repository::REMOTE_CANCELLED_BY_USER) - .next() - .is_some() - { // Hide the cancelled by user message - } else { - workspace.update(cx, |workspace, cx| { - let workspace_weak = cx.weak_entity(); - let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) - .action("View Log", move |window, cx| { - let message = message.clone(); - let action = action.clone(); - workspace_weak - .update(cx, move |workspace, cx| { - Self::open_output(action, workspace, &message, window, cx) - }) - .ok(); - }) - }); - workspace.toggle_status_toast(toast, cx) - }); - } + show_error_toast(workspace, action, e, cx) } fn show_commit_message_error(weak_this: &WeakEntity, err: &E, cx: &mut AsyncApp) @@ -2896,7 +3574,7 @@ impl GitPanel { format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr); workspace_weak .update(cx, move |workspace, cx| { - Self::open_output(operation, workspace, &output, window, cx) + open_output(operation, workspace, &output, window, cx) }) .ok(); }), @@ -2904,35 +3582,12 @@ impl GitPanel { .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action(text, move |_, cx| cx.open_url(&link)), } + .dismiss_button(true) }); workspace.toggle_status_toast(status_toast, cx) }); } - fn open_output( - operation: impl Into, - workspace: &mut Workspace, - output: &str, - window: &mut Window, - cx: &mut Context, - ) { - let operation = operation.into(); - let buffer = cx.new(|cx| Buffer::local(output, cx)); - buffer.update(cx, |buffer, cx| { - buffer.set_capability(language::Capability::ReadOnly, cx); - }); - let editor = cx.new(|cx| { - let mut editor = Editor::for_buffer(buffer, None, window, cx); - editor.buffer().update(cx, |buffer, cx| { - buffer.set_title(format!("Output from git {operation}"), cx); - }); - editor.set_read_only(true); - editor - }); - - workspace.add_item_to_center(Box::new(editor), window, cx); - } - pub fn can_commit(&self) -> bool { (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts() } @@ -2945,10 +3600,48 @@ impl GitPanel { self.has_staged_changes() } - // eventually we'll need to take depth into account here - // if we add a tree view - fn item_width_estimate(path: usize, file_name: usize) -> usize { - path + file_name + fn status_width_estimate( + tree_view: bool, + entry: &GitStatusEntry, + path_style: PathStyle, + depth: usize, + ) -> usize { + if tree_view { + Self::item_width_estimate(0, entry.display_name(path_style).len(), depth) + } else { + Self::item_width_estimate( + entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), + entry.display_name(path_style).len(), + 0, + ) + } + } + + fn width_estimate_for_list_entry( + &self, + tree_view: bool, + entry: &GitListEntry, + path_style: PathStyle, + ) -> Option { + match entry { + GitListEntry::Status(status) => Some(Self::status_width_estimate( + tree_view, status, path_style, 0, + )), + GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate( + tree_view, + &status.entry, + path_style, + status.depth, + )), + GitListEntry::Directory(dir) => { + Some(Self::item_width_estimate(0, dir.name.len(), dir.depth)) + } + GitListEntry::Header(_) => None, + } + } + + fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize { + path + file_name + depth * 2 } fn render_overflow_menu(&self, id: impl Into) -> impl IntoElement { @@ -2975,6 +3668,7 @@ impl GitPanel { has_new_changes, sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, has_stash_items, + tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, cx, @@ -3020,13 +3714,12 @@ impl GitPanel { IconButton::new("generate-commit-message", IconName::AiEdit) .shape(ui::IconButtonShape::Square) .icon_color(Color::Muted) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if can_commit { Tooltip::for_action_in( "Generate Commit Message", &git::GenerateCommitMessage, &editor_focus_handle, - window, cx, ) } else { @@ -3203,23 +3896,17 @@ impl GitPanel { ) -> Option { self.active_repository.as_ref()?; - let text; - let action; - let tooltip; - if self.total_staged_count() == self.entry_count && self.entry_count > 0 { - text = "Unstage All"; - action = git::UnstageAll.boxed_clone(); - tooltip = "git reset"; - } else { - text = "Stage All"; - action = git::StageAll.boxed_clone(); - tooltip = "git add --all ." - } + let (text, action, stage, tooltip) = + if self.total_staged_count() == self.entry_count && self.entry_count > 0 { + ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") + } else { + ("Stage All", StageAll.boxed_clone(), true, "git add --all") + }; - let change_string = match self.entry_count { + let change_string = match self.changes_count { 0 => "No Changes".to_string(), 1 => "1 Change".to_string(), - _ => format!("{} Changes", self.entry_count), + count => format!("{} Changes", count), }; Some( @@ -3252,11 +3939,15 @@ impl GitPanel { &self.focus_handle, )) .disabled(self.entry_count == 0) - .on_click(move |_, _, cx| { - let action = action.boxed_clone(); - cx.defer(move |cx| { - cx.dispatch_action(action.as_ref()); - }) + .on_click({ + let git_panel = cx.weak_entity(); + move |_, _, cx| { + git_panel + .update(cx, |git_panel, cx| { + git_panel.change_all_files_stage(stage, cx); + }) + .ok(); + } }), ), ), @@ -3293,7 +3984,6 @@ impl GitPanel { ) -> Option { let active_repository = self.active_repository.clone()?; let panel_editor_style = panel_editor_style(true, window, cx); - let enable_coauthors = self.render_co_authors(cx); let editor_focus_handle = self.commit_editor.focus_handle(cx); @@ -3388,12 +4078,11 @@ impl GitPanel { panel_icon_button("expand-commit-editor", IconName::Maximize) .icon_size(IconSize::Small) .size(ui::ButtonSize::Default) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::for_action_in( "Open Commit Modal", &git::ExpandCommitEditor, &expand_tooltip_focus_handle, - window, cx, ) }) @@ -3419,6 +4108,12 @@ impl GitPanel { let amend = self.amend_pending(); let signoff = self.signoff_enabled; + let label_color = if self.pending_commit.is_some() { + Color::Disabled + } else { + Color::Default + }; + div() .id("commit-wrapper") .on_hover(cx.listener(move |this, hovered, _, cx| { @@ -3427,14 +4122,15 @@ impl GitPanel { cx.notify() })) .child(SplitButton::new( - ui::ButtonLike::new_rounded_left(ElementId::Name( + ButtonLike::new_rounded_left(ElementId::Name( format!("split-button-left-{}", title).into(), )) - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::Compact) + .layer(ElevationIndex::ModalSurface) + .size(ButtonSize::Compact) .child( - div() - .child(Label::new(title).size(LabelSize::Small)) + Label::new(title) + .size(LabelSize::Small) + .color(label_color) .mr_0p5(), ) .on_click({ @@ -3455,7 +4151,7 @@ impl GitPanel { .disabled(!can_commit || self.modal_open) .tooltip({ let handle = commit_tooltip_focus_handle.clone(); - move |window, cx| { + move |_window, cx| { if can_commit { Tooltip::with_meta_in( tooltip, @@ -3466,7 +4162,6 @@ impl GitPanel { if signoff { " --signoff" } else { "" } ), &handle.clone(), - window, cx, ) } else { @@ -3526,7 +4221,7 @@ impl GitPanel { .border_color(cx.theme().colors().border.opacity(0.8)) .child( div() - .flex_grow() + .cursor_pointer() .overflow_hidden() .line_clamp(1) .child( @@ -3540,9 +4235,11 @@ impl GitPanel { let repo = active_repository.downgrade(); move |_, window, cx| { CommitView::open( - commit.clone(), + commit.sha.to_string(), repo.clone(), workspace.clone(), + None, + None, window, cx, ); @@ -3568,7 +4265,7 @@ impl GitPanel { panel_icon_button("undo", IconName::Undo) .icon_size(IconSize::XSmall) .icon_color(Color::Muted) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( "Uncommit", Some(&git::Uncommit), @@ -3577,7 +4274,6 @@ impl GitPanel { } else { "git reset HEAD^" }, - window, cx, ) }) @@ -3632,12 +4328,23 @@ impl GitPanel { let repo = self.active_repository.as_ref()?.read(cx); let project_path = (file.worktree_id(cx), file.path().clone()).into(); let repo_path = repo.project_path_to_repo_path(&project_path, cx)?; - let ix = self.entry_by_path(&repo_path, cx)?; + let ix = self.entry_by_path(&repo_path)?; let entry = self.entries.get(ix)?; - let entry_staging = self.entry_staging(entry.status_entry()?); + let is_staging_or_staged = repo + .pending_ops_for_path(&repo_path) + .map(|ops| ops.staging() || ops.staged()) + .or_else(|| { + repo.status_for_path(&repo_path) + .and_then(|status| status.status.staging().as_bool()) + }) + .or_else(|| { + entry + .status_entry() + .and_then(|entry| entry.staging.as_bool()) + }); - let checkbox = Checkbox::new("stage-file", entry_staging.as_bool().into()) + let checkbox = Checkbox::new("stage-file", is_staging_or_staged.into()) .disabled(!self.has_write_access(cx)) .fill() .elevation(ElevationIndex::Surface) @@ -3672,7 +4379,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let entry_count = self.entries.len(); + let (is_tree_view, entry_count) = match &self.view_mode { + GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()), + GitPanelViewMode::Flat => (false, self.entries.len()), + }; v_flex() .flex_1() @@ -3692,10 +4402,33 @@ impl GitPanel { cx.processor(move |this, range: Range, window, cx| { let mut items = Vec::with_capacity(range.end - range.start); - for ix in range { + for ix in range.into_iter().map(|ix| match &this.view_mode { + GitPanelViewMode::Tree(state) => state.logical_indices[ix], + GitPanelViewMode::Flat => ix, + }) { match &this.entries.get(ix) { Some(GitListEntry::Status(entry)) => { - items.push(this.render_entry( + items.push(this.render_status_entry( + ix, + entry, + 0, + has_write_access, + window, + cx, + )); + } + Some(GitListEntry::TreeStatus(entry)) => { + items.push(this.render_status_entry( + ix, + &entry.entry, + entry.depth, + has_write_access, + window, + cx, + )); + } + Some(GitListEntry::Directory(entry)) => { + items.push(this.render_directory_entry( ix, entry, has_write_access, @@ -3719,6 +4452,51 @@ impl GitPanel { items }), ) + .when(is_tree_view, |list| { + let indent_size = px(TREE_INDENT); + list.with_decoration( + ui::indent_guides(indent_size, IndentGuideColors::panel(cx)) + .with_compute_indents_fn( + cx.entity(), + |this, range, _window, _cx| { + range + .map(|ix| match this.entries.get(ix) { + Some(GitListEntry::Directory(dir)) => dir.depth, + Some(GitListEntry::TreeStatus(status)) => { + status.depth + } + _ => 0, + }) + .collect() + }, + ) + .with_render_fn(cx.entity(), |_, params, _, _| { + let left_offset = px(TREE_INDENT_GUIDE_OFFSET); + let indent_size = params.indent_size; + let item_height = params.item_height; + + params + .indent_guides + .into_iter() + .map(|layout| { + let bounds = Bounds::new( + point( + layout.offset.x * indent_size + left_offset, + layout.offset.y * item_height, + ), + size(px(1.), layout.length * item_height), + ); + RenderedIndentGuide { + bounds, + layout, + is_active: false, + hitbox: None, + } + }) + .collect() + }), + ) + }) .size_full() .flex_grow() .with_sizing_behavior(ListSizingBehavior::Auto) @@ -3726,7 +4504,7 @@ impl GitPanel { ListHorizontalSizingBehavior::Unconstrained, ) .with_width_from_item(self.max_width_item_index) - .track_scroll(self.scroll_handle.clone()), + .track_scroll(&self.scroll_handle), ) .on_mouse_down( MouseButton::Right, @@ -3736,7 +4514,7 @@ impl GitPanel { ) .custom_scrollbars( Scrollbars::for_settings::() - .tracked_scroll_handle(self.scroll_handle.clone()) + .tracked_scroll_handle(&self.scroll_handle) .with_track_along( ScrollAxes::Horizontal, cx.theme().colors().panel_background, @@ -3817,13 +4595,21 @@ impl GitPanel { "Restore File" }; let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| { + let is_created = entry.status.is_created(); context_menu .context(self.focus_handle.clone()) .action(stage_title, ToggleStaged.boxed_clone()) .action(restore_title, git::RestoreFile::default().boxed_clone()) + .action_disabled_when( + !is_created, + "Add to .gitignore", + git::AddToGitignore.boxed_clone(), + ) .separator() .action("Open Diff", Confirm.boxed_clone()) .action("Open File", SecondaryConfirm.boxed_clone()) + .separator() + .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory)) }); self.selected_entry = Some(ix); self.set_context_menu(context_menu, position, window, cx); @@ -3844,6 +4630,7 @@ impl GitPanel { has_new_changes: self.new_count > 0, sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, has_stash_items: self.stash_entries.entries.len() > 0, + tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, cx, @@ -3875,15 +4662,18 @@ impl GitPanel { cx.notify(); } - fn render_entry( + fn render_status_entry( &self, ix: usize, entry: &GitStatusEntry, + depth: usize, has_write_access: bool, window: &Window, cx: &Context, ) -> AnyElement { + let tree_view = GitPanelSettings::get_global(cx).tree_view; let path_style = self.project.read(cx).path_style(cx); + let git_path_style = ProjectSettings::get_global(cx).git.path_style; let display_name = entry.display_name(path_style); let selected = self.selected_entry == Some(ix); @@ -3894,10 +4684,13 @@ impl GitPanel { let has_conflict = status.is_conflicted(); let is_modified = status.is_modified(); let is_deleted = status.is_deleted(); + let is_created = status.is_created(); let label_color = if status_style == StatusStyle::LabelColor { if has_conflict { Color::VersionControlConflict + } else if is_created { + Color::VersionControlAdded } else if is_modified { Color::VersionControlModified } else if is_deleted { @@ -3922,8 +4715,18 @@ impl GitPanel { let checkbox_id: ElementId = ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into()); - let entry_staging = self.entry_staging(entry); - let mut is_staged: ToggleState = self.entry_staging(entry).as_bool().into(); + let active_repo = self + .project + .read(cx) + .active_repository(cx) + .expect("active repository must be set"); + let repo = active_repo.read(cx); + let stage_status = GitPanel::stage_status_for_entry(entry, &repo); + let mut is_staged: ToggleState = match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + }; if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { is_staged = ToggleState::Selected; } @@ -3963,14 +4766,46 @@ impl GitPanel { cx.theme().colors().ghost_element_active }; + let mut name_row = h_flex() + .items_center() + .gap_1() + .flex_1() + .pl(if tree_view { + px(depth as f32 * TREE_INDENT) + } else { + px(0.) + }) + .child(git_status_icon(status)); + + name_row = if tree_view { + name_row.child( + self.entry_label(display_name, label_color) + .when(status.is_deleted(), Label::strikethrough) + .truncate(), + ) + } else { + name_row.child(h_flex().items_center().flex_1().map(|this| { + self.path_formatted( + this, + entry.parent_dir(path_style), + path_color, + display_name, + label_color, + path_style, + git_path_style, + status.is_deleted(), + ) + })) + }; + h_flex() .id(id) .h(self.list_item_height()) .w_full() - .items_center() .border_1() + .border_r_2() .when(selected && self.focus_handle.is_focused(window), |el| { - el.border_color(cx.theme().colors().border_focused) + el.border_color(cx.theme().colors().panel_focused_border) }) .px(rems(0.75)) // ~12px .overflow_hidden() @@ -4008,6 +4843,7 @@ impl GitPanel { cx.stop_propagation(); }, ) + .child(name_row) .child( div() .id(checkbox_wrapper_id) @@ -4030,54 +4866,230 @@ impl GitPanel { if click.modifiers().shift { this.stage_bulk(ix, cx); } else { - this.toggle_staged_for_entry( - &GitListEntry::Status(entry.clone()), - window, - cx, - ); + let list_entry = + if GitPanelSettings::get_global(cx).tree_view { + GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: entry.clone(), + depth, + }) + } else { + GitListEntry::Status(entry.clone()) + }; + this.toggle_staged_for_entry(&list_entry, window, cx); } cx.stop_propagation(); }) .ok(); } }) - .tooltip(move |window, cx| { - let is_staged = entry_staging.is_fully_staged(); - - let action = if is_staged { "Unstage" } else { "Stage" }; + .tooltip(move |_window, cx| { + let action = match stage_status { + StageStatus::Staged => "Unstage", + StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", + }; let tooltip_name = action.to_string(); - Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx) + Tooltip::for_action(tooltip_name, &ToggleStaged, cx) }), ), ) - .child(git_status_icon(status)) + .into_any_element() + } + + fn render_directory_entry( + &self, + ix: usize, + entry: &GitTreeDirEntry, + has_write_access: bool, + window: &Window, + cx: &Context, + ) -> AnyElement { + // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that + let selected = self.selected_entry == Some(ix); + let label_color = Color::Muted; + + let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into()); + let checkbox_id: ElementId = + ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into()); + let checkbox_wrapper_id: ElementId = + ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into()); + + let selected_bg_alpha = 0.08; + let state_opacity_step = 0.04; + + let base_bg = if selected { + cx.theme().status().info.alpha(selected_bg_alpha) + } else { + cx.theme().colors().ghost_element_background + }; + + let hover_bg = if selected { + cx.theme() + .status() + .info + .alpha(selected_bg_alpha + state_opacity_step) + } else { + cx.theme().colors().ghost_element_hover + }; + + let active_bg = if selected { + cx.theme() + .status() + .info + .alpha(selected_bg_alpha + state_opacity_step * 2.0) + } else { + cx.theme().colors().ghost_element_active + }; + let folder_icon = if entry.expanded { + IconName::FolderOpen + } else { + IconName::Folder + }; + + let stage_status = if let Some(repo) = &self.active_repository { + self.stage_status_for_directory(entry, repo.read(cx)) + } else { + util::debug_panic!( + "Won't have entries to render without an active repository in Git Panel" + ); + StageStatus::PartiallyStaged + }; + + let toggle_state: ToggleState = match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + }; + + let name_row = h_flex() + .items_center() + .gap_1() + .flex_1() + .pl(px(entry.depth as f32 * TREE_INDENT)) .child( - h_flex() - .items_center() - .flex_1() - // .overflow_hidden() - .when_some(entry.parent_dir(path_style), |this, parent| { - if !parent.is_empty() { - this.child( - self.entry_label( - format!("{parent}{}", path_style.separator()), - path_color, - ) - .when(status.is_deleted(), |this| this.strikethrough()), - ) - } else { - this - } - }) + Icon::new(folder_icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(self.entry_label(entry.name.clone(), label_color).truncate()); + + h_flex() + .id(id) + .h(self.list_item_height()) + .w_full() + .items_center() + .border_1() + .border_r_2() + .when(selected && self.focus_handle.is_focused(window), |el| { + el.border_color(cx.theme().colors().panel_focused_border) + }) + .px(rems(0.75)) + .overflow_hidden() + .flex_none() + .gap_1p5() + .bg(base_bg) + .hover(|this| this.bg(hover_bg)) + .active(|this| this.bg(active_bg)) + .on_click({ + let key = entry.key.clone(); + cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.selected_entry = Some(ix); + this.toggle_directory(&key, window, cx); + }) + }) + .child(name_row) + .child( + div() + .id(checkbox_wrapper_id) + .flex_none() + .occlude() + .cursor_pointer() .child( - self.entry_label(display_name, label_color) - .when(status.is_deleted(), |this| this.strikethrough()), + Checkbox::new(checkbox_id, toggle_state) + .disabled(!has_write_access) + .fill() + .elevation(ElevationIndex::Surface) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + this.toggle_staged_for_entry( + &GitListEntry::Directory(entry.clone()), + window, + cx, + ); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| { + let action = match stage_status { + StageStatus::Staged => "Unstage", + StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", + }; + Tooltip::simple(format!("{action} folder"), cx) + }), ), ) .into_any_element() } + fn path_formatted( + &self, + parent: Div, + directory: Option, + path_color: Color, + file_name: String, + label_color: Color, + path_style: PathStyle, + git_path_style: GitPathStyle, + strikethrough: bool, + ) -> Div { + parent + .when(git_path_style == GitPathStyle::FileNameFirst, |this| { + this.child( + self.entry_label( + match directory.as_ref().is_none_or(|d| d.is_empty()) { + true => file_name.clone(), + false => format!("{file_name} "), + }, + label_color, + ) + .when(strikethrough, Label::strikethrough), + ) + }) + .when_some(directory, |this, dir| { + match ( + !dir.is_empty(), + git_path_style == GitPathStyle::FileNameFirst, + ) { + (true, true) => this.child( + self.entry_label(dir, path_color) + .when(strikethrough, Label::strikethrough), + ), + (true, false) => this.child( + self.entry_label( + format!("{dir}{}", path_style.primary_separator()), + path_color, + ) + .when(strikethrough, Label::strikethrough), + ), + _ => this, + } + }) + .when(git_path_style == GitPathStyle::FilePathFirst, |this| { + this.child( + self.entry_label(file_name, label_color) + .when(strikethrough, Label::strikethrough), + ) + }) + } + fn has_write_access(&self, cx: &App) -> bool { !self.project.read(cx).is_read_only(cx) } @@ -4086,6 +5098,9 @@ impl GitPanel { self.amend_pending } + /// Sets the pending amend state, ensuring that the original commit message + /// is either saved, when `value` is `true` and there's no pending amend, or + /// restored, when `value` is `false` and there's a pending amend. pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context) { if value && !self.amend_pending { let current_message = self.commit_message_buffer(cx).read(cx).text(); @@ -4169,7 +5184,7 @@ impl GitPanel { let Some(op) = self.bulk_staging.as_ref() else { return; }; - let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else { + let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else { return; }; if let Some(entry) = self.entries.get(index) @@ -4203,7 +5218,7 @@ impl GitPanel { pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context) { self.set_amend_pending(!self.amend_pending, cx); if self.amend_pending { - self.load_last_commit_message_if_empty(cx); + self.load_last_commit_message(cx); } } } @@ -4234,8 +5249,8 @@ impl Render for GitPanel { .when(has_write_access && !project.is_read_only(cx), |this| { this.on_action(cx.listener(Self::toggle_staged_for_selected)) .on_action(cx.listener(Self::stage_range)) - .on_action(cx.listener(GitPanel::commit)) - .on_action(cx.listener(GitPanel::amend)) + .on_action(cx.listener(GitPanel::on_commit)) + .on_action(cx.listener(GitPanel::on_amend)) .on_action(cx.listener(GitPanel::toggle_signoff_enabled)) .on_action(cx.listener(Self::stage_all)) .on_action(cx.listener(Self::unstage_all)) @@ -4243,6 +5258,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::unstage_selected)) .on_action(cx.listener(Self::restore_tracked_files)) .on_action(cx.listener(Self::revert_selected)) + .on_action(cx.listener(Self::add_to_gitignore)) .on_action(cx.listener(Self::clean_all)) .on_action(cx.listener(Self::generate_commit_message_action)) .on_action(cx.listener(Self::stash_all)) @@ -4255,6 +5271,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::close_panel)) .on_action(cx.listener(Self::open_diff)) .on_action(cx.listener(Self::open_file)) + .on_action(cx.listener(Self::file_history)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::expand_commit_editor)) @@ -4262,6 +5279,7 @@ impl Render for GitPanel { git_panel.on_action(cx.listener(Self::toggle_fill_co_authors)) }) .on_action(cx.listener(Self::toggle_sort_by_path)) + .on_action(cx.listener(Self::toggle_tree_view)) .size_full() .overflow_hidden() .bg(cx.theme().colors().panel_background) @@ -4340,6 +5358,10 @@ impl Panel for GitPanel { "GitPanel" } + fn panel_key() -> &'static str { + GIT_PANEL_KEY + } + fn position(&self, _: &Window, cx: &App) -> DockPosition { GitPanelSettings::get_global(cx).dock } @@ -4501,7 +5523,6 @@ impl RenderOnce for PanelRepoFooter { const MAX_REPO_LEN: usize = 16; const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN; const MAX_SHORT_SHA_LEN: usize = 8; - let branch_name = self .branch .as_ref() @@ -4882,6 +5903,63 @@ impl Component for PanelRepoFooter { } } +fn open_output( + operation: impl Into, + workspace: &mut Workspace, + output: &str, + window: &mut Window, + cx: &mut Context, +) { + let operation = operation.into(); + let buffer = cx.new(|cx| Buffer::local(output, cx)); + buffer.update(cx, |buffer, cx| { + buffer.set_capability(language::Capability::ReadOnly, cx); + }); + let editor = cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer, None, window, cx); + editor.buffer().update(cx, |buffer, cx| { + buffer.set_title(format!("Output from git {operation}"), cx); + }); + editor.set_read_only(true); + editor + }); + + workspace.add_item_to_center(Box::new(editor), window, cx); +} + +pub(crate) fn show_error_toast( + workspace: Entity, + action: impl Into, + e: anyhow::Error, + cx: &mut App, +) { + let action = action.into(); + let message = e.to_string().trim().to_string(); + if message + .matches(git::repository::REMOTE_CANCELLED_BY_USER) + .next() + .is_some() + { // Hide the cancelled by user message + } else { + workspace.update(cx, |workspace, cx| { + let workspace_weak = cx.weak_entity(); + let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| { + this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) + .action("View Log", move |window, cx| { + let message = message.clone(); + let action = action.clone(); + workspace_weak + .update(cx, move |workspace, cx| { + open_output(action, workspace, &message, window, cx) + }) + .ok(); + }) + }); + workspace.toggle_status_toast(toast, cx) + }); + } +} + #[cfg(test)] mod tests { use git::{ @@ -4889,11 +5967,13 @@ mod tests { status::{StatusCode, UnmergedStatus, UnmergedStatusCode}, }; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; - use project::{FakeFs, WorktreeSettings}; + use indoc::indoc; + use project::FakeFs; use serde_json::json; use settings::SettingsStore; use theme::LoadThemes; use util::path; + use util::rel_path::rel_path; use super::*; @@ -4903,13 +5983,8 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - AgentSettings::register(cx); - WorktreeSettings::register(cx); - workspace::init_settings(cx); theme::init(LoadThemes::JustBase, cx); - language::init(cx); editor::init(cx); - Project::init_settings(cx); crate::init(cx); }); } @@ -5516,13 +6591,163 @@ mod tests { }); } + #[gpui::test] + async fn test_amend(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + } + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/project/.git")), + &[("src/main.rs", StatusCode::Modified.worktree())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Wait for the project scanning to finish so that `head_commit(cx)` is + // actually set, otherwise no head commit would be available from which + // to fetch the latest commit message from. + cx.executor().run_until_parked(); + + let panel = workspace.update(cx, GitPanel::new).unwrap(); + panel.read_with(cx, |panel, cx| { + assert!(panel.active_repository.is_some()); + assert!(panel.head_commit(cx).is_some()); + }); + + panel.update_in(cx, |panel, window, cx| { + // Update the commit editor's message to ensure that its contents + // are later restored, after amending is finished. + panel.commit_message_buffer(cx).update(cx, |buffer, cx| { + buffer.set_text("refactor: update main.rs", cx); + }); + + // Start amending the previous commit. + panel.focus_editor(&Default::default(), window, cx); + panel.on_amend(&Amend, window, cx); + }); + + // Since `GitPanel.amend` attempts to fetch the latest commit message in + // a background task, we need to wait for it to complete before being + // able to assert that the commit message editor's state has been + // updated. + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + "initial commit" + ); + assert_eq!( + panel.original_commit_message, + Some("refactor: update main.rs".to_string()) + ); + + // Finish amending the previous commit. + panel.focus_editor(&Default::default(), window, cx); + panel.on_amend(&Amend, window, cx); + }); + + // Since the actual commit logic is run in a background task, we need to + // await its completion to actually ensure that the commit message + // editor's contents are set to the original message and haven't been + // cleared. + cx.run_until_parked(); + + panel.update_in(cx, |panel, _window, cx| { + // After amending, the commit editor's message should be restored to + // the original message. + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + "refactor: update main.rs" + ); + assert!(panel.original_commit_message.is_none()); + }); + } + + #[gpui::test] + async fn test_open_diff(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "tracked": "tracked\n", + "untracked": "\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[("tracked", "old tracked\n".into())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, GitPanel::new).unwrap(); + + // Enable the `sort_by_path` setting and wait for entries to be updated, + // as there should no longer be separators between Tracked and Untracked + // files. + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + }) + }); + }); + + cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }) + .await; + + // Confirm that `Open Diff` still works for the untracked file, updating + // the Project Diff's active path. + panel.update_in(cx, |panel, window, cx| { + panel.selected_entry = Some(1); + panel.open_diff(&Confirm, window, cx); + }); + cx.run_until_parked(); + + let _ = workspace.update(cx, |workspace, _window, cx| { + let active_path = workspace + .item_of_type::(cx) + .expect("ProjectDiff should exist") + .read(cx) + .active_path(cx) + .expect("active_path should exist"); + + assert_eq!(active_path.path, rel_path("untracked").into_arc()); + }); + } + fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) { assert_eq!(entries.len(), expected_paths.len()); for (entry, expected_path) in entries.iter().zip(expected_paths) { assert_eq!( entry.status_entry().map(|status| status .repo_path - .0 + .as_ref() .as_std_path() .to_string_lossy() .to_string()), @@ -5530,4 +6755,257 @@ mod tests { ); } } + + #[test] + fn test_compress_diff_no_truncation() { + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,2 +1,2 @@ + -old + +new + "}; + let result = GitPanel::compress_commit_diff(diff, 1000); + assert_eq!(result, diff); + } + + #[test] + fn test_compress_diff_truncate_long_lines() { + let long_line = "🦀".repeat(300); + let diff = indoc::formatdoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,2 +1,3 @@ + context + +{} + more context + ", long_line}; + let result = GitPanel::compress_commit_diff(&diff, 100); + assert!(result.contains("...[truncated]")); + assert!(result.len() < diff.len()); + } + + #[test] + fn test_compress_diff_truncate_hunks() { + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,2 +1,2 @@ + context + -old1 + +new1 + @@ -5,2 +5,2 @@ + context 2 + -old2 + +new2 + @@ -10,2 +10,2 @@ + context 3 + -old3 + +new3 + "}; + let result = GitPanel::compress_commit_diff(diff, 100); + let expected = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,2 +1,2 @@ + context + -old1 + +new1 + [...skipped 2 hunks...] + "}; + assert_eq!(result, expected); + } + + #[gpui::test] + async fn test_suggest_commit_message(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "tracked": "tracked\n", + "untracked": "\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[("tracked", "old tracked\n".into())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, GitPanel::new).unwrap(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + let entries = panel.read_with(cx, |panel, _| panel.entries.clone()); + + // GitPanel + // - Tracked: + // - [] tracked + // - Untracked + // - [] untracked + // + // The commit message should now read: + // "Update tracked" + let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); + assert_eq!(message, Some("Update tracked".to_string())); + + let first_status_entry = entries[1].clone(); + panel.update_in(cx, |panel, window, cx| { + panel.toggle_staged_for_entry(&first_status_entry, window, cx); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + // GitPanel + // - Tracked: + // - [x] tracked + // - Untracked + // - [] untracked + // + // The commit message should still read: + // "Update tracked" + let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); + assert_eq!(message, Some("Update tracked".to_string())); + + let second_status_entry = entries[3].clone(); + panel.update_in(cx, |panel, window, cx| { + panel.toggle_staged_for_entry(&second_status_entry, window, cx); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + // GitPanel + // - Tracked: + // - [x] tracked + // - Untracked + // - [x] untracked + // + // The commit message should now read: + // "Enter commit message" + // (which means we should see None returned). + let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); + assert!(message.is_none()); + + panel.update_in(cx, |panel, window, cx| { + panel.toggle_staged_for_entry(&first_status_entry, window, cx); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + // GitPanel + // - Tracked: + // - [] tracked + // - Untracked + // - [x] untracked + // + // The commit message should now read: + // "Update untracked" + let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); + assert_eq!(message, Some("Create untracked".to_string())); + + panel.update_in(cx, |panel, window, cx| { + panel.toggle_staged_for_entry(&second_status_entry, window, cx); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + // GitPanel + // - Tracked: + // - [] tracked + // - Untracked + // - [] untracked + // + // The commit message should now read: + // "Update tracked" + let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); + assert_eq!(message, Some("Update tracked".to_string())); + } } diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 342b0105cd..6b5334e555 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -2,7 +2,7 @@ use editor::EditorSettings; use gpui::Pixels; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsContent, StatusStyle}; +use settings::{RegisterSetting, Settings, StatusStyle}; use ui::{ px, scrollbars::{ScrollbarVisibility, ShowScrollbar}, @@ -14,7 +14,7 @@ pub struct ScrollbarSettings { pub show: Option, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, RegisterSetting)] pub struct GitPanelSettings { pub button: bool, pub dock: DockPosition, @@ -24,6 +24,7 @@ pub struct GitPanelSettings { pub fallback_branch_name: String, pub sort_by_path: bool, pub collapse_untracked_diff: bool, + pub tree_view: bool, } impl ScrollbarVisibility for GitPanelSettings { @@ -43,7 +44,7 @@ impl ScrollbarVisibility for GitPanelSettings { } impl Settings for GitPanelSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut ui::App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let git_panel = content.git_panel.clone().unwrap(); Self { button: git_panel.button.unwrap(), @@ -56,18 +57,7 @@ impl Settings for GitPanelSettings { fallback_branch_name: git_panel.fallback_branch_name.unwrap(), sort_by_path: git_panel.sort_by_path.unwrap(), collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), - } - } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut SettingsContent) { - if let Some(git_enabled) = vscode.read_bool("git.enabled") { - current.git_panel.get_or_insert_default().button = Some(git_enabled); - } - if let Some(default_branch) = vscode.read_string("git.defaultBranchName") { - current - .git_panel - .get_or_insert_default() - .fallback_branch_name = Some(default_branch.to_string()); + tree_view: git_panel.tree_view.unwrap(), } } } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index da2e2ca032..54adc8130d 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -1,9 +1,9 @@ use std::any::Any; -use ::settings::Settings; use command_palette_hooks::CommandPaletteFilter; use commit_modal::CommitModal; use editor::{Editor, actions::DiffClipboardWithSelectionData}; +use project::ProjectPath; use ui::{ Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled, StyledExt, div, h_flex, rems, v_flex, @@ -15,7 +15,6 @@ use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, }; -use git_panel_settings::GitPanelSettings; use gpui::{ Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString, Window, actions, @@ -34,9 +33,10 @@ mod askpass_modal; pub mod branch_picker; mod commit_modal; pub mod commit_tooltip; -mod commit_view; +pub mod commit_view; mod conflict_view; pub mod file_diff_view; +pub mod file_history_view; pub mod git_panel; mod git_panel_settings; pub mod onboarding; @@ -46,6 +46,7 @@ pub(crate) mod remote_output; pub mod repository_selector; pub mod stash_picker; pub mod text_diff_view; +pub mod worktree_picker; actions!( git, @@ -56,9 +57,9 @@ actions!( ); pub fn init(cx: &mut App) { - GitPanelSettings::register(cx); - editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx); + commit_view::init(cx); + file_history_view::init(cx); cx.observe_new(|editor: &mut Editor, _, cx| { conflict_view::register_editor(editor, editor.buffer().clone(), cx); @@ -71,6 +72,7 @@ pub fn init(cx: &mut App) { git_panel::register(workspace); repository_selector::register(workspace); branch_picker::register(workspace); + worktree_picker::register(workspace); stash_picker::register(workspace); let project = workspace.project().read(cx); @@ -123,7 +125,15 @@ pub fn init(cx: &mut App) { return; }; panel.update(cx, |panel, cx| { - panel.pull(window, cx); + panel.pull(false, window, cx); + }); + }); + workspace.register_action(|workspace, _: &git::PullRebase, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.pull(true, window, cx); }); }); } @@ -220,6 +230,41 @@ pub fn init(cx: &mut App) { }; }, ); + workspace.register_action(|workspace, _: &git::FileHistory, window, cx| { + let Some(active_item) = workspace.active_item(cx) else { + return; + }; + let Some(editor) = active_item.downcast::() else { + return; + }; + let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { + return; + }; + let Some(file) = buffer.read(cx).file() else { + return; + }; + let worktree_id = file.worktree_id(cx); + let project_path = ProjectPath { + worktree_id, + path: file.path().clone(), + }; + let project = workspace.project(); + let git_store = project.read(cx).git_store(); + let Some((repo, repo_path)) = git_store + .read(cx) + .repository_and_path_for_project_path(&project_path, cx) + else { + return; + }; + file_history_view::FileHistoryView::open( + repo_path, + git_store.downgrade(), + repo.downgrade(), + workspace.weak_handle(), + window, + cx, + ); + }); }) .detach(); } @@ -434,13 +479,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Fetch), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Fetch updates from remote", &git::Fetch, "git fetch", keybinding_target.clone(), - window, cx, ) }, @@ -462,13 +506,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Push committed changes to remote", &git::Push, "git push", keybinding_target.clone(), - window, cx, ) }, @@ -491,13 +534,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Pull), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Pull", &git::Pull, "git pull", keybinding_target.clone(), - window, cx, ) }, @@ -518,13 +560,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Publish branch to remote", &git::Push, "git push --set-upstream", keybinding_target.clone(), - window, cx, ) }, @@ -545,13 +586,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Re-publish branch to remote", &git::Push, "git push --set-upstream", keybinding_target.clone(), - window, cx, ) }, @@ -563,16 +603,15 @@ mod remote_button { action: &dyn Action, command: impl Into, focus_handle: Option, - window: &mut Window, cx: &mut App, ) -> AnyView { let label = label.into(); let command = command.into(); if let Some(handle) = focus_handle { - Tooltip::with_meta_in(label, Some(action), command, &handle, window, cx) + Tooltip::with_meta_in(label, Some(action), command, &handle, cx) } else { - Tooltip::with_meta(label, Some(action), command, window, cx) + Tooltip::with_meta(label, Some(action), command, cx) } } @@ -600,6 +639,7 @@ mod remote_button { .action("Fetch", git::Fetch.boxed_clone()) .action("Fetch From", git::FetchFrom.boxed_clone()) .action("Pull", git::Pull.boxed_clone()) + .action("Pull (Rebase)", git::PullRebase.boxed_clone()) .separator() .action("Push", git::Push.boxed_clone()) .action("Push To", git::PushTo.boxed_clone()) diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 9997b0590c..14daedda61 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -220,7 +220,7 @@ impl PickerDelegate for PickerPromptDelegate { let shortened_option = util::truncate_and_trailoff(&hit.string, self.max_match_length); Some( - ListItem::new(SharedString::from(format!("picker-prompt-menu-{ix}"))) + ListItem::new(format!("picker-prompt-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) @@ -228,7 +228,7 @@ impl PickerDelegate for PickerPromptDelegate { let highlights: Vec<_> = hit .positions .iter() - .filter(|index| index < &&self.max_match_length) + .filter(|&&index| index < self.max_match_length) .copied() .collect(); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 0e59d25222..4d7a27354b 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -4,43 +4,48 @@ use crate::{ git_panel_settings::GitPanelSettings, remote_button::{render_publish_button, render_push_button}, }; -use anyhow::Result; +use anyhow::{Context as _, Result, anyhow}; use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus}; -use collections::HashSet; +use collections::{HashMap, HashSet}; use editor::{ - Editor, EditorEvent, SelectionEffects, + Addon, Editor, EditorEvent, SelectionEffects, SplittableEditor, actions::{GoToHunk, GoToPreviousHunk}, multibuffer_context_lines, scroll::Autoscroll, }; -use futures::StreamExt; use git::{ Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, - repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, + repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::FileStatus, }; use gpui::{ - Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, + Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions, }; use language::{Anchor, Buffer, Capability, OffsetRangeExt}; use multi_buffer::{MultiBuffer, PathKey}; use project::{ Project, ProjectPath, - git_store::{GitStore, GitStoreEvent}, + git_store::{ + Repository, + branch_diff::{self, BranchDiffEvent, DiffBase}, + }, }; use settings::{Settings, SettingsStore}; +use smol::future::yield_now; use std::any::{Any, TypeId}; -use std::ops::Range; +use std::sync::Arc; use theme::ActiveTheme; use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider}; -use util::ResultExt as _; +use util::{ResultExt as _, rel_path::RelPath}; use workspace::{ CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, + notifications::NotifyTaskExt, searchable::SearchableItemHandle, }; +use ztracing::instrument; actions!( git, @@ -48,38 +53,42 @@ actions!( /// Shows the diff between the working directory and the index. Diff, /// Adds files to the git staging area. - Add + Add, + /// Shows the diff between the working directory and your default + /// branch (typically main or master). + BranchDiff, + LeaderAndFollower, ] ); pub struct ProjectDiff { project: Entity, multibuffer: Entity, - editor: Entity, - git_store: Entity, + branch_diff: Entity, + editor: Entity, + buffer_diff_subscriptions: HashMap, (Entity, Subscription)>, workspace: WeakEntity, focus_handle: FocusHandle, - update_needed: postage::watch::Sender<()>, pending_scroll: Option, _task: Task>, _subscription: Subscription, } -#[derive(Debug)] -struct DiffBuffer { - path_key: PathKey, - buffer: Entity, - diff: Entity, - file_status: FileStatus, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefreshReason { + DiffChanged, + StatusesChanged, + EditorSaved, } -const CONFLICT_NAMESPACE: u64 = 1; -const TRACKED_NAMESPACE: u64 = 2; -const NEW_NAMESPACE: u64 = 3; +const CONFLICT_SORT_PREFIX: u64 = 1; +const TRACKED_SORT_PREFIX: u64 = 2; +const NEW_SORT_PREFIX: u64 = 3; impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); + workspace.register_action(Self::deploy_branch_diff); workspace.register_action(|workspace, _: &Add, window, cx| { Self::deploy(workspace, &Diff, window, cx); }); @@ -95,6 +104,40 @@ impl ProjectDiff { Self::deploy_at(workspace, None, window, cx) } + fn deploy_branch_diff( + workspace: &mut Workspace, + _: &BranchDiff, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!("Git Branch Diff Opened"); + let project = workspace.project().clone(); + + let existing = workspace + .items_of_type::(cx) + .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. })); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + return; + } + let workspace = cx.entity(); + window + .spawn(cx, async move |cx| { + let this = cx + .update(|window, cx| { + Self::new_with_default_branch(project, workspace.clone(), window, cx) + })? + .await?; + workspace + .update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx); + }) + .ok(); + anyhow::Ok(()) + }) + .detach_and_notify_err(window, cx); + } + pub fn deploy_at( workspace: &mut Workspace, entry: Option, @@ -109,7 +152,14 @@ impl ProjectDiff { "Action" } ); - let project_diff = if let Some(existing) = workspace.item_of_type::(cx) { + let existing = workspace + .items_of_type::(cx) + .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let project_diff = if let Some(existing) = existing { + existing.update(cx, |project_diff, cx| { + project_diff.move_to_beginning(window, cx); + }); + workspace.activate_item(&existing, true, true, window, cx); existing } else { @@ -134,7 +184,40 @@ impl ProjectDiff { pub fn autoscroll(&self, cx: &mut Context) { self.editor.update(cx, |editor, cx| { - editor.request_autoscroll(Autoscroll::fit(), cx); + editor.primary_editor().update(cx, |editor, cx| { + editor.request_autoscroll(Autoscroll::fit(), cx); + }) + }) + } + + fn new_with_default_branch( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else { + return Task::ready(Err(anyhow!("No active repository"))); + }; + let main_branch = repo.update(cx, |repo, _| repo.default_branch()); + window.spawn(cx, async move |cx| { + let main_branch = main_branch + .await?? + .context("Could not determine default branch")?; + + let branch_diff = cx.new_window_entity(|window, cx| { + branch_diff::BranchDiff::new( + DiffBase::Merge { + base_ref: main_branch, + }, + project.clone(), + window, + cx, + ) + })?; + cx.new_window_entity(|window, cx| { + Self::new_impl(branch_diff, project, workspace, window, cx) + }) }) } @@ -143,113 +226,138 @@ impl ProjectDiff { workspace: Entity, window: &mut Window, cx: &mut Context, + ) -> Self { + let branch_diff = + cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx)); + Self::new_impl(branch_diff, project, workspace, window, cx) + } + + fn new_impl( + branch_diff: Entity, + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); - let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); + multibuffer.set_all_diff_hunks_expanded(cx); + multibuffer + }); let editor = cx.new(|cx| { - let mut diff_display_editor = - Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); - diff_display_editor.disable_diagnostics(cx); - diff_display_editor.set_expand_all_diff_hunks(cx); - diff_display_editor.register_addon(GitPanelAddon { - workspace: workspace.downgrade(), - }); + let diff_display_editor = SplittableEditor::new_unsplit( + multibuffer.clone(), + project.clone(), + workspace.clone(), + window, + cx, + ); diff_display_editor - }); - window.defer(cx, { - let workspace = workspace.clone(); - let editor = editor.clone(); - move |window, cx| { - workspace.update(cx, |workspace, cx| { - editor.update(cx, |editor, cx| { - editor.added_to_workspace(workspace, window, cx); - }) + .primary_editor() + .update(cx, |editor, cx| { + editor.disable_diagnostics(cx); + + match branch_diff.read(cx).diff_base() { + DiffBase::Head => { + editor.register_addon(GitPanelAddon { + workspace: workspace.downgrade(), + }); + } + DiffBase::Merge { .. } => { + editor.register_addon(BranchDiffAddon { + branch_diff: branch_diff.clone(), + }); + editor.start_temporary_diff_override(); + editor.set_render_diff_hunk_controls( + Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()), + cx, + ); + } + } }); - } + diff_display_editor }); cx.subscribe_in(&editor, window, Self::handle_editor_event) .detach(); - let git_store = project.read(cx).git_store().clone(); - let git_store_subscription = cx.subscribe_in( - &git_store, + let branch_diff_subscription = cx.subscribe_in( + &branch_diff, window, - move |this, _git_store, event, _window, _cx| match event { - GitStoreEvent::ActiveRepositoryChanged(_) - | GitStoreEvent::RepositoryUpdated(_, _, true) - | GitStoreEvent::ConflictsUpdated => { - *this.update_needed.borrow_mut() = (); + move |this, _git_store, event, window, cx| match event { + BranchDiffEvent::FileListChanged => { + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await + }) } - _ => {} }, ); let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; let mut was_collapse_untracked_diff = GitPanelSettings::get_global(cx).collapse_untracked_diff; - cx.observe_global::(move |this, cx| { + cx.observe_global_in::(window, move |this, window, cx| { let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; let is_collapse_untracked_diff = GitPanelSettings::get_global(cx).collapse_untracked_diff; if is_sort_by_path != was_sort_by_path || is_collapse_untracked_diff != was_collapse_untracked_diff { - *this.update_needed.borrow_mut() = (); + this._task = { + window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await + }) + } } was_sort_by_path = is_sort_by_path; was_collapse_untracked_diff = is_collapse_untracked_diff; }) .detach(); - let (mut send, recv) = postage::watch::channel::<()>(); - let worker = window.spawn(cx, { + let task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::handle_status_updates(this, recv, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }); - // Kick off a refresh immediately - *send.borrow_mut() = (); Self { project, - git_store: git_store.clone(), workspace: workspace.downgrade(), + branch_diff, focus_handle, editor, multibuffer, + buffer_diff_subscriptions: Default::default(), pending_scroll: None, - update_needed: send, - _task: worker, - _subscription: git_store_subscription, + _task: task, + _subscription: branch_diff_subscription, } } + pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { + self.branch_diff.read(cx).diff_base() + } + pub fn move_to_entry( &mut self, entry: GitStatusEntry, window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.git_store.read(cx).active_repository() else { + let Some(git_repo) = self.branch_diff.read(cx).repo() else { return; }; let repo = git_repo.read(cx); - - let namespace = if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) { - CONFLICT_NAMESPACE - } else if entry.status.is_created() { - NEW_NAMESPACE - } else { - TRACKED_NAMESPACE - }; - - let path_key = PathKey::namespaced(namespace, entry.repo_path.0); + let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx); + let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone()); self.move_to_path(path_key, window, cx) } pub fn active_path(&self, cx: &App) -> Option { - let editor = self.editor.read(cx); + let editor = self.editor.read(cx).last_selected_editor().read(cx); let position = editor.selections.newest_anchor().head(); let multi_buffer = editor.buffer().read(cx); let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?; @@ -261,17 +369,27 @@ impl ProjectDiff { }) } + fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |editor, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + }); + }); + } + fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context) { if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { self.editor.update(cx, |editor, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::focused()), - window, - cx, - |s| { - s.select_ranges([position..position]); - }, - ) + editor.primary_editor().update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::focused()), + window, + cx, + |s| { + s.select_ranges([position..position]); + }, + ) + }) }); } else { self.pending_scroll = Some(path_key); @@ -279,7 +397,7 @@ impl ProjectDiff { } fn button_states(&self, cx: &App) -> ButtonStates { - let editor = self.editor.read(cx); + let editor = self.editor.read(cx).primary_editor().read(cx); let snapshot = self.multibuffer.read(cx).snapshot(cx); let prev_next = snapshot.diff_hunks().nth(1).is_some(); let mut selection = true; @@ -290,12 +408,14 @@ impl ProjectDiff { .collect::>(); if !ranges.iter().any(|range| range.start != range.end) { selection = false; - if let Some((excerpt_id, buffer, range)) = self.editor.read(cx).active_excerpt(cx) { - ranges = vec![multi_buffer::Anchor::range_in_buffer( - excerpt_id, - buffer.read(cx).remote_id(), - range, - )]; + if let Some((excerpt_id, _, range)) = self + .editor + .read(cx) + .primary_editor() + .read(cx) + .active_excerpt(cx) + { + ranges = vec![multi_buffer::Anchor::range_in_buffer(excerpt_id, range)]; } else { ranges = Vec::default(); } @@ -342,24 +462,32 @@ impl ProjectDiff { fn handle_editor_event( &mut self, - editor: &Entity, + editor: &Entity, event: &EditorEvent, window: &mut Window, cx: &mut Context, ) { - if let EditorEvent::SelectionsChanged { local: true } = event { - let Some(project_path) = self.active_path(cx) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - if let Some(git_panel) = workspace.panel::(cx) { - git_panel.update(cx, |git_panel, cx| { - git_panel.select_entry_by_path(project_path, window, cx) - }) - } - }) - .ok(); + match event { + EditorEvent::SelectionsChanged { local: true } => { + let Some(project_path) = self.active_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + git_panel.update(cx, |git_panel, cx| { + git_panel.select_entry_by_path(project_path, window, cx) + }) + } + }) + .ok(); + } + EditorEvent::Saved => { + self._task = cx.spawn_in(window, async move |this, cx| { + Self::refresh(this, RefreshReason::EditorSaved, cx).await + }); + } + _ => {} } if editor.focus_handle(cx).contains_focused(window, cx) && self.multibuffer.read(cx).is_empty() @@ -368,97 +496,60 @@ impl ProjectDiff { } } - fn load_buffers(&mut self, cx: &mut Context) -> Vec>> { - let Some(repo) = self.git_store.read(cx).active_repository() else { - self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.clear(cx); - }); - return vec![]; - }; - - let mut previous_paths = self.multibuffer.read(cx).paths().collect::>(); - - let mut result = vec![]; - repo.update(cx, |repo, cx| { - for entry in repo.cached_status() { - if !entry.status.has_changes() { - continue; - } - let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path, cx) - else { - continue; - }; - let namespace = if GitPanelSettings::get_global(cx).sort_by_path { - TRACKED_NAMESPACE - } else if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) { - CONFLICT_NAMESPACE - } else if entry.status.is_created() { - NEW_NAMESPACE - } else { - TRACKED_NAMESPACE - }; - let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone()); - - previous_paths.remove(&path_key); - let load_buffer = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)); - - let project = self.project.clone(); - result.push(cx.spawn(async move |_, cx| { - let buffer = load_buffer.await?; - let changes = project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? - .await?; - Ok(DiffBuffer { - path_key, - buffer, - diff: changes, - file_status: entry.status, - }) - })); - } - }); - self.multibuffer.update(cx, |multibuffer, cx| { - for path in previous_paths { - multibuffer.remove_excerpts_for_path(path, cx); - } - }); - result - } - + #[instrument(skip_all)] fn register_buffer( &mut self, - diff_buffer: DiffBuffer, + path_key: PathKey, + file_status: FileStatus, + buffer: Entity, + diff: Entity, window: &mut Window, cx: &mut Context, ) { - let path_key = diff_buffer.path_key; - let buffer = diff_buffer.buffer; - let diff = diff_buffer.diff; + let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| { + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await + }) + }); + self.buffer_diff_subscriptions + .insert(path_key.path.clone(), (diff.clone(), subscription)); + // TODO(split-diff) we shouldn't have a conflict addon when split let conflict_addon = self .editor .read(cx) + .primary_editor() + .read(cx) .addon::() .expect("project diff editor should have a conflict addon"); let snapshot = buffer.read(cx).snapshot(); - let diff = diff.read(cx); - let diff_hunk_ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx) - .map(|diff_hunk| diff_hunk.buffer_range); - let conflicts = conflict_addon - .conflict_set(snapshot.remote_id()) - .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts) - .unwrap_or_default(); - let conflicts = conflicts.iter().map(|conflict| conflict.range.clone()); + let diff_read = diff.read(cx); - let excerpt_ranges = merge_anchor_ranges(diff_hunk_ranges, conflicts, &snapshot) - .map(|range| range.to_point(&snapshot)) - .collect::>(); + let excerpt_ranges = { + let diff_hunk_ranges = diff_read + .hunks_intersecting_range( + Anchor::min_max_range_for_buffer(diff_read.buffer_id), + &snapshot, + cx, + ) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); + let conflicts = conflict_addon + .conflict_set(snapshot.remote_id()) + .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts) + .unwrap_or_default(); + let mut conflicts = conflicts + .iter() + .map(|conflict| conflict.range.to_point(&snapshot)) + .peekable(); + + if conflicts.peek().is_some() { + conflicts.collect::>() + } else { + diff_hunk_ranges.collect() + } + }; let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| { let was_empty = multibuffer.is_empty(); @@ -469,23 +560,34 @@ impl ProjectDiff { multibuffer_context_lines(cx), cx, ); + if self.branch_diff.read(cx).diff_base().is_merge_base() { + multibuffer.add_diff(diff.clone(), cx); + } (was_empty, is_newly_added) }); self.editor.update(cx, |editor, cx| { - if was_empty { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - // TODO select the very beginning (possibly inside a deletion) - selections.select_ranges([0..0]) - }); - } - if is_excerpt_newly_added - && (diff_buffer.file_status.is_deleted() - || (diff_buffer.file_status.is_untracked() - && GitPanelSettings::get_global(cx).collapse_untracked_diff)) - { - editor.fold_buffer(snapshot.text.remote_id(), cx) - } + editor.primary_editor().update(cx, |editor, cx| { + if was_empty { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([ + multi_buffer::Anchor::min()..multi_buffer::Anchor::min() + ]) + }, + ); + } + if is_excerpt_newly_added + && (file_status.is_deleted() + || (file_status.is_untracked() + && GitPanelSettings::get_global(cx).collapse_untracked_diff)) + { + editor.fold_buffer(snapshot.text.remote_id(), cx) + } + }) }); if self.multibuffer.read(cx).is_empty() @@ -506,26 +608,95 @@ impl ProjectDiff { } } - pub async fn handle_status_updates( + pub async fn refresh( this: WeakEntity, - mut recv: postage::watch::Receiver<()>, + reason: RefreshReason, cx: &mut AsyncWindowContext, ) -> Result<()> { - while (recv.next().await).is_some() { - let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?; - for buffer_to_load in buffers_to_load { - if let Some(buffer) = buffer_to_load.await.log_err() { - cx.update(|window, cx| { - this.update(cx, |this, cx| this.register_buffer(buffer, window, cx)) - .ok(); - })?; + let mut path_keys = Vec::new(); + let buffers_to_load = this.update(cx, |this, cx| { + let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { + let load_buffers = branch_diff.load_buffers(cx); + (branch_diff.repo().cloned(), load_buffers) + }); + let mut previous_paths = this + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); + + if let Some(repo) = repo { + let repo = repo.read(cx); + + path_keys = Vec::with_capacity(buffers_to_load.len()); + for entry in buffers_to_load.iter() { + let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx); + let path_key = + PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone()); + previous_paths.remove(&path_key); + path_keys.push(path_key) } } - this.update(cx, |this, cx| { - this.pending_scroll.take(); - cx.notify(); - })?; + + this.multibuffer.update(cx, |multibuffer, cx| { + for path in previous_paths { + if let Some(buffer) = multibuffer.buffer_for_path(&path, cx) { + let skip = match reason { + RefreshReason::DiffChanged | RefreshReason::EditorSaved => { + buffer.read(cx).is_dirty() + } + RefreshReason::StatusesChanged => false, + }; + if skip { + continue; + } + } + + this.buffer_diff_subscriptions.remove(&path.path); + multibuffer.remove_excerpts_for_path(path.clone(), cx); + } + }); + buffers_to_load + })?; + + for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) { + if let Some((buffer, diff)) = entry.load.await.log_err() { + // We might be lagging behind enough that all future entry.load futures are no longer pending. + // If that is the case, this task will never yield, starving the foreground thread of execution time. + yield_now().await; + cx.update(|window, cx| { + this.update(cx, |this, cx| { + let multibuffer = this.multibuffer.read(cx); + let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some() + && multibuffer + .diff_for(buffer.read(cx).remote_id()) + .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id()) + && match reason { + RefreshReason::DiffChanged | RefreshReason::EditorSaved => { + buffer.read(cx).is_dirty() + } + RefreshReason::StatusesChanged => false, + }; + if !skip { + this.register_buffer( + path_key, + entry.file_status, + buffer, + diff, + window, + cx, + ) + } + }) + .ok(); + })?; + } } + this.update(cx, |this, cx| { + this.pending_scroll.take(); + cx.notify(); + })?; Ok(()) } @@ -534,13 +705,27 @@ impl ProjectDiff { pub fn excerpt_paths(&self, cx: &App) -> Vec> { self.multibuffer .read(cx) - .excerpt_paths() - .map(|key| key.path()) - .cloned() + .paths() + .map(|key| key.path.clone()) .collect() } } +fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 { + let settings = GitPanelSettings::get_global(cx); + + // Tree view can only sort by path + if settings.sort_by_path || settings.tree_view { + TRACKED_SORT_PREFIX + } else if repo.had_conflict_on_last_merge_head_change(repo_path) { + CONFLICT_SORT_PREFIX + } else if status.is_created() { + NEW_SORT_PREFIX + } else { + TRACKED_SORT_PREFIX + } +} + impl EventEmitter for ProjectDiff {} impl Focusable for ProjectDiff { @@ -565,8 +750,11 @@ impl Item for ProjectDiff { } fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { - self.editor - .update(cx, |editor, cx| editor.deactivated(window, cx)); + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |primary_editor, cx| { + primary_editor.deactivated(window, cx); + }) + }); } fn navigate( @@ -575,16 +763,19 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> bool { - self.editor - .update(cx, |editor, cx| editor.navigate(data, window, cx)) + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |primary_editor, cx| { + primary_editor.navigate(data, window, cx) + }) + }) } fn tab_tooltip_text(&self, _: &App) -> Option { Some("Project Diff".into()) } - fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement { - Label::new("Uncommitted Changes") + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, cx)) .color(if params.selected { Color::Default } else { @@ -593,16 +784,20 @@ impl Item for ProjectDiff { .into_any_element() } - fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString { - "Uncommitted Changes".into() + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + match self.branch_diff.read(cx).diff_base() { + DiffBase::Head => "Uncommitted Changes".into(), + DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(), + } } fn telemetry_event_text(&self) -> Option<&'static str> { Some("Project Diff Opened") } - fn as_searchable(&self, _: &Entity) -> Option> { - Some(Box::new(self.editor.clone())) + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + // TODO(split-diff) SplitEditor should be searchable + Some(Box::new(self.editor.read(cx).primary_editor().clone())) } fn for_each_project_item( @@ -610,7 +805,11 @@ impl Item for ProjectDiff { cx: &App, f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { - self.editor.for_each_project_item(cx, f) + self.editor + .read(cx) + .primary_editor() + .read(cx) + .for_each_project_item(cx, f) } fn set_nav_history( @@ -619,22 +818,32 @@ impl Item for ProjectDiff { _: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, _| { - editor.set_nav_history(Some(nav_history)); + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |primary_editor, _| { + primary_editor.set_nav_history(Some(nav_history)); + }) }); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - let workspace = self.workspace.upgrade()?; - Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx))) + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + Task::ready(Some(cx.new(|cx| { + ProjectDiff::new(self.project.clone(), workspace, window, cx) + }))) } fn is_dirty(&self, cx: &App) -> bool { @@ -656,7 +865,11 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Task> { - self.editor.save(options, project, window, cx) + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |primary_editor, cx| { + primary_editor.save(options, project, window, cx) + }) + }) } fn save_as( @@ -675,19 +888,23 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Task> { - self.editor.reload(project, window, cx) + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |primary_editor, cx| { + primary_editor.reload(project, window, cx) + }) + }) } fn act_as_type<'a>( &'a self, type_id: TypeId, self_handle: &'a Entity, - _: &'a App, - ) -> Option { + cx: &'a App, + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) + Some(self.editor.read(cx).primary_editor().clone().into()) } else { None } @@ -698,7 +915,11 @@ impl Item for ProjectDiff { } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { - self.editor.breadcrumbs(theme, cx) + self.editor + .read(cx) + .last_selected_editor() + .read(cx) + .breadcrumbs(theme, cx) } fn added_to_workspace( @@ -714,7 +935,7 @@ impl Item for ProjectDiff { } impl Render for ProjectDiff { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.multibuffer.read(cx).is_empty(); div() @@ -759,7 +980,6 @@ impl Render for ProjectDiff { .key_binding(KeyBinding::for_action_in( &CloseActiveItem::default(), &keybinding_focus_handle, - window, cx, )) .on_click(move |_, window, cx| { @@ -792,30 +1012,47 @@ impl SerializableItem for ProjectDiff { } fn deserialize( - _project: Entity, + project: Entity, workspace: WeakEntity, - _workspace_id: workspace::WorkspaceId, - _item_id: workspace::ItemId, + workspace_id: workspace::WorkspaceId, + item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { window.spawn(cx, async move |cx| { - workspace.update_in(cx, |workspace, window, cx| { - let workspace_handle = cx.entity(); - cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)) - }) + let diff_base = persistence::PROJECT_DIFF_DB.get_diff_base(item_id, workspace_id)?; + + let diff = cx.update(|window, cx| { + let branch_diff = cx + .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx)); + let workspace = workspace.upgrade().context("workspace gone")?; + anyhow::Ok( + cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)), + ) + })??; + + Ok(diff) }) } fn serialize( &mut self, - _workspace: &mut Workspace, - _item_id: workspace::ItemId, + workspace: &mut Workspace, + item_id: workspace::ItemId, _closing: bool, _window: &mut Window, - _cx: &mut Context, + cx: &mut Context, ) -> Option>> { - None + let workspace_id = workspace.database_id()?; + let diff_base = self.diff_base(cx).clone(); + + Some(cx.background_spawn({ + async move { + persistence::PROJECT_DIFF_DB + .save_diff_base(item_id, workspace_id, diff_base.clone()) + .await + } + })) } fn should_serialize(&self, _: &Self::Event) -> bool { @@ -823,6 +1060,80 @@ impl SerializableItem for ProjectDiff { } } +mod persistence { + + use anyhow::Context as _; + use db::{ + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; + use project::git_store::branch_diff::DiffBase; + use workspace::{ItemId, WorkspaceDb, WorkspaceId}; + + pub struct ProjectDiffDb(ThreadSafeConnection); + + impl Domain for ProjectDiffDb { + const NAME: &str = stringify!(ProjectDiffDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE project_diffs( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + + diff_base TEXT, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]; + } + + db::static_connection!(PROJECT_DIFF_DB, ProjectDiffDb, [WorkspaceDb]); + + impl ProjectDiffDb { + pub async fn save_diff_base( + &self, + item_id: ItemId, + workspace_id: WorkspaceId, + diff_base: DiffBase, + ) -> anyhow::Result<()> { + self.write(move |connection| { + let sql_stmt = sql!( + INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?) + ); + let diff_base_str = serde_json::to_string(&diff_base)?; + let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?; + query((item_id, workspace_id, diff_base_str)).context(format!( + "exec_bound failed to execute or parse for: {}", + sql_stmt + )) + }) + .await + } + + pub fn get_diff_base( + &self, + item_id: ItemId, + workspace_id: WorkspaceId, + ) -> anyhow::Result { + let sql_stmt = + sql!(SELECT diff_base FROM project_diffs WHERE item_id = ?AND workspace_id = ?); + let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?( + (item_id, workspace_id), + ) + .context(::std::format!( + "Error in get_diff_base, select_row_bound failed to execute or parse for: {}", + sql_stmt + ))?; + let Some(diff_base_str) = diff_base_str else { + return Ok(DiffBase::Head); + }; + serde_json::from_str(&diff_base_str).context("deserializing diff base") + } + } +} + pub struct ProjectDiffToolbar { project_diff: Option>, workspace: WeakEntity, @@ -887,6 +1198,7 @@ impl ToolbarItemView for ProjectDiffToolbar { ) -> ToolbarItemLocation { self.project_diff = active_pane_item .and_then(|item| item.act_as::(cx)) + .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head) .map(|entity| entity.downgrade()); if self.project_diff.is_some() { ToolbarItemLocation::PrimaryRight @@ -951,6 +1263,11 @@ impl Render for ProjectDiffToolbar { &StageAndNext, &focus_handle, )) + .disabled( + !button_states.prev_next + && !button_states.stage_all + && !button_states.unstage_all, + ) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&StageAndNext, window, cx) })), @@ -962,6 +1279,11 @@ impl Render for ProjectDiffToolbar { &UnstageAndNext, &focus_handle, )) + .disabled( + !button_states.prev_next + && !button_states.stage_all + && !button_states.unstage_all, + ) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&UnstageAndNext, window, cx) })), @@ -1299,65 +1621,42 @@ mod preview { } } -fn merge_anchor_ranges<'a>( - left: impl 'a + Iterator>, - right: impl 'a + Iterator>, - snapshot: &'a language::BufferSnapshot, -) -> impl 'a + Iterator> { - let mut left = left.fuse().peekable(); - let mut right = right.fuse().peekable(); +struct BranchDiffAddon { + branch_diff: Entity, +} - std::iter::from_fn(move || { - let Some(left_range) = left.peek() else { - return right.next(); - }; - let Some(right_range) = right.peek() else { - return left.next(); - }; +impl Addon for BranchDiffAddon { + fn to_any(&self) -> &dyn std::any::Any { + self + } - let mut next_range = if left_range.start.cmp(&right_range.start, snapshot).is_lt() { - left.next().unwrap() - } else { - right.next().unwrap() - }; - - // Extend the basic range while there's overlap with a range from either stream. - loop { - if let Some(left_range) = left - .peek() - .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le()) - .cloned() - { - left.next(); - next_range.end = left_range.end; - } else if let Some(right_range) = right - .peek() - .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le()) - .cloned() - { - right.next(); - next_range.end = right_range.end; - } else { - break; - } - } - - Some(next_range) - }) + fn override_status_for_buffer_id( + &self, + buffer_id: language::BufferId, + cx: &App, + ) -> Option { + self.branch_diff + .read(cx) + .status_for_buffer_id(buffer_id, cx) + } } #[cfg(test)] mod tests { + use collections::HashMap; use db::indoc; use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; - use git::status::{UnmergedStatus, UnmergedStatusCode}; + use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::TestAppContext; use project::FakeFs; use serde_json::json; use settings::SettingsStore; use std::path::Path; use unindent::Unindent as _; - use util::{path, rel_path::rel_path}; + use util::{ + path, + rel_path::{RelPath, rel_path}, + }; use super::*; @@ -1371,9 +1670,6 @@ mod tests { let store = SettingsStore::test(cx); cx.set_global(store); theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); editor::init(cx); crate::init(cx); }); @@ -1411,7 +1707,7 @@ mod tests { ); cx.run_until_parked(); - let editor = diff.read_with(cx, |diff, _| diff.editor.clone()); + let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone()); assert_state_with_diff( &editor, cx, @@ -1422,9 +1718,13 @@ mod tests { .unindent(), ); - editor.update_in(cx, |editor, window, cx| { - editor.git_restore(&Default::default(), window, cx); - }); + editor + .update_in(cx, |editor, window, cx| { + editor.git_restore(&Default::default(), window, cx); + editor.save(SaveOptions::default(), project.clone(), window, cx) + }) + .await + .unwrap(); cx.run_until_parked(); assert_state_with_diff(&editor, cx, &"ˇ".unindent()); @@ -1463,11 +1763,11 @@ mod tests { let editor = cx.update_window_entity(&diff, |diff, window, cx| { diff.move_to_path( - PathKey::namespaced(TRACKED_NAMESPACE, rel_path("foo").into_arc()), + PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()), window, cx, ); - diff.editor.clone() + diff.editor.read(cx).primary_editor().clone() }); assert_state_with_diff( &editor, @@ -1484,11 +1784,11 @@ mod tests { let editor = cx.update_window_entity(&diff, |diff, window, cx| { diff.move_to_path( - PathKey::namespaced(TRACKED_NAMESPACE, rel_path("bar").into_arc()), + PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()), window, cx, ); - diff.editor.clone() + diff.editor.read(cx).primary_editor().clone() }); assert_state_with_diff( &editor, @@ -1541,7 +1841,8 @@ mod tests { ); cx.run_until_parked(); - let diff_editor = diff.read_with(cx, |diff, _| diff.editor.clone()); + let diff_editor = + diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone()); assert_state_with_diff( &diff_editor, @@ -1623,14 +1924,13 @@ mod tests { project_diff::{self, ProjectDiff}, }; - #[cfg_attr(windows, ignore = "currently fails on windows")] #[gpui::test] async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/a", + path!("/a"), json!({ ".git": {}, "a.txt": "created\n", @@ -1641,7 +1941,7 @@ mod tests { .await; fs.set_head_and_index_for_repo( - Path::new("/a/.git"), + Path::new(path!("/a/.git")), &[ ("b.txt", "before\n".to_string()), ("c.txt", "unchanged\n".to_string()), @@ -1649,7 +1949,7 @@ mod tests { ], ); - let project = Project::test(fs, [Path::new("/a")], cx).await; + let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); @@ -1666,7 +1966,7 @@ mod tests { workspace.active_item_as::(cx).unwrap() }); cx.focus(&item); - let editor = item.read_with(cx, |item, _| item.editor.clone()); + let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone()); let mut cx = EditorTestContext::for_editor_in(editor, cx).await; @@ -1711,7 +2011,6 @@ mod tests { )); } - #[cfg_attr(windows, ignore = "currently fails on windows")] #[gpui::test] async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) { init_test(cx); @@ -1751,7 +2050,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/a", + path!("/a"), json!({ ".git": {}, "main.rs": buffer_contents, @@ -1760,11 +2059,11 @@ mod tests { .await; fs.set_head_and_index_for_repo( - Path::new("/a/.git"), + Path::new(path!("/a/.git")), &[("main.rs", git_contents.to_owned())], ); - let project = Project::test(fs, [Path::new("/a")], cx).await; + let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); @@ -1781,7 +2080,7 @@ mod tests { workspace.active_item_as::(cx).unwrap() }); cx.focus(&item); - let editor = item.read_with(cx, |item, _| item.editor.clone()); + let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone()); let mut cx = EditorTestContext::for_editor_in(editor, cx).await; @@ -1828,7 +2127,7 @@ mod tests { cx.run_until_parked(); cx.update(|window, cx| { - let editor = diff.read(cx).editor.clone(); + let editor = diff.read(cx).editor.read(cx).primary_editor().clone(); let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids(); assert_eq!(excerpt_ids.len(), 1); let excerpt_id = excerpt_ids[0]; @@ -1845,6 +2144,8 @@ mod tests { .read(cx) .editor .read(cx) + .primary_editor() + .read(cx) .addon::() .unwrap() .conflict_set(buffer_id) @@ -1928,7 +2229,8 @@ mod tests { ); cx.run_until_parked(); - let editor = diff.read_with(cx, |diff, _| diff.editor.clone()); + let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone()); + assert_state_with_diff( &editor, cx, @@ -1942,6 +2244,7 @@ mod tests { .unindent(), ); + // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff. let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/project/foo.txt"), cx) @@ -1994,4 +2297,156 @@ mod tests { .unindent(), ); } + + #[gpui::test] + async fn test_branch_diff(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "C", + "b.txt": "new", + "c.txt": "in-merge-base-and-work-tree", + "d.txt": "created-in-head", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let diff = cx + .update(|window, cx| { + ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())], + "sha", + ); + // fs.set_index_for_repo(dot_git, index_state); + fs.set_merge_base_content_for_repo( + Path::new(path!("/project/.git")), + &[ + ("a.txt", "A".into()), + ("c.txt", "in-merge-base-and-work-tree".into()), + ], + ); + cx.run_until_parked(); + + let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone()); + + assert_state_with_diff( + &editor, + cx, + &" + - A + + ˇC + + new + + created-in-head" + .unindent(), + ); + + let statuses: HashMap, Option> = + editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .all_buffers() + .iter() + .map(|buffer| { + ( + buffer.read(cx).file().unwrap().path().clone(), + editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx), + ) + }) + .collect() + }); + + assert_eq!( + statuses, + HashMap::from_iter([ + ( + rel_path("a.txt").into_arc(), + Some(FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Modified, + worktree_status: git::status::StatusCode::Modified + })) + ), + (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)), + ( + rel_path("d.txt").into_arc(), + Some(FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Added, + worktree_status: git::status::StatusCode::Added + })) + ) + ]) + ); + } + + #[gpui::test] + async fn test_update_on_uncommit(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "README.md": "# My cool project\n".to_owned() + }), + ) + .await; + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[("README.md", "# My cool project\n".to_owned())], + ); + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + cx.run_until_parked(); + + let _editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + let item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + cx.focus(&item); + let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone()); + + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[( + "README.md", + "# My cool project\nDetails to come.\n".to_owned(), + )], + ); + cx.run_until_parked(); + + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; + + cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n"); + } } diff --git a/crates/git_ui/src/remote_output.rs b/crates/git_ui/src/remote_output.rs index 8437bf0d0d..7fe863ee29 100644 --- a/crates/git_ui/src/remote_output.rs +++ b/crates/git_ui/src/remote_output.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; + use git::repository::{Remote, RemoteCommandOutput}; use linkify::{LinkFinder, LinkKind}; use ui::SharedString; diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index db080ab0b4..5e60bebc42 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -1,6 +1,6 @@ use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity}; use itertools::Itertools; -use picker::{Picker, PickerDelegate}; +use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::{Project, git_store::Repository}; use std::sync::Arc; use ui::{ListItem, ListItemSpacing, prelude::*}; @@ -36,11 +36,11 @@ impl RepositorySelector { ) -> Self { let git_store = project_handle.read(cx).git_store().clone(); let repository_entries = git_store.update(cx, |git_store, _cx| { - git_store - .repositories() - .values() - .cloned() - .collect::>() + let mut repos: Vec<_> = git_store.repositories().values().cloned().collect(); + + repos.sort_by_key(|a| a.read(_cx).display_name()); + + repos }); let filtered_repositories = repository_entries.clone(); @@ -59,7 +59,7 @@ impl RepositorySelector { }; let picker = cx.new(|cx| { - Picker::nonsearchable_uniform_list(delegate, window, cx) + Picker::uniform_list(delegate, window, cx) .widest_item(widest_item_ix) .max_height(Some(rems(20.).into())) }); @@ -158,6 +158,10 @@ impl PickerDelegate for RepositorySelectorDelegate { "Select a repository...".into() } + fn editor_position(&self) -> PickerEditorPosition { + PickerEditorPosition::End + } + fn update_matches( &mut self, query: String, @@ -166,25 +170,31 @@ impl PickerDelegate for RepositorySelectorDelegate { ) -> Task<()> { let all_repositories = self.repository_entries.clone(); + let repo_names: Vec<(Entity, String)> = all_repositories + .iter() + .map(|repo| (repo.clone(), repo.read(cx).display_name().to_lowercase())) + .collect(); + cx.spawn_in(window, async move |this, cx| { let filtered_repositories = cx .background_spawn(async move { if query.is_empty() { all_repositories } else { - all_repositories + let query_lower = query.to_lowercase(); + repo_names .into_iter() - .filter(|_repo_info| { - // TODO: Implement repository filtering logic - true - }) + .filter(|(_, display_name)| display_name.contains(&query_lower)) + .map(|(repo, _)| repo) .collect() } }) .await; this.update_in(cx, |this, window, cx| { - this.delegate.filtered_repositories = filtered_repositories; + let mut sorted_repositories = filtered_repositories; + sorted_repositories.sort_by_key(|a| a.read(cx).display_name()); + this.delegate.filtered_repositories = sorted_repositories; this.delegate.set_selected_index(0, window, cx); cx.notify(); }) diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index d82498007d..6d0a9d291e 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -1,11 +1,10 @@ use fuzzy::StringMatchCandidate; -use chrono; use git::stash::StashEntry; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, - SharedString, Styled, Subscription, Task, Window, actions, rems, + SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate}; use project::git_store::{Repository, RepositoryEvent}; @@ -17,6 +16,7 @@ use util::ResultExt; use workspace::notifications::DetachAndPromptErr; use workspace::{ModalView, Workspace}; +use crate::commit_view::CommitView; use crate::stash_picker; actions!( @@ -24,6 +24,8 @@ actions!( [ /// Drop the selected stash entry. DropStashItem, + /// Show the diff view of the selected stash entry. + ShowStashItem, ] ); @@ -38,8 +40,9 @@ pub fn open( cx: &mut Context, ) { let repository = workspace.project().read(cx).active_repository(cx); + let weak_workspace = workspace.weak_handle(); workspace.toggle_modal(window, cx, |window, cx| { - StashList::new(repository, rems(34.), window, cx) + StashList::new(repository, weak_workspace, rems(34.), window, cx) }) } @@ -53,6 +56,7 @@ pub struct StashList { impl StashList { fn new( repository: Option>, + workspace: WeakEntity, width: Rems, window: &mut Window, cx: &mut Context, @@ -65,7 +69,7 @@ impl StashList { if let Some(repo) = repository.clone() { _subscriptions.push( cx.subscribe_in(&repo, window, |this, _, event, window, cx| { - if matches!(event, RepositoryEvent::Updated { .. }) { + if matches!(event, RepositoryEvent::StashEntriesChanged) { let stash_entries = this.picker.read_with(cx, |picker, cx| { picker .delegate @@ -98,7 +102,7 @@ impl StashList { }) .detach_and_log_err(cx); - let delegate = StashListDelegate::new(repository, window, cx); + let delegate = StashListDelegate::new(repository, workspace, window, cx); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { @@ -131,6 +135,20 @@ impl StashList { cx.notify(); } + fn handle_show_stash( + &mut self, + _: &ShowStashItem, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker + .delegate + .show_stash_at(picker.delegate.selected_index(), window, cx); + }); + cx.notify(); + } + fn handle_modifiers_changed( &mut self, ev: &ModifiersChangedEvent, @@ -157,6 +175,7 @@ impl Render for StashList { .w(self.width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_drop_stash)) + .on_action(cx.listener(Self::handle_show_stash)) .child(self.picker.clone()) } } @@ -172,6 +191,7 @@ pub struct StashListDelegate { matches: Vec, all_stash_entries: Option>, repo: Option>, + workspace: WeakEntity, selected_index: usize, last_query: String, modifiers: Modifiers, @@ -182,16 +202,16 @@ pub struct StashListDelegate { impl StashListDelegate { fn new( repo: Option>, + workspace: WeakEntity, _window: &mut Window, cx: &mut Context, ) -> Self { - let timezone = - UtcOffset::from_whole_seconds(chrono::Local::now().offset().local_minus_utc()) - .unwrap_or(UtcOffset::UTC); + let timezone = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); Self { matches: vec![], repo, + workspace, all_stash_entries: None, selected_index: 0, last_query: Default::default(), @@ -235,6 +255,26 @@ impl StashListDelegate { }); } + fn show_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry_match) = self.matches.get(ix) else { + return; + }; + let stash_sha = entry_match.entry.oid.to_string(); + let stash_index = entry_match.entry.index; + let Some(repo) = self.repo.clone() else { + return; + }; + CommitView::open( + stash_sha, + repo.downgrade(), + self.workspace.clone(), + Some(stash_index), + None, + window, + cx, + ); + } + fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context>) { let Some(repo) = self.repo.clone() else { return; @@ -402,23 +442,14 @@ impl PickerDelegate for StashListDelegate { .into_any_element(); let branch_name = entry_match.entry.branch.clone().unwrap_or_default(); - let branch_label = h_flex() + let branch_info = h_flex() .gap_1p5() .w_full() .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::GitBranch) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child( - Label::new(branch_name) - .truncate() - .color(Color::Muted) - .size(LabelSize::Small), - ), + Label::new(branch_name) + .truncate() + .color(Color::Muted) + .size(LabelSize::Small), ) .child( Label::new("•") @@ -433,17 +464,11 @@ impl PickerDelegate for StashListDelegate { ); Some( - ListItem::new(SharedString::from(format!("stash-{ix}"))) + ListItem::new(format!("stash-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .child( - v_flex() - .w_full() - .overflow_hidden() - .child(stash_label) - .child(branch_label.into_element()), - ) + .child(v_flex().w_full().child(stash_label).child(branch_info)) .tooltip(Tooltip::text(format!( "stash@{{{}}}", entry_match.entry.index @@ -455,11 +480,7 @@ impl PickerDelegate for StashListDelegate { Some("No stashes found".into()) } - fn render_footer( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> Option { + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { let focus_handle = self.focus_handle.clone(); Some( @@ -470,38 +491,12 @@ impl PickerDelegate for StashListDelegate { .justify_end() .border_t_1() .border_color(cx.theme().colors().border_variant) - .child( - Button::new("apply-stash", "Apply") - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, window, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx) - }), - ) - .child( - Button::new("pop-stash", "Pop") - .key_binding( - KeyBinding::for_action_in( - &menu::SecondaryConfirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) - }), - ) .child( Button::new("drop-stash", "Drop") .key_binding( KeyBinding::for_action_in( &stash_picker::DropStashItem, &focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))), @@ -510,6 +505,42 @@ impl PickerDelegate for StashListDelegate { window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx) }), ) + .child( + Button::new("view-stash", "View") + .key_binding( + KeyBinding::for_action_in( + &stash_picker::ShowStashItem, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(move |picker, _, window, cx| { + cx.stop_propagation(); + let selected_ix = picker.delegate.selected_index(); + picker.delegate.show_stash_at(selected_ix, window, cx); + })), + ) + .child( + Button::new("pop-stash", "Pop") + .key_binding( + KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + .child( + Button::new("apply-stash", "Apply") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) .into_any(), ) } diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 3cafcd43d0..56d55415ba 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -5,8 +5,8 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use editor::{Editor, EditorEvent, MultiBuffer, ToPoint, actions::DiffClipboardWithSelectionData}; use futures::{FutureExt, select_biased}; use gpui::{ - AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, - FocusHandle, Focusable, IntoElement, Render, Task, Window, + AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, Render, Task, Window, }; use language::{self, Buffer, Point}; use project::Project; @@ -49,7 +49,7 @@ impl TextDiffView { let selection_data = source_editor.update(cx, |editor, cx| { let multibuffer = editor.buffer().read(cx); let source_buffer = multibuffer.as_singleton()?; - let selections = editor.selections.all::(cx); + let selections = editor.selections.all::(&editor.display_snapshot(cx)); let buffer_snapshot = source_buffer.read(cx); let first_selection = selections.first()?; let max_point = buffer_snapshot.max_point(); @@ -170,7 +170,7 @@ impl TextDiffView { cx.subscribe(&source_buffer, move |this, _, event, _| match event { language::BufferEvent::Edited - | language::BufferEvent::LanguageChanged + | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); } @@ -329,17 +329,17 @@ impl Item for TextDiffView { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if type_id == TypeId::of::() { - Some(self_handle.to_any()) + Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.diff_editor.to_any()) + Some(self.diff_editor.clone().into()) } else { None } } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.diff_editor.clone())) } @@ -446,11 +446,11 @@ impl Render for TextDiffView { #[cfg(test)] mod tests { use super::*; - use editor::test::editor_test_context::assert_state_with_diff; + use editor::{MultiBufferOffset, test::editor_test_context::assert_state_with_diff}; use gpui::{TestAppContext, VisualContext}; use project::{FakeFs, Project}; use serde_json::json; - use settings::{Settings, SettingsStore}; + use settings::SettingsStore; use unindent::unindent; use util::{path, test::marked_text_ranges}; @@ -458,11 +458,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); - editor::init_settings(cx); - theme::ThemeSettings::register(cx) + theme::init(theme::LoadThemes::JustBase, cx); }); } @@ -695,7 +691,11 @@ mod tests { let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false); editor.set_text(unmarked_text, window, cx); editor.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(selection_ranges) + s.select_ranges( + selection_ranges + .into_iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) }); editor diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs new file mode 100644 index 0000000000..f6b3e47dec --- /dev/null +++ b/crates/git_ui/src/worktree_picker.rs @@ -0,0 +1,743 @@ +use anyhow::Context as _; +use fuzzy::StringMatchCandidate; + +use git::repository::Worktree as GitWorktree; +use gpui::{ + Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, + PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, + actions, rems, +}; +use picker::{Picker, PickerDelegate, PickerEditorPosition}; +use project::{DirectoryLister, git_store::Repository}; +use recent_projects::{RemoteConnectionModal, connect}; +use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier}; +use std::{path::PathBuf, sync::Arc}; +use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use util::ResultExt; +use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr}; + +actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]); + +pub fn register(workspace: &mut Workspace) { + workspace.register_action(open); +} + +pub fn open( + workspace: &mut Workspace, + _: &zed_actions::git::Worktree, + window: &mut Window, + cx: &mut Context, +) { + let repository = workspace.project().read(cx).active_repository(cx); + let workspace_handle = workspace.weak_handle(); + workspace.toggle_modal(window, cx, |window, cx| { + WorktreeList::new(repository, workspace_handle, rems(34.), window, cx) + }) +} + +pub struct WorktreeList { + width: Rems, + pub picker: Entity>, + picker_focus_handle: FocusHandle, + _subscription: Subscription, +} + +impl WorktreeList { + fn new( + repository: Option>, + workspace: WeakEntity, + width: Rems, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let all_worktrees_request = repository + .clone() + .map(|repository| repository.update(cx, |repository, _| repository.worktrees())); + + let default_branch_request = repository + .clone() + .map(|repository| repository.update(cx, |repository, _| repository.default_branch())); + + cx.spawn_in(window, async move |this, cx| { + let all_worktrees = all_worktrees_request + .context("No active repository")? + .await??; + + let default_branch = default_branch_request + .context("No active repository")? + .await + .map(Result::ok) + .ok() + .flatten() + .flatten(); + + this.update_in(cx, |this, window, cx| { + this.picker.update(cx, |picker, cx| { + picker.delegate.all_worktrees = Some(all_worktrees); + picker.delegate.default_branch = default_branch; + picker.refresh(window, cx); + }) + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + let delegate = WorktreeListDelegate::new(workspace, repository, window, cx); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + }); + + let _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + + Self { + picker, + picker_focus_handle, + width, + _subscription, + } + } + + fn handle_modifiers_changed( + &mut self, + ev: &ModifiersChangedEvent, + _: &mut Window, + cx: &mut Context, + ) { + self.picker + .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) + } + + fn handle_new_worktree( + &mut self, + replace_current_window: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + let ix = picker.delegate.selected_index(); + let Some(entry) = picker.delegate.matches.get(ix) else { + return; + }; + let Some(default_branch) = picker.delegate.default_branch.clone() else { + return; + }; + if !entry.is_new { + return; + } + picker.delegate.create_worktree( + entry.worktree.branch(), + replace_current_window, + Some(default_branch.into()), + window, + cx, + ); + }) + } +} +impl ModalView for WorktreeList {} +impl EventEmitter for WorktreeList {} + +impl Focusable for WorktreeList { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.picker_focus_handle.clone() + } +} + +impl Render for WorktreeList { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("GitWorktreeSelector") + .w(self.width) + .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) + .on_action(cx.listener(|this, _: &WorktreeFromDefault, w, cx| { + this.handle_new_worktree(false, w, cx) + })) + .on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| { + this.handle_new_worktree(true, w, cx) + })) + .child(self.picker.clone()) + .on_mouse_down_out({ + cx.listener(move |this, _, window, cx| { + this.picker.update(cx, |this, cx| { + this.cancel(&Default::default(), window, cx); + }) + }) + }) + } +} + +#[derive(Debug, Clone)] +struct WorktreeEntry { + worktree: GitWorktree, + positions: Vec, + is_new: bool, +} + +pub struct WorktreeListDelegate { + matches: Vec, + all_worktrees: Option>, + workspace: WeakEntity, + repo: Option>, + selected_index: usize, + last_query: String, + modifiers: Modifiers, + focus_handle: FocusHandle, + default_branch: Option, +} + +impl WorktreeListDelegate { + fn new( + workspace: WeakEntity, + repo: Option>, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + matches: vec![], + all_worktrees: None, + workspace, + selected_index: 0, + repo, + last_query: Default::default(), + modifiers: Default::default(), + focus_handle: cx.focus_handle(), + default_branch: None, + } + } + + fn create_worktree( + &self, + worktree_branch: &str, + replace_current_window: bool, + commit: Option, + window: &mut Window, + cx: &mut Context>, + ) { + let workspace = self.workspace.clone(); + let Some(repo) = self.repo.clone() else { + return; + }; + + let worktree_path = self + .workspace + .clone() + .update(cx, |this, cx| { + this.prompt_for_open_path( + PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Select directory for new worktree".into()), + }, + DirectoryLister::Project(this.project().clone()), + window, + cx, + ) + }) + .log_err(); + let Some(worktree_path) = worktree_path else { + return; + }; + + let branch = worktree_branch.to_string(); + let window_handle = window.window_handle(); + cx.spawn_in(window, async move |_, cx| { + let Some(paths) = worktree_path.await? else { + return anyhow::Ok(()); + }; + let path = paths.get(0).cloned().context("No path selected")?; + + repo.update(cx, |repo, _| { + repo.create_worktree(branch.clone(), path.clone(), commit) + })? + .await??; + + let final_path = path.join(branch); + + let (connection_options, app_state, is_local) = + workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + let connection_options = project.read(cx).remote_connection_options(cx); + let app_state = workspace.app_state().clone(); + let is_local = project.read(cx).is_local(); + (connection_options, app_state, is_local) + })?; + + if is_local { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_workspace_for_paths( + replace_current_window, + vec![final_path], + window, + cx, + ) + })? + .await?; + } else if let Some(connection_options) = connection_options { + open_remote_worktree( + connection_options, + vec![final_path], + app_state, + window_handle, + replace_current_window, + cx, + ) + .await?; + } + + anyhow::Ok(()) + }) + .detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| { + Some(e.to_string()) + }); + } + + fn open_worktree( + &self, + worktree_path: &PathBuf, + replace_current_window: bool, + window: &mut Window, + cx: &mut Context>, + ) { + let workspace = self.workspace.clone(); + let path = worktree_path.clone(); + + let Some((connection_options, app_state, is_local)) = workspace + .update(cx, |workspace, cx| { + let project = workspace.project().clone(); + let connection_options = project.read(cx).remote_connection_options(cx); + let app_state = workspace.app_state().clone(); + let is_local = project.read(cx).is_local(); + (connection_options, app_state, is_local) + }) + .log_err() + else { + return; + }; + + if is_local { + let open_task = workspace.update(cx, |workspace, cx| { + workspace.open_workspace_for_paths(replace_current_window, vec![path], window, cx) + }); + cx.spawn(async move |_, _| { + open_task?.await?; + anyhow::Ok(()) + }) + .detach_and_prompt_err( + "Failed to open worktree", + window, + cx, + |e, _, _| Some(e.to_string()), + ); + } else if let Some(connection_options) = connection_options { + let window_handle = window.window_handle(); + cx.spawn_in(window, async move |_, cx| { + open_remote_worktree( + connection_options, + vec![path], + app_state, + window_handle, + replace_current_window, + cx, + ) + .await + }) + .detach_and_prompt_err( + "Failed to open worktree", + window, + cx, + |e, _, _| Some(e.to_string()), + ); + } + + cx.emit(DismissEvent); + } + + fn base_branch<'a>(&'a self, cx: &'a mut Context>) -> Option<&'a str> { + self.repo + .as_ref() + .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name())) + } +} + +async fn open_remote_worktree( + connection_options: RemoteConnectionOptions, + paths: Vec, + app_state: Arc, + window: gpui::AnyWindowHandle, + replace_current_window: bool, + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + let workspace_window = window + .downcast::() + .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?; + + let connect_task = workspace_window.update(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx) + }); + + let prompt = workspace + .active_modal::(cx) + .expect("Modal just created") + .read(cx) + .prompt + .clone(); + + connect( + ConnectionIdentifier::setup(), + connection_options.clone(), + prompt, + window, + cx, + ) + .prompt_err("Failed to connect", window, cx, |_, _, _| None) + })?; + + let session = connect_task.await; + + workspace_window.update(cx, |workspace, _window, cx| { + if let Some(prompt) = workspace.active_modal::(cx) { + prompt.update(cx, |prompt, cx| prompt.finished(cx)) + } + })?; + + let Some(Some(session)) = session else { + return Ok(()); + }; + + let new_project = cx.update(|cx| { + project::Project::remote( + session, + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + cx, + ) + })?; + + let window_to_use = if replace_current_window { + workspace_window + } else { + let workspace_position = cx + .update(|cx| { + workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx) + })? + .await + .context("fetching workspace position from db")?; + + let mut options = + cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?; + options.window_bounds = workspace_position.window_bounds; + + cx.open_window(options, |window, cx| { + cx.new(|cx| { + let mut workspace = + Workspace::new(None, new_project.clone(), app_state.clone(), window, cx); + workspace.centered_layout = workspace_position.centered_layout; + workspace + }) + })? + }; + + workspace::open_remote_project_with_existing_connection( + connection_options, + new_project, + paths, + app_state, + window_to_use, + cx, + ) + .await?; + + Ok(()) +} + +impl PickerDelegate for WorktreeListDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Select worktree…".into() + } + + fn editor_position(&self) -> PickerEditorPosition { + PickerEditorPosition::Start + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let Some(all_worktrees) = self.all_worktrees.clone() else { + return Task::ready(()); + }; + + cx.spawn_in(window, async move |picker, cx| { + let mut matches: Vec = if query.is_empty() { + all_worktrees + .into_iter() + .map(|worktree| WorktreeEntry { + worktree, + positions: Vec::new(), + is_new: false, + }) + .collect() + } else { + let candidates = all_worktrees + .iter() + .enumerate() + .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.branch())) + .collect::>(); + fuzzy::match_strings( + &candidates, + &query, + true, + true, + 10000, + &Default::default(), + cx.background_executor().clone(), + ) + .await + .into_iter() + .map(|candidate| WorktreeEntry { + worktree: all_worktrees[candidate.candidate_id].clone(), + positions: candidate.positions, + is_new: false, + }) + .collect() + }; + picker + .update(cx, |picker, _| { + if !query.is_empty() + && !matches + .first() + .is_some_and(|entry| entry.worktree.branch() == query) + { + let query = query.replace(' ', "-"); + matches.push(WorktreeEntry { + worktree: GitWorktree { + path: Default::default(), + ref_name: format!("refs/heads/{query}").into(), + sha: Default::default(), + }, + positions: Vec::new(), + is_new: true, + }) + } + let delegate = &mut picker.delegate; + delegate.matches = matches; + if delegate.matches.is_empty() { + delegate.selected_index = 0; + } else { + delegate.selected_index = + core::cmp::min(delegate.selected_index, delegate.matches.len() - 1); + } + delegate.last_query = query; + }) + .log_err(); + }) + } + + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(self.selected_index()) else { + return; + }; + if entry.is_new { + self.create_worktree(&entry.worktree.branch(), secondary, None, window, cx); + } else { + self.open_worktree(&entry.worktree.path, secondary, window, cx); + } + + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let entry = &self.matches.get(ix)?; + let path = entry.worktree.path.to_string_lossy().to_string(); + let sha = entry + .worktree + .sha + .clone() + .chars() + .take(7) + .collect::(); + + let focus_handle = self.focus_handle.clone(); + let icon = if let Some(default_branch) = self.default_branch.clone() + && entry.is_new + { + Some( + IconButton::new("worktree-from-default", IconName::GitBranchAlt) + .on_click(|_, window, cx| { + window.dispatch_action(WorktreeFromDefault.boxed_clone(), cx) + }) + .on_right_click(|_, window, cx| { + window.dispatch_action(WorktreeFromDefaultOnWindow.boxed_clone(), cx) + }) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + format!("From default branch {default_branch}"), + &WorktreeFromDefault, + &focus_handle, + cx, + ) + }), + ) + } else { + None + }; + + let branch_name = if entry.is_new { + h_flex() + .gap_1() + .child( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!("Create worktree \"{}\"…", entry.worktree.branch())) + .single_line() + .truncate(), + ) + .into_any_element() + } else { + h_flex() + .gap_1() + .child( + Icon::new(IconName::GitBranch) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(HighlightedLabel::new( + entry.worktree.branch().to_owned(), + entry.positions.clone(), + )) + .truncate() + .into_any_element() + }; + + let sublabel = if entry.is_new { + format!( + "based off {}", + self.base_branch(cx).unwrap_or("the current branch") + ) + } else { + format!("at {}", path) + }; + + Some( + ListItem::new(format!("worktree-menu-{ix}")) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + v_flex() + .w_full() + .overflow_hidden() + .child( + h_flex() + .gap_6() + .justify_between() + .overflow_x_hidden() + .child(branch_name) + .when(!entry.is_new, |el| { + el.child( + Label::new(sha) + .size(LabelSize::Small) + .color(Color::Muted) + .into_element(), + ) + }), + ) + .child( + div().max_w_96().child( + Label::new(sublabel) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + .into_any_element(), + ), + ), + ) + .end_slot::(icon), + ) + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + Some("No worktrees found".into()) + } + + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let focus_handle = self.focus_handle.clone(); + + Some( + h_flex() + .w_full() + .p_1p5() + .gap_0p5() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("open-in-new-window", "Open in new window") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + .child( + Button::new("open-in-window", "Open") + .key_binding( + KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + .into_any(), + ) + } +} diff --git a/crates/go_to_line/Cargo.toml b/crates/go_to_line/Cargo.toml index 54a9b4d37c..0260cd2d12 100644 --- a/crates/go_to_line/Cargo.toml +++ b/crates/go_to_line/Cargo.toml @@ -24,7 +24,6 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index b722777262..042d9a46b6 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/crates/go_to_line/src/cursor_position.rs @@ -1,6 +1,6 @@ -use editor::{Editor, MultiBufferSnapshot}; -use gpui::{App, Entity, FocusHandle, Focusable, Subscription, Task, WeakEntity}; -use settings::Settings; +use editor::{Editor, EditorEvent, MBTextSummary, MultiBufferSnapshot}; +use gpui::{App, Entity, FocusHandle, Focusable, Styled, Subscription, Task, WeakEntity}; +use settings::{RegisterSetting, Settings}; use std::{fmt::Write, num::NonZeroU32, time::Duration}; use text::{Point, Selection}; use ui::{ @@ -55,7 +55,7 @@ impl UserCaretPosition { let line_start = Point::new(selection_end.row, 0); let chars_to_last_position = snapshot - .text_summary_for_range::(line_start..selection_end) + .text_summary_for_range::(line_start..selection_end) .chars as u32; (selection_end.row, chars_to_last_position) }; @@ -81,7 +81,7 @@ impl CursorPosition { fn update_position( &mut self, - editor: Entity, + editor: &Entity, debounce: Option, window: &mut Window, cx: &mut Context, @@ -111,13 +111,14 @@ impl CursorPosition { } editor::EditorMode::Full { .. } => { let mut last_selection = None::>; - let snapshot = editor.buffer().read(cx).snapshot(cx); - if snapshot.excerpts().count() > 0 { - for selection in editor.selections.all_adjusted(cx) { + let snapshot = editor.display_snapshot(cx); + if snapshot.buffer_snapshot().excerpts().count() > 0 { + for selection in editor.selections.all_adjusted(&snapshot) { let selection_summary = snapshot - .text_summary_for_range::( - selection.start..selection.end, - ); + .buffer_snapshot() + .text_summary_for_range::( + selection.start..selection.end, + ); cursor_position.selected_count.characters += selection_summary.chars; if selection.end != selection.start { @@ -134,8 +135,12 @@ impl CursorPosition { } } } - cursor_position.position = last_selection - .map(|s| UserCaretPosition::at_selection_end(&s, &snapshot)); + cursor_position.position = last_selection.map(|s| { + UserCaretPosition::at_selection_end( + &s, + snapshot.buffer_snapshot(), + ) + }); cursor_position.context = Some(editor.focus_handle(cx)); } } @@ -206,7 +211,7 @@ impl CursorPosition { impl Render for CursorPosition { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if !StatusBarSettings::get_global(cx).cursor_position_button { - return div(); + return div().hidden(); } div().when_some(self.position, |el, position| { @@ -236,18 +241,16 @@ impl Render for CursorPosition { }); } })) - .tooltip(move |window, cx| match context.as_ref() { + .tooltip(move |_window, cx| match context.as_ref() { Some(context) => Tooltip::for_action_in( "Go to Line/Column", &editor::actions::ToggleGoToLine, context, - window, cx, ), None => Tooltip::for_action( "Go to Line/Column", &editor::actions::ToggleGoToLine, - window, cx, ), }), @@ -266,19 +269,21 @@ impl StatusItemView for CursorPosition { cx: &mut Context, ) { if let Some(editor) = active_pane_item.and_then(|item| item.act_as::(cx)) { - self._observe_active_editor = - Some( - cx.observe_in(&editor, window, |cursor_position, editor, window, cx| { - Self::update_position( - cursor_position, - editor, - Some(UPDATE_DEBOUNCE), - window, - cx, - ) - }), - ); - self.update_position(editor, None, window, cx); + self._observe_active_editor = Some(cx.subscribe_in( + &editor, + window, + |cursor_position, editor, event, window, cx| match event { + EditorEvent::SelectionsChanged { .. } => Self::update_position( + cursor_position, + editor, + Some(UPDATE_DEBOUNCE), + window, + cx, + ), + _ => {} + }, + )); + self.update_position(&editor, None, window, cx); } else { self.position = None; self._observe_active_editor = None; @@ -288,7 +293,7 @@ impl StatusItemView for CursorPosition { } } -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, RegisterSetting)] pub enum LineIndicatorFormat { Short, Long, @@ -304,7 +309,7 @@ impl From for LineIndicatorFormat { } impl Settings for LineIndicatorFormat { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { content.line_indicator_format.unwrap().into() } } diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index f9dd017892..461b0be659 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -1,6 +1,6 @@ pub mod cursor_position; -use cursor_position::{LineIndicatorFormat, UserCaretPosition}; +use cursor_position::UserCaretPosition; use editor::{ Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToOffset, ToPoint, actions::Tab, @@ -11,15 +11,13 @@ use gpui::{ Subscription, div, prelude::*, }; use language::Buffer; -use settings::Settings; use text::{Bias, Point}; use theme::ActiveTheme; use ui::prelude::*; use util::paths::FILE_ROW_COLUMN_DELIMITER; -use workspace::ModalView; +use workspace::{DismissDecision, ModalView}; pub fn init(cx: &mut App) { - LineIndicatorFormat::register(cx); cx.observe_new(GoToLine::register).detach(); } @@ -31,7 +29,16 @@ pub struct GoToLine { _subscriptions: Vec, } -impl ModalView for GoToLine {} +impl ModalView for GoToLine { + fn on_before_dismiss( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> DismissDecision { + self.prev_scroll_position.take(); + DismissDecision::Dismiss(true) + } +} impl Focusable for GoToLine { fn focus_handle(&self, cx: &App) -> FocusHandle { @@ -74,7 +81,9 @@ impl GoToLine { ) -> Self { let (user_caret, last_line, scroll_position) = active_editor.update(cx, |editor, cx| { let user_caret = UserCaretPosition::at_selection_end( - &editor.selections.last::(cx), + &editor + .selections + .last::(&editor.display_snapshot(cx)), &editor.buffer().read(cx).snapshot(cx), ); @@ -739,7 +748,7 @@ mod tests { let selections = editor.update(cx, |editor, cx| { editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.start..s.end) .collect::>() @@ -759,12 +768,176 @@ mod tests { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - language::init(cx); crate::init(cx); editor::init(cx); - workspace::init_settings(cx); - Project::init_settings(cx); state }) } + + #[gpui::test] + async fn test_scroll_position_on_outside_click(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let file_content = (0..100) + .map(|i| format!("struct Line{};", i)) + .collect::>() + .join("\n"); + fs.insert_tree(path!("/dir"), json!({"a.rs": file_content})) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let go_to_line_view = open_go_to_line_view(&workspace, cx); + + let scroll_position_before_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + cx.simulate_input("47"); + let scroll_position_after_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_ne!(scroll_position_before_input, scroll_position_after_input); + + drop(go_to_line_view); + workspace.update_in(cx, |workspace, window, cx| { + workspace.hide_modal(window, cx); + }); + cx.run_until_parked(); + + let scroll_position_after_auto_dismiss = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_eq!( + scroll_position_after_auto_dismiss, scroll_position_after_input, + "Dismissing via outside click should maintain new scroll position" + ); + } + + #[gpui::test] + async fn test_scroll_position_on_cancel(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let file_content = (0..100) + .map(|i| format!("struct Line{};", i)) + .collect::>() + .join("\n"); + fs.insert_tree(path!("/dir"), json!({"a.rs": file_content})) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let go_to_line_view = open_go_to_line_view(&workspace, cx); + + let scroll_position_before_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + cx.simulate_input("47"); + let scroll_position_after_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_ne!(scroll_position_before_input, scroll_position_after_input); + + cx.dispatch_action(menu::Cancel); + drop(go_to_line_view); + cx.run_until_parked(); + + let scroll_position_after_cancel = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_eq!( + scroll_position_after_cancel, scroll_position_after_input, + "Cancel should maintain new scroll position" + ); + } + + #[gpui::test] + async fn test_scroll_position_on_confirm(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let file_content = (0..100) + .map(|i| format!("struct Line{};", i)) + .collect::>() + .join("\n"); + fs.insert_tree(path!("/dir"), json!({"a.rs": file_content})) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let go_to_line_view = open_go_to_line_view(&workspace, cx); + + let scroll_position_before_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + cx.simulate_input("47"); + let scroll_position_after_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_ne!(scroll_position_before_input, scroll_position_after_input); + + cx.dispatch_action(menu::Confirm); + drop(go_to_line_view); + cx.run_until_parked(); + + let scroll_position_after_confirm = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_eq!( + scroll_position_after_confirm, scroll_position_after_input, + "Confirm should maintain new scroll position" + ); + } } diff --git a/crates/google_ai/Cargo.toml b/crates/google_ai/Cargo.toml index ce759698ed..81e05e4836 100644 --- a/crates/google_ai/Cargo.toml +++ b/crates/google_ai/Cargo.toml @@ -23,4 +23,3 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index 9b7e5ec8d1..3eff860e16 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -229,6 +229,10 @@ pub struct GenerativeContentBlob { #[serde(rename_all = "camelCase")] pub struct FunctionCallPart { pub function_call: FunctionCall, + /// Thought signature returned by the model for function calls. + /// Only present on the first function call in parallel call scenarios. + #[serde(skip_serializing_if = "Option::is_none")] + pub thought_signature: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -480,30 +484,19 @@ impl<'de> Deserialize<'de> for ModelName { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Default, Debug, Deserialize, Serialize, PartialEq, Eq, strum::EnumIter)] pub enum Model { - #[serde(rename = "gemini-1.5-pro")] - Gemini15Pro, - #[serde(rename = "gemini-1.5-flash-8b")] - Gemini15Flash8b, - #[serde(rename = "gemini-1.5-flash")] - Gemini15Flash, #[serde( - rename = "gemini-2.0-flash-lite", + rename = "gemini-2.5-flash-lite", + alias = "gemini-2.5-flash-lite-preview-06-17", alias = "gemini-2.0-flash-lite-preview" )] - Gemini20FlashLite, - #[serde(rename = "gemini-2.0-flash")] - Gemini20Flash, - #[serde( - rename = "gemini-2.5-flash-lite-preview", - alias = "gemini-2.5-flash-lite-preview-06-17" - )] - Gemini25FlashLitePreview, + Gemini25FlashLite, #[serde( rename = "gemini-2.5-flash", alias = "gemini-2.0-flash-thinking-exp", alias = "gemini-2.5-flash-preview-04-17", alias = "gemini-2.5-flash-preview-05-20", - alias = "gemini-2.5-flash-preview-latest" + alias = "gemini-2.5-flash-preview-latest", + alias = "gemini-2.0-flash" )] #[default] Gemini25Flash, @@ -517,6 +510,8 @@ pub enum Model { alias = "gemini-2.5-pro-preview-06-05" )] Gemini25Pro, + #[serde(rename = "gemini-3-pro-preview")] + Gemini3Pro, #[serde(rename = "custom")] Custom { name: String, @@ -530,46 +525,34 @@ pub enum Model { impl Model { pub fn default_fast() -> Self { - Self::Gemini20FlashLite + Self::Gemini25FlashLite } pub fn id(&self) -> &str { match self { - Self::Gemini15Pro => "gemini-1.5-pro", - Self::Gemini15Flash8b => "gemini-1.5-flash-8b", - Self::Gemini15Flash => "gemini-1.5-flash", - Self::Gemini20FlashLite => "gemini-2.0-flash-lite", - Self::Gemini20Flash => "gemini-2.0-flash", - Self::Gemini25FlashLitePreview => "gemini-2.5-flash-lite-preview", + Self::Gemini25FlashLite => "gemini-2.5-flash-lite", Self::Gemini25Flash => "gemini-2.5-flash", Self::Gemini25Pro => "gemini-2.5-pro", + Self::Gemini3Pro => "gemini-3-pro-preview", Self::Custom { name, .. } => name, } } pub fn request_id(&self) -> &str { match self { - Self::Gemini15Pro => "gemini-1.5-pro", - Self::Gemini15Flash8b => "gemini-1.5-flash-8b", - Self::Gemini15Flash => "gemini-1.5-flash", - Self::Gemini20FlashLite => "gemini-2.0-flash-lite", - Self::Gemini20Flash => "gemini-2.0-flash", - Self::Gemini25FlashLitePreview => "gemini-2.5-flash-lite-preview-06-17", + Self::Gemini25FlashLite => "gemini-2.5-flash-lite", Self::Gemini25Flash => "gemini-2.5-flash", Self::Gemini25Pro => "gemini-2.5-pro", + Self::Gemini3Pro => "gemini-3-pro-preview", Self::Custom { name, .. } => name, } } pub fn display_name(&self) -> &str { match self { - Self::Gemini15Pro => "Gemini 1.5 Pro", - Self::Gemini15Flash8b => "Gemini 1.5 Flash-8b", - Self::Gemini15Flash => "Gemini 1.5 Flash", - Self::Gemini20FlashLite => "Gemini 2.0 Flash-Lite", - Self::Gemini20Flash => "Gemini 2.0 Flash", - Self::Gemini25FlashLitePreview => "Gemini 2.5 Flash-Lite Preview", + Self::Gemini25FlashLite => "Gemini 2.5 Flash-Lite", Self::Gemini25Flash => "Gemini 2.5 Flash", Self::Gemini25Pro => "Gemini 2.5 Pro", + Self::Gemini3Pro => "Gemini 3 Pro", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -578,28 +561,20 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::Gemini15Pro => 2_097_152, - Self::Gemini15Flash8b => 1_048_576, - Self::Gemini15Flash => 1_048_576, - Self::Gemini20FlashLite => 1_048_576, - Self::Gemini20Flash => 1_048_576, - Self::Gemini25FlashLitePreview => 1_000_000, + Self::Gemini25FlashLite => 1_048_576, Self::Gemini25Flash => 1_048_576, Self::Gemini25Pro => 1_048_576, + Self::Gemini3Pro => 1_048_576, Self::Custom { max_tokens, .. } => *max_tokens, } } pub fn max_output_tokens(&self) -> Option { match self { - Model::Gemini15Pro => Some(8_192), - Model::Gemini15Flash8b => Some(8_192), - Model::Gemini15Flash => Some(8_192), - Model::Gemini20FlashLite => Some(8_192), - Model::Gemini20Flash => Some(8_192), - Model::Gemini25FlashLitePreview => Some(64_000), + Model::Gemini25FlashLite => Some(65_536), Model::Gemini25Flash => Some(65_536), Model::Gemini25Pro => Some(65_536), + Model::Gemini3Pro => Some(65_536), Model::Custom { .. } => None, } } @@ -614,12 +589,10 @@ impl Model { pub fn mode(&self) -> GoogleModelMode { match self { - Self::Gemini15Pro - | Self::Gemini15Flash8b - | Self::Gemini15Flash - | Self::Gemini20FlashLite - | Self::Gemini20Flash => GoogleModelMode::Default, - Self::Gemini25FlashLitePreview | Self::Gemini25Flash | Self::Gemini25Pro => { + Self::Gemini25FlashLite + | Self::Gemini25Flash + | Self::Gemini25Pro + | Self::Gemini3Pro => { GoogleModelMode::Thinking { // By default these models are set to "auto", so we preserve that behavior // but indicate they are capable of thinking mode @@ -636,3 +609,109 @@ impl std::fmt::Display for Model { write!(f, "{}", self.id()) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_function_call_part_with_signature_serializes_correctly() { + let part = FunctionCallPart { + function_call: FunctionCall { + name: "test_function".to_string(), + args: json!({"arg": "value"}), + }, + thought_signature: Some("test_signature".to_string()), + }; + + let serialized = serde_json::to_value(&part).unwrap(); + + assert_eq!(serialized["functionCall"]["name"], "test_function"); + assert_eq!(serialized["functionCall"]["args"]["arg"], "value"); + assert_eq!(serialized["thoughtSignature"], "test_signature"); + } + + #[test] + fn test_function_call_part_without_signature_omits_field() { + let part = FunctionCallPart { + function_call: FunctionCall { + name: "test_function".to_string(), + args: json!({"arg": "value"}), + }, + thought_signature: None, + }; + + let serialized = serde_json::to_value(&part).unwrap(); + + assert_eq!(serialized["functionCall"]["name"], "test_function"); + assert_eq!(serialized["functionCall"]["args"]["arg"], "value"); + // thoughtSignature field should not be present when None + assert!(serialized.get("thoughtSignature").is_none()); + } + + #[test] + fn test_function_call_part_deserializes_with_signature() { + let json = json!({ + "functionCall": { + "name": "test_function", + "args": {"arg": "value"} + }, + "thoughtSignature": "test_signature" + }); + + let part: FunctionCallPart = serde_json::from_value(json).unwrap(); + + assert_eq!(part.function_call.name, "test_function"); + assert_eq!(part.thought_signature, Some("test_signature".to_string())); + } + + #[test] + fn test_function_call_part_deserializes_without_signature() { + let json = json!({ + "functionCall": { + "name": "test_function", + "args": {"arg": "value"} + } + }); + + let part: FunctionCallPart = serde_json::from_value(json).unwrap(); + + assert_eq!(part.function_call.name, "test_function"); + assert_eq!(part.thought_signature, None); + } + + #[test] + fn test_function_call_part_round_trip() { + let original = FunctionCallPart { + function_call: FunctionCall { + name: "test_function".to_string(), + args: json!({"arg": "value", "nested": {"key": "val"}}), + }, + thought_signature: Some("round_trip_signature".to_string()), + }; + + let serialized = serde_json::to_value(&original).unwrap(); + let deserialized: FunctionCallPart = serde_json::from_value(serialized).unwrap(); + + assert_eq!(deserialized.function_call.name, original.function_call.name); + assert_eq!(deserialized.function_call.args, original.function_call.args); + assert_eq!(deserialized.thought_signature, original.thought_signature); + } + + #[test] + fn test_function_call_part_with_empty_signature_serializes() { + let part = FunctionCallPart { + function_call: FunctionCall { + name: "test_function".to_string(), + args: json!({"arg": "value"}), + }, + thought_signature: Some("".to_string()), + }; + + let serialized = serde_json::to_value(&part).unwrap(); + + // Empty string should still be serialized (normalization happens at a higher level) + assert_eq!(serialized["thoughtSignature"], ""); + } +} diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 39cadeb1d4..da7e660a01 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gpui" -version = "0.1.0" +version = "0.2.2" edition.workspace = true authors = ["Nathan Sobo "] description = "Zed's GPU-accelerated UI framework" @@ -21,7 +21,6 @@ default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ "leak-detection", "collections/test-support", - "rand", "util/test-support", "http_client/test-support", "wayland", @@ -39,6 +38,7 @@ macos-blade = [ "objc2-metal", ] wayland = [ + "bitflags", "blade-graphics", "blade-macros", "blade-util", @@ -52,6 +52,7 @@ wayland = [ "wayland-cursor", "wayland-protocols", "wayland-protocols-plasma", + "wayland-protocols-wlr", "filedescriptor", "xkbcommon", "open", @@ -85,7 +86,8 @@ doctest = false [dependencies] anyhow.workspace = true async-task = "4.7" -backtrace = { version = "0.3", optional = true } +backtrace = { workspace = true, optional = true } +bitflags = { workspace = true, optional = true } blade-graphics = { workspace = true, optional = true } blade-macros = { workspace = true, optional = true } blade-util = { workspace = true, optional = true } @@ -106,7 +108,7 @@ parking = "2.0.0" parking_lot.workspace = true postage.workspace = true profiling.workspace = true -rand = { optional = true, workspace = true } +rand.workspace = true raw-window-handle = "0.6" refineable.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ @@ -118,7 +120,7 @@ usvg = { version = "0.45.0", default-features = false } util_macros.workspace = true schemars.workspace = true seahash = "4.1" -semantic_version.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true slotmap.workspace = true @@ -133,12 +135,15 @@ util.workspace = true uuid.workspace = true waker-fn = "1.2.0" lyon = "1.0" -workspace-hack.workspace = true libc.workspace = true +pin-project = "1.1.10" +circular-buffer.workspace = true +spin = "0.10.0" [target.'cfg(target_os = "macos")'.dependencies] block = "0.1" cocoa.workspace = true +cocoa-foundation.workspace = true core-foundation.workspace = true core-foundation-sys.workspace = true core-graphics = "0.24" @@ -152,8 +157,10 @@ media.workspace = true objc.workspace = true objc2 = { version = "0.6", optional = true } objc2-metal = { version = "0.3", optional = true } +mach2.workspace = true #TODO: replace with "objc2" metal.workspace = true +flume = "0.11" [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))'.dependencies] pathfinder_geometry = "0.5" @@ -181,12 +188,12 @@ font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "11052312 "source-fontconfig-dlopen", ], optional = true } -calloop = { version = "0.13.0" } +calloop = { version = "0.14.3" } filedescriptor = { version = "0.8.2", optional = true } open = { version = "5.2.0", optional = true } # Wayland -calloop-wayland-source = { version = "0.3.0", optional = true } +calloop-wayland-source = { version = "0.4.1", optional = true } wayland-backend = { version = "0.3.3", features = [ "client_system", "dlopen", @@ -201,6 +208,9 @@ wayland-protocols = { version = "0.31.2", features = [ wayland-protocols-plasma = { version = "0.2.0", features = [ "client", ], optional = true } +wayland-protocols-wlr = { version = "0.3.9", features = [ + "client", +], optional = true } # X11 as-raw-xcb-connection = { version = "1", optional = true } @@ -233,7 +243,7 @@ windows-numerics = "0.2" windows-registry = "0.5" [dev-dependencies] -backtrace = "0.3" +backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true http_client = { workspace = true, features = ["test-support"] } @@ -246,6 +256,7 @@ util = { workspace = true, features = ["test-support"] } [target.'cfg(target_os = "windows")'.build-dependencies] embed-resource = "3.0" +windows-registry = "0.5" [target.'cfg(target_os = "macos")'.build-dependencies] bindgen = "0.71" @@ -319,3 +330,7 @@ path = "examples/window_shadow.rs" [[example]] name = "grid_layout" path = "examples/grid_layout.rs" + +[[example]] +name = "mouse_pressure" +path = "examples/mouse_pressure.rs" diff --git a/crates/gpui/README.md b/crates/gpui/README.md index 71d22e0cd8..ad3fd37fc5 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -11,6 +11,8 @@ GPUI is still in active development as we work on the Zed code editor, and is st gpui = { version = "*" } ``` + - [Ownership and data flow](_ownership_and_data_flow) + Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. ### Dependencies @@ -61,4 +63,4 @@ In addition to the systems above, GPUI provides a range of smaller services that - The `[gpui::test]` macro provides a convenient way to write tests for your GPUI applications. Tests also have their own kind of context, a `TestAppContext` which provides ways of simulating common platform input. See `app::test_context` and `test` modules for more details. -Currently, the best way to learn about these APIs is to read the Zed source code, ask us about it at a fireside hack, or drop a question in the [Zed Discord](https://zed.dev/community-links). We're working on improving the documentation, creating more examples, and will be publishing more guides to GPUI on our [blog](https://zed.dev/blog). +Currently, the best way to learn about these APIs is to read the Zed source code or drop a question in the [Zed Discord](https://zed.dev/community-links). We're working on improving the documentation, creating more examples, and will be publishing more guides to GPUI on our [blog](https://zed.dev/blog). diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index e48594101f..c7ae7ac9f2 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -49,7 +49,7 @@ fn check_wgsl_shaders() { // All clear } Err(e) => { - eprintln!("WGSL shader compilation failed:\n{}", e); + println!("cargo::error=WGSL shader compilation failed:\n{}", e); process::exit(1); } } @@ -84,6 +84,8 @@ mod macos { .allowlist_var("_dispatch_main_q") .allowlist_var("_dispatch_source_type_data_add") .allowlist_var("DISPATCH_QUEUE_PRIORITY_HIGH") + .allowlist_var("DISPATCH_QUEUE_PRIORITY_DEFAULT") + .allowlist_var("DISPATCH_QUEUE_PRIORITY_LOW") .allowlist_var("DISPATCH_TIME_NOW") .allowlist_function("dispatch_get_global_queue") .allowlist_function("dispatch_async_f") @@ -220,8 +222,8 @@ mod macos { .unwrap(); if !output.status.success() { - eprintln!( - "metal shader compilation failed:\n{}", + println!( + "cargo::error=metal shader compilation failed:\n{}", String::from_utf8_lossy(&output.stderr) ); process::exit(1); @@ -236,8 +238,8 @@ mod macos { .unwrap(); if !output.status.success() { - eprintln!( - "metallib compilation failed:\n{}", + println!( + "cargo::error=metallib compilation failed:\n{}", String::from_utf8_lossy(&output.stderr) ); process::exit(1); @@ -248,6 +250,7 @@ mod macos { #[cfg(target_os = "windows")] mod windows { use std::{ + ffi::OsString, fs, io::Write, path::{Path, PathBuf}, @@ -325,6 +328,49 @@ mod windows { } } + /// Locate `binary` in the newest installed Windows SDK. + pub fn find_latest_windows_sdk_binary( + binary: &str, + ) -> Result, Box> { + let key = windows_registry::LOCAL_MACHINE + .open("SOFTWARE\\WOW6432Node\\Microsoft\\Microsoft SDKs\\Windows\\v10.0")?; + + let install_folder: String = key.get_string("InstallationFolder")?; // "C:\Program Files (x86)\Windows Kits\10\" + let install_folder_bin = Path::new(&install_folder).join("bin"); + + let mut versions: Vec<_> = std::fs::read_dir(&install_folder_bin)? + .flatten() + .filter(|entry| entry.path().is_dir()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect(); + + versions.sort_by_key(|s| { + s.split('.') + .filter_map(|p| p.parse().ok()) + .collect::>() + }); + + let arch = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + _ => Err(format!( + "Unsupported architecture: {}", + std::env::consts::ARCH + ))?, + }; + + if let Some(highest_version) = versions.last() { + return Ok(Some( + install_folder_bin + .join(highest_version) + .join(arch) + .join(binary), + )); + } + + Ok(None) + } + /// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler. fn find_fxc_compiler() -> String { // Check environment variable @@ -345,12 +391,8 @@ mod windows { return path.trim().to_string(); } - // Check the default path - if Path::new(r"C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\fxc.exe") - .exists() - { - return r"C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\fxc.exe" - .to_string(); + if let Ok(Some(path)) = find_latest_windows_sdk_binary("fxc.exe") { + return path.to_string_lossy().into_owned(); } panic!("Failed to find fxc.exe"); @@ -418,15 +460,15 @@ mod windows { if result.status.success() { return; } - eprintln!( - "Shader compilation failed for {}:\n{}", + println!( + "cargo::error=Shader compilation failed for {}:\n{}", entry_point, String::from_utf8_lossy(&result.stderr) ); process::exit(1); } Err(e) => { - eprintln!("Failed to run fxc for {}: {}", entry_point, e); + println!("cargo::error=Failed to run fxc for {}: {}", entry_point, e); process::exit(1); } } diff --git a/crates/gpui/examples/animation.rs b/crates/gpui/examples/animation.rs index 90a8dc5730..16d6e1b269 100644 --- a/crates/gpui/examples/animation.rs +++ b/crates/gpui/examples/animation.rs @@ -3,8 +3,8 @@ use std::time::Duration; use anyhow::Result; use gpui::{ Animation, AnimationExt as _, App, Application, AssetSource, Bounds, Context, SharedString, - Transformation, Window, WindowBounds, WindowOptions, black, bounce, div, ease_in_out, - percentage, prelude::*, px, rgb, size, svg, + Transformation, Window, WindowBounds, WindowOptions, bounce, div, ease_in_out, percentage, + prelude::*, px, size, svg, }; struct Assets {} @@ -37,37 +37,66 @@ struct AnimationExample {} impl Render for AnimationExample { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().flex().flex_col().size_full().justify_around().child( - div().flex().flex_row().w_full().justify_around().child( + div() + .flex() + .flex_col() + .size_full() + .bg(gpui::white()) + .text_color(gpui::black()) + .justify_around() + .child( div() .flex() - .bg(rgb(0x2e7d32)) - .size(px(300.0)) - .justify_center() - .items_center() - .shadow_lg() - .text_xl() - .text_color(black()) - .child("hello") + .flex_col() + .size_full() + .justify_around() .child( - svg() - .size_8() - .path(ARROW_CIRCLE_SVG) - .text_color(black()) - .with_animation( - "image_circle", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(bounce(ease_in_out)), - |svg, delta| { - svg.with_transformation(Transformation::rotate(percentage( - delta, - ))) - }, + div() + .id("content") + .flex() + .flex_col() + .h(px(150.)) + .overflow_y_scroll() + .w_full() + .flex_1() + .justify_center() + .items_center() + .text_xl() + .gap_4() + .child("Hello Animation") + .child( + svg() + .size_20() + .overflow_hidden() + .path(ARROW_CIRCLE_SVG) + .text_color(gpui::black()) + .with_animation( + "image_circle", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(bounce(ease_in_out)), + |svg, delta| { + svg.with_transformation(Transformation::rotate( + percentage(delta), + )) + }, + ), ), + ) + .child( + div() + .flex() + .h(px(64.)) + .w_full() + .p_2() + .justify_center() + .items_center() + .border_t_1() + .border_color(gpui::black().opacity(0.1)) + .bg(gpui::black().opacity(0.05)) + .child("Other Panel"), ), - ), - ) + ) } } diff --git a/crates/gpui/examples/data_table.rs b/crates/gpui/examples/data_table.rs index e176c44d53..dd1a443a9d 100644 --- a/crates/gpui/examples/data_table.rs +++ b/crates/gpui/examples/data_table.rs @@ -374,7 +374,6 @@ impl DataTable { impl Render for DataTable { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() - .font_family(".SystemUIFont") .bg(gpui::white()) .text_sm() .size_full() @@ -439,7 +438,7 @@ impl Render for DataTable { }), ) .size_full() - .track_scroll(self.scroll_handle.clone()), + .track_scroll(&self.scroll_handle), ) .child(self.render_scrollbar(window, cx)), ), diff --git a/crates/gpui/examples/focus_visible.rs b/crates/gpui/examples/focus_visible.rs new file mode 100644 index 0000000000..737317caba --- /dev/null +++ b/crates/gpui/examples/focus_visible.rs @@ -0,0 +1,214 @@ +use gpui::{ + App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, + Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, +}; + +actions!(example, [Tab, TabPrev, Quit]); + +struct Example { + focus_handle: FocusHandle, + items: Vec<(FocusHandle, &'static str)>, + message: SharedString, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let items = vec![ + ( + cx.focus_handle().tab_index(1).tab_stop(true), + "Button with .focus() - always shows border when focused", + ), + ( + cx.focus_handle().tab_index(2).tab_stop(true), + "Button with .focus_visible() - only shows border with keyboard", + ), + ( + cx.focus_handle().tab_index(3).tab_stop(true), + "Button with both .focus() and .focus_visible()", + ), + ]; + + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle); + + Self { + focus_handle, + items, + message: SharedString::from( + "Try clicking vs tabbing! Click shows no border, Tab shows border.", + ), + } + } + + fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { + window.focus_next(); + self.message = SharedString::from("Pressed Tab - focus-visible border should appear!"); + } + + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { + window.focus_prev(); + self.message = + SharedString::from("Pressed Shift-Tab - focus-visible border should appear!"); + } + + fn on_quit(&mut self, _: &Quit, _window: &mut Window, cx: &mut Context) { + cx.quit(); + } +} + +impl Render for Example { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn button_base(id: impl Into, label: &'static str) -> Stateful

{ + div() + .id(id) + .h_16() + .w_full() + .flex() + .justify_center() + .items_center() + .bg(gpui::rgb(0x2563eb)) + .text_color(gpui::white()) + .rounded_md() + .cursor_pointer() + .hover(|style| style.bg(gpui::rgb(0x1d4ed8))) + .child(label) + } + + div() + .id("app") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_tab)) + .on_action(cx.listener(Self::on_tab_prev)) + .on_action(cx.listener(Self::on_quit)) + .size_full() + .flex() + .flex_col() + .p_8() + .gap_6() + .bg(gpui::rgb(0xf3f4f6)) + .child( + div() + .text_2xl() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x111827)) + .child("CSS focus-visible Demo"), + ) + .child( + div() + .p_4() + .rounded_md() + .bg(gpui::rgb(0xdbeafe)) + .text_color(gpui::rgb(0x1e3a8a)) + .child(self.message.clone()), + ) + .child( + div() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child("1. Regular .focus() - always visible:"), + ) + .child( + button_base("button1", self.items[0].1) + .track_focus(&self.items[0].0) + .focus(|style| { + style.border_4().border_color(gpui::rgb(0xfbbf24)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 1 - focus border is visible!".into(); + cx.notify(); + })), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child("2. New .focus_visible() - only keyboard:"), + ) + .child( + button_base("button2", self.items[1].1) + .track_focus(&self.items[1].0) + .focus_visible(|style| { + style.border_4().border_color(gpui::rgb(0x10b981)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 2 - no border! Try Tab instead.".into(); + cx.notify(); + })), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child( + "3. Both .focus() (yellow) and .focus_visible() (green):", + ), + ) + .child( + button_base("button3", self.items[2].1) + .track_focus(&self.items[2].0) + .focus(|style| { + style.border_4().border_color(gpui::rgb(0xfbbf24)) + }) + .focus_visible(|style| { + style.border_4().border_color(gpui::rgb(0x10b981)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 3 - yellow border. Tab shows green!" + .into(); + cx.notify(); + })), + ), + ), + ) + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("tab", Tab, None), + KeyBinding::new("shift-tab", TabPrev, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + + let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| Example::new(window, cx)), + ) + .unwrap(); + + cx.activate(true); + }); +} diff --git a/crates/gpui/examples/gradient.rs b/crates/gpui/examples/gradient.rs index 4a84d2319d..30fb3090a3 100644 --- a/crates/gpui/examples/gradient.rs +++ b/crates/gpui/examples/gradient.rs @@ -20,7 +20,6 @@ impl Render for GradientViewer { let color_space = self.color_space; div() - .font_family(".SystemUIFont") .bg(gpui::white()) .size_full() .p_4() diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs index e7abb196c7..1fa7a8678f 100644 --- a/crates/gpui/examples/image_gallery.rs +++ b/crates/gpui/examples/image_gallery.rs @@ -47,7 +47,6 @@ impl Render for ImageGallery { div() .image_cache(self.image_cache.clone()) .id("main") - .font_family(".SystemUIFont") .text_color(gpui::black()) .bg(rgb(0xE9E9E9)) .overflow_y_scroll() @@ -102,7 +101,6 @@ impl Render for ImageGallery { .child(image_cache(simple_lru_cache("lru-cache", IMAGES_IN_GALLERY)).child( div() .id("main") - .font_family(".SystemUIFont") .bg(rgb(0xE9E9E9)) .text_color(gpui::black()) .overflow_y_scroll() diff --git a/crates/gpui/examples/layer_shell.rs b/crates/gpui/examples/layer_shell.rs new file mode 100644 index 0000000000..51577b1b26 --- /dev/null +++ b/crates/gpui/examples/layer_shell.rs @@ -0,0 +1,87 @@ +fn main() { + #[cfg(all(target_os = "linux", feature = "wayland"))] + example::main(); + + #[cfg(not(all(target_os = "linux", feature = "wayland")))] + panic!("This example requires the `wayland` feature and a linux system."); +} + +#[cfg(all(target_os = "linux", feature = "wayland"))] +mod example { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use gpui::{ + App, Application, Bounds, Context, FontWeight, Size, Window, WindowBackgroundAppearance, + WindowBounds, WindowKind, WindowOptions, div, layer_shell::*, point, prelude::*, px, rems, + rgba, white, + }; + + struct LayerShellExample; + + impl LayerShellExample { + fn new(cx: &mut Context) -> Self { + cx.spawn(async move |this, cx| { + loop { + let _ = this.update(cx, |_, cx| cx.notify()); + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + } + }) + .detach(); + + LayerShellExample + } + } + + impl Render for LayerShellExample { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let hours = (now / 3600) % 24; + let minutes = (now / 60) % 60; + let seconds = now % 60; + + div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_size(rems(4.5)) + .font_weight(FontWeight::EXTRA_BOLD) + .text_color(white()) + .bg(rgba(0x0000044)) + .rounded_xl() + .child(format!("{:02}:{:02}:{:02}", hours, minutes, seconds)) + } + } + + pub fn main() { + Application::new().run(|cx: &mut App| { + cx.open_window( + WindowOptions { + titlebar: None, + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: Size::new(px(500.), px(200.)), + })), + app_id: Some("gpui-layer-shell-example".to_string()), + window_background: WindowBackgroundAppearance::Transparent, + kind: WindowKind::LayerShell(LayerShellOptions { + namespace: "gpui".to_string(), + anchor: Anchor::LEFT | Anchor::RIGHT | Anchor::BOTTOM, + margin: Some((px(0.), px(0.), px(40.), px(0.))), + keyboard_interactivity: KeyboardInteractivity::None, + ..Default::default() + }), + ..Default::default() + }, + |_, cx| cx.new(LayerShellExample::new), + ) + .unwrap(); + }); + } +} diff --git a/crates/gpui/examples/mouse_pressure.rs b/crates/gpui/examples/mouse_pressure.rs new file mode 100644 index 0000000000..12790f988e --- /dev/null +++ b/crates/gpui/examples/mouse_pressure.rs @@ -0,0 +1,66 @@ +use gpui::{ + App, Application, Bounds, Context, MousePressureEvent, PressureStage, Window, WindowBounds, + WindowOptions, div, prelude::*, px, rgb, size, +}; + +struct MousePressureExample { + pressure_stage: PressureStage, + pressure_amount: f32, +} + +impl Render for MousePressureExample { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_3() + .bg(rgb(0x505050)) + .size(px(500.0)) + .justify_center() + .items_center() + .shadow_lg() + .border_1() + .border_color(rgb(0x0000ff)) + .text_xl() + .text_color(rgb(0xffffff)) + .child(format!("Pressure stage: {:?}", &self.pressure_stage)) + .child(format!("Pressure amount: {:.2}", &self.pressure_amount)) + .on_mouse_pressure(cx.listener(Self::on_mouse_pressure)) + } +} + +impl MousePressureExample { + fn on_mouse_pressure( + &mut self, + pressure_event: &MousePressureEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.pressure_amount = pressure_event.pressure; + self.pressure_stage = pressure_event.stage; + + cx.notify(); + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| { + cx.new(|_| MousePressureExample { + pressure_stage: PressureStage::Zero, + pressure_amount: 0.0, + }) + }, + ) + .unwrap(); + + cx.activate(true); + }); +} diff --git a/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index 634df29a4c..b6c01fc3cf 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -1,10 +1,9 @@ -use std::{fs, path::PathBuf, time::Duration}; +use std::{fs, path::PathBuf}; use anyhow::Result; use gpui::{ App, Application, AssetSource, Bounds, BoxShadow, ClickEvent, Context, SharedString, Task, - Timer, Window, WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, - svg, + Window, WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, svg, }; struct Assets { @@ -37,6 +36,7 @@ impl AssetSource for Assets { struct HelloWorld { _task: Option>, opacity: f32, + animating: bool, } impl HelloWorld { @@ -44,39 +44,29 @@ impl HelloWorld { Self { _task: None, opacity: 0.5, + animating: false, } } - fn change_opacity(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + fn start_animation(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { self.opacity = 0.0; + self.animating = true; cx.notify(); - - self._task = Some(cx.spawn_in(window, async move |view, cx| { - loop { - Timer::after(Duration::from_secs_f32(0.05)).await; - let mut stop = false; - let _ = cx.update(|_, cx| { - view.update(cx, |view, cx| { - if view.opacity >= 1.0 { - stop = true; - return; - } - - view.opacity += 0.1; - cx.notify(); - }) - }); - - if stop { - break; - } - } - })); } } impl Render for HelloWorld { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.animating { + self.opacity += 0.005; + if self.opacity >= 1.0 { + self.animating = false; + self.opacity = 1.0; + } else { + window.request_animation_frame(); + } + } + div() .flex() .flex_row() @@ -96,7 +86,7 @@ impl Render for HelloWorld { .child( div() .id("panel") - .on_click(cx.listener(Self::change_opacity)) + .on_click(cx.listener(Self::start_animation)) .absolute() .top_8() .left_8() @@ -150,7 +140,15 @@ impl Render for HelloWorld { .text_2xl() .size_8(), ) - .child("🎊✈️🎉🎈🎁🎂") + .child( + div() + .flex() + .children(["🎊", "✈️", "🎉", "🎈", "🎁", "🎂"].map(|emoji| { + div() + .child(emoji.to_string()) + .hover(|style| style.opacity(0.5)) + })), + ) .child(img("image/black-cat-typing.gif").size_12()), ), ) diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index 668aed2377..9f15d12f46 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -1,7 +1,7 @@ use gpui::{ Application, Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder, - PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, canvas, - div, linear_color_stop, linear_gradient, point, prelude::*, px, quad, rgb, size, + PathStyle, Pixels, Point, Render, StrokeOptions, Window, WindowOptions, canvas, div, + linear_color_stop, linear_gradient, point, prelude::*, px, quad, rgb, size, }; struct PaintingViewer { @@ -309,7 +309,7 @@ fn button( on_click: impl Fn(&mut PaintingViewer, &mut Context) + 'static, ) -> impl IntoElement { div() - .id(SharedString::from(text.to_string())) + .id(text.to_string()) .child(text.to_string()) .bg(gpui::black()) .text_color(gpui::white()) @@ -328,7 +328,6 @@ impl Render for PaintingViewer { let dashed = self.dashed; div() - .font_family(".SystemUIFont") .bg(gpui::white()) .size_full() .p_4() diff --git a/crates/gpui/examples/set_menus.rs b/crates/gpui/examples/set_menus.rs index 8a97a8d8a2..f4e5e5e112 100644 --- a/crates/gpui/examples/set_menus.rs +++ b/crates/gpui/examples/set_menus.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Context, Menu, MenuItem, SystemMenuType, Window, WindowOptions, actions, div, - prelude::*, rgb, + App, Application, Context, Global, Menu, MenuItem, SharedString, SystemMenuType, Window, + WindowOptions, actions, div, prelude::*, rgb, }; struct SetMenus; @@ -21,29 +21,87 @@ impl Render for SetMenus { fn main() { Application::new().run(|cx: &mut App| { + cx.set_global(AppState::new()); + // Bring the menu bar to the foreground (so you can see the menu bar) cx.activate(true); // Register the `quit` function so it can be referenced by the `MenuItem::action` in the menu bar cx.on_action(quit); + cx.on_action(toggle_check); // Add menu items - cx.set_menus(vec![Menu { - name: "set_menus".into(), - items: vec![ - MenuItem::os_submenu("Services", SystemMenuType::Services), - MenuItem::separator(), - MenuItem::action("Quit", Quit), - ], - }]); + set_app_menus(cx); cx.open_window(WindowOptions::default(), |_, cx| cx.new(|_| SetMenus {})) .unwrap(); }); } +#[derive(PartialEq)] +enum ViewMode { + List, + Grid, +} + +impl ViewMode { + fn toggle(&mut self) { + *self = match self { + ViewMode::List => ViewMode::Grid, + ViewMode::Grid => ViewMode::List, + } + } +} + +impl Into for ViewMode { + fn into(self) -> SharedString { + match self { + ViewMode::List => "List", + ViewMode::Grid => "Grid", + } + .into() + } +} + +struct AppState { + view_mode: ViewMode, +} + +impl AppState { + fn new() -> Self { + Self { + view_mode: ViewMode::List, + } + } +} + +impl Global for AppState {} + +fn set_app_menus(cx: &mut App) { + let app_state = cx.global::(); + cx.set_menus(vec![Menu { + name: "set_menus".into(), + items: vec![ + MenuItem::os_submenu("Services", SystemMenuType::Services), + MenuItem::separator(), + MenuItem::action(ViewMode::List, ToggleCheck) + .checked(app_state.view_mode == ViewMode::List), + MenuItem::action(ViewMode::Grid, ToggleCheck) + .checked(app_state.view_mode == ViewMode::Grid), + MenuItem::separator(), + MenuItem::action("Quit", Quit), + ], + }]); +} + // Associate actions using the `actions!` macro (or `Action` derive macro) -actions!(set_menus, [Quit]); +actions!(set_menus, [Quit, ToggleCheck]); // Define the quit function that is registered with the App fn quit(_: &Quit, cx: &mut App) { println!("Gracefully quitting the application . . ."); cx.quit(); } + +fn toggle_check(_: &ToggleCheck, cx: &mut App) { + let app_state = cx.global_mut::(); + app_state.view_mode.toggle(); + set_app_menus(cx); +} diff --git a/crates/gpui/examples/text_layout.rs b/crates/gpui/examples/text_layout.rs index c4cbcd4e5e..8929955ba8 100644 --- a/crates/gpui/examples/text_layout.rs +++ b/crates/gpui/examples/text_layout.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, - size, + App, Application, Bounds, Context, FontStyle, FontWeight, StyledText, Window, WindowBounds, + WindowOptions, div, prelude::*, px, size, }; struct HelloWorld {} @@ -71,6 +71,12 @@ impl Render for HelloWorld { .child("100%"), ), ) + .child(div().flex().gap_2().justify_between().child( + StyledText::new("ABCD").with_highlights([ + (0..1, FontWeight::EXTRA_BOLD.into()), + (2..3, FontStyle::Italic.into()), + ]), + )) } } diff --git a/crates/gpui/examples/text_wrapper.rs b/crates/gpui/examples/text_wrapper.rs index 4c6e5e2ac8..18372ea9e1 100644 --- a/crates/gpui/examples/text_wrapper.rs +++ b/crates/gpui/examples/text_wrapper.rs @@ -7,7 +7,11 @@ struct HelloWorld {} impl Render for HelloWorld { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let text = "The longest word 你好世界这段是中文,こんにちはこの段落は日本語です in any of the major English language dictionaries is pneumonoultramicroscopicsilicovolcanoconiosis, a word that refers to a lung disease contracted from the inhalation of very fine silica particles, specifically from a volcano; medically, it is the same as silicosis."; + let text = "The longest word 你好世界这段是中文,こんにちはこの段落は日本語です in any of the major \ + English language dictionaries is pneumonoultramicroscopicsilicovolcanoconiosis, a word that \ + refers to a lung disease contracted from the inhalation of very fine silica particles, \ + a url https://github.com/zed-industries/zed/pull/35724?query=foo&bar=2, \ + specifically from a volcano; medically, it is the same as silicosis."; div() .id("page") .size_full() diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index 4445f24e4e..06003c4663 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, SharedString, Timer, - Window, WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, + App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Timer, Window, + WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, }; struct SubWindow { @@ -9,7 +9,7 @@ struct SubWindow { fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> impl IntoElement { div() - .id(SharedString::from(text.to_string())) + .id(text.to_string()) .flex_none() .px_2() .bg(rgb(0xf7f7f7)) diff --git a/crates/gpui/resources/windows/gpui.manifest.xml b/crates/gpui/resources/windows/gpui.manifest.xml index 5a69b43486..c3a99d23ff 100644 --- a/crates/gpui/resources/windows/gpui.manifest.xml +++ b/crates/gpui/resources/windows/gpui.manifest.xml @@ -1,16 +1,32 @@ - - - - true + + + + + + + + + + + + + + + + + true/pm PerMonitorV2 - - + + - + version='6.0.0.0' + processorArchitecture='*' + publicKeyToken='6595b64144ccf1df' + /> diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 07ff04e32a..aa1acae33b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -38,10 +38,11 @@ use crate::{ AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder, - PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, - Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, - TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, + PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, + RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, + Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, WindowHandle, WindowId, + WindowInvalidator, colors::{Colors, GlobalColors}, current_platform, hash, init_app_menus, }; @@ -169,6 +170,13 @@ impl Application { self } + /// Configures when the application should automatically quit. + /// By default, [`QuitMode::Default`] is used. + pub fn with_quit_mode(self, mode: QuitMode) -> Self { + self.0.borrow_mut().quit_mode = mode; + self + } + /// Start the application. The provided callback will be called once the /// app is fully launched. pub fn run(self, on_finish_launching: F) @@ -238,6 +246,18 @@ type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; +/// Defines when the application should automatically quit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum QuitMode { + /// Use [`QuitMode::Explicit`] on macOS and [`QuitMode::LastWindowClosed`] on other platforms. + #[default] + Default, + /// Quit automatically when the last window is closed. + LastWindowClosed, + /// Quit only when requested via [`App::quit`]. + Explicit, +} + #[doc(hidden)] #[derive(Clone, PartialEq, Eq)] pub struct SystemWindowTab { @@ -344,13 +364,9 @@ impl SystemWindowTabController { let tab_group = self .tab_groups .iter() - .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group)); + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?; - if let Some(tab_group) = tab_group { - self.tab_groups.get(&tab_group) - } else { - None - } + self.tab_groups.get(&tab_group) } /// Initialize the visibility of the system window tab controller. @@ -415,7 +431,8 @@ impl SystemWindowTabController { for windows in controller.tab_groups.values_mut() { for tab in windows.iter_mut() { if tab.id == id { - tab.title = title.clone(); + tab.title = title; + return; } } } @@ -535,12 +552,39 @@ impl SystemWindowTabController { } } +pub(crate) enum GpuiMode { + #[cfg(any(test, feature = "test-support"))] + Test { + skip_drawing: bool, + }, + Production, +} + +impl GpuiMode { + #[cfg(any(test, feature = "test-support"))] + pub fn test() -> Self { + GpuiMode::Test { + skip_drawing: false, + } + } + + #[inline] + pub(crate) fn skip_drawing(&self) -> bool { + match self { + #[cfg(any(test, feature = "test-support"))] + GpuiMode::Test { skip_drawing } => *skip_drawing, + GpuiMode::Production => false, + } + } +} + /// Contains the state of the full application, and passed as a reference to a variety of callbacks. /// Other [Context] derefs to this type. /// You need a reference to an `App` to access the state of a [Entity]. pub struct App { pub(crate) this: Weak, pub(crate) platform: Rc, + pub(crate) mode: GpuiMode, text_system: Arc, flushing_effects: bool, pending_updates: usize, @@ -556,7 +600,7 @@ pub struct App { pub(crate) entities: EntityMap, pub(crate) window_update_stack: Vec, pub(crate) new_entity_observers: SubscriberSet, - pub(crate) windows: SlotMap>, + pub(crate) windows: SlotMap>>, pub(crate) window_handles: FxHashMap, pub(crate) focus_handles: Arc, pub(crate) keymap: Rc>, @@ -591,6 +635,7 @@ pub struct App { pub(crate) inspector_element_registry: InspectorElementRegistry, #[cfg(any(test, feature = "test-support", debug_assertions))] pub(crate) name: Option<&'static str>, + quit_mode: QuitMode, quitting: bool, } @@ -618,6 +663,7 @@ impl App { this: this.clone(), platform: platform.clone(), text_system, + mode: GpuiMode::Production, actions: Rc::new(ActionRegistry::default()), flushing_effects: false, pending_updates: 0, @@ -662,6 +708,7 @@ impl App { inspector_renderer: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector_element_registry: InspectorElementRegistry::default(), + quit_mode: QuitMode::default(), quitting: false, #[cfg(any(test, feature = "test-support", debug_assertions))] @@ -967,7 +1014,7 @@ impl App { clear.clear(); cx.window_handles.insert(id, window.handle); - cx.windows.get_mut(id).unwrap().replace(window); + cx.windows.get_mut(id).unwrap().replace(Box::new(window)); Ok(handle) } Err(e) => { @@ -1175,6 +1222,12 @@ impl App { self.http_client = new_client; } + /// Configures when the application should automatically quit. + /// By default, [`QuitMode::Default`] is used. + pub fn set_quit_mode(&mut self, mode: QuitMode) { + self.quit_mode = mode; + } + /// Returns the SVG renderer used by the application. pub fn svg_renderer(&self) -> SvgRenderer { self.svg_renderer.clone() @@ -1242,7 +1295,7 @@ impl App { .windows .values() .filter_map(|window| { - let window = window.as_ref()?; + let window = window.as_deref()?; window.invalidator.is_dirty().then_some(window.handle) }) .collect::>() @@ -1323,7 +1376,7 @@ impl App { fn apply_refresh_effect(&mut self) { for window in self.windows.values_mut() { - if let Some(window) = window.as_mut() { + if let Some(window) = window.as_deref_mut() { window.refreshing = true; window.invalidator.set_dirty(true); } @@ -1382,6 +1435,16 @@ impl App { callback(cx); true }); + + let quit_on_empty = match cx.quit_mode { + QuitMode::Explicit => false, + QuitMode::LastWindowClosed => true, + QuitMode::Default => cfg!(not(target_os = "macos")), + }; + + if quit_on_empty && cx.windows.is_empty() { + cx.quit(); + } } else { cx.windows.get_mut(id)?.replace(window); } @@ -1432,6 +1495,24 @@ impl App { .spawn(async move { f(&mut cx).await }) } + /// Spawns the future returned by the given function on the main thread with + /// the given priority. The closure will be invoked with [AsyncApp], which + /// allows the application state to be accessed across await points. + pub fn spawn_with_priority(&self, priority: Priority, f: AsyncFn) -> Task + where + AsyncFn: AsyncFnOnce(&mut AsyncApp) -> R + 'static, + R: 'static, + { + if self.quitting { + debug_panic!("Can't spawn on main thread after on_app_quit") + }; + + let mut cx = self.to_async(); + + self.foreground_executor + .spawn_with_priority(priority, async move { f(&mut cx).await }) + } + /// Schedules the given function to be run at the end of the current effect cycle, allowing entities /// that are currently on the stack to be returned to the app. pub fn defer(&mut self, f: impl FnOnce(&mut App) + 'static) { @@ -1696,7 +1777,10 @@ impl App { /// Register a global handler for actions invoked via the keyboard. These handlers are run at /// the end of the bubble phase for actions, and so will only be invoked if there are no other /// handlers or if they called `cx.propagate()`. - pub fn on_action(&mut self, listener: impl Fn(&A, &mut Self) + 'static) { + pub fn on_action( + &mut self, + listener: impl Fn(&A, &mut Self) + 'static, + ) -> &mut Self { self.global_action_listeners .entry(TypeId::of::()) .or_default() @@ -1706,6 +1790,7 @@ impl App { listener(action, cx) } })); + self } /// Event handlers propagate events by default. Call this method to stop dispatching to @@ -2202,7 +2287,7 @@ impl AppContext for App { .windows .get(window.id) .context("window not found")? - .as_ref() + .as_deref() .expect("attempted to read a window that is already on the stack"); let root_view = window.root.clone().unwrap(); @@ -2366,10 +2451,6 @@ impl HttpClient for NullHttpClient { fn proxy(&self) -> Option<&Url> { None } - - fn type_name(&self) -> &'static str { - type_name::() - } } /// A mutable reference to an entity owned by GPUI diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index cfe7a5a75c..f5dcd30ae9 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -176,7 +176,7 @@ impl AsyncApp { lock.open_window(options, build_root_view) } - /// Schedule a future to be polled in the background. + /// Schedule a future to be polled in the foreground. #[track_caller] pub fn spawn(&self, f: AsyncFn) -> Task where @@ -296,8 +296,8 @@ impl AsyncWindowContext { /// A convenience method for [`Window::on_next_frame`]. pub fn on_next_frame(&mut self, f: impl FnOnce(&mut Window, &mut App) + 'static) { - self.window - .update(self, |_, window, _| window.on_next_frame(f)) + self.app + .update_window(self.window, |_, window, _| window.on_next_frame(f)) .ok(); } @@ -306,8 +306,8 @@ impl AsyncWindowContext { &mut self, read: impl FnOnce(&G, &Window, &App) -> R, ) -> Result { - self.window - .update(self, |_, window, cx| read(cx.global(), window, cx)) + self.app + .update_window(self.window, |_, window, cx| read(cx.global(), window, cx)) } /// A convenience method for [`App::update_global`](BorrowAppContext::update_global). @@ -319,7 +319,7 @@ impl AsyncWindowContext { where G: Global, { - self.window.update(self, |_, window, cx| { + self.app.update_window(self.window, |_, window, cx| { cx.update_global(|global, cx| update(global, window, cx)) }) } @@ -350,8 +350,8 @@ impl AsyncWindowContext { where T: Clone + Into, { - self.window - .update(self, |_, window, cx| { + self.app + .update_window(self.window, |_, window, cx| { window.prompt(level, message, detail, answers, cx) }) .unwrap_or_else(|_| oneshot::channel().1) @@ -365,11 +365,13 @@ impl AppContext for AsyncWindowContext { where T: 'static, { - self.window.update(self, |_, _, cx| cx.new(build_entity)) + self.app + .update_window(self.window, |_, _, cx| cx.new(build_entity)) } fn reserve_entity(&mut self) -> Result> { - self.window.update(self, |_, _, cx| cx.reserve_entity()) + self.app + .update_window(self.window, |_, _, cx| cx.reserve_entity()) } fn insert_entity( @@ -377,8 +379,9 @@ impl AppContext for AsyncWindowContext { reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> T, ) -> Self::Result> { - self.window - .update(self, |_, _, cx| cx.insert_entity(reservation, build_entity)) + self.app.update_window(self.window, |_, _, cx| { + cx.insert_entity(reservation, build_entity) + }) } fn update_entity( @@ -386,8 +389,8 @@ impl AppContext for AsyncWindowContext { handle: &Entity, update: impl FnOnce(&mut T, &mut Context) -> R, ) -> Result { - self.window - .update(self, |_, _, cx| cx.update_entity(handle, update)) + self.app + .update_window(self.window, |_, _, cx| cx.update_entity(handle, update)) } fn as_mut<'a, T>(&'a mut self, _: &Entity) -> Self::Result> @@ -452,8 +455,9 @@ impl VisualContext for AsyncWindowContext { &mut self, build_entity: impl FnOnce(&mut Window, &mut Context) -> T, ) -> Self::Result> { - self.window - .update(self, |_, window, cx| cx.new(|cx| build_entity(window, cx))) + self.app.update_window(self.window, |_, window, cx| { + cx.new(|cx| build_entity(window, cx)) + }) } fn update_window_entity( @@ -461,7 +465,7 @@ impl VisualContext for AsyncWindowContext { view: &Entity, update: impl FnOnce(&mut T, &mut Window, &mut Context) -> R, ) -> Self::Result { - self.window.update(self, |_, window, cx| { + self.app.update_window(self.window, |_, window, cx| { view.update(cx, |entity, cx| update(entity, window, cx)) }) } @@ -473,15 +477,16 @@ impl VisualContext for AsyncWindowContext { where V: 'static + Render, { - self.window - .update(self, |_, window, cx| window.replace_root(cx, build_view)) + self.app.update_window(self.window, |_, window, cx| { + window.replace_root(cx, build_view) + }) } fn focus(&mut self, view: &Entity) -> Self::Result<()> where V: Focusable, { - self.window.update(self, |_, window, cx| { + self.app.update_window(self.window, |_, window, cx| { view.read(cx).focus_handle(cx).focus(window); }) } diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 41d6cac82b..27ccbecaf8 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -1,7 +1,7 @@ use crate::{ AnyView, AnyWindowHandle, AppContext, AsyncApp, DispatchPhase, Effect, EntityId, EventEmitter, - FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Reservation, SubscriberSet, - Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, + FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Priority, Reservation, + SubscriberSet, Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, }; use anyhow::Result; use futures::FutureExt; @@ -667,6 +667,25 @@ impl<'a, T: 'static> Context<'a, T> { window.spawn(self, async move |cx| f(view, cx).await) } + /// Schedule a future to be run asynchronously with the given priority. + /// The given callback is invoked with a [`WeakEntity`] to avoid leaking the entity for a long-running process. + /// It's also given an [`AsyncWindowContext`], which can be used to access the state of the entity across await points. + /// The returned future will be polled on the main thread. + #[track_caller] + pub fn spawn_in_with_priority( + &self, + priority: Priority, + window: &Window, + f: AsyncFn, + ) -> Task + where + R: 'static, + AsyncFn: AsyncFnOnce(WeakEntity, &mut AsyncWindowContext) -> R + 'static, + { + let view = self.weak_entity(); + window.spawn_with_priority(priority, self, async move |cx| f(view, cx).await) + } + /// Register a callback to be invoked when the given global state changes. pub fn observe_global_in( &mut self, @@ -736,14 +755,17 @@ impl Context<'_, T> { impl AppContext for Context<'_, T> { type Result = U; + #[inline] fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> U) -> Entity { self.app.new(build_entity) } + #[inline] fn reserve_entity(&mut self) -> Reservation { self.app.reserve_entity() } + #[inline] fn insert_entity( &mut self, reservation: Reservation, @@ -752,6 +774,7 @@ impl AppContext for Context<'_, T> { self.app.insert_entity(reservation, build_entity) } + #[inline] fn update_entity( &mut self, handle: &Entity, @@ -760,6 +783,7 @@ impl AppContext for Context<'_, T> { self.app.update_entity(handle, update) } + #[inline] fn as_mut<'a, E>(&'a mut self, handle: &Entity) -> Self::Result> where E: 'static, @@ -767,6 +791,7 @@ impl AppContext for Context<'_, T> { self.app.as_mut(handle) } + #[inline] fn read_entity( &self, handle: &Entity, @@ -778,6 +803,7 @@ impl AppContext for Context<'_, T> { self.app.read_entity(handle, read) } + #[inline] fn update_window(&mut self, window: AnyWindowHandle, update: F) -> Result where F: FnOnce(AnyView, &mut Window, &mut App) -> R, @@ -785,6 +811,7 @@ impl AppContext for Context<'_, T> { self.app.update_window(window, update) } + #[inline] fn read_window( &self, window: &WindowHandle, @@ -796,6 +823,7 @@ impl AppContext for Context<'_, T> { self.app.read_window(window, read) } + #[inline] fn background_spawn(&self, future: impl Future + Send + 'static) -> Task where R: Send + 'static, @@ -803,6 +831,7 @@ impl AppContext for Context<'_, T> { self.app.background_executor.spawn(future) } + #[inline] fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result where G: Global, diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index ea52b46d9f..8c1bdfa1ce 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -244,11 +244,13 @@ impl AnyEntity { } /// Returns the id associated with this entity. + #[inline] pub fn entity_id(&self) -> EntityId { self.entity_id } /// Returns the [TypeId] associated with this entity. + #[inline] pub fn entity_type(&self) -> TypeId { self.entity_type } @@ -332,18 +334,21 @@ impl Drop for AnyEntity { } impl From> for AnyEntity { + #[inline] fn from(entity: Entity) -> Self { entity.any_entity } } impl Hash for AnyEntity { + #[inline] fn hash(&self, state: &mut H) { self.entity_id.hash(state); } } impl PartialEq for AnyEntity { + #[inline] fn eq(&self, other: &Self) -> bool { self.entity_id == other.entity_id } @@ -352,12 +357,14 @@ impl PartialEq for AnyEntity { impl Eq for AnyEntity {} impl Ord for AnyEntity { + #[inline] fn cmp(&self, other: &Self) -> Ordering { self.entity_id.cmp(&other.entity_id) } } impl PartialOrd for AnyEntity { + #[inline] fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } @@ -378,14 +385,13 @@ pub struct Entity { #[deref] #[deref_mut] pub(crate) any_entity: AnyEntity, - pub(crate) entity_type: PhantomData, + pub(crate) entity_type: PhantomData T>, } -unsafe impl Send for Entity {} -unsafe impl Sync for Entity {} impl Sealed for Entity {} impl Entity { + #[inline] fn new(id: EntityId, entity_map: Weak>) -> Self where T: 'static, @@ -397,11 +403,13 @@ impl Entity { } /// Get the entity ID associated with this entity + #[inline] pub fn entity_id(&self) -> EntityId { self.any_entity.entity_id } /// Downgrade this entity pointer to a non-retaining weak pointer + #[inline] pub fn downgrade(&self) -> WeakEntity { WeakEntity { any_entity: self.any_entity.downgrade(), @@ -410,16 +418,19 @@ impl Entity { } /// Convert this into a dynamically typed entity. + #[inline] pub fn into_any(self) -> AnyEntity { self.any_entity } /// Grab a reference to this entity from the context. + #[inline] pub fn read<'a>(&self, cx: &'a App) -> &'a T { cx.entities.read(self) } /// Read the entity referenced by this handle with the given function. + #[inline] pub fn read_with( &self, cx: &C, @@ -429,6 +440,7 @@ impl Entity { } /// Updates the entity referenced by this handle with the given function. + #[inline] pub fn update( &self, cx: &mut C, @@ -438,6 +450,7 @@ impl Entity { } /// Updates the entity referenced by this handle with the given function. + #[inline] pub fn as_mut<'a, C: AppContext>(&self, cx: &'a mut C) -> C::Result> { cx.as_mut(self) } @@ -453,6 +466,7 @@ impl Entity { /// Updates the entity referenced by this handle with the given function if /// the referenced entity still exists, within a visual context that has a window. /// Returns an error if the entity has been released. + #[inline] pub fn update_in( &self, cx: &mut C, @@ -463,6 +477,7 @@ impl Entity { } impl Clone for Entity { + #[inline] fn clone(&self) -> Self { Self { any_entity: self.any_entity.clone(), @@ -481,12 +496,14 @@ impl std::fmt::Debug for Entity { } impl Hash for Entity { + #[inline] fn hash(&self, state: &mut H) { self.any_entity.hash(state); } } impl PartialEq for Entity { + #[inline] fn eq(&self, other: &Self) -> bool { self.any_entity == other.any_entity } @@ -495,18 +512,21 @@ impl PartialEq for Entity { impl Eq for Entity {} impl PartialEq> for Entity { + #[inline] fn eq(&self, other: &WeakEntity) -> bool { self.any_entity.entity_id() == other.entity_id() } } impl Ord for Entity { + #[inline] fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.entity_id().cmp(&other.entity_id()) } } impl PartialOrd for Entity { + #[inline] fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } @@ -522,6 +542,7 @@ pub struct AnyWeakEntity { impl AnyWeakEntity { /// Get the entity ID associated with this weak reference. + #[inline] pub fn entity_id(&self) -> EntityId { self.entity_id } @@ -563,7 +584,33 @@ impl AnyWeakEntity { }) } - /// Assert that entity referenced by this weak handle has been released. + /// Asserts that the entity referenced by this weak handle has been fully released. + /// + /// # Example + /// + /// ```ignore + /// let entity = cx.new(|_| MyEntity::new()); + /// let weak = entity.downgrade(); + /// drop(entity); + /// + /// // Verify the entity was released + /// weak.assert_released(); + /// ``` + /// + /// # Debugging Leaks + /// + /// If this method panics due to leaked handles, set the `LEAK_BACKTRACE` environment + /// variable to see where the leaked handles were allocated: + /// + /// ```bash + /// LEAK_BACKTRACE=1 cargo test my_test + /// ``` + /// + /// # Panics + /// + /// - Panics if any strong handles to the entity are still alive. + /// - Panics if the entity was recently dropped but cleanup hasn't completed yet + /// (resources are retained until the end of the effect cycle). #[cfg(any(test, feature = "leak-detection"))] pub fn assert_released(&self) { self.entity_ref_counts @@ -620,18 +667,21 @@ impl std::fmt::Debug for AnyWeakEntity { } impl From> for AnyWeakEntity { + #[inline] fn from(entity: WeakEntity) -> Self { entity.any_entity } } impl Hash for AnyWeakEntity { + #[inline] fn hash(&self, state: &mut H) { self.entity_id.hash(state); } } impl PartialEq for AnyWeakEntity { + #[inline] fn eq(&self, other: &Self) -> bool { self.entity_id == other.entity_id } @@ -640,12 +690,14 @@ impl PartialEq for AnyWeakEntity { impl Eq for AnyWeakEntity {} impl Ord for AnyWeakEntity { + #[inline] fn cmp(&self, other: &Self) -> Ordering { self.entity_id.cmp(&other.entity_id) } } impl PartialOrd for AnyWeakEntity { + #[inline] fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } @@ -657,7 +709,7 @@ pub struct WeakEntity { #[deref] #[deref_mut] any_entity: AnyWeakEntity, - entity_type: PhantomData, + entity_type: PhantomData T>, } impl std::fmt::Debug for WeakEntity { @@ -669,9 +721,6 @@ impl std::fmt::Debug for WeakEntity { } } -unsafe impl Send for WeakEntity {} -unsafe impl Sync for WeakEntity {} - impl Clone for WeakEntity { fn clone(&self) -> Self { Self { @@ -745,6 +794,7 @@ impl WeakEntity { } /// Create a new weak entity that can never be upgraded. + #[inline] pub fn new_invalid() -> Self { Self { any_entity: AnyWeakEntity::new_invalid(), @@ -754,12 +804,14 @@ impl WeakEntity { } impl Hash for WeakEntity { + #[inline] fn hash(&self, state: &mut H) { self.any_entity.hash(state); } } impl PartialEq for WeakEntity { + #[inline] fn eq(&self, other: &Self) -> bool { self.any_entity == other.any_entity } @@ -768,33 +820,90 @@ impl PartialEq for WeakEntity { impl Eq for WeakEntity {} impl PartialEq> for WeakEntity { + #[inline] fn eq(&self, other: &Entity) -> bool { self.entity_id() == other.any_entity.entity_id() } } impl Ord for WeakEntity { + #[inline] fn cmp(&self, other: &Self) -> Ordering { self.entity_id().cmp(&other.entity_id()) } } impl PartialOrd for WeakEntity { + #[inline] fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } +/// Controls whether backtraces are captured when entity handles are created. +/// +/// Set the `LEAK_BACKTRACE` environment variable to any non-empty value to enable +/// backtrace capture. This helps identify where leaked handles were allocated. #[cfg(any(test, feature = "leak-detection"))] static LEAK_BACKTRACE: std::sync::LazyLock = std::sync::LazyLock::new(|| std::env::var("LEAK_BACKTRACE").is_ok_and(|b| !b.is_empty())); +/// Unique identifier for a specific entity handle instance. +/// +/// This is distinct from `EntityId` - while multiple handles can point to the same +/// entity (same `EntityId`), each handle has its own unique `HandleId`. #[cfg(any(test, feature = "leak-detection"))] #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] pub(crate) struct HandleId { - id: u64, // id of the handle itself, not the pointed at object + id: u64, } +/// Tracks entity handle allocations to detect leaks. +/// +/// The leak detector is enabled in tests and when the `leak-detection` feature is active. +/// It tracks every `Entity` and `AnyEntity` handle that is created and released, +/// allowing you to verify that all handles to an entity have been properly dropped. +/// +/// # How do leaks happen? +/// +/// Entities are reference-counted structures that can own other entities +/// allowing to form cycles. If such a strong-reference counted cycle is +/// created, all participating strong entities in this cycle will effectively +/// leak as they cannot be released anymore. +/// +/// # Usage +/// +/// You can use `WeakEntity::assert_released` or `AnyWeakEntity::assert_released` +/// to verify that an entity has been fully released: +/// +/// ```ignore +/// let entity = cx.new(|_| MyEntity::new()); +/// let weak = entity.downgrade(); +/// drop(entity); +/// +/// // This will panic if any handles to the entity are still alive +/// weak.assert_released(); +/// ``` +/// +/// # Debugging Leaks +/// +/// When a leak is detected, the detector will panic with information about the leaked +/// handles. To see where the leaked handles were allocated, set the `LEAK_BACKTRACE` +/// environment variable: +/// +/// ```bash +/// LEAK_BACKTRACE=1 cargo test my_test +/// ``` +/// +/// This will capture and display backtraces for each leaked handle, helping you +/// identify where handles were created but not released. +/// +/// # How It Works +/// +/// - When an entity handle is created (via `Entity::new`, `Entity::clone`, or +/// `WeakEntity::upgrade`), `handle_created` is called to register the handle. +/// - When a handle is dropped, `handle_released` removes it from tracking. +/// - `assert_released` verifies that no handles remain for a given entity. #[cfg(any(test, feature = "leak-detection"))] pub(crate) struct LeakDetector { next_handle_id: u64, @@ -803,6 +912,11 @@ pub(crate) struct LeakDetector { #[cfg(any(test, feature = "leak-detection"))] impl LeakDetector { + /// Records that a new handle has been created for the given entity. + /// + /// Returns a unique `HandleId` that must be passed to `handle_released` when + /// the handle is dropped. If `LEAK_BACKTRACE` is set, captures a backtrace + /// at the allocation site. #[track_caller] pub fn handle_created(&mut self, entity_id: EntityId) -> HandleId { let id = util::post_inc(&mut self.next_handle_id); @@ -815,23 +929,40 @@ impl LeakDetector { handle_id } + /// Records that a handle has been released (dropped). + /// + /// This removes the handle from tracking. The `handle_id` should be the same + /// one returned by `handle_created` when the handle was allocated. pub fn handle_released(&mut self, entity_id: EntityId, handle_id: HandleId) { let handles = self.entity_handles.entry(entity_id).or_default(); handles.remove(&handle_id); } + /// Asserts that all handles to the given entity have been released. + /// + /// # Panics + /// + /// Panics if any handles to the entity are still alive. The panic message + /// includes backtraces for each leaked handle if `LEAK_BACKTRACE` is set, + /// otherwise it suggests setting the environment variable to get more info. pub fn assert_released(&mut self, entity_id: EntityId) { + use std::fmt::Write as _; let handles = self.entity_handles.entry(entity_id).or_default(); if !handles.is_empty() { + let mut out = String::new(); for backtrace in handles.values_mut() { if let Some(mut backtrace) = backtrace.take() { backtrace.resolve(); - eprintln!("Leaked handle: {:#?}", backtrace); + writeln!(out, "Leaked handle:\n{:?}", backtrace).unwrap(); } else { - eprintln!("Leaked handle: export LEAK_BACKTRACE to find allocation site"); + writeln!( + out, + "Leaked handle: (export LEAK_BACKTRACE to find allocation site)" + ) + .unwrap(); } } - panic!(); + panic!("{out}"); } } } diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index b3d342b09b..5be2e394e8 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -5,12 +5,14 @@ use crate::{ ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, - WindowHandle, WindowOptions, + WindowHandle, WindowOptions, app::GpuiMode, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; use rand::{SeedableRng, rngs::StdRng}; -use std::{cell::RefCell, future::Future, ops::Deref, rc::Rc, sync::Arc, time::Duration}; +use std::{ + cell::RefCell, future::Future, ops::Deref, path::PathBuf, rc::Rc, sync::Arc, time::Duration, +}; /// A TestAppContext is provided to tests created with `#[gpui::test]`, it provides /// an implementation of `Context` with additional methods that are useful in tests. @@ -130,8 +132,11 @@ impl TestAppContext { let http_client = http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); + let mut app = App::new_app(platform.clone(), asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + Self { - app: App::new_app(platform.clone(), asset_source, http_client), + app, background_executor, foreground_executor, dispatcher, @@ -142,6 +147,11 @@ impl TestAppContext { } } + /// Skip all drawing operations for the duration of this test. + pub fn skip_drawing(&mut self) { + self.app.borrow_mut().mode = GpuiMode::Test { skip_drawing: true }; + } + /// Create a single TestAppContext, for non-multi-client tests pub fn single() -> Self { let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); @@ -331,6 +341,13 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + /// Returns true if there's an alert dialog open. + pub fn expect_restart(&self) -> oneshot::Receiver> { + let (tx, rx) = futures::channel::oneshot::channel(); + self.test_platform.expect_restart.borrow_mut().replace(tx); + rx + } + /// Causes the given sources to be returned if the application queries for screen /// capture sources. pub fn set_screen_capture_sources(&self, sources: Vec) { @@ -455,7 +472,7 @@ impl TestAppContext { .windows .get_mut(window.id) .unwrap() - .as_mut() + .as_deref_mut() .unwrap() .platform_window .as_test() @@ -836,7 +853,7 @@ impl VisualTestContext { }) } - /// Simulate an event from the platform, e.g. a SrollWheelEvent + /// Simulate an event from the platform, e.g. a ScrollWheelEvent /// Make sure you've called [VisualTestContext::draw] first! pub fn simulate_event(&mut self, event: E) { self.test_window(self.window) @@ -888,7 +905,9 @@ impl VisualTestContext { // safety: on_quit will be called after the test has finished. // the executor will ensure that all tasks related to the test have stopped. // so there is no way for cx to be accessed after on_quit is called. - let cx = Box::leak(unsafe { Box::from_raw(ptr) }); + // todo: This is unsound under stacked borrows (also tree borrows probably?) + // the mutable reference invalidates `ptr` which is later used in the closure + let cx = unsafe { &mut *ptr }; cx.on_quit(move || unsafe { drop(Box::from_raw(ptr)); }); diff --git a/crates/gpui/src/arena.rs b/crates/gpui/src/arena.rs index a0d0c23987..9898c8056a 100644 --- a/crates/gpui/src/arena.rs +++ b/crates/gpui/src/arena.rs @@ -15,9 +15,7 @@ struct ArenaElement { impl Drop for ArenaElement { #[inline(always)] fn drop(&mut self) { - unsafe { - (self.drop)(self.value); - } + unsafe { (self.drop)(self.value) }; } } @@ -40,33 +38,29 @@ impl Drop for Chunk { impl Chunk { fn new(chunk_size: NonZeroUsize) -> Self { - unsafe { - // this only fails if chunk_size is unreasonably huge - let layout = alloc::Layout::from_size_align(chunk_size.get(), 1).unwrap(); - let start = alloc::alloc(layout); - if start.is_null() { - handle_alloc_error(layout); - } - let end = start.add(chunk_size.get()); - Self { - start, - end, - offset: start, - } + // this only fails if chunk_size is unreasonably huge + let layout = alloc::Layout::from_size_align(chunk_size.get(), 1).unwrap(); + let start = unsafe { alloc::alloc(layout) }; + if start.is_null() { + handle_alloc_error(layout); + } + let end = unsafe { start.add(chunk_size.get()) }; + Self { + start, + end, + offset: start, } } fn allocate(&mut self, layout: alloc::Layout) -> Option> { - unsafe { - let aligned = self.offset.add(self.offset.align_offset(layout.align())); - let next = aligned.add(layout.size()); + let aligned = unsafe { self.offset.add(self.offset.align_offset(layout.align())) }; + let next = unsafe { aligned.add(layout.size()) }; - if next <= self.end { - self.offset = next; - NonNull::new(aligned) - } else { - None - } + if next <= self.end { + self.offset = next; + NonNull::new(aligned) + } else { + None } } @@ -122,54 +116,48 @@ impl Arena { where F: FnOnce() -> T, { - unsafe { - ptr::write(ptr, f()); - } + unsafe { ptr::write(ptr, f()) }; } unsafe fn drop(ptr: *mut u8) { - unsafe { - std::ptr::drop_in_place(ptr.cast::()); - } + unsafe { std::ptr::drop_in_place(ptr.cast::()) }; } - unsafe { - let layout = alloc::Layout::new::(); - let mut current_chunk = &mut self.chunks[self.current_chunk_index]; - let ptr = if let Some(ptr) = current_chunk.allocate(layout) { + let layout = alloc::Layout::new::(); + let mut current_chunk = &mut self.chunks[self.current_chunk_index]; + let ptr = if let Some(ptr) = current_chunk.allocate(layout) { + ptr.as_ptr() + } else { + self.current_chunk_index += 1; + if self.current_chunk_index >= self.chunks.len() { + self.chunks.push(Chunk::new(self.chunk_size)); + assert_eq!(self.current_chunk_index, self.chunks.len() - 1); + log::trace!( + "increased element arena capacity to {}kb", + self.capacity() / 1024, + ); + } + current_chunk = &mut self.chunks[self.current_chunk_index]; + if let Some(ptr) = current_chunk.allocate(layout) { ptr.as_ptr() } else { - self.current_chunk_index += 1; - if self.current_chunk_index >= self.chunks.len() { - self.chunks.push(Chunk::new(self.chunk_size)); - assert_eq!(self.current_chunk_index, self.chunks.len() - 1); - log::trace!( - "increased element arena capacity to {}kb", - self.capacity() / 1024, - ); - } - current_chunk = &mut self.chunks[self.current_chunk_index]; - if let Some(ptr) = current_chunk.allocate(layout) { - ptr.as_ptr() - } else { - panic!( - "Arena chunk_size of {} is too small to allocate {} bytes", - self.chunk_size, - layout.size() - ); - } - }; - - inner_writer(ptr.cast(), f); - self.elements.push(ArenaElement { - value: ptr, - drop: drop::, - }); - - ArenaBox { - ptr: ptr.cast(), - valid: self.valid.clone(), + panic!( + "Arena chunk_size of {} is too small to allocate {} bytes", + self.chunk_size, + layout.size() + ); } + }; + + unsafe { inner_writer(ptr.cast(), f) }; + self.elements.push(ArenaElement { + value: ptr, + drop: drop::, + }); + + ArenaBox { + ptr: ptr.cast(), + valid: self.valid.clone(), } } } diff --git a/crates/gpui/src/bounds_tree.rs b/crates/gpui/src/bounds_tree.rs index a96bfe55b9..9cf86a2cc9 100644 --- a/crates/gpui/src/bounds_tree.rs +++ b/crates/gpui/src/bounds_tree.rs @@ -5,14 +5,91 @@ use std::{ ops::{Add, Sub}, }; +/// Maximum children per internal node (R-tree style branching factor). +/// Higher values = shorter tree = fewer cache misses, but more work per node. +const MAX_CHILDREN: usize = 12; + +/// A spatial tree optimized for finding maximum ordering among intersecting bounds. +/// +/// This is an R-tree variant specifically designed for the use case of assigning +/// z-order to overlapping UI elements. Key optimizations: +/// - Tracks the leaf with global max ordering for O(1) fast-path queries +/// - Uses higher branching factor (4) for lower tree height +/// - Aggressive pruning during search based on max_order metadata #[derive(Debug)] pub(crate) struct BoundsTree where U: Clone + Debug + Default + PartialEq, { - root: Option, + /// All nodes stored contiguously for cache efficiency. nodes: Vec>, - stack: Vec, + /// Index of the root node, if any. + root: Option, + /// Index of the leaf with the highest ordering (for fast-path lookups). + max_leaf: Option, + /// Reusable stack for tree traversal during insertion. + insert_path: Vec, + /// Reusable stack for search operations. + search_stack: Vec, +} + +/// A node in the bounds tree. +#[derive(Debug, Clone)] +struct Node +where + U: Clone + Debug + Default + PartialEq, +{ + /// Bounding box containing this node and all descendants. + bounds: Bounds, + /// Maximum ordering value in this subtree. + max_order: u32, + /// Node-specific data. + kind: NodeKind, +} + +#[derive(Debug, Clone)] +enum NodeKind { + /// Leaf node containing actual bounds data. + Leaf { + /// The ordering assigned to this bounds. + order: u32, + }, + /// Internal node with children. + Internal { + /// Indices of child nodes (2 to MAX_CHILDREN). + children: NodeChildren, + }, +} + +/// Fixed-size array for child indices, avoiding heap allocation. +#[derive(Debug, Clone)] +struct NodeChildren { + // Keeps an invariant where the max order child is always at the end + indices: [usize; MAX_CHILDREN], + len: u8, +} + +impl NodeChildren { + fn new() -> Self { + Self { + indices: [0; MAX_CHILDREN], + len: 0, + } + } + + fn push(&mut self, index: usize) { + debug_assert!((self.len as usize) < MAX_CHILDREN); + self.indices[self.len as usize] = index; + self.len += 1; + } + + fn len(&self) -> usize { + self.len as usize + } + + fn as_slice(&self) -> &[usize] { + &self.indices[..self.len as usize] + } } impl BoundsTree @@ -26,159 +103,250 @@ where + Half + Default, { + /// Clears all nodes from the tree. pub fn clear(&mut self) { - self.root = None; self.nodes.clear(); - self.stack.clear(); + self.root = None; + self.max_leaf = None; + self.insert_path.clear(); + self.search_stack.clear(); } + /// Inserts bounds into the tree and returns its assigned ordering. + /// + /// The ordering is one greater than the maximum ordering of any + /// existing bounds that intersect with the new bounds. pub fn insert(&mut self, new_bounds: Bounds) -> u32 { - // If the tree is empty, make the root the new leaf. - if self.root.is_none() { - let new_node = self.push_leaf(new_bounds, 1); - self.root = Some(new_node); - return 1; - } + // Find maximum ordering among intersecting bounds + let max_intersecting = self.find_max_ordering(&new_bounds); + let ordering = max_intersecting + 1; - // Search for the best place to add the new leaf based on heuristics. - let mut max_intersecting_ordering = 0; - let mut index = self.root.unwrap(); - while let Node::Internal { - left, - right, - bounds: node_bounds, - .. - } = &mut self.nodes[index] - { - let left = *left; - let right = *right; - *node_bounds = node_bounds.union(&new_bounds); - self.stack.push(index); + // Insert the new leaf + let new_leaf_idx = self.insert_leaf(new_bounds, ordering); - // Descend to the best-fit child, based on which one would increase - // the surface area the least. This attempts to keep the tree balanced - // in terms of surface area. If there is an intersection with the other child, - // add its keys to the intersections vector. - let left_cost = new_bounds.union(self.nodes[left].bounds()).half_perimeter(); - let right_cost = new_bounds - .union(self.nodes[right].bounds()) - .half_perimeter(); - if left_cost < right_cost { - max_intersecting_ordering = - self.find_max_ordering(right, &new_bounds, max_intersecting_ordering); - index = left; - } else { - max_intersecting_ordering = - self.find_max_ordering(left, &new_bounds, max_intersecting_ordering); - index = right; - } - } - - // We've found a leaf ('index' now refers to a leaf node). - // We'll insert a new parent node above the leaf and attach our new leaf to it. - let sibling = index; - - // Check for collision with the located leaf node - let Node::Leaf { - bounds: sibling_bounds, - order: sibling_ordering, - .. - } = &self.nodes[index] - else { - unreachable!(); + // Update max_leaf tracking + self.max_leaf = match self.max_leaf { + None => Some(new_leaf_idx), + Some(old_idx) if self.nodes[old_idx].max_order < ordering => Some(new_leaf_idx), + some => some, }; - if sibling_bounds.intersects(&new_bounds) { - max_intersecting_ordering = cmp::max(max_intersecting_ordering, *sibling_ordering); - } - - let ordering = max_intersecting_ordering + 1; - let new_node = self.push_leaf(new_bounds, ordering); - let new_parent = self.push_internal(sibling, new_node); - - // If there was an old parent, we need to update its children indices. - if let Some(old_parent) = self.stack.last().copied() { - let Node::Internal { left, right, .. } = &mut self.nodes[old_parent] else { - unreachable!(); - }; - - if *left == sibling { - *left = new_parent; - } else { - *right = new_parent; - } - } else { - // If the old parent was the root, the new parent is the new root. - self.root = Some(new_parent); - } - - for node_index in self.stack.drain(..).rev() { - let Node::Internal { - max_order: max_ordering, - .. - } = &mut self.nodes[node_index] - else { - unreachable!() - }; - if *max_ordering >= ordering { - break; - } - *max_ordering = ordering; - } ordering } - fn find_max_ordering(&self, index: usize, bounds: &Bounds, mut max_ordering: u32) -> u32 { - match &self.nodes[index] { - Node::Leaf { - bounds: node_bounds, - order: ordering, - .. - } => { - if bounds.intersects(node_bounds) { - max_ordering = cmp::max(*ordering, max_ordering); - } + /// Finds the maximum ordering among all bounds that intersect with the query. + fn find_max_ordering(&mut self, query: &Bounds) -> u32 { + let Some(root_idx) = self.root else { + return 0; + }; + + // Fast path: check if the max-ordering leaf intersects + if let Some(max_idx) = self.max_leaf { + let max_node = &self.nodes[max_idx]; + if query.intersects(&max_node.bounds) { + return max_node.max_order; } - Node::Internal { - left, - right, - bounds: node_bounds, - max_order: node_max_ordering, - .. - } => { - if bounds.intersects(node_bounds) && max_ordering < *node_max_ordering { - let left_max_ordering = self.nodes[*left].max_ordering(); - let right_max_ordering = self.nodes[*right].max_ordering(); - if left_max_ordering > right_max_ordering { - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); - } else { - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); + } + + // Slow path: search the tree + self.search_stack.clear(); + self.search_stack.push(root_idx); + + let mut max_found = 0u32; + + while let Some(node_idx) = self.search_stack.pop() { + let node = &self.nodes[node_idx]; + + // Pruning: skip if this subtree can't improve our result + if node.max_order <= max_found { + continue; + } + + // Spatial pruning: skip if bounds don't intersect + if !query.intersects(&node.bounds) { + continue; + } + + match &node.kind { + NodeKind::Leaf { order } => { + max_found = cmp::max(max_found, *order); + } + NodeKind::Internal { children } => { + // Children are maintained with highest max_order at the end. + // Push in forward order to highest (last) is popped first. + for &child_idx in children.as_slice() { + if self.nodes[child_idx].max_order > max_found { + self.search_stack.push(child_idx); + } } } } } - max_ordering + + max_found } - fn push_leaf(&mut self, bounds: Bounds, order: u32) -> usize { - self.nodes.push(Node::Leaf { bounds, order }); - self.nodes.len() - 1 - } - - fn push_internal(&mut self, left: usize, right: usize) -> usize { - let left_node = &self.nodes[left]; - let right_node = &self.nodes[right]; - let new_bounds = left_node.bounds().union(right_node.bounds()); - let max_ordering = cmp::max(left_node.max_ordering(), right_node.max_ordering()); - self.nodes.push(Node::Internal { - bounds: new_bounds, - left, - right, - max_order: max_ordering, + /// Inserts a leaf node with the given bounds and ordering. + /// Returns the index of the new leaf. + fn insert_leaf(&mut self, bounds: Bounds, order: u32) -> usize { + let new_leaf_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: bounds.clone(), + max_order: order, + kind: NodeKind::Leaf { order }, }); - self.nodes.len() - 1 + + let Some(root_idx) = self.root else { + // Tree is empty, new leaf becomes root + self.root = Some(new_leaf_idx); + return new_leaf_idx; + }; + + // If root is a leaf, create internal node with both + if matches!(self.nodes[root_idx].kind, NodeKind::Leaf { .. }) { + let root_bounds = self.nodes[root_idx].bounds.clone(); + let root_order = self.nodes[root_idx].max_order; + + let mut children = NodeChildren::new(); + // Max end invariant + if order > root_order { + children.push(root_idx); + children.push(new_leaf_idx); + } else { + children.push(new_leaf_idx); + children.push(root_idx); + } + + let new_root_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: root_bounds.union(&bounds), + max_order: cmp::max(root_order, order), + kind: NodeKind::Internal { children }, + }); + self.root = Some(new_root_idx); + return new_leaf_idx; + } + + // Descend to find the best internal node to insert into + self.insert_path.clear(); + let mut current_idx = root_idx; + + loop { + let current = &self.nodes[current_idx]; + let NodeKind::Internal { children } = ¤t.kind else { + unreachable!("Should only traverse internal nodes"); + }; + + self.insert_path.push(current_idx); + + // Find the best child to descend into + let mut best_child_idx = children.as_slice()[0]; + let mut best_child_pos = 0; + let mut best_cost = bounds + .union(&self.nodes[best_child_idx].bounds) + .half_perimeter(); + + for (pos, &child_idx) in children.as_slice().iter().enumerate().skip(1) { + let cost = bounds.union(&self.nodes[child_idx].bounds).half_perimeter(); + if cost < best_cost { + best_cost = cost; + best_child_idx = child_idx; + best_child_pos = pos; + } + } + + // Check if best child is a leaf or internal + if matches!(self.nodes[best_child_idx].kind, NodeKind::Leaf { .. }) { + // Best child is a leaf. Check if current node has room for another child. + if children.len() < MAX_CHILDREN { + // Add new leaf directly to this node + let node = &mut self.nodes[current_idx]; + + if let NodeKind::Internal { children } = &mut node.kind { + children.push(new_leaf_idx); + // Swap new leaf only if it has the highest max_order + if order <= node.max_order { + let last = children.len() - 1; + children.indices.swap(last - 1, last); + } + } + + node.bounds = node.bounds.union(&bounds); + node.max_order = cmp::max(node.max_order, order); + break; + } else { + // Node is full, create new internal with [best_leaf, new_leaf] + let sibling_bounds = self.nodes[best_child_idx].bounds.clone(); + let sibling_order = self.nodes[best_child_idx].max_order; + + let mut new_children = NodeChildren::new(); + // Max end invariant + if order > sibling_order { + new_children.push(best_child_idx); + new_children.push(new_leaf_idx); + } else { + new_children.push(new_leaf_idx); + new_children.push(best_child_idx); + } + + let new_internal_idx = self.nodes.len(); + let new_internal_max = cmp::max(sibling_order, order); + self.nodes.push(Node { + bounds: sibling_bounds.union(&bounds), + max_order: new_internal_max, + kind: NodeKind::Internal { + children: new_children, + }, + }); + + // Replace the leaf with the new internal in parent + let parent = &mut self.nodes[current_idx]; + if let NodeKind::Internal { children } = &mut parent.kind { + let children_len = children.len(); + + children.indices[best_child_pos] = new_internal_idx; + + // If new internal has highest max_order, swap it to the end + // to maintain sorting invariant + if new_internal_max > parent.max_order { + children.indices.swap(best_child_pos, children_len - 1); + } + } + break; + } + } else { + // Best child is internal, continue descent + current_idx = best_child_idx; + } + } + + // Propagate bounds and max_order updates up the tree + let mut updated_child_idx = None; + for &node_idx in self.insert_path.iter().rev() { + let node = &mut self.nodes[node_idx]; + node.bounds = node.bounds.union(&bounds); + + if node.max_order < order { + node.max_order = order; + + // Swap updated child to end (skip first iteration since the invariant is already handled by previous cases) + if let Some(child_idx) = updated_child_idx { + if let NodeKind::Internal { children } = &mut node.kind { + if let Some(pos) = children.as_slice().iter().position(|&c| c == child_idx) + { + let last = children.len() - 1; + if pos != last { + children.indices.swap(pos, last); + } + } + } + } + } + + updated_child_idx = Some(node_idx); + } + + new_leaf_idx } } @@ -188,50 +356,11 @@ where { fn default() -> Self { BoundsTree { - root: None, nodes: Vec::new(), - stack: Vec::new(), - } - } -} - -#[derive(Debug, Clone)] -enum Node -where - U: Clone + Debug + Default + PartialEq, -{ - Leaf { - bounds: Bounds, - order: u32, - }, - Internal { - left: usize, - right: usize, - bounds: Bounds, - max_order: u32, - }, -} - -impl Node -where - U: Clone + Debug + Default + PartialEq, -{ - fn bounds(&self) -> &Bounds { - match self { - Node::Leaf { bounds, .. } => bounds, - Node::Internal { bounds, .. } => bounds, - } - } - - fn max_ordering(&self) -> u32 { - match self { - Node::Leaf { - order: ordering, .. - } => *ordering, - Node::Internal { - max_order: max_ordering, - .. - } => *max_ordering, + root: None, + max_leaf: None, + insert_path: Vec::new(), + search_stack: Vec::new(), } } } diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index a3fc6269f3..2c695486c5 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -37,11 +37,11 @@ use crate::{ util::FluentBuilder, }; use derive_more::{Deref, DerefMut}; -pub(crate) use smallvec::SmallVec; use std::{ any::{Any, type_name}, fmt::{self, Debug, Display}, mem, panic, + sync::Arc, }; /// Implemented by types that participate in laying out and painting the contents of a window. @@ -272,8 +272,8 @@ impl IntoElement for Component { } /// A globally unique identifier for an element, used to track state across frames. -#[derive(Deref, DerefMut, Default, Debug, Eq, PartialEq, Hash)] -pub struct GlobalElementId(pub(crate) SmallVec<[ElementId; 32]>); +#[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)] +pub struct GlobalElementId(pub(crate) Arc<[ElementId]>); impl Display for GlobalElementId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -353,7 +353,7 @@ impl Drawable { ElementDrawPhase::Start => { let global_id = self.element.id().map(|element_id| { window.element_id_stack.push(element_id); - GlobalElementId(window.element_id_stack.clone()) + GlobalElementId(Arc::from(&*window.element_id_stack)) }); let inspector_id; @@ -361,7 +361,7 @@ impl Drawable { { inspector_id = self.element.source_location().map(|source| { let path = crate::InspectorElementPath { - global_id: GlobalElementId(window.element_id_stack.clone()), + global_id: GlobalElementId(Arc::from(&*window.element_id_stack)), source_location: source, }; window.build_inspector_element_id(path) @@ -412,7 +412,7 @@ impl Drawable { } => { if let Some(element_id) = self.element.id() { window.element_id_stack.push(element_id); - debug_assert_eq!(global_id.as_ref().unwrap().0, window.element_id_stack); + debug_assert_eq!(&*global_id.as_ref().unwrap().0, &*window.element_id_stack); } let bounds = window.layout_bounds(layout_id); @@ -461,7 +461,7 @@ impl Drawable { } => { if let Some(element_id) = self.element.id() { window.element_id_stack.push(element_id); - debug_assert_eq!(global_id.as_ref().unwrap().0, window.element_id_stack); + debug_assert_eq!(&*global_id.as_ref().unwrap().0, &*window.element_id_stack); } window.next_frame.dispatch_tree.set_active_node(node_id); @@ -741,7 +741,17 @@ impl Element for Empty { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - (window.request_layout(Style::default(), None, cx), ()) + ( + window.request_layout( + Style { + display: crate::Display::None, + ..Default::default() + }, + None, + cx, + ), + (), + ) } fn prepaint( diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 58ce51e95b..374fd2c55a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -17,12 +17,13 @@ use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, - DispatchPhase, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, - HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, - KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, - MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, ParentElement, Pixels, - Point, Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, - TooltipId, Visibility, Window, WindowControlArea, point, px, size, + DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, + Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, + KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, + MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, + StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, + size, }; use collections::HashMap; use refineable::Refineable; @@ -113,8 +114,8 @@ impl Interactivity { } } - /// Bind the given callback to the mouse down event for the given mouse button, during the bubble phase - /// The imperative API equivalent of [`InteractiveElement::on_mouse_down`] + /// Bind the given callback to the mouse down event for the given mouse button, during the bubble phase. + /// The imperative API equivalent of [`InteractiveElement::on_mouse_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to the view state from this callback. pub fn on_mouse_down( @@ -133,8 +134,8 @@ impl Interactivity { })); } - /// Bind the given callback to the mouse down event for any button, during the capture phase - /// The imperative API equivalent of [`InteractiveElement::capture_any_mouse_down`] + /// Bind the given callback to the mouse down event for any button, during the capture phase. + /// The imperative API equivalent of [`InteractiveElement::capture_any_mouse_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn capture_any_mouse_down( @@ -149,8 +150,8 @@ impl Interactivity { })); } - /// Bind the given callback to the mouse down event for any button, during the bubble phase - /// the imperative API equivalent to [`InteractiveElement::on_any_mouse_down`] + /// Bind the given callback to the mouse down event for any button, during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_any_mouse_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_any_mouse_down( @@ -165,8 +166,40 @@ impl Interactivity { })); } - /// Bind the given callback to the mouse up event for the given button, during the bubble phase - /// the imperative API equivalent to [`InteractiveElement::on_mouse_up`] + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn capture_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Capture && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + + /// Bind the given callback to the mouse up event for the given button, during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_mouse_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_mouse_up( @@ -185,8 +218,8 @@ impl Interactivity { })); } - /// Bind the given callback to the mouse up event for any button, during the capture phase - /// the imperative API equivalent to [`InteractiveElement::capture_any_mouse_up`] + /// Bind the given callback to the mouse up event for any button, during the capture phase. + /// The imperative API equivalent to [`InteractiveElement::capture_any_mouse_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn capture_any_mouse_up( @@ -201,8 +234,8 @@ impl Interactivity { })); } - /// Bind the given callback to the mouse up event for any button, during the bubble phase - /// the imperative API equivalent to [`Interactivity::on_any_mouse_up`] + /// Bind the given callback to the mouse up event for any button, during the bubble phase. + /// The imperative API equivalent to [`Interactivity::on_any_mouse_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_any_mouse_up( @@ -219,7 +252,7 @@ impl Interactivity { /// Bind the given callback to the mouse down event, on any button, during the capture phase, /// when the mouse is outside of the bounds of this element. - /// The imperative API equivalent to [`InteractiveElement::on_mouse_down_out`] + /// The imperative API equivalent to [`InteractiveElement::on_mouse_down_out`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_mouse_down_out( @@ -236,7 +269,7 @@ impl Interactivity { /// Bind the given callback to the mouse up event, for the given button, during the capture phase, /// when the mouse is outside of the bounds of this element. - /// The imperative API equivalent to [`InteractiveElement::on_mouse_up_out`] + /// The imperative API equivalent to [`InteractiveElement::on_mouse_up_out`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_mouse_up_out( @@ -255,8 +288,8 @@ impl Interactivity { })); } - /// Bind the given callback to the mouse move event, during the bubble phase - /// The imperative API equivalent to [`InteractiveElement::on_mouse_move`] + /// Bind the given callback to the mouse move event, during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_mouse_move`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_mouse_move( @@ -275,7 +308,7 @@ impl Interactivity { /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable /// UIs that don't conform to a drag and drop style interaction, like resizing. - /// The imperative API equivalent to [`InteractiveElement::on_drag_move`] + /// The imperative API equivalent to [`InteractiveElement::on_drag_move`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_drag_move( @@ -304,8 +337,8 @@ impl Interactivity { })); } - /// Bind the given callback to scroll wheel events during the bubble phase - /// The imperative API equivalent to [`InteractiveElement::on_scroll_wheel`] + /// Bind the given callback to scroll wheel events during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_scroll_wheel`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_scroll_wheel( @@ -320,8 +353,8 @@ impl Interactivity { })); } - /// Bind the given callback to an action dispatch during the capture phase - /// The imperative API equivalent to [`InteractiveElement::capture_action`] + /// Bind the given callback to an action dispatch during the capture phase. + /// The imperative API equivalent to [`InteractiveElement::capture_action`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn capture_action( @@ -341,8 +374,8 @@ impl Interactivity { )); } - /// Bind the given callback to an action dispatch during the bubble phase - /// The imperative API equivalent to [`InteractiveElement::on_action`] + /// Bind the given callback to an action dispatch during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_action`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_action(&mut self, listener: impl Fn(&A, &mut Window, &mut App) + 'static) { @@ -360,7 +393,7 @@ impl Interactivity { /// Bind the given callback to an action dispatch, based on a dynamic action parameter /// instead of a type parameter. Useful for component libraries that want to expose /// action bindings to their users. - /// The imperative API equivalent to [`InteractiveElement::on_boxed_action`] + /// The imperative API equivalent to [`InteractiveElement::on_boxed_action`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_boxed_action( @@ -379,8 +412,8 @@ impl Interactivity { )); } - /// Bind the given callback to key down events during the bubble phase - /// The imperative API equivalent to [`InteractiveElement::on_key_down`] + /// Bind the given callback to key down events during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_key_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_key_down( @@ -395,8 +428,8 @@ impl Interactivity { })); } - /// Bind the given callback to key down events during the capture phase - /// The imperative API equivalent to [`InteractiveElement::capture_key_down`] + /// Bind the given callback to key down events during the capture phase. + /// The imperative API equivalent to [`InteractiveElement::capture_key_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn capture_key_down( @@ -411,8 +444,8 @@ impl Interactivity { })); } - /// Bind the given callback to key up events during the bubble phase - /// The imperative API equivalent to [`InteractiveElement::on_key_up`] + /// Bind the given callback to key up events during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_key_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_key_up(&mut self, listener: impl Fn(&KeyUpEvent, &mut Window, &mut App) + 'static) { @@ -424,8 +457,8 @@ impl Interactivity { })); } - /// Bind the given callback to key up events during the capture phase - /// The imperative API equivalent to [`InteractiveElement::on_key_up`] + /// Bind the given callback to key up events during the capture phase. + /// The imperative API equivalent to [`InteractiveElement::on_key_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn capture_key_up( @@ -441,7 +474,7 @@ impl Interactivity { } /// Bind the given callback to modifiers changing events. - /// The imperative API equivalent to [`InteractiveElement::on_modifiers_changed`] + /// The imperative API equivalent to [`InteractiveElement::on_modifiers_changed`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_modifiers_changed( @@ -454,8 +487,8 @@ impl Interactivity { })); } - /// Bind the given callback to drop events of the given type, whether or not the drag started on this element - /// The imperative API equivalent to [`InteractiveElement::on_drop`] + /// Bind the given callback to drop events of the given type, whether or not the drag started on this element. + /// The imperative API equivalent to [`InteractiveElement::on_drop`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_drop(&mut self, listener: impl Fn(&T, &mut Window, &mut App) + 'static) { @@ -467,8 +500,8 @@ impl Interactivity { )); } - /// Use the given predicate to determine whether or not a drop event should be dispatched to this element - /// The imperative API equivalent to [`InteractiveElement::can_drop`] + /// Use the given predicate to determine whether or not a drop event should be dispatched to this element. + /// The imperative API equivalent to [`InteractiveElement::can_drop`]. pub fn can_drop( &mut self, predicate: impl Fn(&dyn Any, &mut Window, &mut App) -> bool + 'static, @@ -476,8 +509,8 @@ impl Interactivity { self.can_drop_predicate = Some(Box::new(predicate)); } - /// Bind the given callback to click events of this element - /// The imperative API equivalent to [`StatefulInteractiveElement::on_click`] + /// Bind the given callback to click events of this element. + /// The imperative API equivalent to [`StatefulInteractiveElement::on_click`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_click(&mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) @@ -491,8 +524,8 @@ impl Interactivity { /// On drag initiation, this callback will be used to create a new view to render the dragged value for a /// drag and drop operation. This API should also be used as the equivalent of 'on drag start' with - /// the [`Self::on_drag_move`] API - /// The imperative API equivalent to [`StatefulInteractiveElement::on_drag`] + /// the [`Self::on_drag_move`] API. + /// The imperative API equivalent to [`StatefulInteractiveElement::on_drag`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_drag( @@ -518,7 +551,7 @@ impl Interactivity { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. - /// The imperative API equivalent to [`StatefulInteractiveElement::on_hover`] + /// The imperative API equivalent to [`StatefulInteractiveElement::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. pub fn on_hover(&mut self, listener: impl Fn(&bool, &mut Window, &mut App) + 'static) @@ -533,7 +566,7 @@ impl Interactivity { } /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. - /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`] + /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`]. pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) where Self: Sized, @@ -550,7 +583,7 @@ impl Interactivity { /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into - /// the tooltip. The imperative API equivalent to [`StatefulInteractiveElement::hoverable_tooltip`] + /// the tooltip. The imperative API equivalent to [`StatefulInteractiveElement::hoverable_tooltip`]. pub fn hoverable_tooltip( &mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, @@ -581,10 +614,10 @@ impl Interactivity { self.window_control = Some(area); } - /// Block non-scroll mouse interactions with elements behind this element's hitbox. See - /// [`Hitbox::is_hovered`] for details. + /// Block non-scroll mouse interactions with elements behind this element's hitbox. + /// The imperative API equivalent to [`InteractiveElement::block_mouse_except_scroll`]. /// - /// The imperative API equivalent to [`InteractiveElement::block_mouse_except_scroll`] + /// See [`Hitbox::is_hovered`] for details. pub fn block_mouse_except_scroll(&mut self) { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } @@ -688,8 +721,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to the mouse down event for the given mouse button, - /// the fluent API equivalent to [`Interactivity::on_mouse_down`] + /// Bind the given callback to the mouse down event for the given mouse button. + /// The fluent API equivalent to [`Interactivity::on_mouse_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to the view state from this callback. fn on_mouse_down( @@ -719,8 +752,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to the mouse down event for any button, during the capture phase - /// the fluent API equivalent to [`Interactivity::capture_any_mouse_down`] + /// Bind the given callback to the mouse down event for any button, during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_any_mouse_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn capture_any_mouse_down( @@ -731,8 +764,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to the mouse down event for any button, during the capture phase - /// the fluent API equivalent to [`Interactivity::on_any_mouse_down`] + /// Bind the given callback to the mouse down event for any button, during the capture phase. + /// The fluent API equivalent to [`Interactivity::on_any_mouse_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_any_mouse_down( @@ -743,8 +776,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to the mouse up event for the given button, during the bubble phase - /// the fluent API equivalent to [`Interactivity::on_mouse_up`] + /// Bind the given callback to the mouse up event for the given button, during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_mouse_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_mouse_up( @@ -756,8 +789,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to the mouse up event for any button, during the capture phase - /// the fluent API equivalent to [`Interactivity::capture_any_mouse_up`] + /// Bind the given callback to the mouse up event for any button, during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_any_mouse_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn capture_any_mouse_up( @@ -768,9 +801,33 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_mouse_pressure(listener); + self + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn capture_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_mouse_pressure(listener); + self + } + /// Bind the given callback to the mouse down event, on any button, during the capture phase, /// when the mouse is outside of the bounds of this element. - /// The fluent API equivalent to [`Interactivity::on_mouse_down_out`] + /// The fluent API equivalent to [`Interactivity::on_mouse_down_out`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_mouse_down_out( @@ -783,7 +840,7 @@ pub trait InteractiveElement: Sized { /// Bind the given callback to the mouse up event, for the given button, during the capture phase, /// when the mouse is outside of the bounds of this element. - /// The fluent API equivalent to [`Interactivity::on_mouse_up_out`] + /// The fluent API equivalent to [`Interactivity::on_mouse_up_out`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_mouse_up_out( @@ -795,8 +852,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to the mouse move event, during the bubble phase - /// The fluent API equivalent to [`Interactivity::on_mouse_move`] + /// Bind the given callback to the mouse move event, during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_mouse_move`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_mouse_move( @@ -811,7 +868,7 @@ pub trait InteractiveElement: Sized { /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable /// UIs that don't conform to a drag and drop style interaction, like resizing. - /// The fluent API equivalent to [`Interactivity::on_drag_move`] + /// The fluent API equivalent to [`Interactivity::on_drag_move`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_drag_move( @@ -822,8 +879,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to scroll wheel events during the bubble phase - /// The fluent API equivalent to [`Interactivity::on_scroll_wheel`] + /// Bind the given callback to scroll wheel events during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_scroll_wheel`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_scroll_wheel( @@ -834,8 +891,8 @@ pub trait InteractiveElement: Sized { self } - /// Capture the given action, before normal action dispatch can fire - /// The fluent API equivalent to [`Interactivity::on_scroll_wheel`] + /// Capture the given action, before normal action dispatch can fire. + /// The fluent API equivalent to [`Interactivity::capture_action`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn capture_action( @@ -846,8 +903,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to an action dispatch during the bubble phase - /// The fluent API equivalent to [`Interactivity::on_action`] + /// Bind the given callback to an action dispatch during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_action`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_action( @@ -861,7 +918,7 @@ pub trait InteractiveElement: Sized { /// Bind the given callback to an action dispatch, based on a dynamic action parameter /// instead of a type parameter. Useful for component libraries that want to expose /// action bindings to their users. - /// The fluent API equivalent to [`Interactivity::on_boxed_action`] + /// The fluent API equivalent to [`Interactivity::on_boxed_action`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_boxed_action( @@ -873,8 +930,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to key down events during the bubble phase - /// The fluent API equivalent to [`Interactivity::on_key_down`] + /// Bind the given callback to key down events during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_key_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_key_down( @@ -885,8 +942,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to key down events during the capture phase - /// The fluent API equivalent to [`Interactivity::capture_key_down`] + /// Bind the given callback to key down events during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_key_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn capture_key_down( @@ -897,8 +954,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to key up events during the bubble phase - /// The fluent API equivalent to [`Interactivity::on_key_up`] + /// Bind the given callback to key up events during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_key_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_key_up( @@ -909,8 +966,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to key up events during the capture phase - /// The fluent API equivalent to [`Interactivity::capture_key_up`] + /// Bind the given callback to key up events during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_key_up`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn capture_key_up( @@ -922,7 +979,7 @@ pub trait InteractiveElement: Sized { } /// Bind the given callback to modifiers changing events. - /// The fluent API equivalent to [`Interactivity::on_modifiers_changed`] + /// The fluent API equivalent to [`Interactivity::on_modifiers_changed`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_modifiers_changed( @@ -968,8 +1025,8 @@ pub trait InteractiveElement: Sized { self } - /// Bind the given callback to drop events of the given type, whether or not the drag started on this element - /// The fluent API equivalent to [`Interactivity::on_drop`] + /// Bind the given callback to drop events of the given type, whether or not the drag started on this element. + /// The fluent API equivalent to [`Interactivity::on_drop`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_drop( @@ -980,8 +1037,8 @@ pub trait InteractiveElement: Sized { self } - /// Use the given predicate to determine whether or not a drop event should be dispatched to this element - /// The fluent API equivalent to [`Interactivity::can_drop`] + /// Use the given predicate to determine whether or not a drop event should be dispatched to this element. + /// The fluent API equivalent to [`Interactivity::can_drop`]. fn can_drop( mut self, predicate: impl Fn(&dyn Any, &mut Window, &mut App) -> bool + 'static, @@ -992,23 +1049,23 @@ pub trait InteractiveElement: Sized { /// Block the mouse from all interactions with elements behind this element's hitbox. Typically /// `block_mouse_except_scroll` should be preferred. - /// The fluent API equivalent to [`Interactivity::occlude_mouse`] + /// The fluent API equivalent to [`Interactivity::occlude_mouse`]. fn occlude(mut self) -> Self { self.interactivity().occlude_mouse(); self } /// Set the bounds of this element as a window control area for the platform window. - /// The fluent API equivalent to [`Interactivity::window_control_area`] + /// The fluent API equivalent to [`Interactivity::window_control_area`]. fn window_control_area(mut self, area: WindowControlArea) -> Self { self.interactivity().window_control_area(area); self } - /// Block non-scroll mouse interactions with elements behind this element's hitbox. See - /// [`Hitbox::is_hovered`] for details. + /// Block non-scroll mouse interactions with elements behind this element's hitbox. + /// The fluent API equivalent to [`Interactivity::block_mouse_except_scroll`]. /// - /// The fluent API equivalent to [`Interactivity::block_mouse_except_scroll`] + /// See [`Hitbox::is_hovered`] for details. fn block_mouse_except_scroll(mut self) -> Self { self.interactivity().block_mouse_except_scroll(); self @@ -1033,6 +1090,18 @@ pub trait InteractiveElement: Sized { self.interactivity().in_focus_style = Some(Box::new(f(StyleRefinement::default()))); self } + + /// Set the given styles to be applied when this element is focused via keyboard navigation. + /// This is similar to CSS's `:focus-visible` pseudo-class - it only applies when the element + /// is focused AND the user is navigating via keyboard (not mouse clicks). + /// Requires that the element is focusable. Elements can be made focusable using [`InteractiveElement::track_focus`]. + fn focus_visible(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self + where + Self: Sized, + { + self.interactivity().focus_visible_style = Some(Box::new(f(StyleRefinement::default()))); + self + } } /// A trait for elements that want to use the standard GPUI interactivity features @@ -1109,8 +1178,8 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } - /// Bind the given callback to click events of this element - /// The fluent API equivalent to [`Interactivity::on_click`] + /// Bind the given callback to click events of this element. + /// The fluent API equivalent to [`Interactivity::on_click`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self @@ -1125,7 +1194,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// drag and drop operation. This API should also be used as the equivalent of 'on drag start' with /// the [`InteractiveElement::on_drag_move`] API. /// The callback also has access to the offset of triggering click from the origin of parent element. - /// The fluent API equivalent to [`Interactivity::on_drag`] + /// The fluent API equivalent to [`Interactivity::on_drag`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_drag( @@ -1144,7 +1213,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. - /// The fluent API equivalent to [`Interactivity::on_hover`] + /// The fluent API equivalent to [`Interactivity::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. fn on_hover(mut self, listener: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self @@ -1156,7 +1225,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { } /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. - /// The fluent API equivalent to [`Interactivity::tooltip`] + /// The fluent API equivalent to [`Interactivity::tooltip`]. fn tooltip(mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self where Self: Sized, @@ -1167,7 +1236,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into - /// the tooltip. The fluent API equivalent to [`Interactivity::hoverable_tooltip`] + /// the tooltip. The fluent API equivalent to [`Interactivity::hoverable_tooltip`]. fn hoverable_tooltip( mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, @@ -1184,7 +1253,8 @@ pub(crate) type MouseDownListener = Box; pub(crate) type MouseUpListener = Box; - +pub(crate) type MousePressureListener = + Box; pub(crate) type MouseMoveListener = Box; @@ -1403,7 +1473,12 @@ impl Element for Div { content_size, window, cx, - |_style, scroll_offset, hitbox, window, cx| { + |style, scroll_offset, hitbox, window, cx| { + // skip children + if style.display == Display::None { + return hitbox; + } + window.with_element_offset(scroll_offset, |window| { for child in &mut self.children { child.prepaint(window, cx); @@ -1443,7 +1518,12 @@ impl Element for Div { hitbox.as_ref(), window, cx, - |_style, window, cx| { + |style, window, cx| { + // skip children + if style.display == Display::None { + return; + } + for child in &mut self.children { child.paint(window, cx); } @@ -1486,6 +1566,7 @@ pub struct Interactivity { pub base_style: Box, pub(crate) focus_style: Option>, pub(crate) in_focus_style: Option>, + pub(crate) focus_visible_style: Option>, pub(crate) hover_style: Option>, pub(crate) group_hover_style: Option, pub(crate) active_style: Option>, @@ -1497,6 +1578,7 @@ pub struct Interactivity { pub(crate) group_drag_over_styles: Vec<(TypeId, GroupStyle)>, pub(crate) mouse_down_listeners: Vec, pub(crate) mouse_up_listeners: Vec, + pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) key_down_listeners: Vec, @@ -1690,6 +1772,7 @@ impl Interactivity { || self.group_hover_style.is_some() || self.hover_listener.is_some() || !self.mouse_up_listeners.is_empty() + || !self.mouse_pressure_listeners.is_empty() || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() || !self.click_listeners.is_empty() @@ -2040,6 +2123,13 @@ impl Interactivity { }) } + for listener in self.mouse_pressure_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MousePressureEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.mouse_move_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { @@ -2481,6 +2571,13 @@ impl Interactivity { { style.refine(focus_style); } + + if let Some(focus_visible_style) = self.focus_visible_style.as_ref() + && focus_handle.is_focused(window) + && window.last_input_was_keyboard() + { + style.refine(focus_visible_style); + } } if let Some(hitbox) = hitbox { @@ -3034,7 +3131,20 @@ struct ScrollHandleState { child_bounds: Vec>, scroll_to_bottom: bool, overflow: Point, - active_item: Option, + active_item: Option, +} + +#[derive(Default, Debug, Clone, Copy)] +struct ScrollActiveItem { + index: usize, + strategy: ScrollStrategy, +} + +#[derive(Default, Debug, Clone, Copy)] +enum ScrollStrategy { + #[default] + FirstVisible, + Top, } /// A handle to the scrollable aspects of an element. @@ -3084,6 +3194,25 @@ impl ScrollHandle { } } + /// Get the bottom child that's scrolled into view. + pub fn bottom_item(&self) -> usize { + let state = self.0.borrow(); + let bottom = state.bounds.bottom() - state.offset.borrow().y; + + match state.child_bounds.binary_search_by(|bounds| { + if bottom < bounds.top() { + Ordering::Greater + } else if bottom > bounds.bottom() { + Ordering::Less + } else { + Ordering::Equal + } + }) { + Ok(ix) => ix, + Err(ix) => ix.min(state.child_bounds.len().saturating_sub(1)), + } + } + /// Return the bounds into which this child is painted pub fn bounds(&self) -> Bounds { self.0.borrow().bounds @@ -3097,31 +3226,61 @@ impl ScrollHandle { /// Update [ScrollHandleState]'s active item for scrolling to in prepaint pub fn scroll_to_item(&self, ix: usize) { let mut state = self.0.borrow_mut(); - state.active_item = Some(ix); + state.active_item = Some(ScrollActiveItem { + index: ix, + strategy: ScrollStrategy::default(), + }); } - /// Scrolls the minimal amount to ensure that the child is - /// fully visible + /// Update [ScrollHandleState]'s active item for scrolling to in prepaint + /// This scrolls the minimal amount to ensure that the child is the first visible element + pub fn scroll_to_top_of_item(&self, ix: usize) { + let mut state = self.0.borrow_mut(); + state.active_item = Some(ScrollActiveItem { + index: ix, + strategy: ScrollStrategy::Top, + }); + } + + /// Scrolls the minimal amount to either ensure that the child is + /// fully visible or the top element of the view depends on the + /// scroll strategy fn scroll_to_active_item(&self) { let mut state = self.0.borrow_mut(); - let Some(active_item_index) = state.active_item else { + let Some(active_item) = state.active_item else { return; }; - let active_item = match state.child_bounds.get(active_item_index) { + + let active_item = match state.child_bounds.get(active_item.index) { Some(bounds) => { let mut scroll_offset = state.offset.borrow_mut(); - if state.overflow.y == Overflow::Scroll { - if bounds.top() + scroll_offset.y < state.bounds.top() { + match active_item.strategy { + ScrollStrategy::FirstVisible => { + if state.overflow.y == Overflow::Scroll { + let child_height = bounds.size.height; + let viewport_height = state.bounds.size.height; + if child_height > viewport_height { + scroll_offset.y = state.bounds.top() - bounds.top(); + } else if bounds.top() + scroll_offset.y < state.bounds.top() { + scroll_offset.y = state.bounds.top() - bounds.top(); + } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() { + scroll_offset.y = state.bounds.bottom() - bounds.bottom(); + } + } + } + ScrollStrategy::Top => { scroll_offset.y = state.bounds.top() - bounds.top(); - } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() { - scroll_offset.y = state.bounds.bottom() - bounds.bottom(); } } if state.overflow.x == Overflow::Scroll { - if bounds.left() + scroll_offset.x < state.bounds.left() { + let child_width = bounds.size.width; + let viewport_width = state.bounds.size.width; + if child_width > viewport_width { + scroll_offset.x = state.bounds.left() - bounds.left(); + } else if bounds.left() + scroll_offset.x < state.bounds.left() { scroll_offset.x = state.bounds.left() - bounds.left(); } else if bounds.right() + scroll_offset.x > state.bounds.right() { scroll_offset.x = state.bounds.right() - bounds.right(); @@ -3129,7 +3288,7 @@ impl ScrollHandle { } None } - None => Some(active_item_index), + None => Some(active_item), }; state.active_item = active_item; } @@ -3163,8 +3322,66 @@ impl ScrollHandle { } } + /// Get the logical scroll bottom, based on a child index and a pixel offset. + pub fn logical_scroll_bottom(&self) -> (usize, Pixels) { + let ix = self.bottom_item(); + let state = self.0.borrow(); + + if let Some(child_bounds) = state.child_bounds.get(ix) { + ( + ix, + child_bounds.bottom() + state.offset.borrow().y - state.bounds.bottom(), + ) + } else { + (ix, px(0.)) + } + } + /// Get the count of children for scrollable item. pub fn children_count(&self) -> usize { self.0.borrow().child_bounds.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_handle_aligns_wide_children_to_left_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(80.), px(20.))); + state.child_bounds = vec![Bounds::new(point(px(25.), px(0.)), size(px(200.), px(20.)))]; + state.overflow.x = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().x, px(-25.)); + } + + #[test] + fn scroll_handle_aligns_tall_children_to_top_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(20.), px(80.))); + state.child_bounds = vec![Bounds::new(point(px(0.), px(25.)), size(px(20.), px(200.)))]; + state.overflow.y = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().y, px(-25.)); + } +} diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 9760dd7d9e..fcba6a6a4e 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -1,15 +1,14 @@ use crate::{ - AbsoluteLength, AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, - Element, ElementId, Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, - InteractiveElement, Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, - RenderImage, Resource, SMOOTH_SVG_SCALE_FACTOR, SharedString, SharedUri, StyleRefinement, - Styled, SvgSize, Task, Window, px, swap_rgba_pa_to_bgra, + AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId, + Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, InteractiveElement, + Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource, + SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px, }; use anyhow::{Context as _, Result}; use futures::{AsyncReadExt, Future}; use image::{ - AnimationDecoder, DynamicImage, Frame, ImageBuffer, ImageError, ImageFormat, Rgba, + AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba, codecs::{gif::GifDecoder, webp::WebPDecoder}, }; use smallvec::SmallVec; @@ -160,13 +159,15 @@ pub trait StyledImage: Sized { self } - /// Set the object fit for the image. + /// Set a fallback function that will be invoked to render an error view should + /// the image fail to load. fn with_fallback(mut self, fallback: impl Fn() -> AnyElement + 'static) -> Self { self.image_style().fallback = Some(Box::new(fallback)); self } - /// Set the object fit for the image. + /// Set a fallback function that will be invoked to render a view while the image + /// is still being loaded. fn with_loading(mut self, loading: impl Fn() -> AnyElement + 'static) -> Self { self.image_style().loading = Some(Box::new(loading)); self @@ -337,24 +338,28 @@ impl Element for Img { if let Length::Auto = style.size.width { style.size.width = match style.size.height { - Length::Definite(DefiniteLength::Absolute( - AbsoluteLength::Pixels(height), - )) => Length::Definite( - px(image_size.width.0 * height.0 / image_size.height.0) + Length::Definite(DefiniteLength::Absolute(abs_length)) => { + let height_px = abs_length.to_pixels(window.rem_size()); + Length::Definite( + px(image_size.width.0 * height_px.0 + / image_size.height.0) .into(), - ), + ) + } _ => Length::Definite(image_size.width.into()), }; } if let Length::Auto = style.size.height { style.size.height = match style.size.width { - Length::Definite(DefiniteLength::Absolute( - AbsoluteLength::Pixels(width), - )) => Length::Definite( - px(image_size.height * f32::from(width) / image_size.width) + Length::Definite(DefiniteLength::Absolute(abs_length)) => { + let width_px = abs_length.to_pixels(window.rem_size()); + Length::Definite( + px(image_size.height.0 * width_px.0 + / image_size.width.0) .into(), - ), + ) + } _ => Length::Definite(image_size.height.into()), }; } @@ -627,7 +632,7 @@ impl Asset for ImageAssetLoader { } }; - let data = if let Ok(format) = image::guess_format(&bytes) { + if let Ok(format) = image::guess_format(&bytes) { let data = match format { ImageFormat::Gif => { let decoder = GifDecoder::new(Cursor::new(&bytes))?; @@ -685,25 +690,12 @@ impl Asset for ImageAssetLoader { } }; - RenderImage::new(data) + Ok(Arc::new(RenderImage::new(data))) } else { - let pixmap = - // TODO: Can we make svgs always rescale? - svg_renderer.render_pixmap(&bytes, SvgSize::ScaleFactor(SMOOTH_SVG_SCALE_FACTOR))?; - - let mut buffer = - ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap(); - - for pixel in buffer.chunks_exact_mut(4) { - swap_rgba_pa_to_bgra(pixel); - } - - let mut image = RenderImage::new(SmallVec::from_elem(Frame::new(buffer), 1)); - image.scale_factor = SMOOTH_SVG_SCALE_FACTOR; - image - }; - - Ok(Arc::new(data)) + svg_renderer + .render_single_frame(&bytes, 1.0, true) + .map_err(Into::into) + } } } } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index d82d7a67a1..78566208c8 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -70,6 +70,7 @@ struct StateInner { #[allow(clippy::type_complexity)] scroll_handler: Option>, scrollbar_drag_start_height: Option, + measuring_behavior: ListMeasuringBehavior, } /// Whether the list is scrolling from top to bottom or bottom to top. @@ -103,6 +104,26 @@ pub enum ListSizingBehavior { Auto, } +/// The measuring behavior to apply during layout. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ListMeasuringBehavior { + /// Measure all items in the list. + /// Note: This can be expensive for the first frame in a large list. + Measure(bool), + /// Only measure visible items + #[default] + Visible, +} + +impl ListMeasuringBehavior { + fn reset(&mut self) { + match self { + ListMeasuringBehavior::Measure(has_measured) => *has_measured = false, + ListMeasuringBehavior::Visible => {} + } + } +} + /// The horizontal sizing behavior to apply during layout. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ListHorizontalSizingBehavior { @@ -203,11 +224,20 @@ impl ListState { scroll_handler: None, reset: false, scrollbar_drag_start_height: None, + measuring_behavior: ListMeasuringBehavior::default(), }))); this.splice(0..0, item_count); this } + /// Set the list to measure all items in the list in the first layout phase. + /// + /// This is useful for ensuring that the scrollbar size is correct instead of based on only rendered elements. + pub fn measure_all(self) -> Self { + self.0.borrow_mut().measuring_behavior = ListMeasuringBehavior::Measure(false); + self + } + /// Reset this instantiation of the list state. /// /// Note that this will cause scroll events to be dropped until the next paint. @@ -215,6 +245,7 @@ impl ListState { let old_count = { let state = &mut *self.0.borrow_mut(); state.reset = true; + state.measuring_behavior.reset(); state.logical_scroll_top = None; state.scrollbar_drag_start_height = None; state.items.summary().count @@ -478,10 +509,11 @@ impl StateInner { if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { - let mut cursor = self.items.cursor::(()); - cursor.seek(&Height(new_scroll_top), Bias::Right); - let item_ix = cursor.start().count; - let offset_in_item = new_scroll_top - cursor.start().height; + let (start, ..) = + self.items + .find::((), &Height(new_scroll_top), Bias::Right); + let item_ix = start.count; + let offset_in_item = new_scroll_top - start.height; self.logical_scroll_top = Some(ListOffset { item_ix, offset_in_item, @@ -519,9 +551,54 @@ impl StateInner { } fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels { - let mut cursor = self.items.cursor::(()); - cursor.seek(&Count(logical_scroll_top.item_ix), Bias::Right); - cursor.start().height + logical_scroll_top.offset_in_item + let (start, ..) = self.items.find::( + (), + &Count(logical_scroll_top.item_ix), + Bias::Right, + ); + start.height + logical_scroll_top.offset_in_item + } + + fn layout_all_items( + &mut self, + available_width: Pixels, + render_item: &mut RenderItemFn, + window: &mut Window, + cx: &mut App, + ) { + match &mut self.measuring_behavior { + ListMeasuringBehavior::Visible => { + return; + } + ListMeasuringBehavior::Measure(has_measured) => { + if *has_measured { + return; + } + *has_measured = true; + } + } + + let mut cursor = self.items.cursor::(()); + let available_item_space = size( + AvailableSpace::Definite(available_width), + AvailableSpace::MinContent, + ); + + let mut measured_items = Vec::default(); + + for (ix, item) in cursor.enumerate() { + let size = item.size().unwrap_or_else(|| { + let mut element = render_item(ix, window, cx); + element.layout_as_root(available_item_space, window, cx) + }); + + measured_items.push(ListItem::Measured { + size, + focus_handle: item.focus_handle(), + }); + } + + self.items = SumTree::from_iter(measured_items, ()); } fn layout_items( @@ -711,6 +788,13 @@ impl StateInner { cx: &mut App, ) -> Result { window.transact(|window| { + match self.measuring_behavior { + ListMeasuringBehavior::Measure(has_measured) if !has_measured => { + self.layout_all_items(bounds.size.width, render_item, window, cx); + } + _ => {} + } + let mut layout_response = self.layout_items( Some(bounds.size.width), bounds.size.height, @@ -802,11 +886,12 @@ impl StateInner { if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { - let mut cursor = self.items.cursor::(()); - cursor.seek(&Height(new_scroll_top), Bias::Right); + let (start, _, _) = + self.items + .find::((), &Height(new_scroll_top), Bias::Right); - let item_ix = cursor.start().count; - let offset_in_item = new_scroll_top - cursor.start().height; + let item_ix = start.count; + let offset_in_item = new_scroll_top - start.height; self.logical_scroll_top = Some(ListOffset { item_ix, offset_in_item, diff --git a/crates/gpui/src/elements/svg.rs b/crates/gpui/src/elements/svg.rs index a55245dcdf..57b2d712e5 100644 --- a/crates/gpui/src/elements/svg.rs +++ b/crates/gpui/src/elements/svg.rs @@ -1,5 +1,7 @@ +use std::{fs, path::Path, sync::Arc}; + use crate::{ - App, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, + App, Asset, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, Size, StyleRefinement, Styled, TransformationMatrix, Window, geometry::Negate as _, point, px, radians, size, @@ -11,6 +13,7 @@ pub struct Svg { interactivity: Interactivity, transformation: Option, path: Option, + external_path: Option, } /// Create a new SVG element. @@ -20,6 +23,7 @@ pub fn svg() -> Svg { interactivity: Interactivity::new(), transformation: None, path: None, + external_path: None, } } @@ -30,6 +34,12 @@ impl Svg { self } + /// Set the path to the SVG file for this element. + pub fn external_path(mut self, path: impl Into) -> Self { + self.external_path = Some(path.into()); + self + } + /// Transform the SVG element with the given transformation. /// Note that this won't effect the hitbox or layout of the element, only the rendering. pub fn with_transformation(mut self, transformation: Transformation) -> Self { @@ -117,7 +127,35 @@ impl Element for Svg { .unwrap_or_default(); window - .paint_svg(bounds, path.clone(), transformation, color, cx) + .paint_svg(bounds, path.clone(), None, transformation, color, cx) + .log_err(); + } else if let Some((path, color)) = + self.external_path.as_ref().zip(style.text.color) + { + let Some(bytes) = window + .use_asset::(path, cx) + .and_then(|asset| asset.log_err()) + else { + return; + }; + + let transformation = self + .transformation + .as_ref() + .map(|transformation| { + transformation.into_matrix(bounds.center(), window.scale_factor()) + }) + .unwrap_or_default(); + + window + .paint_svg( + bounds, + path.clone(), + Some(&bytes), + transformation, + color, + cx, + ) .log_err(); } }, @@ -219,3 +257,21 @@ impl Transformation { .translate(center.scale(scale_factor).negate()) } } + +enum SvgAsset {} + +impl Asset for SvgAsset { + type Source = SharedString; + type Output = Result, Arc>; + + fn load( + source: Self::Source, + _cx: &mut App, + ) -> impl Future + Send + 'static { + async move { + let bytes = fs::read(Path::new(source.as_ref())).map_err(|e| Arc::new(e))?; + let bytes = Arc::from(bytes); + Ok(bytes) + } + } +} diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index b5e0712796..914e8a2865 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -8,6 +8,7 @@ use crate::{ use anyhow::Context as _; use smallvec::SmallVec; use std::{ + borrow::Cow, cell::{Cell, RefCell}, mem, ops::Range, @@ -180,8 +181,7 @@ impl StyledText { "Can't use `with_default_highlights` and `with_highlights`" ); let runs = Self::compute_runs(&self.text, default_style, highlights); - self.runs = Some(runs); - self + self.with_runs(runs) } /// Set the styling attributes for the given text, as well as @@ -194,7 +194,15 @@ impl StyledText { self.runs.is_none(), "Can't use `with_highlights` and `with_default_highlights`" ); - self.delayed_highlights = Some(highlights.into_iter().collect::>()); + self.delayed_highlights = Some( + highlights + .into_iter() + .inspect(|(run, _)| { + debug_assert!(self.text.is_char_boundary(run.start)); + debug_assert!(self.text.is_char_boundary(run.end)); + }) + .collect::>(), + ); self } @@ -207,8 +215,10 @@ impl StyledText { let mut ix = 0; for (range, highlight) in highlights { if ix < range.start { + debug_assert!(text.is_char_boundary(range.start)); runs.push(default_style.clone().to_run(range.start - ix)); } + debug_assert!(text.is_char_boundary(range.end)); runs.push( default_style .clone() @@ -225,6 +235,11 @@ impl StyledText { /// Set the text runs for this piece of text. pub fn with_runs(mut self, runs: Vec) -> Self { + let mut text = &**self.text; + for run in &runs { + text = text.get(run.len..).expect("invalid text run"); + } + assert!(text.is_empty(), "invalid text run"); self.runs = Some(runs); self } @@ -320,12 +335,11 @@ impl TextLayout { .line_height .to_pixels(font_size.into(), window.rem_size()); - let mut runs = if let Some(runs) = runs { + let runs = if let Some(runs) = runs { runs } else { vec![text_style.to_run(text.len())] }; - window.request_measured_layout(Default::default(), { let element_state = self.clone(); @@ -364,15 +378,15 @@ impl TextLayout { } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let text = if let Some(truncate_width) = truncate_width { + let (text, runs) = if let Some(truncate_width) = truncate_width { line_wrapper.truncate_line( text.clone(), truncate_width, &truncation_suffix, - &mut runs, + &runs, ) } else { - text.clone() + (text.clone(), Cow::Borrowed(&*runs)) }; let len = text.len(); diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index b265fb390e..1e38b0e7ac 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -11,7 +11,7 @@ use crate::{ StyleRefinement, Styled, Window, point, size, }; use smallvec::SmallVec; -use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; +use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize}; use super::ListHorizontalSizingBehavior; @@ -92,6 +92,10 @@ pub enum ScrollStrategy { /// May not be possible if there's not enough list items above the item scrolled to: /// in this case, the element will be placed at the closest possible position. Bottom, + /// If the element is not visible attempt to place it at: + /// - The top of the list's viewport if the target element is above currently visible elements. + /// - The bottom of the list's viewport if the target element is above currently visible elements. + Nearest, } #[derive(Clone, Copy, Debug)] @@ -138,7 +142,11 @@ impl UniformListScrollHandle { }))) } - /// Scroll the list so that the given item index is onscreen. + /// Scroll the list so that the given item index is visible. + /// + /// This uses non-strict scrolling: if the item is already fully visible, no scrolling occurs. + /// If the item is out of view, it scrolls the minimum amount to bring it into view according + /// to the strategy. pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) { self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { item_index: ix, @@ -149,6 +157,9 @@ impl UniformListScrollHandle { } /// Scroll the list so that the given item index is at scroll strategy position. + /// + /// This uses strict scrolling: the item will always be scrolled to match the strategy position, + /// even if it's already visible. Use this when you need precise positioning. pub fn scroll_to_item_strict(&self, ix: usize, strategy: ScrollStrategy) { self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { item_index: ix, @@ -158,11 +169,16 @@ impl UniformListScrollHandle { }); } - /// Scroll the list to the given item index with an offset. + /// Scroll the list to the given item index with an offset in number of items. /// - /// For ScrollStrategy::Top, the item will be placed at the offset position from the top. + /// This uses non-strict scrolling: if the item is already visible within the offset region, + /// no scrolling occurs. /// - /// For ScrollStrategy::Center, the item will be centered between offset and the last visible item. + /// The offset parameter shrinks the effective viewport by the specified number of items + /// from the corresponding edge, then applies the scroll strategy within that reduced viewport: + /// - `ScrollStrategy::Top`: Shrinks from top, positions item at the new top + /// - `ScrollStrategy::Center`: Shrinks from top, centers item in the reduced viewport + /// - `ScrollStrategy::Bottom`: Shrinks from bottom, positions item at the new bottom pub fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) { self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { item_index: ix, @@ -172,6 +188,30 @@ impl UniformListScrollHandle { }); } + /// Scroll the list so that the given item index is at the exact scroll strategy position with an offset. + /// + /// This uses strict scrolling: the item will always be scrolled to match the strategy position, + /// even if it's already visible. + /// + /// The offset parameter shrinks the effective viewport by the specified number of items + /// from the corresponding edge, then applies the scroll strategy within that reduced viewport: + /// - `ScrollStrategy::Top`: Shrinks from top, positions item at the new top + /// - `ScrollStrategy::Center`: Shrinks from top, centers item in the reduced viewport + /// - `ScrollStrategy::Bottom`: Shrinks from bottom, positions item at the new bottom + pub fn scroll_to_item_strict_with_offset( + &self, + ix: usize, + strategy: ScrollStrategy, + offset: usize, + ) { + self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { + item_index: ix, + strategy, + offset, + scroll_strict: true, + }); + } + /// Check if the list is flipped vertically. pub fn y_flipped(&self) -> bool { self.0.borrow().y_flipped @@ -195,6 +235,11 @@ impl UniformListScrollHandle { false } } + + /// Scroll to the bottom of the list. + pub fn scroll_to_bottom(&self) { + self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom); + } } impl Styled for UniformList { @@ -307,7 +352,7 @@ impl Element for UniformList { }; let content_size = Size { width: content_width, - height: longest_item_size.height * self.item_count + padding.top + padding.bottom, + height: longest_item_size.height * self.item_count, }; let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap(); @@ -328,17 +373,7 @@ impl Element for UniformList { content_size, window, cx, - |style, mut scroll_offset, hitbox, window, cx| { - let border = style.border_widths.to_pixels(window.rem_size()); - let padding = style - .padding - .to_pixels(bounds.size.into(), window.rem_size()); - - let padded_bounds = Bounds::from_corners( - bounds.origin + point(border.left + padding.left, border.top), - bounds.bottom_right() - point(border.right + padding.right, border.bottom), - ); - + |_style, mut scroll_offset, hitbox, window, cx| { let y_flipped = if let Some(scroll_handle) = &self.scroll_handle { let scroll_state = scroll_handle.0.borrow(); scroll_state.y_flipped @@ -347,13 +382,14 @@ impl Element for UniformList { }; if self.item_count > 0 { - let content_height = - item_height * self.item_count + padding.top + padding.bottom; + let content_height = item_height * self.item_count; + let is_scrolled_vertically = !scroll_offset.y.is_zero(); - let min_vertical_scroll_offset = padded_bounds.size.height - content_height; - if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset { - shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset; - scroll_offset.y = min_vertical_scroll_offset; + let max_scroll_offset = padded_bounds.size.height - content_height; + + if is_scrolled_vertically && scroll_offset.y < max_scroll_offset { + shared_scroll_offset.borrow_mut().y = max_scroll_offset; + scroll_offset.y = max_scroll_offset; } let content_width = content_size.width + padding.left + padding.right; @@ -364,38 +400,42 @@ impl Element for UniformList { scroll_offset.x = Pixels::ZERO; } - if let Some(deferred_scroll) = shared_scroll_to_item { - let mut ix = deferred_scroll.item_index; + if let Some(DeferredScrollToItem { + mut item_index, + mut strategy, + offset, + scroll_strict, + }) = shared_scroll_to_item + { if y_flipped { - ix = self.item_count.saturating_sub(ix + 1); + item_index = self.item_count.saturating_sub(item_index + 1); } let list_height = padded_bounds.size.height; let mut updated_scroll_offset = shared_scroll_offset.borrow_mut(); - let item_top = item_height * ix + padding.top; + let item_top = item_height * item_index; let item_bottom = item_top + item_height; let scroll_top = -updated_scroll_offset.y; - let offset_pixels = item_height * deferred_scroll.offset; - let mut scrolled_to_top = false; + let offset_pixels = item_height * offset; - if item_top < scroll_top + padding.top + offset_pixels { - scrolled_to_top = true; - updated_scroll_offset.y = -(item_top) + padding.top + offset_pixels; - } else if item_bottom > scroll_top + list_height - padding.bottom { - scrolled_to_top = true; - updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom; - } + // is the selected item above/below currently visible items + let is_above = item_top < scroll_top + offset_pixels; + let is_below = item_bottom > scroll_top + list_height; - if deferred_scroll.scroll_strict - || (scrolled_to_top - && (item_top < scroll_top + offset_pixels - || item_bottom > scroll_top + list_height)) - { - match deferred_scroll.strategy { + if scroll_strict || is_above || is_below { + if strategy == ScrollStrategy::Nearest { + if is_above { + strategy = ScrollStrategy::Top; + } else if is_below { + strategy = ScrollStrategy::Bottom; + } + } + + let max_scroll_offset = + (content_height - list_height).max(Pixels::ZERO); + match strategy { ScrollStrategy::Top => { - updated_scroll_offset.y = -item_top - .max(Pixels::ZERO) - .min(content_height - list_height) - .max(Pixels::ZERO); + updated_scroll_offset.y = -(item_top - offset_pixels) + .clamp(Pixels::ZERO, max_scroll_offset); } ScrollStrategy::Center => { let item_center = item_top + item_height / 2.0; @@ -403,17 +443,15 @@ impl Element for UniformList { let viewport_height = list_height - offset_pixels; let viewport_center = offset_pixels + viewport_height / 2.0; let target_scroll_top = item_center - viewport_center; - - updated_scroll_offset.y = -target_scroll_top - .max(Pixels::ZERO) - .min(content_height - list_height) - .max(Pixels::ZERO); + updated_scroll_offset.y = + -target_scroll_top.clamp(Pixels::ZERO, max_scroll_offset); } ScrollStrategy::Bottom => { updated_scroll_offset.y = -(item_bottom - list_height) - .max(Pixels::ZERO) - .min(content_height - list_height) - .max(Pixels::ZERO); + .clamp(Pixels::ZERO, max_scroll_offset); + } + ScrollStrategy::Nearest => { + // Nearest, but the item is visible -> no scroll is required } } } @@ -443,14 +481,9 @@ impl Element for UniformList { window.with_content_mask(Some(content_mask), |window| { for (mut item, ix) in items.into_iter().zip(visible_range.clone()) { let item_origin = padded_bounds.origin - + point( - if can_scroll_horizontally { - scroll_offset.x + padding.left - } else { - scroll_offset.x - }, - item_height * ix + scroll_offset.y + padding.top, - ); + + scroll_offset + + point(Pixels::ZERO, item_height * ix); + let available_width = if can_scroll_horizontally { padded_bounds.size.width + scroll_offset.x.abs() } else { @@ -465,18 +498,8 @@ impl Element for UniformList { frame_state.items.push(item); } - let bounds = Bounds::new( - padded_bounds.origin - + point( - if can_scroll_horizontally { - scroll_offset.x + padding.left - } else { - scroll_offset.x - }, - scroll_offset.y + padding.top, - ), - padded_bounds.size, - ); + let bounds = + Bounds::new(padded_bounds.origin + scroll_offset, padded_bounds.size); for decoration in &self.decorations { let mut decoration = decoration.as_ref().compute( visible_range.clone(), @@ -645,9 +668,9 @@ impl UniformList { } /// Track and render scroll state of this list with reference to the given scroll handle. - pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self { + pub fn track_scroll(mut self, handle: &UniformListScrollHandle) -> Self { self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone()); - self.scroll_handle = Some(handle); + self.scroll_handle = Some(handle.clone()); self } @@ -681,3 +704,150 @@ impl InteractiveElement for UniformList { &mut self.interactivity } } + +#[cfg(test)] +mod test { + use crate::TestAppContext; + + #[gpui::test] + fn test_scroll_strategy_nearest(cx: &mut TestAppContext) { + use crate::{ + Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, actions, div, + prelude::*, px, uniform_list, + }; + use std::ops::Range; + + actions!(example, [SelectNext, SelectPrev]); + + struct TestView { + index: usize, + length: usize, + scroll_handle: UniformListScrollHandle, + focus_handle: FocusHandle, + visible_range: Range, + } + + impl TestView { + pub fn select_next( + &mut self, + _: &SelectNext, + window: &mut Window, + _: &mut Context, + ) { + if self.index + 1 == self.length { + self.index = 0 + } else { + self.index += 1; + } + self.scroll_handle + .scroll_to_item(self.index, ScrollStrategy::Nearest); + window.refresh(); + } + + pub fn select_previous( + &mut self, + _: &SelectPrev, + window: &mut Window, + _: &mut Context, + ) { + if self.index == 0 { + self.index = self.length - 1 + } else { + self.index -= 1; + } + self.scroll_handle + .scroll_to_item(self.index, ScrollStrategy::Nearest); + window.refresh(); + } + } + + impl Render for TestView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("list-example") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .size_full() + .child( + uniform_list( + "entries", + self.length, + cx.processor(|this, range: Range, _window, _cx| { + this.visible_range = range.clone(); + range + .map(|ix| div().id(ix).h(px(20.0)).child(format!("Item {ix}"))) + .collect() + }), + ) + .track_scroll(&self.scroll_handle) + .h(px(200.0)), + ) + } + } + + let (view, cx) = cx.add_window_view(|window, cx| { + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle); + TestView { + scroll_handle: UniformListScrollHandle::new(), + index: 0, + focus_handle, + length: 47, + visible_range: 0..0, + } + }); + + // 10 out of 47 items are visible + + // First 9 times selecting next item does not scroll + for ix in 1..10 { + cx.dispatch_action(SelectNext); + view.read_with(cx, |view, _| { + assert_eq!(view.index, ix); + assert_eq!(view.visible_range, 0..10); + }) + } + + // Now each time the list scrolls down by 1 + for ix in 10..47 { + cx.dispatch_action(SelectNext); + view.read_with(cx, |view, _| { + assert_eq!(view.index, ix); + assert_eq!(view.visible_range, ix - 9..ix + 1); + }) + } + + // After the last item we move back to the start + cx.dispatch_action(SelectNext); + view.read_with(cx, |view, _| { + assert_eq!(view.index, 0); + assert_eq!(view.visible_range, 0..10); + }); + + // Return to the last element + cx.dispatch_action(SelectPrev); + view.read_with(cx, |view, _| { + assert_eq!(view.index, 46); + assert_eq!(view.visible_range, 37..47); + }); + + // First 9 times selecting previous does not scroll + for ix in (37..46).rev() { + cx.dispatch_action(SelectPrev); + view.read_with(cx, |view, _| { + assert_eq!(view.index, ix); + assert_eq!(view.visible_range, 37..47); + }) + } + + // Now each time the list scrolls up by 1 + for ix in (0..37).rev() { + cx.dispatch_action(SelectPrev); + view.read_with(cx, |view, _| { + assert_eq!(view.index, ix); + assert_eq!(view.visible_range, ix..ix + 10); + }) + } + } +} diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 0b28dd030b..a219a20e92 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,22 +1,22 @@ -use crate::{App, PlatformDispatcher}; +use crate::{App, PlatformDispatcher, RunnableMeta, RunnableVariant, TaskTiming, profiler}; use async_task::Runnable; use futures::channel::mpsc; +use parking_lot::{Condvar, Mutex}; use smol::prelude::*; -use std::mem::ManuallyDrop; -use std::panic::Location; -use std::thread::{self, ThreadId}; use std::{ fmt::Debug, marker::PhantomData, - mem, + mem::{self, ManuallyDrop}, num::NonZeroUsize, + panic::Location, pin::Pin, rc::Rc, sync::{ Arc, - atomic::{AtomicUsize, Ordering::SeqCst}, + atomic::{AtomicUsize, Ordering}, }, task::{Context, Poll}, + thread::{self, ThreadId}, time::{Duration, Instant}, }; use util::TryFutureExt; @@ -39,7 +39,7 @@ pub struct BackgroundExecutor { /// This is intentionally `!Send` via the `not_send` marker field. This is because /// `ForegroundExecutor::spawn` does not require `Send` but checks at runtime that the future is /// only polled from the same thread it was spawned from. These checks would fail when spawning -/// foreground tasks from from background threads. +/// foreground tasks from background threads. #[derive(Clone)] pub struct ForegroundExecutor { #[doc(hidden)] @@ -47,6 +47,52 @@ pub struct ForegroundExecutor { not_send: PhantomData>, } +/// Realtime task priority +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum RealtimePriority { + /// Audio task + Audio, + /// Other realtime task + #[default] + Other, +} + +/// Task priority +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum Priority { + /// Realtime priority + /// + /// Spawning a task with this priority will spin it off on a separate thread dedicated just to that task. + Realtime(RealtimePriority), + /// High priority + /// + /// Only use for tasks that are critical to the user experience / responsiveness of the editor. + High, + /// Medium priority, probably suits most of your use cases. + #[default] + Medium, + /// Low priority + /// + /// Prioritize this for background work that can come in large quantities + /// to not starve the executor of resources for high priority tasks + Low, +} + +impl Priority { + #[allow(dead_code)] + pub(crate) const fn probability(&self) -> u32 { + match self { + // realtime priorities are not considered for probability scheduling + Priority::Realtime(_) => 0, + Priority::High => 60, + Priority::Medium => 30, + Priority::Low => 10, + } + } +} + /// Task is a primitive that allows work to happen in the background. /// /// It implements [`Future`] so you can `.await` on it. @@ -63,7 +109,7 @@ enum TaskState { Ready(Option), /// A task that is currently running. - Spawned(async_task::Task), + Spawned(async_task::Task), } impl Task { @@ -123,7 +169,12 @@ impl TaskLabel { /// Construct a new task label. pub fn new() -> Self { static NEXT_TASK_LABEL: AtomicUsize = AtomicUsize::new(1); - Self(NEXT_TASK_LABEL.fetch_add(1, SeqCst).try_into().unwrap()) + Self( + NEXT_TASK_LABEL + .fetch_add(1, Ordering::SeqCst) + .try_into() + .unwrap(), + ) } } @@ -142,15 +193,87 @@ impl BackgroundExecutor { } /// Enqueues the given future to be run to completion on a background thread. + #[track_caller] pub fn spawn(&self, future: impl Future + Send + 'static) -> Task where R: Send + 'static, { - self.spawn_internal::(Box::pin(future), None) + self.spawn_with_priority(Priority::default(), future) + } + + /// Enqueues the given future to be run to completion on a background thread. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + future: impl Future + Send + 'static, + ) -> Task + where + R: Send + 'static, + { + self.spawn_internal::(Box::pin(future), None, priority) + } + + /// Enqueues the given future to be run to completion on a background thread and blocking the current task on it. + /// + /// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to + /// completion before the current task is resumed, even if the current task is slated for cancellation. + pub async fn await_on_background(&self, future: impl Future + Send) -> R + where + R: Send, + { + // We need to ensure that cancellation of the parent task does not drop the environment + // before the our own task has completed or got cancelled. + struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); + + impl Drop for NotifyOnDrop<'_> { + fn drop(&mut self) { + *self.0.1.lock() = true; + self.0.0.notify_all(); + } + } + + struct WaitOnDrop<'a>(&'a (Condvar, Mutex)); + + impl Drop for WaitOnDrop<'_> { + fn drop(&mut self) { + let mut done = self.0.1.lock(); + if !*done { + self.0.0.wait(&mut done); + } + } + } + + let dispatcher = self.dispatcher.clone(); + let location = core::panic::Location::caller(); + + let pair = &(Condvar::new(), Mutex::new(false)); + let _wait_guard = WaitOnDrop(pair); + + let (runnable, task) = unsafe { + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn_unchecked( + move |_| async { + let _notify_guard = NotifyOnDrop(pair); + future.await + }, + move |runnable| { + dispatcher.dispatch( + RunnableVariant::Meta(runnable), + None, + Priority::default(), + ) + }, + ) + }; + runnable.schedule(); + task.await } /// Enqueues the given future to be run to completion on a background thread. /// The given label can be used to control the priority of the task in tests. + #[track_caller] pub fn spawn_labeled( &self, label: TaskLabel, @@ -159,17 +282,63 @@ impl BackgroundExecutor { where R: Send + 'static, { - self.spawn_internal::(Box::pin(future), Some(label)) + self.spawn_internal::(Box::pin(future), Some(label), Priority::default()) } + #[track_caller] fn spawn_internal( &self, future: AnyFuture, label: Option, + priority: Priority, ) -> Task { let dispatcher = self.dispatcher.clone(); - let (runnable, task) = - async_task::spawn(future, move |runnable| dispatcher.dispatch(runnable, label)); + let (runnable, task) = if let Priority::Realtime(realtime) = priority { + let location = core::panic::Location::caller(); + let (mut tx, rx) = flume::bounded::>(1); + + dispatcher.spawn_realtime( + realtime, + Box::new(move || { + while let Ok(runnable) = rx.recv() { + let start = Instant::now(); + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + + let end = Instant::now(); + timing.end = Some(end); + profiler::add_task_timing(timing); + } + }), + ); + + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn( + move |_| future, + move |runnable| { + let _ = tx.send(runnable); + }, + ) + } else { + let location = core::panic::Location::caller(); + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn( + move |_| future, + move |runnable| { + dispatcher.dispatch(RunnableVariant::Meta(runnable), label, priority) + }, + ) + }; + runnable.schedule(); Task(TaskState::Spawned(task)) } @@ -210,7 +379,8 @@ impl BackgroundExecutor { } let deadline = timeout.map(|timeout| Instant::now() + timeout); - let unparker = self.dispatcher.unparker(); + let parker = parking::Parker::new(); + let unparker = parker.unparker(); let waker = waker_fn(move || { unparker.unpark(); }); @@ -222,10 +392,14 @@ impl BackgroundExecutor { Poll::Pending => { let timeout = deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); - if !self.dispatcher.park(timeout) - && deadline.is_some_and(|deadline| deadline < Instant::now()) - { - return Err(future); + if let Some(timeout) = timeout { + if !parker.park_timeout(timeout) + && deadline.is_some_and(|deadline| deadline < Instant::now()) + { + return Err(future); + } + } else { + parker.park(); } } } @@ -242,6 +416,8 @@ impl BackgroundExecutor { ) -> Result + use> { use std::sync::atomic::AtomicBool; + use parking::Parker; + let mut future = Box::pin(future); if timeout == Some(Duration::ZERO) { return Err(future); @@ -255,17 +431,28 @@ impl BackgroundExecutor { } else { usize::MAX }; - let unparker = self.dispatcher.unparker(); + + let parker = Parker::new(); + let unparker = parker.unparker(); + let awoken = Arc::new(AtomicBool::new(false)); let waker = waker_fn({ let awoken = awoken.clone(); + let unparker = unparker.clone(); move || { - awoken.store(true, SeqCst); + awoken.store(true, Ordering::SeqCst); unparker.unpark(); } }); let mut cx = std::task::Context::from_waker(&waker); + let duration = Duration::from_secs( + option_env!("GPUI_TEST_TIMEOUT") + .and_then(|s| s.parse::().ok()) + .unwrap_or(180), + ); + let mut test_should_end_by = Instant::now() + duration; + loop { match future.as_mut().poll(&mut cx) { Poll::Ready(result) => return Ok(result), @@ -276,7 +463,7 @@ impl BackgroundExecutor { max_ticks -= 1; if !dispatcher.tick(background_only) { - if awoken.swap(false, SeqCst) { + if awoken.swap(false, Ordering::SeqCst) { continue; } @@ -297,7 +484,11 @@ impl BackgroundExecutor { "parked with nothing left to run{waiting_message}{backtrace_message}", ) } - self.dispatcher.park(None); + dispatcher.push_unparker(unparker.clone()); + parker.park_timeout(Duration::from_millis(1)); + if Instant::now() > test_should_end_by { + panic!("test timed out after {duration:?} with allow_parking") + } } } } @@ -320,11 +511,28 @@ impl BackgroundExecutor { where F: FnOnce(&mut Scope<'scope>), { - let mut scope = Scope::new(self.clone()); + let mut scope = Scope::new(self.clone(), Priority::default()); (scheduler)(&mut scope); let spawned = mem::take(&mut scope.futures) .into_iter() - .map(|f| self.spawn(f)) + .map(|f| self.spawn_with_priority(scope.priority, f)) + .collect::>(); + for task in spawned { + task.await; + } + } + + /// Scoped lets you start a number of tasks and waits + /// for all of them to complete before returning. + pub async fn scoped_priority<'scope, F>(&self, priority: Priority, scheduler: F) + where + F: FnOnce(&mut Scope<'scope>), + { + let mut scope = Scope::new(self.clone(), priority); + (scheduler)(&mut scope); + let spawned = mem::take(&mut scope.futures) + .into_iter() + .map(|f| self.spawn_with_priority(scope.priority, f)) .collect::>(); for task in spawned { task.await; @@ -346,10 +554,13 @@ impl BackgroundExecutor { if duration.is_zero() { return Task::ready(()); } - let (runnable, task) = async_task::spawn(async move {}, { - let dispatcher = self.dispatcher.clone(); - move |runnable| dispatcher.dispatch_after(duration, runnable) - }); + let location = core::panic::Location::caller(); + let (runnable, task) = async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn(move |_| async move {}, { + let dispatcher = self.dispatcher.clone(); + move |runnable| dispatcher.dispatch_after(duration, RunnableVariant::Meta(runnable)) + }); runnable.schedule(); Task(TaskState::Spawned(task)) } @@ -457,23 +668,43 @@ impl ForegroundExecutor { /// Enqueues the given Task to run on the main thread at some point in the future. #[track_caller] pub fn spawn(&self, future: impl Future + 'static) -> Task + where + R: 'static, + { + self.spawn_with_priority(Priority::default(), future) + } + + /// Enqueues the given Task to run on the main thread at some point in the future. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + future: impl Future + 'static, + ) -> Task where R: 'static, { let dispatcher = self.dispatcher.clone(); + let location = core::panic::Location::caller(); #[track_caller] fn inner( dispatcher: Arc, future: AnyLocalFuture, + location: &'static core::panic::Location<'static>, + priority: Priority, ) -> Task { - let (runnable, task) = spawn_local_with_source_location(future, move |runnable| { - dispatcher.dispatch_on_main_thread(runnable) - }); + let (runnable, task) = spawn_local_with_source_location( + future, + move |runnable| { + dispatcher.dispatch_on_main_thread(RunnableVariant::Meta(runnable), priority) + }, + RunnableMeta { location }, + ); runnable.schedule(); Task(TaskState::Spawned(task)) } - inner::(dispatcher, Box::pin(future)) + inner::(dispatcher, Box::pin(future), location, priority) } } @@ -482,14 +713,16 @@ impl ForegroundExecutor { /// Copy-modified from: /// #[track_caller] -fn spawn_local_with_source_location( +fn spawn_local_with_source_location( future: Fut, schedule: S, -) -> (Runnable<()>, async_task::Task) + metadata: M, +) -> (Runnable, async_task::Task) where Fut: Future + 'static, Fut::Output: 'static, - S: async_task::Schedule<()> + Send + Sync + 'static, + S: async_task::Schedule + Send + Sync + 'static, + M: 'static, { #[inline] fn thread_id() -> ThreadId { @@ -513,9 +746,7 @@ where "local task dropped by a thread that didn't spawn it. Task spawned at {}", self.location ); - unsafe { - ManuallyDrop::drop(&mut self.inner); - } + unsafe { ManuallyDrop::drop(&mut self.inner) }; } } @@ -539,12 +770,17 @@ where location: Location::caller(), }; - unsafe { async_task::spawn_unchecked(future, schedule) } + unsafe { + async_task::Builder::new() + .metadata(metadata) + .spawn_unchecked(move |_| future, schedule) + } } /// Scope manages a set of tasks that are enqueued and waited on together. See [`BackgroundExecutor::scoped`]. pub struct Scope<'a> { executor: BackgroundExecutor, + priority: Priority, futures: Vec + Send + 'static>>>, tx: Option>, rx: mpsc::Receiver<()>, @@ -552,10 +788,11 @@ pub struct Scope<'a> { } impl<'a> Scope<'a> { - fn new(executor: BackgroundExecutor) -> Self { + fn new(executor: BackgroundExecutor, priority: Priority) -> Self { let (tx, rx) = mpsc::channel(1); Self { executor, + priority, tx: Some(tx), rx, futures: Default::default(), @@ -569,6 +806,7 @@ impl<'a> Scope<'a> { } /// Spawn a future into this scope. + #[track_caller] pub fn spawn(&mut self, f: F) where F: Future + Send + 'a, diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 0261e7e0f3..fc735ba5e0 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -748,7 +748,7 @@ impl Size { /// assert_eq!(bounds.origin, origin); /// assert_eq!(bounds.size, size); /// ``` -#[derive(Refineable, Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)] +#[derive(Refineable, Copy, Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)] #[refineable(Debug)] #[repr(C)] pub struct Bounds { @@ -1416,9 +1416,9 @@ where /// ``` pub fn contains(&self, point: &Point) -> bool { point.x >= self.origin.x - && point.x <= self.origin.x.clone() + self.size.width.clone() + && point.x < self.origin.x.clone() + self.size.width.clone() && point.y >= self.origin.y - && point.y <= self.origin.y.clone() + self.size.height.clone() + && point.y < self.origin.y.clone() + self.size.height.clone() } /// Checks if this bounds is completely contained within another bounds. @@ -1676,8 +1676,6 @@ impl Bounds { } } -impl Copy for Bounds {} - /// Represents the edges of a box in a 2D space, such as padding or margin. /// /// Each field represents the size of the edge on one side of the box: `top`, `right`, `bottom`, and `left`. @@ -2650,6 +2648,18 @@ impl Debug for Pixels { } } +impl std::iter::Sum for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + b) + } +} + +impl<'a> std::iter::Sum<&'a Pixels> for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + *b) + } +} + impl TryFrom<&'_ str> for Pixels { type Error = anyhow::Error; @@ -2968,6 +2978,15 @@ impl ScaledPixels { /// # Returns /// /// Returns a new `ScaledPixels` instance with the rounded value. + pub fn round(&self) -> Self { + Self(self.0.round()) + } + + /// Ceils the `ScaledPixels` value to the nearest whole number. + /// + /// # Returns + /// + /// Returns a new `ScaledPixels` instance with the ceiled value. pub fn ceil(&self) -> Self { Self(self.0.ceil()) } @@ -3560,7 +3579,7 @@ pub const fn relative(fraction: f32) -> DefiniteLength { } /// Returns the Golden Ratio, i.e. `~(1.0 + sqrt(5.0)) / 2.0`. -pub fn phi() -> DefiniteLength { +pub const fn phi() -> DefiniteLength { relative(1.618_034) } @@ -3573,7 +3592,7 @@ pub fn phi() -> DefiniteLength { /// # Returns /// /// A `Rems` representing the specified number of rems. -pub fn rems(rems: f32) -> Rems { +pub const fn rems(rems: f32) -> Rems { Rems(rems) } @@ -3601,7 +3620,7 @@ pub const fn px(pixels: f32) -> Pixels { /// # Returns /// /// A `Length` variant set to `Auto`. -pub fn auto() -> Length { +pub const fn auto() -> Length { Length::Auto } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 0c1670bf42..e5c726f58e 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -1,67 +1,4 @@ -//! # Welcome to GPUI! -//! -//! GPUI is a hybrid immediate and retained mode, GPU accelerated, UI framework -//! for Rust, designed to support a wide variety of applications. -//! -//! ## Getting Started -//! -//! GPUI is still in active development as we work on the Zed code editor and isn't yet on crates.io. -//! You'll also need to use the latest version of stable rust. Add the following to your Cargo.toml: -//! -//! ```toml -//! [dependencies] -//! gpui = { git = "https://github.com/zed-industries/zed" } -//! ``` -//! -//! - [Ownership and data flow](_ownership_and_data_flow) -//! -//! Everything in GPUI starts with an [`Application`]. You can create one with [`Application::new`], and -//! kick off your application by passing a callback to [`Application::run`]. Inside this callback, -//! you can create a new window with [`App::open_window`], and register your first root -//! view. See [gpui.rs](https://www.gpui.rs/) for a complete example. -//! -//! ## The Big Picture -//! -//! GPUI offers three different [registers](https://en.wikipedia.org/wiki/Register_(sociolinguistics)) depending on your needs: -//! -//! - State management and communication with [`Entity`]'s. Whenever you need to store application state -//! that communicates between different parts of your application, you'll want to use GPUI's -//! entities. Entities are owned by GPUI and are only accessible through an owned smart pointer -//! similar to an [`std::rc::Rc`]. See [`app::Context`] for more information. -//! -//! - High level, declarative UI with views. All UI in GPUI starts with a view. A view is simply -//! a [`Entity`] that can be rendered, by implementing the [`Render`] trait. At the start of each frame, GPUI -//! will call this render method on the root view of a given window. Views build a tree of -//! [`Element`]s, lay them out and style them with a tailwind-style API, and then give them to -//! GPUI to turn into pixels. See the [`elements::Div`] element for an all purpose swiss-army -//! knife for UI. -//! -//! - Low level, imperative UI with Elements. Elements are the building blocks of UI in GPUI, and they -//! provide a nice wrapper around an imperative API that provides as much flexibility and control as -//! you need. Elements have total control over how they and their child elements are rendered and -//! can be used for making efficient views into large lists, implement custom layouting for a code editor, -//! and anything else you can think of. See the [`elements`] module for more information. -//! -//! Each of these registers has one or more corresponding contexts that can be accessed from all GPUI services. -//! This context is your main interface to GPUI, and is used extensively throughout the framework. -//! -//! ## Other Resources -//! -//! In addition to the systems above, GPUI provides a range of smaller services that are useful for building -//! complex applications: -//! -//! - Actions are user-defined structs that are used for converting keystrokes into logical operations in your UI. -//! Use this for implementing keyboard shortcuts, such as cmd-q (See `action` module for more information). -//! - Platform services, such as `quit the app` or `open a URL` are available as methods on the [`app::App`]. -//! - An async executor that is integrated with the platform's event loop. See the [`executor`] module for more information., -//! - The [`gpui::test`](macro@test) macro provides a convenient way to write tests for your GPUI applications. Tests also have their -//! own kind of context, a [`TestAppContext`] which provides ways of simulating common platform input. See [`TestAppContext`] -//! and [`mod@test`] modules for more details. -//! -//! Currently, the best way to learn about these APIs is to read the Zed source code, ask us about it at a fireside hack, or drop -//! a question in the [Zed Discord](https://zed.dev/community-links). We're working on improving the documentation, creating more examples, -//! and will be publishing more guides to GPUI on our [blog](https://zed.dev/blog). - +#![doc = include_str!("../README.md")] #![deny(missing_docs)] #![allow(clippy::type_complexity)] // Not useful, GPUI makes heavy use of callbacks #![allow(clippy::collapsible_else_if)] // False positives in platform specific code @@ -93,6 +30,9 @@ mod keymap; mod path_builder; mod platform; pub mod prelude; +mod profiler; +#[cfg(any(target_os = "windows", target_os = "linux"))] +mod queue; mod scene; mod shared_string; mod shared_uri; @@ -150,16 +90,21 @@ use key_dispatch::*; pub use keymap::*; pub use path_builder::*; pub use platform::*; +pub use profiler::*; +#[cfg(any(target_os = "windows", target_os = "linux"))] +pub(crate) use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; pub use shared_string::*; pub use shared_uri::*; pub use smol::Timer; +use std::{any::Any, future::Future}; pub use style::*; pub use styled::*; pub use subscription::*; -use svg_renderer::*; +pub use svg_renderer::*; pub(crate) use tab_stop::*; +use taffy::TaffyLayoutEngine; pub use taffy::{AvailableSpace, LayoutId}; #[cfg(any(test, feature = "test-support"))] pub use test::*; @@ -170,9 +115,6 @@ pub use util::{FutureExt, Timeout, arc_cow::ArcCow}; pub use view::*; pub use window::*; -use std::{any::Any, borrow::BorrowMut, future::Future}; -use taffy::TaffyLayoutEngine; - /// The context trait, allows the different contexts in GPUI to be used /// interchangeably for certain operations. pub trait AppContext { @@ -316,7 +258,7 @@ pub trait BorrowAppContext { impl BorrowAppContext for C where - C: BorrowMut, + C: std::borrow::BorrowMut, { fn set_global(&mut self, global: G) { self.borrow_mut().set_global(global) diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index dc36ef9e16..c9c0a85cad 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -70,6 +70,11 @@ pub trait EntityInputHandler: 'static + Sized { window: &mut Window, cx: &mut Context, ) -> Option; + + /// See [`InputHandler::accepts_text_input`] for details + fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { + true + } } /// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`] @@ -177,4 +182,9 @@ impl InputHandler for ElementInputHandler { view.character_index_for_point(point, window, cx) }) } + + fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool { + self.view + .update(cx, |view, cx| view.accepts_text_input(window, cx)) + } } diff --git a/crates/gpui/src/inspector.rs b/crates/gpui/src/inspector.rs index 9f86576a59..ad3ba6a4b6 100644 --- a/crates/gpui/src/inspector.rs +++ b/crates/gpui/src/inspector.rs @@ -39,7 +39,7 @@ mod conditional { impl Clone for InspectorElementPath { fn clone(&self) -> Self { Self { - global_id: crate::GlobalElementId(self.global_id.0.clone()), + global_id: self.global_id.clone(), source_location: self.source_location, } } diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index dafe623dfa..6852b9596a 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -25,6 +25,10 @@ pub struct KeyDownEvent { /// Whether the key is currently held down. pub is_held: bool, + + /// Whether to prefer character input over keybindings for this keystroke. + /// In some cases, like AltGr on Windows, modifiers are significant for character input. + pub prefer_character_input: bool, } impl Sealed for KeyDownEvent {} @@ -115,6 +119,16 @@ impl InputEvent for MouseDownEvent { } impl MouseEvent for MouseDownEvent {} +impl MouseDownEvent { + /// Returns true if this mouse up event should focus the element. + pub fn is_focusing(&self) -> bool { + match self.button { + MouseButton::Left => true, + _ => false, + } + } +} + /// A mouse up event from the platform #[derive(Clone, Debug, Default)] pub struct MouseUpEvent { @@ -137,8 +151,19 @@ impl InputEvent for MouseUpEvent { PlatformInput::MouseUp(self) } } + impl MouseEvent for MouseUpEvent {} +impl MouseUpEvent { + /// Returns true if this mouse up event should focus the element. + pub fn is_focusing(&self) -> bool { + match self.button { + MouseButton::Left => true, + _ => false, + } + } +} + /// A click event, generated when a mouse button is pressed and released. #[derive(Clone, Debug, Default)] pub struct MouseClickEvent { @@ -149,6 +174,40 @@ pub struct MouseClickEvent { pub up: MouseUpEvent, } +/// The stage of a pressure click event. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum PressureStage { + /// No pressure. + #[default] + Zero, + /// Normal click pressure. + Normal, + /// High pressure, enough to trigger a force click. + Force, +} + +/// A mouse pressure event from the platform. Generated when a force-sensitive trackpad is pressed hard. +/// Currently only implemented for macOS trackpads. +#[derive(Debug, Clone, Default)] +pub struct MousePressureEvent { + /// Pressure of the current stage as a float between 0 and 1 + pub pressure: f32, + /// The pressure stage of the event. + pub stage: PressureStage, + /// The position of the mouse on the window. + pub position: Point, + /// The modifiers that were held down when the mouse pressure changed. + pub modifiers: Modifiers, +} + +impl Sealed for MousePressureEvent {} +impl InputEvent for MousePressureEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MousePressure(self) + } +} +impl MouseEvent for MousePressureEvent {} + /// A click event that was generated by a keyboard button being pressed and released. #[derive(Clone, Debug, Default)] pub struct KeyboardClickEvent { @@ -280,9 +339,10 @@ pub enum KeyboardButton { } /// An enum representing the mouse button that was pressed. -#[derive(Hash, PartialEq, Eq, Copy, Clone, Debug)] +#[derive(Hash, Default, PartialEq, Eq, Copy, Clone, Debug)] pub enum MouseButton { /// The left mouse button. + #[default] Left, /// The right mouse button. @@ -308,29 +368,18 @@ impl MouseButton { } } -impl Default for MouseButton { - fn default() -> Self { - Self::Left - } -} - /// A navigation direction, such as back or forward. -#[derive(Hash, PartialEq, Eq, Copy, Clone, Debug)] +#[derive(Hash, Default, PartialEq, Eq, Copy, Clone, Debug)] pub enum NavigationDirection { /// The back button. + #[default] Back, /// The forward button. Forward, } -impl Default for NavigationDirection { - fn default() -> Self { - Self::Back - } -} - -/// A mouse move event from the platform +/// A mouse move event from the platform. #[derive(Clone, Debug, Default)] pub struct MouseMoveEvent { /// The position of the mouse on the window. @@ -358,7 +407,7 @@ impl MouseMoveEvent { } } -/// A mouse wheel event from the platform +/// A mouse wheel event from the platform. #[derive(Clone, Debug, Default)] pub struct ScrollWheelEvent { /// The position of the mouse on the window. @@ -482,6 +531,7 @@ impl InputEvent for MouseExitEvent { PlatformInput::MouseExited(self) } } + impl MouseEvent for MouseExitEvent {} impl Deref for MouseExitEvent { @@ -493,7 +543,7 @@ impl Deref for MouseExitEvent { } /// A collection of paths from the platform, such as from a file drop. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Eq, PartialEq)] pub struct ExternalPaths(pub(crate) SmallVec<[PathBuf; 2]>); impl ExternalPaths { @@ -555,6 +605,8 @@ pub enum PlatformInput { MouseDown(MouseDownEvent), /// The mouse was released. MouseUp(MouseUpEvent), + /// Mouse pressure. + MousePressure(MousePressureEvent), /// The mouse was moved. MouseMove(MouseMoveEvent), /// The mouse exited the window. @@ -574,6 +626,7 @@ impl PlatformInput { PlatformInput::MouseDown(event) => Some(event), PlatformInput::MouseUp(event) => Some(event), PlatformInput::MouseMove(event) => Some(event), + PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), @@ -588,6 +641,7 @@ impl PlatformInput { PlatformInput::MouseDown(_) => None, PlatformInput::MouseUp(_) => None, PlatformInput::MouseMove(_) => None, + PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, PlatformInput::FileDrop(_) => None, diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 03ee31fdad..ae4553408f 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -121,6 +121,7 @@ pub(crate) struct Replay { #[derive(Default, Debug)] pub(crate) struct DispatchResult { pub(crate) pending: SmallVec<[Keystroke; 1]>, + pub(crate) pending_has_binding: bool, pub(crate) bindings: SmallVec<[KeyBinding; 1]>, pub(crate) to_replay: SmallVec<[Replay; 1]>, pub(crate) context_stack: Vec, @@ -480,6 +481,7 @@ impl DispatchTree { if pending { return DispatchResult { pending: input, + pending_has_binding: !bindings.is_empty(), context_stack, ..Default::default() }; @@ -572,18 +574,14 @@ impl DispatchTree { focus_path } - pub fn view_path(&self, view_id: EntityId) -> SmallVec<[EntityId; 8]> { - let mut view_path: SmallVec<[EntityId; 8]> = SmallVec::new(); + pub fn view_path_reversed(&self, view_id: EntityId) -> impl Iterator { let mut current_node_id = self.view_node_ids.get(&view_id).copied(); - while let Some(node_id) = current_node_id { - let node = self.node(node_id); - if let Some(view_id) = node.view_id { - view_path.push(view_id); - } - current_node_id = node.parent; - } - view_path.reverse(); // Reverse the path so it goes from the root to the view node. - view_path + + std::iter::successors( + current_node_id.map(|node_id| self.node(node_id)), + |node_id| Some(self.node(node_id.parent?)), + ) + .filter_map(|node| node.view_id) } pub fn node(&self, node_id: DispatchNodeId) -> &DispatchNode { @@ -612,9 +610,11 @@ impl DispatchTree { #[cfg(test)] mod tests { use crate::{ - self as gpui, Element, ElementId, GlobalElementId, InspectorElementId, LayoutId, Style, + self as gpui, DispatchResult, Element, ElementId, GlobalElementId, InspectorElementId, + Keystroke, LayoutId, Style, }; use core::panic; + use smallvec::SmallVec; use std::{cell::RefCell, ops::Range, rc::Rc}; use crate::{ @@ -680,6 +680,49 @@ mod tests { assert!(keybinding[0].action.partial_eq(&TestAction)) } + #[test] + fn test_pending_has_binding_state() { + let bindings = vec![ + KeyBinding::new("ctrl-b h", TestAction, None), + KeyBinding::new("space", TestAction, Some("ContextA")), + KeyBinding::new("space f g", TestAction, Some("ContextB")), + ]; + let keymap = Rc::new(RefCell::new(Keymap::new(bindings))); + let mut registry = ActionRegistry::default(); + registry.load_action::(); + let mut tree = DispatchTree::new(keymap, Rc::new(registry)); + + type DispatchPath = SmallVec<[super::DispatchNodeId; 32]>; + fn dispatch( + tree: &mut DispatchTree, + pending: SmallVec<[Keystroke; 1]>, + key: &str, + path: &DispatchPath, + ) -> DispatchResult { + tree.dispatch_key(pending, Keystroke::parse(key).unwrap(), path) + } + + let dispatch_path: DispatchPath = SmallVec::new(); + let result = dispatch(&mut tree, SmallVec::new(), "ctrl-b", &dispatch_path); + assert_eq!(result.pending.len(), 1); + assert!(!result.pending_has_binding); + + let result = dispatch(&mut tree, result.pending, "h", &dispatch_path); + assert_eq!(result.pending.len(), 0); + assert_eq!(result.bindings.len(), 1); + assert!(!result.pending_has_binding); + + let node_id = tree.push_node(); + tree.set_key_context(KeyContext::parse("ContextB").unwrap()); + tree.pop_node(); + + let dispatch_path = tree.dispatch_path(node_id); + let result = dispatch(&mut tree, SmallVec::new(), "space", &dispatch_path); + + assert_eq!(result.pending.len(), 1); + assert!(!result.pending_has_binding); + } + #[crate::test] fn test_input_handler_pending(cx: &mut TestAppContext) { #[derive(Clone)] diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index e26123339b..33d9569170 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -118,10 +118,12 @@ impl Keymap { pub fn all_bindings_for_input(&self, input: &[Keystroke]) -> Vec { self.bindings() .rev() - .filter_map(|binding| { - binding.match_keystrokes(input).filter(|pending| !pending)?; - Some(binding.clone()) + .filter(|binding| { + binding + .match_keystrokes(input) + .is_some_and(|pending| !pending) }) + .cloned() .collect() } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 444b60ac15..f120e075fe 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -39,16 +39,16 @@ use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, - Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, - ShapedRun, SharedString, Size, SvgRenderer, SvgSize, SystemWindowTab, Task, TaskLabel, Window, - WindowControlArea, hash, point, px, size, + Point, Priority, RealtimePriority, RenderGlyphParams, RenderImage, RenderImageParams, + RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, + SystemWindowTab, Task, TaskLabel, TaskTiming, ThreadTaskTimings, Window, WindowControlArea, + hash, point, px, size, }; use anyhow::Result; use async_task::Runnable; use futures::channel::oneshot; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, Frame}; -use parking::Unparker; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use schemars::JsonSchema; use seahash::SeaHasher; @@ -77,12 +77,14 @@ pub use keystroke::*; pub(crate) use linux::*; #[cfg(target_os = "macos")] pub(crate) use mac::*; -pub use semantic_version::SemanticVersion; #[cfg(any(test, feature = "test-support"))] pub(crate) use test::*; #[cfg(target_os = "windows")] pub(crate) use windows::*; +#[cfg(all(target_os = "linux", feature = "wayland"))] +pub use linux::layer_shell; + #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; @@ -121,6 +123,15 @@ pub(crate) fn current_platform(headless: bool) -> Rc { } } +#[cfg(target_os = "windows")] +pub(crate) fn current_platform(_headless: bool) -> Rc { + Rc::new( + WindowsPlatform::new() + .inspect_err(|err| show_error("Failed to launch", err.to_string())) + .unwrap(), + ) +} + /// Return which compositor we're guessing we'll use. /// Does not attempt to connect to the given compositor #[cfg(any(target_os = "linux", target_os = "freebsd"))] @@ -152,15 +163,6 @@ pub fn guess_compositor() -> &'static str { } } -#[cfg(target_os = "windows")] -pub(crate) fn current_platform(_headless: bool) -> Rc { - Rc::new( - WindowsPlatform::new() - .inspect_err(|err| show_error("Failed to launch", err.to_string())) - .unwrap(), - ) -} - pub(crate) trait Platform: 'static { fn background_executor(&self) -> BackgroundExecutor; fn foreground_executor(&self) -> ForegroundExecutor; @@ -288,12 +290,22 @@ pub trait PlatformDisplay: Send + Sync + Debug { /// Get the bounds for this display fn bounds(&self) -> Bounds; + /// Get the visible bounds for this display, excluding taskbar/dock areas. + /// This is the usable area where windows can be placed without being obscured. + /// Defaults to the full display bounds if not overridden. + fn visible_bounds(&self) -> Bounds { + self.bounds() + } + /// Get the default bounds for this display to place a window fn default_bounds(&self) -> Bounds { - let center = self.bounds().center(); - let offset = DEFAULT_WINDOW_SIZE / 2.0; + let bounds = self.bounds(); + let center = bounds.center(); + let clipped_window_size = DEFAULT_WINDOW_SIZE.min(&bounds.size); + + let offset = clipped_window_size / 2.0; let origin = point(center.x - offset.width, center.y - offset.height); - Bounds::new(origin, DEFAULT_WINDOW_SIZE) + Bounds::new(origin, clipped_window_size) } } @@ -349,8 +361,6 @@ impl Debug for DisplayId { } } -unsafe impl Send for DisplayId {} - /// Which part of the window to resize #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResizeEdge { @@ -556,16 +566,33 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { } } +/// This type is public so that our test macro can generate and use it, but it should not +/// be considered part of our public API. +#[doc(hidden)] +#[derive(Debug)] +pub struct RunnableMeta { + /// Location of the runnable + pub location: &'static core::panic::Location<'static>, +} + +#[doc(hidden)] +pub enum RunnableVariant { + Meta(Runnable), + Compat(Runnable), +} + /// This type is public so that our test macro can generate and use it, but it should not /// be considered part of our public API. #[doc(hidden)] pub trait PlatformDispatcher: Send + Sync { + fn get_all_timings(&self) -> Vec; + fn get_current_thread_timings(&self) -> Vec; fn is_main_thread(&self) -> bool; - fn dispatch(&self, runnable: Runnable, label: Option); - fn dispatch_on_main_thread(&self, runnable: Runnable); - fn dispatch_after(&self, duration: Duration, runnable: Runnable); - fn park(&self, timeout: Option) -> bool; - fn unparker(&self) -> Unparker; + fn dispatch(&self, runnable: RunnableVariant, label: Option, priority: Priority); + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority); + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant); + fn spawn_realtime(&self, priority: RealtimePriority, f: Box); + fn now(&self) -> Instant { Instant::now() } @@ -713,6 +740,41 @@ impl PlatformTextSystem for NoopTextSystem { } } +// Adapted from https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.cpp +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +#[allow(dead_code)] +pub(crate) fn get_gamma_correction_ratios(gamma: f32) -> [f32; 4] { + const GAMMA_INCORRECT_TARGET_RATIOS: [[f32; 4]; 13] = [ + [0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0], // gamma = 1.0 + [0.0166 / 4.0, -0.0807 / 4.0, 0.2227 / 4.0, -0.0751 / 4.0], // gamma = 1.1 + [0.0350 / 4.0, -0.1760 / 4.0, 0.4325 / 4.0, -0.1370 / 4.0], // gamma = 1.2 + [0.0543 / 4.0, -0.2821 / 4.0, 0.6302 / 4.0, -0.1876 / 4.0], // gamma = 1.3 + [0.0739 / 4.0, -0.3963 / 4.0, 0.8167 / 4.0, -0.2287 / 4.0], // gamma = 1.4 + [0.0933 / 4.0, -0.5161 / 4.0, 0.9926 / 4.0, -0.2616 / 4.0], // gamma = 1.5 + [0.1121 / 4.0, -0.6395 / 4.0, 1.1588 / 4.0, -0.2877 / 4.0], // gamma = 1.6 + [0.1300 / 4.0, -0.7649 / 4.0, 1.3159 / 4.0, -0.3080 / 4.0], // gamma = 1.7 + [0.1469 / 4.0, -0.8911 / 4.0, 1.4644 / 4.0, -0.3234 / 4.0], // gamma = 1.8 + [0.1627 / 4.0, -1.0170 / 4.0, 1.6051 / 4.0, -0.3347 / 4.0], // gamma = 1.9 + [0.1773 / 4.0, -1.1420 / 4.0, 1.7385 / 4.0, -0.3426 / 4.0], // gamma = 2.0 + [0.1908 / 4.0, -1.2652 / 4.0, 1.8650 / 4.0, -0.3476 / 4.0], // gamma = 2.1 + [0.2031 / 4.0, -1.3864 / 4.0, 1.9851 / 4.0, -0.3501 / 4.0], // gamma = 2.2 + ]; + + const NORM13: f32 = ((0x10000 as f64) / (255.0 * 255.0) * 4.0) as f32; + const NORM24: f32 = ((0x100 as f64) / (255.0) * 4.0) as f32; + + let index = ((gamma * 10.0).round() as usize).clamp(10, 22) - 10; + let ratios = GAMMA_INCORRECT_TARGET_RATIOS[index]; + + [ + ratios[0] * NORM13, + ratios[1] * NORM24, + ratios[2] * NORM13, + ratios[3] * NORM24, + ] +} + #[derive(PartialEq, Eq, Hash, Clone)] pub(crate) enum AtlasKey { Glyph(RenderGlyphParams), @@ -976,6 +1038,11 @@ impl PlatformInputHandler { .ok() .flatten() } + + #[allow(dead_code)] + pub(crate) fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool { + self.handler.accepts_text_input(window, cx) + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1084,6 +1151,11 @@ pub trait InputHandler: 'static { fn apple_press_and_hold_enabled(&mut self) -> bool { true } + + /// Returns whether this handler is accepting text input to be inserted. + fn accepts_text_input(&mut self, _window: &mut Window, _cx: &mut App) -> bool { + true + } } /// The variables that can be configured when creating a new window @@ -1213,6 +1285,11 @@ impl WindowBounds { WindowBounds::Fullscreen(bounds) => *bounds, } } + + /// Creates a new window bounds that centers the window on the screen. + pub fn centered(size: Size, cx: &App) -> Self { + WindowBounds::Windowed(Bounds::centered(None, size, cx)) + } } impl Default for WindowOptions { @@ -1255,7 +1332,7 @@ pub struct TitlebarOptions { } /// The kind of window to create -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum WindowKind { /// A normal application window Normal, @@ -1263,17 +1340,26 @@ pub enum WindowKind { /// A window that appears above all other windows, usually used for alerts or popups /// use sparingly! PopUp, + + /// A floating window that appears on top of its parent window + Floating, + + /// A Wayland LayerShell window, used to draw overlays or backgrounds for applications such as + /// docks, notifications or wallpapers. + #[cfg(all(target_os = "linux", feature = "wayland"))] + LayerShell(layer_shell::LayerShellOptions), } /// The appearance of the window, as defined by the operating system. /// /// On macOS, this corresponds to named [`NSAppearance`](https://developer.apple.com/documentation/appkit/nsappearance) /// values. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub enum WindowAppearance { /// A light appearance. /// /// On macOS, this corresponds to the `aqua` appearance. + #[default] Light, /// A light appearance with vibrant colors. @@ -1292,12 +1378,6 @@ pub enum WindowAppearance { VibrantDark, } -impl Default for WindowAppearance { - fn default() -> Self { - Self::Light - } -} - /// The appearance of the background of the window itself, when there is /// no content or the content is transparent. #[derive(Copy, Clone, Debug, Default, PartialEq)] @@ -1317,6 +1397,10 @@ pub enum WindowBackgroundAppearance { /// /// Not always supported. Blurred, + /// The Mica backdrop material, supported on Windows 11. + MicaBackdrop, + /// The Mica Alt backdrop material, supported on Windows 11. + MicaAltBackdrop, } /// The options that can be configured for a file dialog prompt @@ -1398,9 +1482,10 @@ impl From<&str> for PromptButton { } /// The style of the cursor (pointer) -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub enum CursorStyle { /// The default cursor + #[default] Arrow, /// A text input cursor @@ -1487,12 +1572,6 @@ pub enum CursorStyle { None, } -impl Default for CursorStyle { - fn default() -> Self { - Self::Arrow - } -} - /// A clipboard item that should be copied to the clipboard #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClipboardItem { @@ -1506,6 +1585,8 @@ pub enum ClipboardEntry { String(ClipboardString), /// An image entry Image(Image), + /// A file entry + ExternalPaths(crate::ExternalPaths), } impl ClipboardItem { @@ -1546,16 +1627,29 @@ impl ClipboardItem { /// Returns None if there were no ClipboardString entries. pub fn text(&self) -> Option { let mut answer = String::new(); - let mut any_entries = false; for entry in self.entries.iter() { if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry { answer.push_str(text); - any_entries = true; } } - if any_entries { Some(answer) } else { None } + if answer.is_empty() { + for entry in self.entries.iter() { + if let ClipboardEntry::ExternalPaths(paths) = entry { + for path in &paths.0 { + use std::fmt::Write as _; + _ = write!(answer, "{}", path.display()); + } + } + } + } + + if !answer.is_empty() { + Some(answer) + } else { + None + } } /// If this item is one ClipboardEntry::String, returns its metadata. @@ -1638,6 +1732,8 @@ pub enum ImageFormat { Bmp, /// .tif or .tiff Tiff, + /// .ico + Ico, } impl ImageFormat { @@ -1651,6 +1747,7 @@ impl ImageFormat { ImageFormat::Svg => "image/svg+xml", ImageFormat::Bmp => "image/bmp", ImageFormat::Tiff => "image/tiff", + ImageFormat::Ico => "image/ico", } } @@ -1664,6 +1761,7 @@ impl ImageFormat { "image/svg+xml" => Some(Self::Svg), "image/bmp" => Some(Self::Bmp), "image/tiff" | "image/tif" => Some(Self::Tiff), + "image/ico" => Some(Self::Ico), _ => None, } } @@ -1770,14 +1868,11 @@ impl Image { ImageFormat::Webp => frames_for_image(&self.bytes, image::ImageFormat::WebP)?, ImageFormat::Bmp => frames_for_image(&self.bytes, image::ImageFormat::Bmp)?, ImageFormat::Tiff => frames_for_image(&self.bytes, image::ImageFormat::Tiff)?, + ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?, ImageFormat::Svg => { - let pixmap = svg_renderer.render_pixmap(&self.bytes, SvgSize::ScaleFactor(1.0))?; - - let buffer = - image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()) - .unwrap(); - - SmallVec::from_elem(Frame::new(buffer), 1) + return svg_renderer + .render_single_frame(&self.bytes, 1.0, false) + .map_err(Into::into); } }; diff --git a/crates/gpui/src/platform/app_menu.rs b/crates/gpui/src/platform/app_menu.rs index 4069fee726..39e7556b2d 100644 --- a/crates/gpui/src/platform/app_menu.rs +++ b/crates/gpui/src/platform/app_menu.rs @@ -64,12 +64,15 @@ pub enum MenuItem { /// The name of this menu item name: SharedString, - /// the action to perform when this menu item is selected + /// The action to perform when this menu item is selected action: Box, /// The OS Action that corresponds to this action, if any /// See [`OsAction`] for more information os_action: Option, + + /// Whether this action is checked + checked: bool, }, } @@ -98,6 +101,7 @@ impl MenuItem { name: name.into(), action: Box::new(action), os_action: None, + checked: false, } } @@ -111,6 +115,7 @@ impl MenuItem { name: name.into(), action: Box::new(action), os_action: Some(os_action), + checked: false, } } @@ -123,14 +128,36 @@ impl MenuItem { name, action, os_action, + checked, } => OwnedMenuItem::Action { name: name.into(), action, os_action, + checked, }, MenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.owned()), } } + + /// Set whether this menu item is checked + /// + /// Only for [`MenuItem::Action`], otherwise, will be ignored + pub fn checked(mut self, checked: bool) -> Self { + match self { + MenuItem::Action { + action, + os_action, + name, + .. + } => MenuItem::Action { + name, + action, + os_action, + checked, + }, + _ => self, + } + } } /// OS menus are menus that are recognized by the operating system @@ -171,12 +198,15 @@ pub enum OwnedMenuItem { /// The name of this menu item name: String, - /// the action to perform when this menu item is selected + /// The action to perform when this menu item is selected action: Box, /// The OS Action that corresponds to this action, if any /// See [`OsAction`] for more information os_action: Option, + + /// Whether this action is checked + checked: bool, }, } @@ -189,10 +219,12 @@ impl Clone for OwnedMenuItem { name, action, os_action, + checked, } => OwnedMenuItem::Action { name: name.clone(), action: action.boxed_clone(), os_action: *os_action, + checked: *checked, }, OwnedMenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.clone()), } diff --git a/crates/gpui/src/platform/blade/blade_renderer.rs b/crates/gpui/src/platform/blade/blade_renderer.rs index d00fbdc7f1..dd0be7db43 100644 --- a/crates/gpui/src/platform/blade/blade_renderer.rs +++ b/crates/gpui/src/platform/blade/blade_renderer.rs @@ -5,6 +5,7 @@ use super::{BladeAtlas, BladeContext}; use crate::{ Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline, + get_gamma_correction_ratios, }; use blade_graphics as gpu; use blade_util::{BufferBelt, BufferBeltDescriptor}; @@ -1023,7 +1024,7 @@ impl RenderingParameters { .and_then(|v| v.parse().ok()) .unwrap_or(1.8_f32) .clamp(1.0, 2.2); - let gamma_ratios = Self::get_gamma_ratios(gamma); + let gamma_ratios = get_gamma_correction_ratios(gamma); let grayscale_enhanced_contrast = env::var("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST") .ok() .and_then(|v| v.parse().ok()) @@ -1036,37 +1037,4 @@ impl RenderingParameters { grayscale_enhanced_contrast, } } - - // Gamma ratios for brightening/darkening edges for better contrast - // https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.cpp#L50 - fn get_gamma_ratios(gamma: f32) -> [f32; 4] { - const GAMMA_INCORRECT_TARGET_RATIOS: [[f32; 4]; 13] = [ - [0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0], // gamma = 1.0 - [0.0166 / 4.0, -0.0807 / 4.0, 0.2227 / 4.0, -0.0751 / 4.0], // gamma = 1.1 - [0.0350 / 4.0, -0.1760 / 4.0, 0.4325 / 4.0, -0.1370 / 4.0], // gamma = 1.2 - [0.0543 / 4.0, -0.2821 / 4.0, 0.6302 / 4.0, -0.1876 / 4.0], // gamma = 1.3 - [0.0739 / 4.0, -0.3963 / 4.0, 0.8167 / 4.0, -0.2287 / 4.0], // gamma = 1.4 - [0.0933 / 4.0, -0.5161 / 4.0, 0.9926 / 4.0, -0.2616 / 4.0], // gamma = 1.5 - [0.1121 / 4.0, -0.6395 / 4.0, 1.1588 / 4.0, -0.2877 / 4.0], // gamma = 1.6 - [0.1300 / 4.0, -0.7649 / 4.0, 1.3159 / 4.0, -0.3080 / 4.0], // gamma = 1.7 - [0.1469 / 4.0, -0.8911 / 4.0, 1.4644 / 4.0, -0.3234 / 4.0], // gamma = 1.8 - [0.1627 / 4.0, -1.0170 / 4.0, 1.6051 / 4.0, -0.3347 / 4.0], // gamma = 1.9 - [0.1773 / 4.0, -1.1420 / 4.0, 1.7385 / 4.0, -0.3426 / 4.0], // gamma = 2.0 - [0.1908 / 4.0, -1.2652 / 4.0, 1.8650 / 4.0, -0.3476 / 4.0], // gamma = 2.1 - [0.2031 / 4.0, -1.3864 / 4.0, 1.9851 / 4.0, -0.3501 / 4.0], // gamma = 2.2 - ]; - - const NORM13: f32 = ((0x10000 as f64) / (255.0 * 255.0) * 4.0) as f32; - const NORM24: f32 = ((0x100 as f64) / (255.0) * 4.0) as f32; - - let index = ((gamma * 10.0).round() as usize).clamp(10, 22) - 10; - let ratios = GAMMA_INCORRECT_TARGET_RATIOS[index]; - - [ - ratios[0] * NORM13, - ratios[1] * NORM24, - ratios[2] * NORM13, - ratios[3] * NORM24, - ] - } } diff --git a/crates/gpui/src/platform/blade/shaders.wgsl b/crates/gpui/src/platform/blade/shaders.wgsl index 14e5ff4fa8..2981b1446c 100644 --- a/crates/gpui/src/platform/blade/shaders.wgsl +++ b/crates/gpui/src/platform/blade/shaders.wgsl @@ -28,6 +28,9 @@ fn heat_map_color(value: f32, minValue: f32, maxValue: f32, position: vec2) */ +// Contrast and gamma correction adapted from https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.hlsl +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. fn color_brightness(color: vec3) -> f32 { // REC. 601 luminance coefficients for perceived brightness return dot(color, vec3(0.30, 0.59, 0.11)); @@ -172,6 +175,12 @@ fn distance_from_clip_rect(unit_vertex: vec2, bounds: Bounds, clip_bounds: return distance_from_clip_rect_impl(position, clip_bounds); } +fn distance_from_clip_rect_transformed(unit_vertex: vec2, bounds: Bounds, clip_bounds: Bounds, transform: TransformationMatrix) -> vec4 { + let position = unit_vertex * vec2(bounds.size) + bounds.origin; + let transformed = transpose(transform.rotation_scale) * position + transform.translation; + return distance_from_clip_rect_impl(transformed, clip_bounds); +} + // https://gamedev.stackexchange.com/questions/92015/optimized-linear-to-srgb-glsl fn srgb_to_linear(srgb: vec3) -> vec3 { let cutoff = srgb < vec3(0.04045); @@ -677,7 +686,24 @@ fn fs_quad(input: QuadVarying) -> @location(0) vec4 { let is_horizontal = corner_center_to_point.x < corner_center_to_point.y; - let border_width = select(border.y, border.x, is_horizontal); + + // When applying dashed borders to just some, not all, the sides. + // The way we chose border widths above sometimes comes with a 0 width value. + // So we choose again to avoid division by zero. + // TODO: A better solution exists taking a look at the whole file. + // this does not fix single dashed borders at the corners + let dashed_border = vec2( + max( + quad.border_widths.bottom, + quad.border_widths.top, + ), + max( + quad.border_widths.right, + quad.border_widths.left, + ) + ); + + let border_width = select(dashed_border.y, dashed_border.x, is_horizontal); dash_velocity = dv_numerator / border_width; t = select(point.y, point.x, is_horizontal) * dash_velocity; max_t = select(size.y, size.x, is_horizontal) * dash_velocity; @@ -1150,7 +1176,7 @@ fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index out.tile_position = to_tile_position(unit_vertex, sprite.tile); out.color = hsla_to_rgba(sprite.color); - out.clip_distances = distance_from_clip_rect(unit_vertex, sprite.bounds, sprite.content_mask); + out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); return out; } diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 4a2bfc785e..e1f1b0c9fb 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -572,6 +572,14 @@ impl Modifiers { } } + /// Returns [`Modifiers`] with just function. + pub fn function() -> Modifiers { + Modifiers { + function: true, + ..Default::default() + } + } + /// Returns [`Modifiers`] with command + shift. pub fn command_shift() -> Modifiers { Modifiers { diff --git a/crates/gpui/src/platform/linux.rs b/crates/gpui/src/platform/linux.rs index 5221f71f99..f7d7ed0eba 100644 --- a/crates/gpui/src/platform/linux.rs +++ b/crates/gpui/src/platform/linux.rs @@ -27,3 +27,6 @@ pub(crate) use x11::*; pub(crate) type PlatformScreenCaptureFrame = scap::frame::Frame; #[cfg(not(all(feature = "screen-capture", any(feature = "wayland", feature = "x11"))))] pub(crate) type PlatformScreenCaptureFrame = (); + +#[cfg(feature = "wayland")] +pub use wayland::layer_shell; diff --git a/crates/gpui/src/platform/linux/dispatcher.rs b/crates/gpui/src/platform/linux/dispatcher.rs index 2f6cd83756..c8ae7269ed 100644 --- a/crates/gpui/src/platform/linux/dispatcher.rs +++ b/crates/gpui/src/platform/linux/dispatcher.rs @@ -1,49 +1,84 @@ -use crate::{PlatformDispatcher, TaskLabel}; -use async_task::Runnable; use calloop::{ - EventLoop, + EventLoop, PostAction, channel::{self, Sender}, timer::TimeoutAction, }; -use parking::{Parker, Unparker}; -use parking_lot::Mutex; +use util::ResultExt; + use std::{ + mem::MaybeUninit, thread, time::{Duration, Instant}, }; -use util::ResultExt; + +use crate::{ + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, + PriorityQueueSender, RealtimePriority, RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, + ThreadTaskTimings, profiler, +}; struct TimerAfter { duration: Duration, - runnable: Runnable, + runnable: RunnableVariant, } pub(crate) struct LinuxDispatcher { - parker: Mutex, - main_sender: Sender, + main_sender: PriorityQueueCalloopSender, timer_sender: Sender, - background_sender: flume::Sender, + background_sender: PriorityQueueSender, _background_threads: Vec>, main_thread_id: thread::ThreadId, } -impl LinuxDispatcher { - pub fn new(main_sender: Sender) -> Self { - let (background_sender, background_receiver) = flume::unbounded::(); - let thread_count = std::thread::available_parallelism() - .map(|i| i.get()) - .unwrap_or(1); +const MIN_THREADS: usize = 2; +impl LinuxDispatcher { + pub fn new(main_sender: PriorityQueueCalloopSender) -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + let thread_count = + std::thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); + + // These thread should really be lower prio then the foreground + // executor let mut background_threads = (0..thread_count) .map(|i| { - let receiver = background_receiver.clone(); + let mut receiver = background_receiver.clone(); std::thread::Builder::new() .name(format!("Worker-{i}")) .spawn(move || { - for runnable in receiver { + for runnable in receiver.iter() { let start = Instant::now(); - runnable.run(); + let mut location = match runnable { + RunnableVariant::Meta(runnable) => { + let location = runnable.metadata().location; + let timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + timing + } + RunnableVariant::Compat(runnable) => { + let location = core::panic::Location::caller(); + let timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + timing + } + }; + + let end = Instant::now(); + location.end = Some(end); + profiler::add_task_timing(location); log::trace!( "background thread {}: ran runnable. took: {:?}", @@ -75,7 +110,36 @@ impl LinuxDispatcher { calloop::timer::Timer::from_duration(timer.duration), move |_, _, _| { if let Some(runnable) = runnable.take() { - runnable.run(); + let start = Instant::now(); + let mut timing = match runnable { + RunnableVariant::Meta(runnable) => { + let location = runnable.metadata().location; + let timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + timing + } + RunnableVariant::Compat(runnable) => { + let timing = TaskTiming { + location: core::panic::Location::caller(), + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + timing + } + }; + let end = Instant::now(); + + timing.end = Some(end); + profiler::add_task_timing(timing); } TimeoutAction::Drop }, @@ -92,7 +156,6 @@ impl LinuxDispatcher { background_threads.push(timer_thread); Self { - parker: Mutex::new(Parker::new()), main_sender, timer_sender, background_sender, @@ -103,44 +166,305 @@ impl LinuxDispatcher { } impl PlatformDispatcher for LinuxDispatcher { + fn get_all_timings(&self) -> Vec { + let global_timings = GLOBAL_THREAD_TIMINGS.lock(); + ThreadTaskTimings::convert(&global_timings) + } + + fn get_current_thread_timings(&self) -> Vec { + THREAD_TIMINGS.with(|timings| { + let timings = timings.lock(); + let timings = &timings.timings; + + let mut vec = Vec::with_capacity(timings.len()); + + let (s1, s2) = timings.as_slices(); + vec.extend_from_slice(s1); + vec.extend_from_slice(s2); + vec + }) + } + fn is_main_thread(&self) -> bool { thread::current().id() == self.main_thread_id } - fn dispatch(&self, runnable: Runnable, _: Option) { - self.background_sender.send(runnable).unwrap(); + fn dispatch(&self, runnable: RunnableVariant, _: Option, priority: Priority) { + self.background_sender + .send(priority, runnable) + .unwrap_or_else(|_| panic!("blocking sender returned without value")); } - fn dispatch_on_main_thread(&self, runnable: Runnable) { - self.main_sender.send(runnable).unwrap_or_else(|runnable| { - // NOTE: Runnable may wrap a Future that is !Send. - // - // This is usually safe because we only poll it on the main thread. - // However if the send fails, we know that: - // 1. main_receiver has been dropped (which implies the app is shutting down) - // 2. we are on a background thread. - // It is not safe to drop something !Send on the wrong thread, and - // the app will exit soon anyway, so we must forget the runnable. - std::mem::forget(runnable); - }); + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + self.main_sender + .send(priority, runnable) + .unwrap_or_else(|runnable| { + // NOTE: Runnable may wrap a Future that is !Send. + // + // This is usually safe because we only poll it on the main thread. + // However if the send fails, we know that: + // 1. main_receiver has been dropped (which implies the app is shutting down) + // 2. we are on a background thread. + // It is not safe to drop something !Send on the wrong thread, and + // the app will exit soon anyway, so we must forget the runnable. + std::mem::forget(runnable); + }); } - fn dispatch_after(&self, duration: Duration, runnable: Runnable) { + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { self.timer_sender .send(TimerAfter { duration, runnable }) .ok(); } - fn park(&self, timeout: Option) -> bool { - if let Some(timeout) = timeout { - self.parker.lock().park_timeout(timeout) + fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + std::thread::spawn(move || { + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + let policy = match priority { + RealtimePriority::Audio => libc::SCHED_FIFO, + RealtimePriority::Other => libc::SCHED_RR, + }; + let sched_priority = match priority { + RealtimePriority::Audio => 65, + RealtimePriority::Other => 45, + }; + + // SAFETY: all sched_param members are valid when initialized to zero. + let mut sched_param = + unsafe { MaybeUninit::::zeroed().assume_init() }; + sched_param.sched_priority = sched_priority; + // SAFETY: sched_param is a valid initialized structure + let result = unsafe { libc::pthread_setschedparam(thread_id, policy, &sched_param) }; + if result != 0 { + log::warn!("failed to set realtime thread priority to {:?}", priority); + } + + f(); + }); + } +} + +pub struct PriorityQueueCalloopSender { + sender: PriorityQueueSender, + ping: calloop::ping::Ping, +} + +impl PriorityQueueCalloopSender { + fn new(tx: PriorityQueueSender, ping: calloop::ping::Ping) -> Self { + Self { sender: tx, ping } + } + + fn send(&self, priority: Priority, item: T) -> Result<(), crate::queue::SendError> { + let res = self.sender.send(priority, item); + if res.is_ok() { + self.ping.ping(); + } + res + } +} + +impl Drop for PriorityQueueCalloopSender { + fn drop(&mut self) { + self.ping.ping(); + } +} + +pub struct PriorityQueueCalloopReceiver { + receiver: PriorityQueueReceiver, + source: calloop::ping::PingSource, + ping: calloop::ping::Ping, +} + +impl PriorityQueueCalloopReceiver { + pub fn new() -> (PriorityQueueCalloopSender, Self) { + let (ping, source) = calloop::ping::make_ping().expect("Failed to create a Ping."); + + let (tx, rx) = PriorityQueueReceiver::new(); + + ( + PriorityQueueCalloopSender::new(tx, ping.clone()), + Self { + receiver: rx, + source, + ping, + }, + ) + } +} + +use calloop::channel::Event; + +#[derive(Debug)] +pub struct ChannelError(calloop::ping::PingError); + +impl std::fmt::Display for ChannelError { + #[cfg_attr(feature = "nightly_coverage", coverage(off))] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl std::error::Error for ChannelError { + #[cfg_attr(feature = "nightly_coverage", coverage(off))] + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl calloop::EventSource for PriorityQueueCalloopReceiver { + type Event = Event; + type Metadata = (); + type Ret = (); + type Error = ChannelError; + + fn process_events( + &mut self, + readiness: calloop::Readiness, + token: calloop::Token, + mut callback: F, + ) -> Result + where + F: FnMut(Self::Event, &mut Self::Metadata) -> Self::Ret, + { + let mut clear_readiness = false; + let mut disconnected = false; + + let action = self + .source + .process_events(readiness, token, |(), &mut ()| { + let mut is_empty = true; + + let mut receiver = self.receiver.clone(); + for runnable in receiver.try_iter() { + match runnable { + Ok(r) => { + callback(Event::Msg(r), &mut ()); + is_empty = false; + } + Err(_) => { + disconnected = true; + } + } + } + + if disconnected { + callback(Event::Closed, &mut ()); + } + + if is_empty { + clear_readiness = true; + } + }) + .map_err(ChannelError)?; + + if disconnected { + Ok(PostAction::Remove) + } else if clear_readiness { + Ok(action) } else { - self.parker.lock().park(); - true + // Re-notify the ping source so we can try again. + self.ping.ping(); + Ok(PostAction::Continue) } } - fn unparker(&self) -> Unparker { - self.parker.lock().unparker() + fn register( + &mut self, + poll: &mut calloop::Poll, + token_factory: &mut calloop::TokenFactory, + ) -> calloop::Result<()> { + self.source.register(poll, token_factory) + } + + fn reregister( + &mut self, + poll: &mut calloop::Poll, + token_factory: &mut calloop::TokenFactory, + ) -> calloop::Result<()> { + self.source.reregister(poll, token_factory) + } + + fn unregister(&mut self, poll: &mut calloop::Poll) -> calloop::Result<()> { + self.source.unregister(poll) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calloop_works() { + let mut event_loop = calloop::EventLoop::try_new().unwrap(); + let handle = event_loop.handle(); + + let (tx, rx) = PriorityQueueCalloopReceiver::new(); + + struct Data { + got_msg: bool, + got_closed: bool, + } + + let mut data = Data { + got_msg: false, + got_closed: false, + }; + + let _channel_token = handle + .insert_source(rx, move |evt, &mut (), data: &mut Data| match evt { + Event::Msg(()) => { + data.got_msg = true; + } + + Event::Closed => { + data.got_closed = true; + } + }) + .unwrap(); + + // nothing is sent, nothing is received + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(!data.got_msg); + assert!(!data.got_closed); + // a message is send + + tx.send(Priority::Medium, ()).unwrap(); + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(data.got_msg); + assert!(!data.got_closed); + + // the sender is dropped + drop(tx); + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(data.got_msg); + assert!(data.got_closed); + } +} + +// running 1 test +// test platform::linux::dispatcher::tests::tomato ... FAILED + +// failures: + +// ---- platform::linux::dispatcher::tests::tomato stdout ---- +// [crates/gpui/src/platform/linux/dispatcher.rs:262:9] +// returning 1 tasks to process +// [crates/gpui/src/platform/linux/dispatcher.rs:480:75] evt = Msg( +// (), +// ) +// returning 0 tasks to process + +// thread 'platform::linux::dispatcher::tests::tomato' (478301) panicked at crates/gpui/src/platform/linux/dispatcher.rs:515:9: +// assertion failed: data.got_closed +// note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/crates/gpui/src/platform/linux/headless/client.rs b/crates/gpui/src/platform/linux/headless/client.rs index da54db3710..33f1bb17e3 100644 --- a/crates/gpui/src/platform/linux/headless/client.rs +++ b/crates/gpui/src/platform/linux/headless/client.rs @@ -31,7 +31,10 @@ impl HeadlessClient { handle .insert_source(main_receiver, |event, _, _: &mut HeadlessClient| { if let calloop::channel::Event::Msg(runnable) = event { - runnable.run(); + match runnable { + crate::RunnableVariant::Meta(runnable) => runnable.run(), + crate::RunnableVariant::Compat(runnable) => runnable.run(), + }; } }) .ok(); diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 322f5d7611..06a81ec342 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -1,7 +1,6 @@ use std::{ env, path::{Path, PathBuf}, - process::Command, rc::Rc, sync::Arc, }; @@ -15,10 +14,10 @@ use std::{ }; use anyhow::{Context as _, anyhow}; -use async_task::Runnable; -use calloop::{LoopSignal, channel::Channel}; +use calloop::LoopSignal; use futures::channel::oneshot; use util::ResultExt as _; +use util::command::{new_smol_command, new_std_command}; #[cfg(any(feature = "wayland", feature = "x11"))] use xkbcommon::xkb::{self, Keycode, Keysym, State}; @@ -26,7 +25,8 @@ use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, - PlatformTextSystem, PlatformWindow, Point, Result, Task, WindowAppearance, WindowParams, px, + PlatformTextSystem, PlatformWindow, Point, PriorityQueueCalloopReceiver, Result, + RunnableVariant, Task, WindowAppearance, WindowParams, px, }; #[cfg(any(feature = "wayland", feature = "x11"))] @@ -43,6 +43,50 @@ pub(crate) const KEYRING_LABEL: &str = "zed-github-account"; const FILE_PICKER_PORTAL_MISSING: &str = "Couldn't open file picker due to missing xdg-desktop-portal implementation."; +#[cfg(any(feature = "x11", feature = "wayland"))] +pub trait ResultExt { + type Ok; + + fn notify_err(self, msg: &'static str) -> Self::Ok; +} + +#[cfg(any(feature = "x11", feature = "wayland"))] +impl ResultExt for anyhow::Result { + type Ok = T; + + fn notify_err(self, msg: &'static str) -> T { + match self { + Ok(v) => v, + Err(e) => { + use ashpd::desktop::notification::{Notification, NotificationProxy, Priority}; + use futures::executor::block_on; + + let proxy = block_on(NotificationProxy::new()).expect(msg); + + let notification_id = "dev.zed.Oops"; + block_on( + proxy.add_notification( + notification_id, + Notification::new("Zed failed to launch") + .body(Some( + format!( + "{e:?}. See https://zed.dev/docs/linux for troubleshooting steps." + ) + .as_str(), + )) + .priority(Priority::High) + .icon(ashpd::desktop::Icon::with_names(&[ + "dialog-question-symbolic", + ])), + ) + ).expect(msg); + + panic!("{msg}"); + } + } + } +} + pub trait LinuxClient { fn compositor_name(&self) -> &'static str; fn with_common(&self, f: impl FnOnce(&mut LinuxCommon) -> R) -> R; @@ -105,8 +149,8 @@ pub(crate) struct LinuxCommon { } impl LinuxCommon { - pub fn new(signal: LoopSignal) -> (Self, Channel) { - let (main_sender, main_receiver) = calloop::channel::channel::(); + pub fn new(signal: LoopSignal) -> (Self, PriorityQueueCalloopReceiver) { + let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); #[cfg(any(feature = "wayland", feature = "x11"))] let text_system = Arc::new(crate::CosmicTextSystem::new()); @@ -215,7 +259,7 @@ impl Platform for P { clippy::disallowed_methods, reason = "We are restarting ourselves, using std command thus is fine" )] - let restart_process = Command::new("/usr/bin/env") + let restart_process = new_std_command("/usr/bin/env") .arg("bash") .arg("-c") .arg(script) @@ -422,7 +466,7 @@ impl Platform for P { let path = path.to_owned(); self.background_executor() .spawn(async move { - let _ = smol::process::Command::new("xdg-open") + let _ = new_smol_command("xdg-open") .arg(path) .spawn() .context("invoking xdg-open") @@ -605,8 +649,9 @@ pub(super) fn open_uri_internal( .activation_token(activation_token.clone().map(ashpd::ActivationToken::from)) .send_uri(&uri) .await + .and_then(|e| e.response()) { - Ok(_) => return, + Ok(()) => return, Err(e) => log::error!("Failed to open with dbus: {}", e), } diff --git a/crates/gpui/src/platform/linux/wayland.rs b/crates/gpui/src/platform/linux/wayland.rs index 487bc9f38c..366b5703e4 100644 --- a/crates/gpui/src/platform/linux/wayland.rs +++ b/crates/gpui/src/platform/linux/wayland.rs @@ -5,6 +5,9 @@ mod display; mod serial; mod window; +/// Contains Types for configuring layer_shell surfaces. +pub mod layer_shell; + pub(crate) use client::*; use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index f8672971ec..0e7bf8fbf8 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -17,7 +17,7 @@ use collections::HashMap; use filedescriptor::Pipe; use http_client::Url; use smallvec::SmallVec; -use util::ResultExt; +use util::ResultExt as _; use wayland_backend::client::ObjectId; use wayland_backend::protocol::WEnum; use wayland_client::event_created_child; @@ -62,6 +62,7 @@ use wayland_protocols::xdg::decoration::zv1::client::{ }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager}; +use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1; use xkbcommon::xkb::{self, KEYMAP_COMPILE_NO_FLAGS, Keycode}; @@ -70,14 +71,17 @@ use super::{ window::{ImeInput, WaylandWindowStatePtr}, }; -use crate::platform::{PlatformWindow, blade::BladeContext}; use crate::{ AnyWindowHandle, Bounds, Capslock, CursorStyle, DOUBLE_CLICK_INTERVAL, DevicePixels, DisplayId, FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, LinuxCommon, LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, - PlatformInput, PlatformKeyboardLayout, Point, SCROLL_LINES, ScrollDelta, ScrollWheelEvent, - Size, TouchPhase, WindowParams, point, px, size, + PlatformInput, PlatformKeyboardLayout, Point, ResultExt as _, SCROLL_LINES, ScrollDelta, + ScrollWheelEvent, Size, TouchPhase, WindowParams, point, profiler, px, size, +}; +use crate::{ + RunnableVariant, TaskTiming, + platform::{PlatformWindow, blade::BladeContext}, }; use crate::{ SharedString, @@ -115,6 +119,7 @@ pub struct Globals { pub fractional_scale_manager: Option, pub decoration_manager: Option, + pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, pub executor: ForegroundExecutor, @@ -152,6 +157,7 @@ impl Globals { viewporter: globals.bind(&qh, 1..=1, ()).ok(), fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), + layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), executor, @@ -384,9 +390,6 @@ impl WaylandClientStatePtr { { state.keyboard_focused_window = Some(window); } - if state.windows.is_empty() { - state.common.signal.stop(); - } } } @@ -491,14 +494,45 @@ impl WaylandClient { move |event, _, _: &mut WaylandClientStatePtr| { if let calloop::channel::Event::Msg(runnable) = event { handle.insert_idle(|_| { - runnable.run(); + let start = Instant::now(); + let mut timing = match runnable { + RunnableVariant::Meta(runnable) => { + let location = runnable.metadata().location; + let timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + timing + } + RunnableVariant::Compat(runnable) => { + let location = core::panic::Location::caller(); + let timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + timing + } + }; + + let end = Instant::now(); + timing.end = Some(end); + profiler::add_task_timing(timing); }); } } }) .unwrap(); - let gpu_context = BladeContext::new().expect("Unable to init GPU context"); + // This could be unified with the notification handling in zed/main:fail_to_open_window. + let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); let seat = seat.unwrap(); let globals = Globals::new( @@ -695,6 +729,11 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); + let parent = state + .keyboard_focused_window + .as_ref() + .and_then(|w| w.toplevel()); + let (window, surface_id) = WaylandWindow::new( handle, state.globals.clone(), @@ -702,6 +741,7 @@ impl LinuxClient for WaylandClient { WaylandClientStatePtr(Rc::downgrade(&self.0)), params, state.common.appearance, + parent, )?; state.windows.insert(surface_id, window.0.clone()); @@ -942,6 +982,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_buffer::WlBuffer); delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion); delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); +delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager); delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur); @@ -1084,6 +1125,31 @@ impl Dispatch for WaylandClientStatePtr { } } +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + event: ::Event, + surface_id: &ObjectId, + _: &Connection, + _: &QueueHandle, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + let Some(window) = get_window(&mut state, surface_id) else { + return; + }; + + drop(state); + let should_close = window.handle_layersurface_event(event); + + if should_close { + // The close logic will be handled in drop_window() + window.close(); + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( _: &mut Self, @@ -1347,12 +1413,14 @@ impl Dispatch for WaylandClientStatePtr { let input = PlatformInput::KeyDown(KeyDownEvent { keystroke: keystroke.clone(), is_held: false, + prefer_character_input: false, }); state.repeat.current_id += 1; state.repeat.current_keycode = Some(keycode); let rate = state.repeat.characters_per_second; + let repeat_interval = Duration::from_secs(1) / rate.max(1); let id = state.repeat.current_id; state .loop_handle @@ -1360,8 +1428,9 @@ impl Dispatch for WaylandClientStatePtr { let input = PlatformInput::KeyDown(KeyDownEvent { keystroke, is_held: true, + prefer_character_input: false, }); - move |_event, _metadata, this| { + move |event_timestamp, _metadata, this| { let mut client = this.get_client(); let mut state = client.borrow_mut(); let is_repeating = id == state.repeat.current_id @@ -1378,7 +1447,8 @@ impl Dispatch for WaylandClientStatePtr { drop(state); focused_window.handle_input(input.clone()); - TimeoutAction::ToDuration(Duration::from_secs(1) / rate) + // If the new scheduled time is in the past the event will repeat as soon as possible + TimeoutAction::ToInstant(event_timestamp + repeat_interval) } }) .unwrap(); @@ -1444,6 +1514,7 @@ impl Dispatch for WaylandClientStatePtr { key_char: Some(commit_text), }, is_held: false, + prefer_character_input: false, })); } else { window.handle_ime(ImeInput::InsertText(commit_text)); diff --git a/crates/gpui/src/platform/linux/wayland/layer_shell.rs b/crates/gpui/src/platform/linux/wayland/layer_shell.rs new file mode 100644 index 0000000000..0f165ed8e0 --- /dev/null +++ b/crates/gpui/src/platform/linux/wayland/layer_shell.rs @@ -0,0 +1,111 @@ +use bitflags::bitflags; +use thiserror::Error; +use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; + +use crate::Pixels; + +/// The layer the surface is rendered on. Multiple surfaces can share a layer, and ordering within +/// a single layer is undefined. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum Layer { + /// The background layer, typically used for wallpapers. + Background, + + /// The bottom layer. + Bottom, + + /// The top layer, typically used for fullscreen windows. + Top, + + /// The overlay layer, used for surfaces that should always be on top. + #[default] + Overlay, +} + +impl From for zwlr_layer_shell_v1::Layer { + fn from(layer: Layer) -> Self { + match layer { + Layer::Background => Self::Background, + Layer::Bottom => Self::Bottom, + Layer::Top => Self::Top, + Layer::Overlay => Self::Overlay, + } + } +} + +bitflags! { + /// Screen anchor point for layer_shell surfaces. These can be used in any combination, e.g. + /// specifying `Anchor::LEFT | Anchor::RIGHT` will stretch the surface across the width of the + /// screen. + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct Anchor: u32 { + /// Anchor to the top edge of the screen. + const TOP = 1; + /// Anchor to the bottom edge of the screen. + const BOTTOM = 2; + /// Anchor to the left edge of the screen. + const LEFT = 4; + /// Anchor to the right edge of the screen. + const RIGHT = 8; + } +} + +impl From for zwlr_layer_surface_v1::Anchor { + fn from(anchor: Anchor) -> Self { + Self::from_bits_truncate(anchor.bits()) + } +} + +/// Keyboard interactivity mode for the layer_shell surfaces. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum KeyboardInteractivity { + /// No keyboard inputs will be delivered to the surface and it won't be able to receive + /// keyboard focus. + None, + + /// The surface will receive exclusive keyboard focus as long as it is above the shell surface + /// layer, and no other layer_shell surfaces are above it. + Exclusive, + + /// The surface can be focused similarly to a normal window. + #[default] + OnDemand, +} + +impl From for zwlr_layer_surface_v1::KeyboardInteractivity { + fn from(value: KeyboardInteractivity) -> Self { + match value { + KeyboardInteractivity::None => Self::None, + KeyboardInteractivity::Exclusive => Self::Exclusive, + KeyboardInteractivity::OnDemand => Self::OnDemand, + } + } +} + +/// Options for creating a layer_shell window. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LayerShellOptions { + /// The namespace for the surface, mostly used by compositors to apply rules, can not be + /// changed after the surface is created. + pub namespace: String, + /// The layer the surface is rendered on. + pub layer: Layer, + /// The anchor point of the surface. + pub anchor: Anchor, + /// Requests that the compositor avoids occluding an area with other surfaces. + pub exclusive_zone: Option, + /// The anchor point of the exclusive zone, will be determined using the anchor if left + /// unspecified. + pub exclusive_edge: Option, + /// Margins between the surface and its anchor point(s). + /// Specified in CSS order: top, right, bottom, left. + pub margin: Option<(Pixels, Pixels, Pixels, Pixels)>, + /// How keyboard events should be delivered to the surface. + pub keyboard_interactivity: KeyboardInteractivity, +} + +/// An error indicating that an action failed because the compositor doesn't support the required +/// layer_shell protocol. +#[derive(Debug, Error)] +#[error("Compositor doesn't support zwlr_layer_shell_v1")] +pub struct LayerShellNotSupportedError; diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 76dd89c940..3334ae28a3 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -14,19 +14,23 @@ use raw_window_handle as rwh; use wayland_backend::client::ObjectId; use wayland_client::WEnum; use wayland_client::{Proxy, protocol::wl_surface}; -use wayland_protocols::wp::fractional_scale::v1::client::wp_fractional_scale_v1; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; +use wayland_protocols::{ + wp::fractional_scale::v1::client::wp_fractional_scale_v1, + xdg::shell::client::xdg_toplevel::XdgToplevel, +}; use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; +use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; -use crate::scene::Scene; use crate::{ AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels, PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, size, + WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, + layer_shell::LayerShellNotSupportedError, px, size, }; use crate::{ Capslock, @@ -36,6 +40,7 @@ use crate::{ linux::wayland::{display::WaylandDisplay, serial::SerialKind}, }, }; +use crate::{WindowKind, scene::Scene}; #[derive(Default)] pub(crate) struct Callbacks { @@ -80,14 +85,12 @@ struct InProgressConfigure { } pub struct WaylandWindowState { - xdg_surface: xdg_surface::XdgSurface, + surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, pub surface: wl_surface::WlSurface, - decoration: Option, app_id: Option, appearance: WindowAppearance, blur: Option, - toplevel: xdg_toplevel::XdgToplevel, viewport: Option, outputs: HashMap, display: Option<(ObjectId, Output)>, @@ -113,6 +116,161 @@ pub struct WaylandWindowState { client_inset: Option, } +pub enum WaylandSurfaceState { + Xdg(WaylandXdgSurfaceState), + LayerShell(WaylandLayerSurfaceState), +} + +impl WaylandSurfaceState { + fn new( + surface: &wl_surface::WlSurface, + globals: &Globals, + params: &WindowParams, + parent: Option, + ) -> anyhow::Result { + // For layer_shell windows, create a layer surface instead of an xdg surface + if let WindowKind::LayerShell(options) = ¶ms.kind { + let Some(layer_shell) = globals.layer_shell.as_ref() else { + return Err(LayerShellNotSupportedError.into()); + }; + + let layer_surface = layer_shell.get_layer_surface( + &surface, + None, + options.layer.into(), + options.namespace.clone(), + &globals.qh, + surface.id(), + ); + + let width = params.bounds.size.width.0; + let height = params.bounds.size.height.0; + layer_surface.set_size(width as u32, height as u32); + + layer_surface.set_anchor(options.anchor.into()); + layer_surface.set_keyboard_interactivity(options.keyboard_interactivity.into()); + + if let Some(margin) = options.margin { + layer_surface.set_margin( + margin.0.0 as i32, + margin.1.0 as i32, + margin.2.0 as i32, + margin.3.0 as i32, + ) + } + + if let Some(exclusive_zone) = options.exclusive_zone { + layer_surface.set_exclusive_zone(exclusive_zone.0 as i32); + } + + if let Some(exclusive_edge) = options.exclusive_edge { + layer_surface.set_exclusive_edge(exclusive_edge.into()); + } + + return Ok(WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { + layer_surface, + })); + } + + // All other WindowKinds result in a regular xdg surface + let xdg_surface = globals + .wm_base + .get_xdg_surface(&surface, &globals.qh, surface.id()); + + let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); + if params.kind == WindowKind::Floating { + toplevel.set_parent(parent.as_ref()); + } + + if let Some(size) = params.window_min_size { + toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); + } + + // Attempt to set up window decorations based on the requested configuration + let decoration = globals + .decoration_manager + .as_ref() + .map(|decoration_manager| { + decoration_manager.get_toplevel_decoration(&toplevel, &globals.qh, surface.id()) + }); + + Ok(WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { + xdg_surface, + toplevel, + decoration, + })) + } +} + +pub struct WaylandXdgSurfaceState { + xdg_surface: xdg_surface::XdgSurface, + toplevel: xdg_toplevel::XdgToplevel, + decoration: Option, +} + +pub struct WaylandLayerSurfaceState { + layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, +} + +impl WaylandSurfaceState { + fn ack_configure(&self, serial: u32) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + xdg_surface.ack_configure(serial); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { + layer_surface.ack_configure(serial); + } + } + } + + fn decoration(&self) -> Option<&zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1> { + if let WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { decoration, .. }) = self { + decoration.as_ref() + } else { + None + } + } + + fn toplevel(&self) -> Option<&xdg_toplevel::XdgToplevel> { + if let WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { toplevel, .. }) = self { + Some(toplevel) + } else { + None + } + } + + fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + xdg_surface.set_window_geometry(x, y, width, height); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { + // cannot set window position of a layer surface + layer_surface.set_size(width as u32, height as u32); + } + } + } + + fn destroy(&mut self) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { + xdg_surface, + toplevel, + decoration: _decoration, + }) => { + // The role object (toplevel) must always be destroyed before the xdg_surface. + // See https://wayland.app/protocols/xdg-shell#xdg_surface:request:destroy + toplevel.destroy(); + xdg_surface.destroy(); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => { + layer_surface.destroy(); + } + } + } +} + #[derive(Clone)] pub struct WaylandWindowStatePtr { state: Rc>, @@ -123,9 +281,7 @@ impl WaylandWindowState { pub(crate) fn new( handle: AnyWindowHandle, surface: wl_surface::WlSurface, - xdg_surface: xdg_surface::XdgSurface, - toplevel: xdg_toplevel::XdgToplevel, - decoration: Option, + surface_state: WaylandSurfaceState, appearance: WindowAppearance, viewport: Option, client: WaylandClientStatePtr, @@ -154,14 +310,18 @@ impl WaylandWindowState { BladeRenderer::new(gpu_context, &raw_window, config)? }; + if let WaylandSurfaceState::Xdg(ref xdg_state) = surface_state { + if let Some(title) = options.titlebar.and_then(|titlebar| titlebar.title) { + xdg_state.toplevel.set_title(title.to_string()); + } + } + Ok(Self { - xdg_surface, + surface_state, acknowledged_first_configure: false, surface, - decoration, app_id: None, blur: None, - toplevel, viewport, globals, outputs: HashMap::default(), @@ -234,17 +394,29 @@ impl Drop for WaylandWindow { let client = state.client.clone(); state.renderer.destroy(); - if let Some(decoration) = &state.decoration { - decoration.destroy(); - } + + // Destroy blur first, this has no dependencies. if let Some(blur) = &state.blur { blur.release(); } - state.toplevel.destroy(); + + // Decorations must be destroyed before the xdg state. + // See https://wayland.app/protocols/xdg-decoration-unstable-v1#zxdg_toplevel_decoration_v1 + if let Some(decoration) = &state.surface_state.decoration() { + decoration.destroy(); + } + + // Surface state might contain xdg_toplevel/xdg_surface which can be destroyed now that + // decorations are gone. layer_surface has no dependencies. + state.surface_state.destroy(); + + // Viewport must be destroyed before the wl_surface. + // See https://wayland.app/protocols/viewporter#wp_viewport if let Some(viewport) = &state.viewport { viewport.destroy(); } - state.xdg_surface.destroy(); + + // The wl_surface itself should always be destroyed last. state.surface.destroy(); let state_ptr = self.0.clone(); @@ -276,29 +448,15 @@ impl WaylandWindow { client: WaylandClientStatePtr, params: WindowParams, appearance: WindowAppearance, + parent: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let xdg_surface = globals - .wm_base - .get_xdg_surface(&surface, &globals.qh, surface.id()); - let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); - - if let Some(size) = params.window_min_size { - toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); - } + let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent)?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); } - // Attempt to set up window decorations based on the requested configuration - let decoration = globals - .decoration_manager - .as_ref() - .map(|decoration_manager| { - decoration_manager.get_toplevel_decoration(&toplevel, &globals.qh, surface.id()) - }); - let viewport = globals .viewporter .as_ref() @@ -308,9 +466,7 @@ impl WaylandWindow { state: Rc::new(RefCell::new(WaylandWindowState::new( handle, surface.clone(), - xdg_surface, - toplevel, - decoration, + surface_state, appearance, viewport, client, @@ -337,6 +493,10 @@ impl WaylandWindowStatePtr { self.state.borrow().surface.clone() } + pub fn toplevel(&self) -> Option { + self.state.borrow().surface_state.toplevel().cloned() + } + pub fn ptr_eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.state, &other.state) } @@ -401,7 +561,7 @@ impl WaylandWindowStatePtr { } } let mut state = self.state.borrow_mut(); - state.xdg_surface.ack_configure(serial); + state.surface_state.ack_configure(serial); let window_geometry = inset_by_tiling( state.bounds.map_origin(|_| px(0.0)), @@ -411,7 +571,7 @@ impl WaylandWindowStatePtr { .map(|v| v.0 as i32) .map_size(|v| if v <= 0 { 1 } else { v }); - state.xdg_surface.set_window_geometry( + state.surface_state.set_geometry( window_geometry.origin.x, window_geometry.origin.y, window_geometry.size.width, @@ -570,6 +730,42 @@ impl WaylandWindowStatePtr { } } + pub fn handle_layersurface_event(&self, event: zwlr_layer_surface_v1::Event) -> bool { + match event { + zwlr_layer_surface_v1::Event::Configure { + width, + height, + serial, + } => { + let mut size = if width == 0 || height == 0 { + None + } else { + Some(size(px(width as f32), px(height as f32))) + }; + + let mut state = self.state.borrow_mut(); + state.in_progress_configure = Some(InProgressConfigure { + size, + fullscreen: false, + maximized: false, + resizing: false, + tiling: Tiling::default(), + }); + drop(state); + + // just do the same thing we'd do as an xdg_surface + self.handle_xdg_surface_event(xdg_surface::Event::Configure { serial }); + + false + } + zwlr_layer_surface_v1::Event::Closed => { + // unlike xdg, we don't have a choice here: the surface is closing. + true + } + _ => false, + } + } + #[allow(clippy::mutable_key_type)] pub fn handle_surface_event( &self, @@ -831,7 +1027,7 @@ impl PlatformWindow for WaylandWindow { let state_ptr = self.0.clone(); let dp_size = size.to_device_pixels(self.scale_factor()); - state.xdg_surface.set_window_geometry( + state.surface_state.set_geometry( state.bounds.origin.x.0 as i32, state.bounds.origin.y.0 as i32, dp_size.width.0, @@ -925,12 +1121,16 @@ impl PlatformWindow for WaylandWindow { } fn set_title(&mut self, title: &str) { - self.borrow().toplevel.set_title(title.to_string()); + if let Some(toplevel) = self.borrow().surface_state.toplevel() { + toplevel.set_title(title.to_string()); + } } fn set_app_id(&mut self, app_id: &str) { let mut state = self.borrow_mut(); - state.toplevel.set_app_id(app_id.to_owned()); + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel.set_app_id(app_id.to_owned()); + } state.app_id = Some(app_id.to_owned()); } @@ -941,24 +1141,30 @@ impl PlatformWindow for WaylandWindow { } fn minimize(&self) { - self.borrow().toplevel.set_minimized(); + if let Some(toplevel) = self.borrow().surface_state.toplevel() { + toplevel.set_minimized(); + } } fn zoom(&self) { let state = self.borrow(); - if !state.maximized { - state.toplevel.set_maximized(); - } else { - state.toplevel.unset_maximized(); + if let Some(toplevel) = state.surface_state.toplevel() { + if !state.maximized { + toplevel.set_maximized(); + } else { + toplevel.unset_maximized(); + } } } fn toggle_fullscreen(&self) { - let mut state = self.borrow_mut(); - if !state.fullscreen { - state.toplevel.set_fullscreen(None); - } else { - state.toplevel.unset_fullscreen(); + let mut state = self.borrow(); + if let Some(toplevel) = state.surface_state.toplevel() { + if !state.fullscreen { + toplevel.set_fullscreen(None); + } else { + toplevel.unset_fullscreen(); + } } } @@ -1023,27 +1229,33 @@ impl PlatformWindow for WaylandWindow { fn show_window_menu(&self, position: Point) { let state = self.borrow(); let serial = state.client.get_serial(SerialKind::MousePress); - state.toplevel.show_window_menu( - &state.globals.seat, - serial, - position.x.0 as i32, - position.y.0 as i32, - ); + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel.show_window_menu( + &state.globals.seat, + serial, + position.x.0 as i32, + position.y.0 as i32, + ); + } } fn start_window_move(&self) { let state = self.borrow(); let serial = state.client.get_serial(SerialKind::MousePress); - state.toplevel._move(&state.globals.seat, serial); + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel._move(&state.globals.seat, serial); + } } fn start_window_resize(&self, edge: crate::ResizeEdge) { let state = self.borrow(); - state.toplevel.resize( - &state.globals.seat, - state.client.get_serial(SerialKind::MousePress), - edge.to_xdg(), - ) + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel.resize( + &state.globals.seat, + state.client.get_serial(SerialKind::MousePress), + edge.to_xdg(), + ) + } } fn window_decorations(&self) -> Decorations { @@ -1058,10 +1270,21 @@ impl PlatformWindow for WaylandWindow { fn request_decorations(&self, decorations: WindowDecorations) { let mut state = self.borrow_mut(); - state.decorations = decorations; - if let Some(decoration) = state.decoration.as_ref() { - decoration.set_mode(decorations.to_xdg()); - update_window(state); + match state.surface_state.decoration().as_ref() { + Some(decoration) => { + decoration.set_mode(decorations.to_xdg()); + state.decorations = decorations; + update_window(state); + } + None => { + if matches!(decorations, WindowDecorations::Server) { + log::info!( + "Server-side decorations requested, but the Wayland server does not support them. Falling back to client-side decorations." + ); + } + state.decorations = WindowDecorations::Client; + update_window(state); + } } } diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index 497e3ff709..60400dada5 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1,4 +1,4 @@ -use crate::{Capslock, xcb_flush}; +use crate::{Capslock, ResultExt as _, RunnableVariant, TaskTiming, profiler, xcb_flush}; use anyhow::{Context as _, anyhow}; use ashpd::WindowIdentifier; use calloop::{ @@ -18,7 +18,7 @@ use std::{ rc::{Rc, Weak}, time::{Duration, Instant}, }; -use util::ResultExt; +use util::ResultExt as _; use x11rb::{ connection::{Connection, RequestConnection}, @@ -246,10 +246,6 @@ impl X11ClientStatePtr { state.keyboard_focused_window = None; } state.cursor_styles.remove(&x_window); - - if state.windows.is_empty() { - state.common.signal.stop(); - } } pub fn update_ime_position(&self, bounds: Bounds) { @@ -317,7 +313,37 @@ impl X11Client { // events have higher priority and runnables are only worked off after the event // callbacks. handle.insert_idle(|_| { - runnable.run(); + let start = Instant::now(); + let mut timing = match runnable { + RunnableVariant::Meta(runnable) => { + let location = runnable.metadata().location; + let timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + timing + } + RunnableVariant::Compat(runnable) => { + let location = core::panic::Location::caller(); + let timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + timing + } + }; + + let end = Instant::now(); + timing.end = Some(end); + profiler::add_task_timing(timing); }); } } @@ -411,7 +437,7 @@ impl X11Client { .to_string(); let keyboard_layout = LinuxKeyboardLayout::new(layout_name.into()); - let gpu_context = BladeContext::new().context("Unable to init GPU context")?; + let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection) .context("Failed to create resource database")?; @@ -1047,6 +1073,7 @@ impl X11Client { window.handle_input(PlatformInput::KeyDown(crate::KeyDownEvent { keystroke, is_held: false, + prefer_character_input: false, })); } Event::KeyRelease(event) => { @@ -1448,6 +1475,10 @@ impl LinuxClient for X11Client { params: WindowParams, ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); + let parent_window = state + .keyboard_focused_window + .and_then(|focused_window| state.windows.get(&focused_window)) + .map(|window| window.window.x_window); let x_window = state .xcb_connection .generate_id() @@ -1466,6 +1497,7 @@ impl LinuxClient for X11Client { &state.atoms, state.scale_factor, state.common.appearance, + parent_window, )?; check_reply( || "Failed to set XdndAware property", diff --git a/crates/gpui/src/platform/linux/x11/clipboard.rs b/crates/gpui/src/platform/linux/x11/clipboard.rs index 65ad16e82b..3be5008505 100644 --- a/crates/gpui/src/platform/linux/x11/clipboard.rs +++ b/crates/gpui/src/platform/linux/x11/clipboard.rs @@ -86,6 +86,7 @@ x11rb::atom_manager! { SVG__MIME: ImageFormat::mime_type(ImageFormat::Svg ).as_bytes(), BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(), TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(), + ICO__MIME: ImageFormat::mime_type(ImageFormat::Ico ).as_bytes(), // This is just some random name for the property on our window, into which // the clipboard owner writes the data we requested. @@ -1003,6 +1004,7 @@ impl Clipboard { ImageFormat::Svg => self.inner.atoms.SVG__MIME, ImageFormat::Bmp => self.inner.atoms.BMP__MIME, ImageFormat::Tiff => self.inner.atoms.TIFF_MIME, + ImageFormat::Ico => self.inner.atoms.ICO__MIME, }; let data = vec![ClipboardData { bytes: image.bytes, diff --git a/crates/gpui/src/platform/linux/x11/window.rs b/crates/gpui/src/platform/linux/x11/window.rs index 001b853afe..fe197a6701 100644 --- a/crates/gpui/src/platform/linux/x11/window.rs +++ b/crates/gpui/src/platform/linux/x11/window.rs @@ -57,6 +57,7 @@ x11rb::atom_manager! { WM_PROTOCOLS, WM_DELETE_WINDOW, WM_CHANGE_STATE, + WM_TRANSIENT_FOR, _NET_WM_PID, _NET_WM_NAME, _NET_WM_STATE, @@ -72,6 +73,7 @@ x11rb::atom_manager! { _NET_WM_MOVERESIZE, _NET_WM_WINDOW_TYPE, _NET_WM_WINDOW_TYPE_NOTIFICATION, + _NET_WM_WINDOW_TYPE_DIALOG, _NET_WM_SYNC, _NET_SUPPORTED, _MOTIF_WM_HINTS, @@ -392,6 +394,7 @@ impl X11WindowState { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, + parent_window: Option, ) -> anyhow::Result { let x_screen_index = params .display_id @@ -529,6 +532,7 @@ impl X11WindowState { ), )?; } + if params.kind == WindowKind::PopUp { check_reply( || "X11 ChangeProperty32 setting window type for pop-up failed.", @@ -542,6 +546,38 @@ impl X11WindowState { )?; } + if params.kind == WindowKind::Floating { + if let Some(parent_window) = parent_window { + // WM_TRANSIENT_FOR hint indicating the main application window. For floating windows, we set + // a parent window (WM_TRANSIENT_FOR) such that the window manager knows where to + // place the floating window in relation to the main window. + // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html + check_reply( + || "X11 ChangeProperty32 setting WM_TRANSIENT_FOR for floating window failed.", + xcb.change_property32( + xproto::PropMode::REPLACE, + x_window, + atoms.WM_TRANSIENT_FOR, + xproto::AtomEnum::WINDOW, + &[parent_window], + ), + )?; + } + + // _NET_WM_WINDOW_TYPE_DIALOG indicates that this is a dialog (floating) window + // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html + check_reply( + || "X11 ChangeProperty32 setting window type for floating window failed.", + xcb.change_property32( + xproto::PropMode::REPLACE, + x_window, + atoms._NET_WM_WINDOW_TYPE, + xproto::AtomEnum::ATOM, + &[atoms._NET_WM_WINDOW_TYPE_DIALOG], + ), + )?; + } + check_reply( || "X11 ChangeProperty32 setting protocols failed.", xcb.change_property32( @@ -737,6 +773,7 @@ impl X11Window { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, + parent_window: Option, ) -> anyhow::Result { let ptr = X11WindowStatePtr { state: Rc::new(RefCell::new(X11WindowState::new( @@ -752,6 +789,7 @@ impl X11Window { atoms, scale_factor, appearance, + parent_window, )?)), callbacks: Rc::new(RefCell::new(Callbacks::default())), xcb: xcb.clone(), diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index 76d636b457..aa056846e6 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -135,6 +135,8 @@ unsafe impl objc::Encode for NSRange { } } +/// Allow NSString::alloc use here because it sets autorelease +#[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } diff --git a/crates/gpui/src/platform/mac/attributed_string.rs b/crates/gpui/src/platform/mac/attributed_string.rs index 5f313ac699..42fe1e5bf7 100644 --- a/crates/gpui/src/platform/mac/attributed_string.rs +++ b/crates/gpui/src/platform/mac/attributed_string.rs @@ -50,10 +50,12 @@ impl NSMutableAttributedString for id {} #[cfg(test)] mod tests { + use crate::platform::mac::ns_string; + use super::*; use cocoa::appkit::NSImage; use cocoa::base::nil; - use cocoa::foundation::NSString; + use cocoa::foundation::NSAutoreleasePool; #[test] #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348 fn test_nsattributed_string() { @@ -68,26 +70,34 @@ mod tests { impl NSTextAttachment for id {} unsafe { - let image: id = msg_send![class!(NSImage), alloc]; - image.initWithContentsOfFile_(NSString::alloc(nil).init_str("test.jpeg")); + let image: id = { + let img: id = msg_send![class!(NSImage), alloc]; + let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")]; + let img: id = msg_send![img, autorelease]; + img + }; let _size = image.size(); - let string = NSString::alloc(nil).init_str("Test String"); - let attr_string = NSMutableAttributedString::alloc(nil).init_attributed_string(string); - let hello_string = NSString::alloc(nil).init_str("Hello World"); - let hello_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(hello_string); + let string = ns_string("Test String"); + let attr_string = NSMutableAttributedString::alloc(nil) + .init_attributed_string(string) + .autorelease(); + let hello_string = ns_string("Hello World"); + let hello_attr_string = NSAttributedString::alloc(nil) + .init_attributed_string(hello_string) + .autorelease(); attr_string.appendAttributedString_(hello_attr_string); - let attachment = NSTextAttachment::alloc(nil); + let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease]; let _: () = msg_send![attachment, setImage: image]; let image_attr_string = msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment]; attr_string.appendAttributedString_(image_attr_string); - let another_string = NSString::alloc(nil).init_str("Another String"); - let another_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(another_string); + let another_string = ns_string("Another String"); + let another_attr_string = NSAttributedString::alloc(nil) + .init_attributed_string(another_string) + .autorelease(); attr_string.appendAttributedString_(another_attr_string); let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length]; diff --git a/crates/gpui/src/platform/mac/dispatcher.rs b/crates/gpui/src/platform/mac/dispatcher.rs index 137295fb91..1dfea82d58 100644 --- a/crates/gpui/src/platform/mac/dispatcher.rs +++ b/crates/gpui/src/platform/mac/dispatcher.rs @@ -2,21 +2,35 @@ #![allow(non_camel_case_types)] #![allow(non_snake_case)] -use crate::{PlatformDispatcher, TaskLabel}; +use crate::{ + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RealtimePriority, RunnableMeta, + RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, ThreadTaskTimings, +}; + +use anyhow::Context; use async_task::Runnable; +use mach2::{ + kern_return::KERN_SUCCESS, + mach_time::mach_timebase_info_data_t, + thread_policy::{ + THREAD_EXTENDED_POLICY, THREAD_EXTENDED_POLICY_COUNT, THREAD_PRECEDENCE_POLICY, + THREAD_PRECEDENCE_POLICY_COUNT, THREAD_TIME_CONSTRAINT_POLICY, + THREAD_TIME_CONSTRAINT_POLICY_COUNT, thread_extended_policy_data_t, + thread_precedence_policy_data_t, thread_time_constraint_policy_data_t, + }, +}; use objc::{ class, msg_send, runtime::{BOOL, YES}, sel, sel_impl, }; -use parking::{Parker, Unparker}; -use parking_lot::Mutex; use std::{ ffi::c_void, + mem::MaybeUninit, ptr::{NonNull, addr_of}, - sync::Arc, - time::Duration, + time::{Duration, Instant}, }; +use util::ResultExt; /// All items in the generated file are marked as pub, so we're gonna wrap it in a separate mod to prevent /// these pub items from leaking into public API. @@ -29,79 +43,280 @@ pub(crate) fn dispatch_get_main_queue() -> dispatch_queue_t { addr_of!(_dispatch_main_q) as *const _ as dispatch_queue_t } -pub(crate) struct MacDispatcher { - parker: Arc>, -} - -impl Default for MacDispatcher { - fn default() -> Self { - Self::new() - } -} - -impl MacDispatcher { - pub fn new() -> Self { - MacDispatcher { - parker: Arc::new(Mutex::new(Parker::new())), - } - } -} +pub(crate) struct MacDispatcher; impl PlatformDispatcher for MacDispatcher { + fn get_all_timings(&self) -> Vec { + let global_timings = GLOBAL_THREAD_TIMINGS.lock(); + ThreadTaskTimings::convert(&global_timings) + } + + fn get_current_thread_timings(&self) -> Vec { + THREAD_TIMINGS.with(|timings| { + let timings = &timings.lock().timings; + + let mut vec = Vec::with_capacity(timings.len()); + + let (s1, s2) = timings.as_slices(); + vec.extend_from_slice(s1); + vec.extend_from_slice(s2); + vec + }) + } + fn is_main_thread(&self) -> bool { let is_main_thread: BOOL = unsafe { msg_send![class!(NSThread), isMainThread] }; is_main_thread == YES } - fn dispatch(&self, runnable: Runnable, _: Option) { + fn dispatch(&self, runnable: RunnableVariant, _: Option, priority: Priority) { + let (context, trampoline) = match runnable { + RunnableVariant::Meta(runnable) => ( + runnable.into_raw().as_ptr() as *mut c_void, + Some(trampoline as unsafe extern "C" fn(*mut c_void)), + ), + RunnableVariant::Compat(runnable) => ( + runnable.into_raw().as_ptr() as *mut c_void, + Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)), + ), + }; + + let queue_priority = match priority { + Priority::Realtime(_) => unreachable!(), + Priority::High => DISPATCH_QUEUE_PRIORITY_HIGH as isize, + Priority::Medium => DISPATCH_QUEUE_PRIORITY_DEFAULT as isize, + Priority::Low => DISPATCH_QUEUE_PRIORITY_LOW as isize, + }; + unsafe { dispatch_async_f( - dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.try_into().unwrap(), 0), - runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline), + dispatch_get_global_queue(queue_priority, 0), + context, + trampoline, ); } } - fn dispatch_on_main_thread(&self, runnable: Runnable) { - unsafe { - dispatch_async_f( - dispatch_get_main_queue(), + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { + let (context, trampoline) = match runnable { + RunnableVariant::Meta(runnable) => ( runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline), - ); + Some(trampoline as unsafe extern "C" fn(*mut c_void)), + ), + RunnableVariant::Compat(runnable) => ( + runnable.into_raw().as_ptr() as *mut c_void, + Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)), + ), + }; + unsafe { + dispatch_async_f(dispatch_get_main_queue(), context, trampoline); } } - fn dispatch_after(&self, duration: Duration, runnable: Runnable) { + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + let (context, trampoline) = match runnable { + RunnableVariant::Meta(runnable) => ( + runnable.into_raw().as_ptr() as *mut c_void, + Some(trampoline as unsafe extern "C" fn(*mut c_void)), + ), + RunnableVariant::Compat(runnable) => ( + runnable.into_raw().as_ptr() as *mut c_void, + Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)), + ), + }; unsafe { let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.try_into().unwrap(), 0); let when = dispatch_time(DISPATCH_TIME_NOW as u64, duration.as_nanos() as i64); - dispatch_after_f( - when, - queue, - runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline), - ); + dispatch_after_f(when, queue, context, trampoline); } } - fn park(&self, timeout: Option) -> bool { - if let Some(timeout) = timeout { - self.parker.lock().park_timeout(timeout) - } else { - self.parker.lock().park(); - true - } + fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + std::thread::spawn(move || { + match priority { + RealtimePriority::Audio => set_audio_thread_priority(), + RealtimePriority::Other => set_high_thread_priority(), + } + .context(format!("for priority {:?}", priority)) + .log_err(); + + f(); + }); + } +} + +fn set_high_thread_priority() -> anyhow::Result<()> { + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + // SAFETY: all sched_param members are valid when initialized to zero. + let mut sched_param = unsafe { MaybeUninit::::zeroed().assume_init() }; + sched_param.sched_priority = 45; + + let result = unsafe { libc::pthread_setschedparam(thread_id, libc::SCHED_FIFO, &sched_param) }; + if result != 0 { + anyhow::bail!("failed to set realtime thread priority") } - fn unparker(&self) -> Unparker { - self.parker.lock().unparker() + Ok(()) +} + +fn set_audio_thread_priority() -> anyhow::Result<()> { + // https://chromium.googlesource.com/chromium/chromium/+/master/base/threading/platform_thread_mac.mm#93 + + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + // SAFETY: thread_id is a valid thread id + let thread_id = unsafe { libc::pthread_mach_thread_np(thread_id) }; + + // Fixed priority thread + let mut policy = thread_extended_policy_data_t { timeshare: 0 }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_extended_policy_data_t is passed as THREAD_EXTENDED_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_EXTENDED_POLICY, + &mut policy as *mut _ as *mut _, + THREAD_EXTENDED_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread extended policy"); } + + // relatively high priority + let mut precedence = thread_precedence_policy_data_t { importance: 63 }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_precedence_policy_data_t is passed as THREAD_PRECEDENCE_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_PRECEDENCE_POLICY, + &mut precedence as *mut _ as *mut _, + THREAD_PRECEDENCE_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread precedence policy"); + } + + const GUARANTEED_AUDIO_DUTY_CYCLE: f32 = 0.75; + const MAX_AUDIO_DUTY_CYCLE: f32 = 0.85; + + // ~128 frames @ 44.1KHz + const TIME_QUANTUM: f32 = 2.9; + + const AUDIO_TIME_NEEDED: f32 = GUARANTEED_AUDIO_DUTY_CYCLE * TIME_QUANTUM; + const MAX_TIME_ALLOWED: f32 = MAX_AUDIO_DUTY_CYCLE * TIME_QUANTUM; + + let mut timebase_info = mach_timebase_info_data_t { numer: 0, denom: 0 }; + // SAFETY: timebase_info is a valid pointer to a mach_timebase_info_data_t struct + unsafe { mach2::mach_time::mach_timebase_info(&mut timebase_info) }; + + let ms_to_abs_time = ((timebase_info.denom as f32) / (timebase_info.numer as f32)) * 1000000f32; + + let mut time_constraints = thread_time_constraint_policy_data_t { + period: (TIME_QUANTUM * ms_to_abs_time) as u32, + computation: (AUDIO_TIME_NEEDED * ms_to_abs_time) as u32, + constraint: (MAX_TIME_ALLOWED * ms_to_abs_time) as u32, + preemptible: 0, + }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_precedence_pthread_time_constraint_policy_data_t is passed as THREAD_TIME_CONSTRAINT_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_TIME_CONSTRAINT_POLICY, + &mut time_constraints as *mut _ as *mut _, + THREAD_TIME_CONSTRAINT_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread time constraint policy"); + } + + Ok(()) } extern "C" fn trampoline(runnable: *mut c_void) { - let task = unsafe { Runnable::<()>::from_raw(NonNull::new_unchecked(runnable as *mut ())) }; + let task = + unsafe { Runnable::::from_raw(NonNull::new_unchecked(runnable as *mut ())) }; + + let location = task.metadata().location; + + let start = Instant::now(); + let timing = TaskTiming { + location, + start, + end: None, + }; + + THREAD_TIMINGS.with(|timings| { + let mut timings = timings.lock(); + let timings = &mut timings.timings; + if let Some(last_timing) = timings.iter_mut().rev().next() { + if last_timing.location == timing.location { + return; + } + } + + timings.push_back(timing); + }); + task.run(); + let end = Instant::now(); + + THREAD_TIMINGS.with(|timings| { + let mut timings = timings.lock(); + let timings = &mut timings.timings; + let Some(last_timing) = timings.iter_mut().rev().next() else { + return; + }; + last_timing.end = Some(end); + }); +} + +extern "C" fn trampoline_compat(runnable: *mut c_void) { + let task = unsafe { Runnable::<()>::from_raw(NonNull::new_unchecked(runnable as *mut ())) }; + + let location = core::panic::Location::caller(); + + let start = Instant::now(); + let timing = TaskTiming { + location, + start, + end: None, + }; + THREAD_TIMINGS.with(|timings| { + let mut timings = timings.lock(); + let timings = &mut timings.timings; + if let Some(last_timing) = timings.iter_mut().rev().next() { + if last_timing.location == timing.location { + return; + } + } + + timings.push_back(timing); + }); + + task.run(); + let end = Instant::now(); + + THREAD_TIMINGS.with(|timings| { + let mut timings = timings.lock(); + let timings = &mut timings.timings; + let Some(last_timing) = timings.iter_mut().rev().next() else { + return; + }; + last_timing.end = Some(end); + }); } diff --git a/crates/gpui/src/platform/mac/display.rs b/crates/gpui/src/platform/mac/display.rs index 4ee27027d5..94791620e8 100644 --- a/crates/gpui/src/platform/mac/display.rs +++ b/crates/gpui/src/platform/mac/display.rs @@ -1,9 +1,10 @@ -use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, px, size}; +use super::ns_string; +use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, point, px, size}; use anyhow::Result; use cocoa::{ appkit::NSScreen, base::{id, nil}, - foundation::{NSDictionary, NSString}, + foundation::{NSArray, NSDictionary}, }; use core_foundation::uuid::{CFUUIDGetUUIDBytes, CFUUIDRef}; use core_graphics::display::{CGDirectDisplayID, CGDisplayBounds, CGGetActiveDisplayList}; @@ -35,7 +36,7 @@ impl MacDisplay { let screens = NSScreen::screens(nil); let screen = cocoa::foundation::NSArray::objectAtIndex(screens, 0); let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; Self(screen_number) @@ -114,4 +115,53 @@ impl PlatformDisplay for MacDisplay { } } } + + fn visible_bounds(&self) -> Bounds { + unsafe { + let dominated_screen = self.get_nsscreen(); + + if dominated_screen == nil { + return self.bounds(); + } + + let screen_frame = NSScreen::frame(dominated_screen); + let visible_frame = NSScreen::visibleFrame(dominated_screen); + + // Convert from bottom-left origin (AppKit) to top-left origin + let origin_y = + screen_frame.size.height - visible_frame.origin.y - visible_frame.size.height + + screen_frame.origin.y; + + Bounds { + origin: point( + px(visible_frame.origin.x as f32 - screen_frame.origin.x as f32), + px(origin_y as f32), + ), + size: size( + px(visible_frame.size.width as f32), + px(visible_frame.size.height as f32), + ), + } + } + } +} + +impl MacDisplay { + /// Find the NSScreen corresponding to this display + unsafe fn get_nsscreen(&self) -> id { + let screens = unsafe { NSScreen::screens(nil) }; + let count = unsafe { NSArray::count(screens) }; + let screen_number_key: id = unsafe { ns_string("NSScreenNumber") }; + + for i in 0..count { + let screen = unsafe { NSArray::objectAtIndex(screens, i) }; + let device_description = unsafe { NSScreen::deviceDescription(screen) }; + let screen_number = unsafe { device_description.objectForKey_(screen_number_key) }; + let screen_id: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; + if screen_id == self.0 { + return screen; + } + } + nil + } } diff --git a/crates/gpui/src/platform/mac/events.rs b/crates/gpui/src/platform/mac/events.rs index 938db4b762..7a12e8d3d7 100644 --- a/crates/gpui/src/platform/mac/events.rs +++ b/crates/gpui/src/platform/mac/events.rs @@ -1,7 +1,8 @@ use crate::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, - PlatformInput, ScrollDelta, ScrollWheelEvent, TouchPhase, + MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, + TouchPhase, platform::mac::{ LMGetKbdType, NSStringExt, TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, UCKeyTranslate, kTISPropertyUnicodeKeyLayoutData, @@ -131,6 +132,7 @@ impl PlatformInput { NSEventType::NSKeyDown => Some(Self::KeyDown(KeyDownEvent { keystroke: parse_keystroke(native_event), is_held: native_event.isARepeat() == YES, + prefer_character_input: false, })), NSEventType::NSKeyUp => Some(Self::KeyUp(KeyUpEvent { keystroke: parse_keystroke(native_event), @@ -186,6 +188,26 @@ impl PlatformInput { }) }) } + NSEventType::NSEventTypePressure => { + let stage = native_event.stage(); + let pressure = native_event.pressure(); + + window_height.map(|window_height| { + Self::MousePressure(MousePressureEvent { + stage: match stage { + 1 => PressureStage::Normal, + 2 => PressureStage::Force, + _ => PressureStage::Zero, + }, + pressure, + modifiers: read_modifiers(native_event), + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + }) + }) + } // Some mice (like Logitech MX Master) send navigation buttons as swipe events NSEventType::NSEventTypeSwipe => { let navigation_direction = match native_event.phase() { diff --git a/crates/gpui/src/platform/mac/metal_atlas.rs b/crates/gpui/src/platform/mac/metal_atlas.rs index 8282530c5e..9b43efe361 100644 --- a/crates/gpui/src/platform/mac/metal_atlas.rs +++ b/crates/gpui/src/platform/mac/metal_atlas.rs @@ -15,6 +15,9 @@ pub(crate) struct MetalAtlas(Mutex); impl MetalAtlas { pub(crate) fn new(device: Device) -> Self { MetalAtlas(Mutex::new(MetalAtlasState { + // Shared memory can be used only if CPU and GPU share the same memory space. + // https://developer.apple.com/documentation/metal/setting-resource-storage-modes + unified_memory: device.has_unified_memory(), device: AssertSend(device), monochrome_textures: Default::default(), polychrome_textures: Default::default(), @@ -29,6 +32,7 @@ impl MetalAtlas { struct MetalAtlasState { device: AssertSend, + unified_memory: bool, monochrome_textures: AtlasTextureList, polychrome_textures: AtlasTextureList, tiles_by_key: FxHashMap, @@ -146,6 +150,11 @@ impl MetalAtlasState { } texture_descriptor.set_pixel_format(pixel_format); texture_descriptor.set_usage(usage); + texture_descriptor.set_storage_mode(if self.unified_memory { + metal::MTLStorageMode::Shared + } else { + metal::MTLStorageMode::Managed + }); let metal_texture = self.device.new_texture(&texture_descriptor); let texture_list = match kind { diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index 9e5d6ec5ff..6d7b82507f 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -76,12 +76,22 @@ impl InstanceBufferPool { self.buffers.clear(); } - pub(crate) fn acquire(&mut self, device: &metal::Device) -> InstanceBuffer { + pub(crate) fn acquire( + &mut self, + device: &metal::Device, + unified_memory: bool, + ) -> InstanceBuffer { let buffer = self.buffers.pop().unwrap_or_else(|| { - device.new_buffer( - self.buffer_size as u64, - MTLResourceOptions::StorageModeManaged, - ) + let options = if unified_memory { + MTLResourceOptions::StorageModeShared + // Buffers are write only which can benefit from the combined cache + // https://developer.apple.com/documentation/metal/mtlresourceoptions/cpucachemodewritecombined + | MTLResourceOptions::CPUCacheModeWriteCombined + } else { + MTLResourceOptions::StorageModeManaged + }; + + device.new_buffer(self.buffer_size as u64, options) }); InstanceBuffer { metal_buffer: buffer, @@ -99,6 +109,7 @@ impl InstanceBufferPool { pub(crate) struct MetalRenderer { device: metal::Device, layer: metal::MetalLayer, + unified_memory: bool, presents_with_transaction: bool, command_queue: CommandQueue, paths_rasterization_pipeline_state: metal::RenderPipelineState, @@ -132,11 +143,21 @@ impl MetalRenderer { // Prefer low‐power integrated GPUs on Intel Mac. On Apple // Silicon, there is only ever one GPU, so this is equivalent to // `metal::Device::system_default()`. - let mut devices = metal::Device::all(); - devices.sort_by_key(|device| (device.is_removable(), device.is_low_power())); - let Some(device) = devices.pop() else { - log::error!("unable to access a compatible graphics device"); - std::process::exit(1); + let device = if let Some(d) = metal::Device::all() + .into_iter() + .min_by_key(|d| (d.is_removable(), !d.is_low_power())) + { + d + } else { + // For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689 + // In that case, we fall back to the system default device. + log::error!( + "Unable to enumerate Metal devices; attempting to use system default device" + ); + metal::Device::system_default().unwrap_or_else(|| { + log::error!("unable to access a compatible graphics device"); + std::process::exit(1); + }) }; let layer = metal::MetalLayer::new(); @@ -169,6 +190,10 @@ impl MetalRenderer { output } + // Shared memory can be used only if CPU and GPU share the same memory space. + // https://developer.apple.com/documentation/metal/setting-resource-storage-modes + let unified_memory = device.has_unified_memory(); + let unit_vertices = [ to_float2_bits(point(0., 0.)), to_float2_bits(point(1., 0.)), @@ -180,7 +205,12 @@ impl MetalRenderer { let unit_vertices = device.new_buffer_with_data( unit_vertices.as_ptr() as *const c_void, mem::size_of_val(&unit_vertices) as u64, - MTLResourceOptions::StorageModeManaged, + if unified_memory { + MTLResourceOptions::StorageModeShared + | MTLResourceOptions::CPUCacheModeWriteCombined + } else { + MTLResourceOptions::StorageModeManaged + }, ); let paths_rasterization_pipeline_state = build_path_rasterization_pipeline_state( @@ -258,6 +288,7 @@ impl MetalRenderer { device, layer, presents_with_transaction: false, + unified_memory, command_queue, paths_rasterization_pipeline_state, path_sprites_pipeline_state, @@ -327,14 +358,23 @@ impl MetalRenderer { texture_descriptor.set_width(size.width.0 as u64); texture_descriptor.set_height(size.height.0 as u64); texture_descriptor.set_pixel_format(metal::MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); texture_descriptor .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); self.path_intermediate_texture = Some(self.device.new_texture(&texture_descriptor)); if self.path_sample_count > 1 { + // https://developer.apple.com/documentation/metal/choosing-a-resource-storage-mode-for-apple-gpus + // Rendering MSAA textures are done in a single pass, so we can use memory-less storage on Apple Silicon + let storage_mode = if self.unified_memory { + metal::MTLStorageMode::Memoryless + } else { + metal::MTLStorageMode::Private + }; + let mut msaa_descriptor = texture_descriptor; msaa_descriptor.set_texture_type(metal::MTLTextureType::D2Multisample); - msaa_descriptor.set_storage_mode(metal::MTLStorageMode::Private); + msaa_descriptor.set_storage_mode(storage_mode); msaa_descriptor.set_sample_count(self.path_sample_count as _); self.path_intermediate_msaa_texture = Some(self.device.new_texture(&msaa_descriptor)); } else { @@ -368,7 +408,10 @@ impl MetalRenderer { }; loop { - let mut instance_buffer = self.instance_buffer_pool.lock().acquire(&self.device); + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.unified_memory); let command_buffer = self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size); @@ -540,10 +583,14 @@ impl MetalRenderer { command_encoder.end_encoding(); - instance_buffer.metal_buffer.did_modify_range(NSRange { - location: 0, - length: instance_offset as NSUInteger, - }); + if !self.unified_memory { + // Sync the instance buffer to the GPU + instance_buffer.metal_buffer.did_modify_range(NSRange { + location: 0, + length: instance_offset as NSUInteger, + }); + } + Ok(command_buffer.to_owned()) } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index bf92ca6dfb..ee67f465e3 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -2,15 +2,14 @@ use super::{ BoolExt, MacKeyboardLayout, MacKeyboardMapper, attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, - renderer, + ns_string, renderer, }; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, - hash, + PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; @@ -19,7 +18,7 @@ use cocoa::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString, - NSPasteboardTypeTIFF, NSSavePanel, NSWindow, + NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ @@ -47,20 +46,23 @@ use objc::{ }; use parking_lot::Mutex; use ptr::null_mut; +use semver::Version; use std::{ cell::Cell, convert::TryInto, ffi::{CStr, OsStr, c_void}, os::{raw::c_char, unix::ffi::OsStrExt}, path::{Path, PathBuf}, - process::Command, ptr, rc::Rc, slice, str, sync::{Arc, OnceLock}, }; use strum::IntoEnumIterator; -use util::ResultExt; +use util::{ + ResultExt, + command::{new_smol_command, new_std_command}, +}; #[allow(non_upper_case_globals)] const NSUTF8StringEncoding: NSUInteger = 4; @@ -187,7 +189,7 @@ impl Default for MacPlatform { impl MacPlatform { pub(crate) fn new(headless: bool) -> Self { - let dispatcher = Arc::new(MacDispatcher::new()); + let dispatcher = Arc::new(MacDispatcher); #[cfg(feature = "font-kit")] let text_system = Arc::new(crate::MacTextSystem::new()); @@ -315,6 +317,7 @@ impl MacPlatform { name, action, os_action, + checked, } => { // Note that this is intentionally using earlier bindings, whereas typically // later ones take display precedence. See the discussion on @@ -386,7 +389,7 @@ impl MacPlatform { ns_string(key_to_native(keystroke.key()).as_ref()), ) .autorelease(); - if Self::os_version() >= SemanticVersion::new(12, 0, 0) { + if Self::os_version() >= Version::new(12, 0, 0) { let _: () = msg_send![item, setAllowsAutomaticKeyEquivalentLocalization: NO]; } item.setKeyEquivalentModifierMask_(mask); @@ -409,6 +412,10 @@ impl MacPlatform { .autorelease(); } + if *checked { + item.setState_(NSVisualEffectState::Active); + } + let tag = actions.len() as NSInteger; let _: () = msg_send![item, setTag: tag]; actions.push(action.boxed_clone()); @@ -445,15 +452,15 @@ impl MacPlatform { } } - fn os_version() -> SemanticVersion { + fn os_version() -> Version { let version = unsafe { let process_info = NSProcessInfo::processInfo(nil); process_info.operatingSystemVersion() }; - SemanticVersion::new( - version.majorVersion as usize, - version.minorVersion as usize, - version.patchVersion as usize, + Version::new( + version.majorVersion, + version.minorVersion, + version.patchVersion, ) } } @@ -547,7 +554,7 @@ impl Platform for MacPlatform { clippy::disallowed_methods, reason = "We are restarting ourselves, using std command thus is fine" )] - let restart_process = Command::new("/bin/bash") + let restart_process = new_std_command("/bin/bash") .arg("-c") .arg(script) .arg(app_pid) @@ -646,9 +653,12 @@ impl Platform for MacPlatform { fn open_url(&self, url: &str) { unsafe { - let url = NSURL::alloc(nil) - .initWithString_(ns_string(url)) - .autorelease(); + let ns_url = NSURL::alloc(nil).initWithString_(ns_string(url)); + if ns_url.is_null() { + log::error!("Failed to create NSURL from string: {}", url); + return; + } + let url = ns_url.autorelease(); let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; msg_send![workspace, openURL: url] } @@ -658,7 +668,7 @@ impl Platform for MacPlatform { // API only available post Monterey // https://developer.apple.com/documentation/appkit/nsworkspace/3753004-setdefaultapplicationaturl let (done_tx, done_rx) = oneshot::channel(); - if Self::os_version() < SemanticVersion::new(12, 0, 0) { + if Self::os_version() < Version::new(12, 0, 0) { return Task::ready(Err(anyhow!( "macOS 12.0 or later is required to register URL schemes" ))); @@ -802,7 +812,7 @@ impl Platform for MacPlatform { // to break that use-case than breaking `a.sql`. if chunks.len() == 3 && chunks[1].starts_with(chunks[2]) - && Self::os_version() >= SemanticVersion::new(15, 0, 0) + && Self::os_version() >= Version::new(15, 0, 0) { let new_filename = OsStr::from_bytes( &filename.as_bytes() @@ -859,7 +869,7 @@ impl Platform for MacPlatform { .lock() .background_executor .spawn(async move { - if let Some(mut child) = smol::process::Command::new("open") + if let Some(mut child) = new_smol_command("open") .arg(path) .spawn() .context("invoking open command") @@ -1038,6 +1048,7 @@ impl Platform for MacPlatform { ClipboardEntry::Image(image) => { self.write_image_to_clipboard(image); } + ClipboardEntry::ExternalPaths(_) => {} }, None => { // Writing an empty list of entries just clears the clipboard. @@ -1050,13 +1061,15 @@ impl Platform for MacPlatform { let attributed_string = { let mut buf = NSMutableAttributedString::alloc(nil) // TODO can we skip this? Or at least part of it? - .init_attributed_string(NSString::alloc(nil).init_str("")); + .init_attributed_string(ns_string("")) + .autorelease(); for entry in item.entries { if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry { let to_append = NSAttributedString::alloc(nil) - .init_attributed_string(NSString::alloc(nil).init_str(&text)); + .init_attributed_string(ns_string(&text)) + .autorelease(); buf.appendAttributedString_(to_append); } @@ -1532,10 +1545,6 @@ extern "C" fn handle_dock_menu(this: &mut Object, _: Sel, _: id) -> id { } } -unsafe fn ns_string(string: &str) -> id { - unsafe { NSString::alloc(nil).init_str(string).autorelease() } -} - unsafe fn ns_url_to_path(url: id) -> Result { let path: *mut c_char = msg_send![url, fileSystemRepresentation]; anyhow::ensure!(!path.is_null(), "url is not a file path: {}", unsafe { @@ -1607,6 +1616,7 @@ impl From for UTType { ImageFormat::Gif => Self::gif(), ImageFormat::Bmp => Self::bmp(), ImageFormat::Svg => Self::svg(), + ImageFormat::Ico => Self::ico(), } } } @@ -1645,6 +1655,11 @@ impl UTType { Self(unsafe { ns_string("public.svg-image") }) } + pub fn ico() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico + Self(unsafe { ns_string("com.microsoft.ico") }) + } + pub fn tiff() -> Self { // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs index 4d4ffa6896..2f2c1eae33 100644 --- a/crates/gpui/src/platform/mac/screen_capture.rs +++ b/crates/gpui/src/platform/mac/screen_capture.rs @@ -1,3 +1,4 @@ +use super::ns_string; use crate::{ DevicePixels, ForegroundExecutor, SharedString, SourceMetadata, platform::{ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream}, @@ -7,7 +8,7 @@ use anyhow::{Result, anyhow}; use block::ConcreteBlock; use cocoa::{ base::{YES, id, nil}, - foundation::{NSArray, NSString}, + foundation::NSArray, }; use collections::HashMap; use core_foundation::base::TCFType; @@ -195,7 +196,7 @@ unsafe fn screen_id_to_human_label() -> HashMap { let screens: id = msg_send![class!(NSScreen), screens]; let count: usize = msg_send![screens, count]; let mut map = HashMap::default(); - let screen_number_key = unsafe { NSString::alloc(nil).init_str("NSScreenNumber") }; + let screen_number_key = unsafe { ns_string("NSScreenNumber") }; for i in 0..count { let screen: id = msg_send![screens, objectAtIndex: i]; let device_desc: id = msg_send![screen, deviceDescription]; diff --git a/crates/gpui/src/platform/mac/shaders.metal b/crates/gpui/src/platform/mac/shaders.metal index 83c978b853..7c3886031a 100644 --- a/crates/gpui/src/platform/mac/shaders.metal +++ b/crates/gpui/src/platform/mac/shaders.metal @@ -18,6 +18,8 @@ float2 to_tile_position(float2 unit_vertex, AtlasTile tile, constant Size_DevicePixels *atlas_size); float4 distance_from_clip_rect(float2 unit_vertex, Bounds_ScaledPixels bounds, Bounds_ScaledPixels clip_bounds); +float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds_ScaledPixels bounds, + Bounds_ScaledPixels clip_bounds, TransformationMatrix transformation); float corner_dash_velocity(float dv1, float dv2); float dash_alpha(float t, float period, float length, float dash_velocity, float antialias_threshold); @@ -243,7 +245,15 @@ fragment float4 quad_fragment(QuadFragmentInput input [[stage_in]], // out on each straight line, rather than around the whole // perimeter. This way each line starts and ends with a dash. bool is_horizontal = corner_center_to_point.x < corner_center_to_point.y; - float border_width = is_horizontal ? border.x : border.y; + + // Choosing the right border width for dashed borders. + // TODO: A better solution exists taking a look at the whole file. + // this does not fix single dashed borders at the corners + float2 dashed_border = float2( + fmax(quad.border_widths.bottom, quad.border_widths.top), + fmax(quad.border_widths.right, quad.border_widths.left)); + + float border_width = is_horizontal ? dashed_border.x : dashed_border.y; dash_velocity = dv_numerator / border_width; t = is_horizontal ? point.x : point.y; t *= dash_velocity; @@ -599,13 +609,14 @@ struct MonochromeSpriteVertexOutput { float4 position [[position]]; float2 tile_position; float4 color [[flat]]; - float clip_distance [[clip_distance]][4]; + float4 clip_distance; }; struct MonochromeSpriteFragmentInput { float4 position [[position]]; float2 tile_position; float4 color [[flat]]; + float4 clip_distance; }; vertex MonochromeSpriteVertexOutput monochrome_sprite_vertex( @@ -620,8 +631,8 @@ vertex MonochromeSpriteVertexOutput monochrome_sprite_vertex( MonochromeSprite sprite = sprites[sprite_id]; float4 device_position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation, viewport_size); - float4 clip_distance = distance_from_clip_rect(unit_vertex, sprite.bounds, - sprite.content_mask.bounds); + float4 clip_distance = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, + sprite.content_mask.bounds, sprite.transformation); float2 tile_position = to_tile_position(unit_vertex, sprite.tile, atlas_size); float4 color = hsla_to_rgba(sprite.color); return MonochromeSpriteVertexOutput{ @@ -635,6 +646,10 @@ fragment float4 monochrome_sprite_fragment( MonochromeSpriteFragmentInput input [[stage_in]], constant MonochromeSprite *sprites [[buffer(SpriteInputIndex_Sprites)]], texture2d atlas_texture [[texture(SpriteInputIndex_AtlasTexture)]]) { + if (any(input.clip_distance < float4(0.0))) { + return float4(0.0); + } + constexpr sampler atlas_texture_sampler(mag_filter::linear, min_filter::linear); float4 sample = @@ -1096,6 +1111,23 @@ float4 distance_from_clip_rect(float2 unit_vertex, Bounds_ScaledPixels bounds, clip_bounds.origin.y + clip_bounds.size.height - position.y); } +float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds_ScaledPixels bounds, + Bounds_ScaledPixels clip_bounds, TransformationMatrix transformation) { + float2 position = + unit_vertex * float2(bounds.size.width, bounds.size.height) + + float2(bounds.origin.x, bounds.origin.y); + float2 transformed_position = float2(0, 0); + transformed_position[0] = position[0] * transformation.rotation_scale[0][0] + position[1] * transformation.rotation_scale[0][1]; + transformed_position[1] = position[0] * transformation.rotation_scale[1][0] + position[1] * transformation.rotation_scale[1][1]; + transformed_position[0] += transformation.translation[0]; + transformed_position[1] += transformation.translation[1]; + + return float4(transformed_position.x - clip_bounds.origin.x, + clip_bounds.origin.x + clip_bounds.size.width - transformed_position.x, + transformed_position.y - clip_bounds.origin.y, + clip_bounds.origin.y + clip_bounds.size.height - transformed_position.y); +} + float4 over(float4 below, float4 above) { float4 result; float alpha = above.a + below.a * (1.0 - above.a); diff --git a/crates/gpui/src/platform/mac/text_system.rs b/crates/gpui/src/platform/mac/text_system.rs index 505c665ded..3faf4e6491 100644 --- a/crates/gpui/src/platform/mac/text_system.rs +++ b/crates/gpui/src/platform/mac/text_system.rs @@ -43,7 +43,7 @@ use pathfinder_geometry::{ vector::{Vector2F, Vector2I}, }; use smallvec::SmallVec; -use std::{borrow::Cow, char, cmp, convert::TryFrom, sync::Arc}; +use std::{borrow::Cow, char, convert::TryFrom, sync::Arc}; use super::open_type::apply_features_and_fallbacks; @@ -430,27 +430,35 @@ impl MacTextSystemState { fn layout_line(&mut self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout { // Construct the attributed string, converting UTF8 ranges to UTF16 ranges. let mut string = CFMutableAttributedString::new(); + let mut max_ascent = 0.0f32; + let mut max_descent = 0.0f32; + { - string.replace_str(&CFString::new(text), CFRange::init(0, 0)); - let utf16_line_len = string.char_len() as usize; - - let mut ix_converter = StringIndexConverter::new(text); + let mut text = text; + let mut break_ligature = true; for run in font_runs { - let utf8_end = ix_converter.utf8_ix + run.len; - let utf16_start = ix_converter.utf16_ix; + let text_run; + (text_run, text) = text.split_at(run.len); - if utf16_start >= utf16_line_len { - break; - } + let utf16_start = string.char_len(); // insert at end of string + // note: replace_str may silently ignore codepoints it dislikes (e.g., BOM at start of string) + string.replace_str(&CFString::new(text_run), CFRange::init(utf16_start, 0)); + let utf16_end = string.char_len(); - ix_converter.advance_to_utf8_ix(utf8_end); - let utf16_end = cmp::min(ix_converter.utf16_ix, utf16_line_len); + let length = utf16_end - utf16_start; + let cf_range = CFRange::init(utf16_start, length); + let font = &self.fonts[run.font_id.0]; - let cf_range = - CFRange::init(utf16_start as isize, (utf16_end - utf16_start) as isize); - - let font: &FontKitFont = &self.fonts[run.font_id.0]; + let font_metrics = font.metrics(); + let font_scale = font_size.0 / font_metrics.units_per_em as f32; + max_ascent = max_ascent.max(font_metrics.ascent * font_scale); + max_descent = max_descent.max(-font_metrics.descent * font_scale); + let font_size = if break_ligature { + px(font_size.0.next_up()) + } else { + font_size + }; unsafe { string.set_attribute( cf_range, @@ -458,17 +466,13 @@ impl MacTextSystemState { &font.native_font().clone_with_font_size(font_size.into()), ); } - - if utf16_end == utf16_line_len { - break; - } + break_ligature = !break_ligature; } } - // Retrieve the glyphs from the shaped line, converting UTF16 offsets to UTF8 offsets. let line = CTLine::new_with_attributed_string(string.as_concrete_TypeRef()); let glyph_runs = line.glyph_runs(); - let mut runs = Vec::with_capacity(glyph_runs.len() as usize); + let mut runs = >::with_capacity(glyph_runs.len() as usize); let mut ix_converter = StringIndexConverter::new(text); for run in glyph_runs.into_iter() { let attributes = run.attributes().unwrap(); @@ -480,45 +484,54 @@ impl MacTextSystemState { }; let font_id = self.id_for_native_font(font); - let mut glyphs = Vec::with_capacity(run.glyph_count().try_into().unwrap_or(0)); - for ((glyph_id, position), glyph_utf16_ix) in run + let mut glyphs = match runs.last_mut() { + Some(run) if run.font_id == font_id => &mut run.glyphs, + _ => { + runs.push(ShapedRun { + font_id, + glyphs: Vec::with_capacity(run.glyph_count().try_into().unwrap_or(0)), + }); + &mut runs.last_mut().unwrap().glyphs + } + }; + for ((&glyph_id, position), &glyph_utf16_ix) in run .glyphs() .iter() .zip(run.positions().iter()) .zip(run.string_indices().iter()) { - let glyph_utf16_ix = usize::try_from(*glyph_utf16_ix).unwrap(); + let mut glyph_utf16_ix = usize::try_from(glyph_utf16_ix).unwrap(); if ix_converter.utf16_ix > glyph_utf16_ix { // We cannot reuse current index converter, as it can only seek forward. Restart the search. ix_converter = StringIndexConverter::new(text); } ix_converter.advance_to_utf16_ix(glyph_utf16_ix); glyphs.push(ShapedGlyph { - id: GlyphId(*glyph_id as u32), + id: GlyphId(glyph_id as u32), position: point(position.x as f32, position.y as f32).map(px), index: ix_converter.utf8_ix, is_emoji: self.is_emoji(font_id), }); } - - runs.push(ShapedRun { font_id, glyphs }); } let typographic_bounds = line.get_typographic_bounds(); LineLayout { runs, font_size, width: typographic_bounds.width.into(), - ascent: typographic_bounds.ascent.into(), - descent: typographic_bounds.descent.into(), + ascent: max_ascent.into(), + descent: max_descent.into(), len: text.len(), } } } -#[derive(Clone)] +#[derive(Debug, Clone)] struct StringIndexConverter<'a> { text: &'a str, + /// Index in UTF-8 bytes utf8_ix: usize, + /// Index in UTF-16 code units utf16_ix: usize, } @@ -531,17 +544,6 @@ impl<'a> StringIndexConverter<'a> { } } - fn advance_to_utf8_ix(&mut self, utf8_target: usize) { - for (ix, c) in self.text[self.utf8_ix..].char_indices() { - if self.utf8_ix + ix >= utf8_target { - self.utf8_ix += ix; - return; - } - self.utf16_ix += c.len_utf16(); - } - self.utf8_ix = self.text.len(); - } - fn advance_to_utf16_ix(&mut self, utf16_target: usize) { for (ix, c) in self.text[self.utf8_ix..].char_indices() { if self.utf16_ix >= utf16_target { @@ -699,5 +701,113 @@ mod tests { assert_eq!(layout.runs[0].glyphs[0].id, GlyphId(68u32)); // a // There's no glyph for \u{feff} assert_eq!(layout.runs[0].glyphs[1].id, GlyphId(69u32)); // b + + let line = "\u{feff}ab"; + let font_runs = &[ + FontRun { + len: "\u{feff}".len(), + font_id, + }, + FontRun { + len: "ab".len(), + font_id, + }, + ]; + let layout = fonts.layout_line(line, px(16.), font_runs); + assert_eq!(layout.len, line.len()); + assert_eq!(layout.runs.len(), 1); + assert_eq!(layout.runs[0].glyphs.len(), 2); + // There's no glyph for \u{feff} + assert_eq!(layout.runs[0].glyphs[0].id, GlyphId(68u32)); // a + assert_eq!(layout.runs[0].glyphs[1].id, GlyphId(69u32)); // b + } + + #[test] + fn test_layout_line_zwnj_insertion() { + let fonts = MacTextSystem::new(); + let font_id = fonts.font_id(&font("Helvetica")).unwrap(); + + let text = "hello world"; + let font_runs = &[ + FontRun { font_id, len: 5 }, // "hello" + FontRun { font_id, len: 6 }, // " world" + ]; + + let layout = fonts.layout_line(text, px(16.), font_runs); + assert_eq!(layout.len, text.len()); + + for run in &layout.runs { + for glyph in &run.glyphs { + assert!( + glyph.index < text.len(), + "Glyph index {} is out of bounds for text length {}", + glyph.index, + text.len() + ); + } + } + + // Test with different font runs - should not insert ZWNJ + let font_id2 = fonts.font_id(&font("Times")).unwrap_or(font_id); + let font_runs_different = &[ + FontRun { font_id, len: 5 }, // "hello" + // " world" + FontRun { + font_id: font_id2, + len: 6, + }, + ]; + + let layout2 = fonts.layout_line(text, px(16.), font_runs_different); + assert_eq!(layout2.len, text.len()); + + for run in &layout2.runs { + for glyph in &run.glyphs { + assert!( + glyph.index < text.len(), + "Glyph index {} is out of bounds for text length {}", + glyph.index, + text.len() + ); + } + } + } + + #[test] + fn test_layout_line_zwnj_edge_cases() { + let fonts = MacTextSystem::new(); + let font_id = fonts.font_id(&font("Helvetica")).unwrap(); + + let text = "hello"; + let font_runs = &[FontRun { font_id, len: 5 }]; + let layout = fonts.layout_line(text, px(16.), font_runs); + assert_eq!(layout.len, text.len()); + + let text = "abc"; + let font_runs = &[ + FontRun { font_id, len: 1 }, // "a" + FontRun { font_id, len: 1 }, // "b" + FontRun { font_id, len: 1 }, // "c" + ]; + let layout = fonts.layout_line(text, px(16.), font_runs); + assert_eq!(layout.len, text.len()); + + for run in &layout.runs { + for glyph in &run.glyphs { + assert!( + glyph.index < text.len(), + "Glyph index {} is out of bounds for text length {}", + glyph.index, + text.len() + ); + } + } + + // Test with empty text + let text = ""; + let font_runs = &[]; + let layout = fonts.layout_line(text, px(16.), font_runs); + assert_eq!(layout.len, 0); + assert!(layout.runs.is_empty()); } } diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 899ac4498b..19ad177757 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -153,6 +153,10 @@ unsafe fn build_classes() { sel!(mouseMoved:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(pressureChangeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(mouseExited:), handle_view_event as extern "C" fn(&Object, Sel, id), @@ -618,7 +622,7 @@ impl MacWindow { } let native_window: id = match kind { - WindowKind::Normal => msg_send![WINDOW_CLASS, alloc], + WindowKind::Normal | WindowKind::Floating => msg_send![WINDOW_CLASS, alloc], WindowKind::PopUp => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] @@ -776,12 +780,12 @@ impl MacWindow { native_window.makeFirstResponder_(native_view); match kind { - WindowKind::Normal => { + WindowKind::Normal | WindowKind::Floating => { native_window.setLevel_(NSNormalWindowLevel); native_window.setAcceptsMouseMovedEvents_(YES); if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -904,8 +908,8 @@ impl MacWindow { pub fn get_user_tabbing_preference() -> Option { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleWindowTabbingMode"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let value: id = if !dict.is_null() { @@ -1033,7 +1037,7 @@ impl PlatformWindow for MacWindow { } if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -1059,10 +1063,8 @@ impl PlatformWindow for MacWindow { return None; } let device_description: id = msg_send![screen, deviceDescription]; - let screen_number: id = NSDictionary::valueForKey_( - device_description, - NSString::alloc(nil).init_str("NSScreenNumber"), - ); + let screen_number: id = + NSDictionary::valueForKey_(device_description, ns_string("NSScreenNumber")); let screen_number: u32 = msg_send![screen_number, unsignedIntValue]; @@ -1505,8 +1507,8 @@ impl PlatformWindow for MacWindow { .spawn(async move { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleActionOnDoubleClick"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleActionOnDoubleClick"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let action: id = if !dict.is_null() { @@ -1543,6 +1545,17 @@ impl PlatformWindow for MacWindow { }) .detach(); } + + fn start_window_move(&self) { + let this = self.0.lock(); + let window = this.native_window; + + unsafe { + let app = NSApplication::sharedApplication(nil); + let mut event: id = msg_send![app, currentEvent]; + let _: () = msg_send![window, performWindowDragWithEvent: event]; + } + } } impl rwh::HasWindowHandle for MacWindow { @@ -1753,9 +1766,9 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: } } - // Don't send key equivalents to the input handler, - // or macOS shortcuts like cmd-` will stop working. - if key_equivalent { + // Don't send key equivalents to the input handler if there are key modifiers other + // than Function key, or macOS shortcuts like cmd-` will stop working. + if key_equivalent && key_down_event.keystroke.modifiers != Modifiers::function() { return NO; } @@ -1967,10 +1980,36 @@ extern "C" fn window_did_move(this: &Object, _: Sel, _: id) { } } +// Update the window scale factor and drawable size, and call the resize callback if any. +fn update_window_scale_factor(window_state: &Arc>) { + let mut lock = window_state.as_ref().lock(); + let scale_factor = lock.scale_factor(); + let size = lock.content_size(); + let drawable_size = size.to_device_pixels(scale_factor); + unsafe { + let _: () = msg_send![ + lock.renderer.layer(), + setContentsScale: scale_factor as f64 + ]; + } + + lock.renderer.update_drawable_size(drawable_size); + + if let Some(mut callback) = lock.resize_callback.take() { + let content_size = lock.content_size(); + let scale_factor = lock.scale_factor(); + drop(lock); + callback(content_size, scale_factor); + window_state.as_ref().lock().resize_callback = Some(callback); + }; +} + extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); lock.start_display_link(); + drop(lock); + update_window_scale_factor(&window_state); } extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) { @@ -2079,27 +2118,7 @@ extern "C" fn make_backing_layer(this: &Object, _: Sel) -> id { extern "C" fn view_did_change_backing_properties(this: &Object, _: Sel) { let window_state = unsafe { get_window_state(this) }; - let mut lock = window_state.as_ref().lock(); - - let scale_factor = lock.scale_factor(); - let size = lock.content_size(); - let drawable_size = size.to_device_pixels(scale_factor); - unsafe { - let _: () = msg_send![ - lock.renderer.layer(), - setContentsScale: scale_factor as f64 - ]; - } - - lock.renderer.update_drawable_size(drawable_size); - - if let Some(mut callback) = lock.resize_callback.take() { - let content_size = lock.content_size(); - let scale_factor = lock.scale_factor(); - drop(lock); - callback(content_size, scale_factor); - window_state.as_ref().lock().resize_callback = Some(callback); - }; + update_window_scale_factor(&window_state); } extern "C" fn set_frame_size(this: &Object, _: Sel, size: NSSize) { @@ -2318,6 +2337,7 @@ extern "C" fn do_command_by_selector(this: &Object, _: Sel, _: Sel) { let handled = (callback)(PlatformInput::KeyDown(KeyDownEvent { keystroke, is_held: false, + prefer_character_input: false, })); state.as_ref().lock().do_command_handled = Some(!handled.propagate); } @@ -2490,7 +2510,7 @@ where unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { unsafe { let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; screen_number as CGDirectDisplayID @@ -2536,7 +2556,7 @@ unsafe fn remove_layer_background(layer: id) { // `description` reflects its name and some parameters. Currently `NSVisualEffectView` // uses a `CAFilter` named "colorSaturate". If one day they switch to `CIFilter`, the // `description` will still contain "Saturat" ("... inputSaturation = ..."). - let test_string: id = NSString::alloc(nil).init_str("Saturat").autorelease(); + let test_string: id = ns_string("Saturat"); let count = NSArray::count(filters); for i in 0..count { let description: id = msg_send![filters.objectAtIndex(i), description]; diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index e19710effd..c271430586 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -1,8 +1,7 @@ -use crate::{PlatformDispatcher, TaskLabel}; -use async_task::Runnable; +use crate::{PlatformDispatcher, Priority, RunnableVariant, TaskLabel}; use backtrace::Backtrace; use collections::{HashMap, HashSet, VecDeque}; -use parking::{Parker, Unparker}; +use parking::Unparker; use parking_lot::Mutex; use rand::prelude::*; use std::{ @@ -22,16 +21,14 @@ struct TestDispatcherId(usize); pub struct TestDispatcher { id: TestDispatcherId, state: Arc>, - parker: Arc>, - unparker: Unparker, } struct TestDispatcherState { random: StdRng, - foreground: HashMap>, - background: Vec, - deprioritized_background: Vec, - delayed: Vec<(Duration, Runnable)>, + foreground: HashMap>, + background: Vec, + deprioritized_background: Vec, + delayed: Vec<(Duration, RunnableVariant)>, start_time: Instant, time: Duration, is_main_thread: bool, @@ -41,11 +38,11 @@ struct TestDispatcherState { waiting_backtrace: Option, deprioritized_task_labels: HashSet, block_on_ticks: RangeInclusive, + unparkers: Vec, } impl TestDispatcher { pub fn new(random: StdRng) -> Self { - let (parker, unparker) = parking::pair(); let state = TestDispatcherState { random, foreground: HashMap::default(), @@ -61,13 +58,12 @@ impl TestDispatcher { waiting_backtrace: None, deprioritized_task_labels: Default::default(), block_on_ticks: 0..=1000, + unparkers: Default::default(), }; TestDispatcher { id: TestDispatcherId(0), state: Arc::new(Mutex::new(state)), - parker: Arc::new(Mutex::new(parker)), - unparker, } } @@ -178,7 +174,13 @@ impl TestDispatcher { let was_main_thread = state.is_main_thread; state.is_main_thread = main_thread; drop(state); - runnable.run(); + + // todo(localcc): add timings to tests + match runnable { + RunnableVariant::Meta(runnable) => runnable.run(), + RunnableVariant::Compat(runnable) => runnable.run(), + }; + self.state.lock().is_main_thread = was_main_thread; true @@ -243,6 +245,15 @@ impl TestDispatcher { let block_on_ticks = lock.block_on_ticks.clone(); lock.random.random_range(block_on_ticks) } + + pub fn unpark_all(&self) { + self.state.lock().unparkers.retain(|parker| parker.unpark()); + } + + pub fn push_unparker(&self, unparker: Unparker) { + let mut state = self.state.lock(); + state.unparkers.push(unparker); + } } impl Clone for TestDispatcher { @@ -251,13 +262,19 @@ impl Clone for TestDispatcher { Self { id: TestDispatcherId(id), state: self.state.clone(), - parker: self.parker.clone(), - unparker: self.unparker.clone(), } } } impl PlatformDispatcher for TestDispatcher { + fn get_all_timings(&self) -> Vec { + Vec::new() + } + + fn get_current_thread_timings(&self) -> Vec { + Vec::new() + } + fn is_main_thread(&self) -> bool { self.state.lock().is_main_thread } @@ -267,7 +284,7 @@ impl PlatformDispatcher for TestDispatcher { state.start_time + state.time } - fn dispatch(&self, runnable: Runnable, label: Option) { + fn dispatch(&self, runnable: RunnableVariant, label: Option, _priority: Priority) { { let mut state = self.state.lock(); if label.is_some_and(|label| state.deprioritized_task_labels.contains(&label)) { @@ -276,20 +293,20 @@ impl PlatformDispatcher for TestDispatcher { state.background.push(runnable); } } - self.unparker.unpark(); + self.unpark_all(); } - fn dispatch_on_main_thread(&self, runnable: Runnable) { + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { self.state .lock() .foreground .entry(self.id) .or_default() .push_back(runnable); - self.unparker.unpark(); + self.unpark_all(); } - fn dispatch_after(&self, duration: std::time::Duration, runnable: Runnable) { + fn dispatch_after(&self, duration: std::time::Duration, runnable: RunnableVariant) { let mut state = self.state.lock(); let next_time = state.time + duration; let ix = match state.delayed.binary_search_by_key(&next_time, |e| e.0) { @@ -297,16 +314,14 @@ impl PlatformDispatcher for TestDispatcher { }; state.delayed.insert(ix, (next_time, runnable)); } - fn park(&self, _: Option) -> bool { - self.parker.lock().park(); - true - } - - fn unparker(&self) -> Unparker { - self.unparker.clone() - } fn as_test(&self) -> Option<&TestDispatcher> { Some(self) } + + fn spawn_realtime(&self, _priority: crate::RealtimePriority, f: Box) { + std::thread::spawn(move || { + f(); + }); + } } diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 15b909199f..dfada36466 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -36,6 +36,7 @@ pub(crate) struct TestPlatform { screen_capture_sources: RefCell>, pub opened_url: RefCell>, pub text_system: Arc, + pub expect_restart: RefCell>>>, #[cfg(target_os = "windows")] bitmap_factory: std::mem::ManuallyDrop, weak: Weak, @@ -112,6 +113,7 @@ impl TestPlatform { active_cursor: Default::default(), active_display: Rc::new(TestDisplay::new()), active_window: Default::default(), + expect_restart: Default::default(), current_clipboard_item: Mutex::new(None), #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex::new(None), @@ -250,8 +252,10 @@ impl Platform for TestPlatform { fn quit(&self) {} - fn restart(&self, _: Option) { - // + fn restart(&self, path: Option) { + if let Some(tx) = self.expect_restart.take() { + tx.send(path).unwrap(); + } } fn activate(&self, _ignoring_other_apps: bool) { diff --git a/crates/gpui/src/platform/windows/alpha_correction.hlsl b/crates/gpui/src/platform/windows/alpha_correction.hlsl index dc8d0b5dc5..b0a9ca2e6b 100644 --- a/crates/gpui/src/platform/windows/alpha_correction.hlsl +++ b/crates/gpui/src/platform/windows/alpha_correction.hlsl @@ -1,3 +1,7 @@ +// Adapted from https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.hlsl +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + float color_brightness(float3 color) { // REC. 601 luminance coefficients for perceived brightness return dot(color, float3(0.30f, 0.59f, 0.11f)); diff --git a/crates/gpui/src/platform/windows/clipboard.rs b/crates/gpui/src/platform/windows/clipboard.rs index 90d97a84c0..2a5e8dcbbe 100644 --- a/crates/gpui/src/platform/windows/clipboard.rs +++ b/crates/gpui/src/platform/windows/clipboard.rs @@ -1,7 +1,7 @@ use std::sync::LazyLock; use anyhow::Result; -use collections::{FxHashMap, FxHashSet}; +use collections::FxHashMap; use itertools::Itertools; use windows::Win32::{ Foundation::{HANDLE, HGLOBAL}, @@ -18,7 +18,9 @@ use windows::Win32::{ }; use windows_core::PCWSTR; -use crate::{ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, hash}; +use crate::{ + ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash, +}; // https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-dragqueryfilew const DRAGDROP_GET_FILES_COUNT: u32 = 0xFFFFFFFF; @@ -48,16 +50,6 @@ static FORMATS_MAP: LazyLock> = LazyLock::ne formats_map.insert(CF_HDROP.0 as u32, ClipboardFormatType::Files); formats_map }); -static FORMATS_SET: LazyLock> = LazyLock::new(|| { - let mut formats_map = FxHashSet::default(); - formats_map.insert(CF_UNICODETEXT.0 as u32); - formats_map.insert(*CLIPBOARD_PNG_FORMAT); - formats_map.insert(*CLIPBOARD_GIF_FORMAT); - formats_map.insert(*CLIPBOARD_JPG_FORMAT); - formats_map.insert(*CLIPBOARD_SVG_FORMAT); - formats_map.insert(CF_HDROP.0 as u32); - formats_map -}); static IMAGE_FORMATS_MAP: LazyLock> = LazyLock::new(|| { let mut formats_map = FxHashMap::default(); formats_map.insert(*CLIPBOARD_PNG_FORMAT, ImageFormat::Png); @@ -138,6 +130,11 @@ fn register_clipboard_format(format: PCWSTR) -> u32 { std::io::Error::last_os_error() ); } + log::debug!( + "Registered clipboard format {} as {}", + unsafe { format.display() }, + ret + ); ret } @@ -159,6 +156,7 @@ fn write_to_clipboard_inner(item: ClipboardItem) -> Result<()> { ClipboardEntry::Image(image) => { write_image_to_clipboard(image)?; } + ClipboardEntry::ExternalPaths(_) => {} }, None => { // Writing an empty list of entries just clears the clipboard. @@ -249,19 +247,33 @@ fn with_best_match_format(f: F) -> Option where F: Fn(u32) -> Option, { + let mut text = None; + let mut image = None; + let mut files = None; let count = unsafe { CountClipboardFormats() }; let mut clipboard_format = 0; for _ in 0..count { clipboard_format = unsafe { EnumClipboardFormats(clipboard_format) }; - let Some(item_format) = FORMATS_SET.get(&clipboard_format) else { + let Some(item_format) = FORMATS_MAP.get(&clipboard_format) else { continue; }; - if let Some(entry) = f(*item_format) { - return Some(ClipboardItem { - entries: vec![entry], - }); + let bucket = match item_format { + ClipboardFormatType::Text if text.is_none() => &mut text, + ClipboardFormatType::Image if image.is_none() => &mut image, + ClipboardFormatType::Files if files.is_none() => &mut files, + _ => continue, + }; + if let Some(entry) = f(clipboard_format) { + *bucket = Some(entry); } } + + if let Some(entry) = [image, files, text].into_iter().flatten().next() { + return Some(ClipboardItem { + entries: vec![entry], + }); + } + // log the formats that we don't support yet. { clipboard_format = 0; @@ -346,18 +358,17 @@ fn read_image_for_type(format_number: u32, format: ImageFormat) -> Option Option { - let text = with_clipboard_data(CF_HDROP.0 as u32, |data_ptr, _size| { + let filenames = with_clipboard_data(CF_HDROP.0 as u32, |data_ptr, _size| { let hdrop = HDROP(data_ptr); - let mut filenames = String::new(); + let mut filenames = Vec::new(); with_file_names(hdrop, |file_name| { - filenames.push_str(&file_name); + filenames.push(std::path::PathBuf::from(file_name)); }); filenames })?; - Some(ClipboardEntry::String(ClipboardString { - text, - metadata: None, - })) + Some(ClipboardEntry::ExternalPaths(ExternalPaths( + filenames.into(), + ))) } fn with_clipboard_data(format: u32, f: F) -> Option diff --git a/crates/gpui/src/platform/windows/destination_list.rs b/crates/gpui/src/platform/windows/destination_list.rs index fdfa52aaec..1bfc97d935 100644 --- a/crates/gpui/src/platform/windows/destination_list.rs +++ b/crates/gpui/src/platform/windows/destination_list.rs @@ -171,7 +171,9 @@ fn add_recent_folders( )?)?; } - list.AppendCategory(&HSTRING::from("Recent Folders"), &tasks)?; + if tasks.GetCount().unwrap_or(0) > 0 { + list.AppendCategory(&HSTRING::from("Recent Folders"), &tasks)?; + } Ok(()) } } diff --git a/crates/gpui/src/platform/windows/direct_write.rs b/crates/gpui/src/platform/windows/direct_write.rs index e187fc4b09..22b8e6231a 100644 --- a/crates/gpui/src/platform/windows/direct_write.rs +++ b/crates/gpui/src/platform/windows/direct_write.rs @@ -211,8 +211,8 @@ impl DirectWriteTextSystem { }))) } - pub(crate) fn handle_gpu_lost(&self, directx_devices: &DirectXDevices) { - self.0.write().handle_gpu_lost(directx_devices); + pub(crate) fn handle_gpu_lost(&self, directx_devices: &DirectXDevices) -> Result<()> { + self.0.write().handle_gpu_lost(directx_devices) } } @@ -231,7 +231,9 @@ impl PlatformTextSystem for DirectWriteTextSystem { Ok(*font_id) } else { let mut lock = RwLockUpgradableReadGuard::upgrade(lock); - let font_id = lock.select_font(font); + let font_id = lock + .select_font(font) + .with_context(|| format!("Failed to select font: {:?}", font))?; lock.font_selections.insert(font.clone(), font_id); Ok(font_id) } @@ -457,7 +459,7 @@ impl DirectWriteState { } } - fn select_font(&mut self, target_font: &Font) -> FontId { + fn select_font(&mut self, target_font: &Font) -> Option { unsafe { if target_font.family == ".SystemUIFont" { let family = self.system_ui_font_name.clone(); @@ -468,7 +470,6 @@ impl DirectWriteState { &target_font.features, target_font.fallbacks.as_ref(), ) - .unwrap() } else { let family = self.system_ui_font_name.clone(); self.find_font_id( @@ -478,7 +479,7 @@ impl DirectWriteState { &target_font.features, target_font.fallbacks.as_ref(), ) - .unwrap_or_else(|| { + .or_else(|| { #[cfg(any(test, feature = "test-support"))] { panic!("ERROR: {} font not found!", target_font.family); @@ -494,7 +495,6 @@ impl DirectWriteState { target_font.fallbacks.as_ref(), true, ) - .unwrap() } }) } @@ -608,6 +608,7 @@ impl DirectWriteState { let mut first_run = true; let mut ascent = Pixels::default(); let mut descent = Pixels::default(); + let mut break_ligatures = false; for run in font_runs { if first_run { first_run = false; @@ -616,6 +617,7 @@ impl DirectWriteState { text_layout.GetLineMetrics(Some(&mut metrics), &mut line_count as _)?; ascent = px(metrics[0].baseline); descent = px(metrics[0].height - metrics[0].baseline); + break_ligatures = !break_ligatures; continue; } let font_info = &self.fonts[run.font_id.0]; @@ -636,10 +638,17 @@ impl DirectWriteState { text_layout.SetFontCollection(collection, text_range)?; text_layout .SetFontFamilyName(&HSTRING::from(&font_info.font_family), text_range)?; - text_layout.SetFontSize(font_size.0, text_range)?; + let font_size = if break_ligatures { + font_size.0.next_up() + } else { + font_size.0 + }; + text_layout.SetFontSize(font_size, text_range)?; text_layout.SetFontStyle(font_info.font_face.GetStyle(), text_range)?; text_layout.SetFontWeight(font_info.font_face.GetWeight(), text_range)?; text_layout.SetTypography(&font_info.features, text_range)?; + + break_ligatures = !break_ligatures; } let mut runs = Vec::new(); @@ -1215,18 +1224,11 @@ impl DirectWriteState { result } - fn handle_gpu_lost(&mut self, directx_devices: &DirectXDevices) { - try_to_recover_from_device_lost( - || GPUState::new(directx_devices).context("Recreating GPU state for DirectWrite"), - |gpu_state| self.components.gpu_state = gpu_state, - || { - log::error!( - "Failed to recreate GPU state for DirectWrite after multiple attempts." - ); - // Do something here? - // At this point, the device loss is considered unrecoverable. - }, - ); + fn handle_gpu_lost(&mut self, directx_devices: &DirectXDevices) -> Result<()> { + try_to_recover_from_device_lost(|| { + GPUState::new(directx_devices).context("Recreating GPU state for DirectWrite") + }) + .map(|gpu_state| self.components.gpu_state = gpu_state) } } @@ -1479,8 +1481,10 @@ impl IDWriteTextRenderer_Impl for TextRenderer_Impl { .get(&font_identifier) { *id + } else if let Some(id) = context.text_system.select_font(&font_struct) { + id } else { - context.text_system.select_font(&font_struct) + return Err(Error::new(DWRITE_E_NOFONT, "Failed to select font")); }; let glyph_ids = unsafe { std::slice::from_raw_parts(glyphrun.glyphIndices, glyph_count) }; @@ -1511,7 +1515,7 @@ impl IDWriteTextRenderer_Impl for TextRenderer_Impl { id, position: point( px(context.width + glyph_offsets[this_glyph_idx].advanceOffset), - px(0.0), + px(-glyph_offsets[this_glyph_idx].ascenderOffset), ), index: context.index_converter.utf8_ix, is_emoji, diff --git a/crates/gpui/src/platform/windows/directx_atlas.rs b/crates/gpui/src/platform/windows/directx_atlas.rs index 38c22a41bf..9deae392d1 100644 --- a/crates/gpui/src/platform/windows/directx_atlas.rs +++ b/crates/gpui/src/platform/windows/directx_atlas.rs @@ -234,11 +234,14 @@ impl DirectXAtlasState { } fn texture(&self, id: AtlasTextureId) -> &DirectXAtlasTexture { - let textures = match id.kind { - crate::AtlasTextureKind::Monochrome => &self.monochrome_textures, - crate::AtlasTextureKind::Polychrome => &self.polychrome_textures, - }; - textures[id.index as usize].as_ref().unwrap() + match id.kind { + crate::AtlasTextureKind::Monochrome => &self.monochrome_textures[id.index as usize] + .as_ref() + .unwrap(), + crate::AtlasTextureKind::Polychrome => &self.polychrome_textures[id.index as usize] + .as_ref() + .unwrap(), + } } } diff --git a/crates/gpui/src/platform/windows/directx_devices.rs b/crates/gpui/src/platform/windows/directx_devices.rs index 4fa4db8274..980093719a 100644 --- a/crates/gpui/src/platform/windows/directx_devices.rs +++ b/crates/gpui/src/platform/windows/directx_devices.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use itertools::Itertools; use util::ResultExt; use windows::Win32::{ Foundation::HMODULE, @@ -14,29 +15,24 @@ use windows::Win32::{ }, Dxgi::{ CreateDXGIFactory2, DXGI_CREATE_FACTORY_DEBUG, DXGI_CREATE_FACTORY_FLAGS, - DXGI_GPU_PREFERENCE_MINIMUM_POWER, IDXGIAdapter1, IDXGIFactory6, + IDXGIAdapter1, IDXGIFactory6, }, }, }; +use windows::core::Interface; -pub(crate) fn try_to_recover_from_device_lost( - mut f: impl FnMut() -> Result, - on_success: impl FnOnce(T), - on_error: impl FnOnce(), -) { - let result = (0..5).find_map(|i| { - if i > 0 { - // Add a small delay before retrying - std::thread::sleep(std::time::Duration::from_millis(100)); - } - f().log_err() - }); - - if let Some(result) = result { - on_success(result); - } else { - on_error(); - } +pub(crate) fn try_to_recover_from_device_lost(mut f: impl FnMut() -> Result) -> Result { + (0..5) + .map(|i| { + if i > 0 { + // Add a small delay before retrying + std::thread::sleep(std::time::Duration::from_millis(100 + i * 10)); + } + f() + }) + .find_or_last(Result::is_ok) + .unwrap() + .context("DirectXRenderer failed to recover from lost device after multiple attempts") } #[derive(Clone)] @@ -121,10 +117,7 @@ fn get_dxgi_factory(debug_layer_available: bool) -> Result { #[inline] fn get_adapter(dxgi_factory: &IDXGIFactory6, debug_layer_available: bool) -> Result { for adapter_index in 0.. { - let adapter: IDXGIAdapter1 = unsafe { - dxgi_factory - .EnumAdapterByGpuPreference(adapter_index, DXGI_GPU_PREFERENCE_MINIMUM_POWER) - }?; + let adapter: IDXGIAdapter1 = unsafe { dxgi_factory.EnumAdapters(adapter_index)?.cast()? }; if let Ok(desc) = unsafe { adapter.GetDesc1() } { let gpu_name = String::from_utf16_lossy(&desc.Description) .trim_matches(char::from(0)) diff --git a/crates/gpui/src/platform/windows/directx_renderer.rs b/crates/gpui/src/platform/windows/directx_renderer.rs index 2baa237cda..608ac2c3b0 100644 --- a/crates/gpui/src/platform/windows/directx_renderer.rs +++ b/crates/gpui/src/platform/windows/directx_renderer.rs @@ -1,5 +1,5 @@ use std::{ - mem::ManuallyDrop, + slice, sync::{Arc, OnceLock}, }; @@ -39,12 +39,21 @@ pub(crate) struct FontInfo { pub(crate) struct DirectXRenderer { hwnd: HWND, atlas: Arc, - devices: ManuallyDrop, - resources: ManuallyDrop, + devices: Option, + resources: Option, globals: DirectXGlobalElements, pipelines: DirectXRenderPipelines, direct_composition: Option, font_info: &'static FontInfo, + + width: u32, + height: u32, + + /// Whether we want to skip drwaing due to device lost events. + /// + /// In that case we want to discard the first frame that we draw as we got reset in the middle of a frame + /// meaning we lost all the allocated gpu textures and scene resources. + skip_draws: bool, } /// Direct3D objects @@ -60,19 +69,17 @@ pub(crate) struct DirectXRendererDevices { struct DirectXResources { // Direct3D rendering objects swap_chain: IDXGISwapChain1, - render_target: ManuallyDrop, - render_target_view: [Option; 1], + render_target: Option, + render_target_view: Option, // Path intermediate textures (with MSAA) path_intermediate_texture: ID3D11Texture2D, - path_intermediate_srv: [Option; 1], + path_intermediate_srv: Option, path_intermediate_msaa_texture: ID3D11Texture2D, - path_intermediate_msaa_view: [Option; 1], + path_intermediate_msaa_view: Option, - // Cached window size and viewport - width: u32, - height: u32, - viewport: [D3D11_VIEWPORT; 1], + // Cached viewport + viewport: D3D11_VIEWPORT, } struct DirectXRenderPipelines { @@ -86,8 +93,8 @@ struct DirectXRenderPipelines { } struct DirectXGlobalElements { - global_params_buffer: [Option; 1], - sampler: [Option; 1], + global_params_buffer: Option, + sampler: Option, } struct DirectComposition { @@ -100,7 +107,7 @@ impl DirectXRendererDevices { pub(crate) fn new( directx_devices: &DirectXDevices, disable_direct_composition: bool, - ) -> Result> { + ) -> Result { let DirectXDevices { adapter, dxgi_factory, @@ -113,13 +120,13 @@ impl DirectXRendererDevices { Some(device.cast().context("Creating DXGI device")?) }; - Ok(ManuallyDrop::new(Self { + Ok(Self { adapter: adapter.clone(), dxgi_factory: dxgi_factory.clone(), device: device.clone(), device_context: device_context.clone(), dxgi_device, - })) + }) } } @@ -158,12 +165,15 @@ impl DirectXRenderer { Ok(DirectXRenderer { hwnd, atlas, - devices, - resources, + devices: Some(devices), + resources: Some(resources), globals, pipelines, direct_composition, font_info: Self::get_font_info(), + width: 1, + height: 1, + skip_draws: false, }) } @@ -172,55 +182,54 @@ impl DirectXRenderer { } fn pre_draw(&self) -> Result<()> { + let resources = self.resources.as_ref().expect("resources missing"); + let device_context = &self + .devices + .as_ref() + .expect("devices missing") + .device_context; update_buffer( - &self.devices.device_context, - self.globals.global_params_buffer[0].as_ref().unwrap(), + device_context, + self.globals.global_params_buffer.as_ref().unwrap(), &[GlobalParams { gamma_ratios: self.font_info.gamma_ratios, - viewport_size: [ - self.resources.viewport[0].Width, - self.resources.viewport[0].Height, - ], + viewport_size: [resources.viewport.Width, resources.viewport.Height], grayscale_enhanced_contrast: self.font_info.grayscale_enhanced_contrast, _pad: 0, }], )?; unsafe { - self.devices.device_context.ClearRenderTargetView( - self.resources.render_target_view[0].as_ref().unwrap(), + device_context.ClearRenderTargetView( + resources + .render_target_view + .as_ref() + .context("missing render target view")?, &[0.0; 4], ); - self.devices - .device_context - .OMSetRenderTargets(Some(&self.resources.render_target_view), None); - self.devices - .device_context - .RSSetViewports(Some(&self.resources.viewport)); + device_context + .OMSetRenderTargets(Some(slice::from_ref(&resources.render_target_view)), None); + device_context.RSSetViewports(Some(slice::from_ref(&resources.viewport))); } Ok(()) } #[inline] fn present(&mut self) -> Result<()> { - let result = unsafe { self.resources.swap_chain.Present(0, DXGI_PRESENT(0)) }; + let result = unsafe { + self.resources + .as_ref() + .expect("resources missing") + .swap_chain + .Present(0, DXGI_PRESENT(0)) + }; result.ok().context("Presenting swap chain failed") } - pub(crate) fn handle_device_lost(&mut self, directx_devices: &DirectXDevices) { - try_to_recover_from_device_lost( - || { - self.handle_device_lost_impl(directx_devices) - .context("DirectXRenderer handling device lost") - }, - |_| {}, - || { - log::error!( - "DirectXRenderer failed to recover from device lost after multiple attempts" - ); - // Do something here? - // At this point, the device loss is considered unrecoverable. - }, - ); + pub(crate) fn handle_device_lost(&mut self, directx_devices: &DirectXDevices) -> Result<()> { + try_to_recover_from_device_lost(|| { + self.handle_device_lost_impl(directx_devices) + .context("DirectXRenderer handling device lost") + }) } fn handle_device_lost_impl(&mut self, directx_devices: &DirectXDevices) -> Result<()> { @@ -228,35 +237,41 @@ impl DirectXRenderer { unsafe { #[cfg(debug_assertions)] - report_live_objects(&self.devices.device) - .context("Failed to report live objects after device lost") - .log_err(); + if let Some(devices) = &self.devices { + report_live_objects(&devices.device) + .context("Failed to report live objects after device lost") + .log_err(); + } - ManuallyDrop::drop(&mut self.resources); - self.devices.device_context.OMSetRenderTargets(None, None); - self.devices.device_context.ClearState(); - self.devices.device_context.Flush(); + self.resources.take(); + if let Some(devices) = &self.devices { + devices.device_context.OMSetRenderTargets(None, None); + devices.device_context.ClearState(); + devices.device_context.Flush(); + #[cfg(debug_assertions)] + report_live_objects(&devices.device) + .context("Failed to report live objects after device lost") + .log_err(); + } - #[cfg(debug_assertions)] - report_live_objects(&self.devices.device) - .context("Failed to report live objects after device lost") - .log_err(); - - drop(self.direct_composition.take()); - ManuallyDrop::drop(&mut self.devices); + self.direct_composition.take(); + self.devices.take(); } let devices = DirectXRendererDevices::new(directx_devices, disable_direct_composition) .context("Recreating DirectX devices")?; let resources = DirectXResources::new( &devices, - self.resources.width, - self.resources.height, + self.width, + self.height, self.hwnd, disable_direct_composition, - )?; - let globals = DirectXGlobalElements::new(&devices.device)?; - let pipelines = DirectXRenderPipelines::new(&devices.device)?; + ) + .context("Creating DirectX resources")?; + let globals = DirectXGlobalElements::new(&devices.device) + .context("Creating DirectXGlobalElements")?; + let pipelines = DirectXRenderPipelines::new(&devices.device) + .context("Creating DirectXRenderPipelines")?; let direct_composition = if disable_direct_composition { None @@ -269,21 +284,27 @@ impl DirectXRenderer { self.atlas .handle_device_lost(&devices.device, &devices.device_context); - self.devices = devices; - self.resources = resources; + + unsafe { + devices + .device_context + .OMSetRenderTargets(Some(slice::from_ref(&resources.render_target_view)), None); + } + self.devices = Some(devices); + self.resources = Some(resources); self.globals = globals; self.pipelines = pipelines; self.direct_composition = direct_composition; - - unsafe { - self.devices - .device_context - .OMSetRenderTargets(Some(&self.resources.render_target_view), None); - } + self.skip_draws = true; Ok(()) } pub(crate) fn draw(&mut self, scene: &Scene) -> Result<()> { + if self.skip_draws { + // skip drawing this frame, we just recovered from a device lost event + // and so likely do not have the textures anymore that are required for drawing + return Ok(()); + } self.pre_draw()?; for batch in scene.batches() { match batch { @@ -303,14 +324,18 @@ impl DirectXRenderer { sprites, } => self.draw_polychrome_sprites(texture_id, sprites), PrimitiveBatch::Surfaces(surfaces) => self.draw_surfaces(surfaces), - }.context(format!("scene too large: {} paths, {} shadows, {} quads, {} underlines, {} mono, {} poly, {} surfaces", - scene.paths.len(), - scene.shadows.len(), - scene.quads.len(), - scene.underlines.len(), - scene.monochrome_sprites.len(), - scene.polychrome_sprites.len(), - scene.surfaces.len(),))?; + } + .context(format!( + "scene too large:\ + {} paths, {} shadows, {} quads, {} underlines, {} mono, {} poly, {} surfaces", + scene.paths.len(), + scene.shadows.len(), + scene.quads.len(), + scene.underlines.len(), + scene.monochrome_sprites.len(), + scene.polychrome_sprites.len(), + scene.surfaces.len(), + ))?; } self.present() } @@ -318,23 +343,25 @@ impl DirectXRenderer { pub(crate) fn resize(&mut self, new_size: Size) -> Result<()> { let width = new_size.width.0.max(1) as u32; let height = new_size.height.0.max(1) as u32; - if self.resources.width == width && self.resources.height == height { + if self.width == width && self.height == height { return Ok(()); } - self.resources.width = width; - self.resources.height = height; + self.width = width; + self.height = height; // Clear the render target before resizing - unsafe { self.devices.device_context.OMSetRenderTargets(None, None) }; - unsafe { ManuallyDrop::drop(&mut self.resources.render_target) }; - drop(self.resources.render_target_view[0].take().unwrap()); + let devices = self.devices.as_ref().context("devices missing")?; + unsafe { devices.device_context.OMSetRenderTargets(None, None) }; + let resources = self.resources.as_mut().context("resources missing")?; + resources.render_target.take(); + resources.render_target_view.take(); // Resizing the swap chain requires a call to the underlying DXGI adapter, which can return the device removed error. // The app might have moved to a monitor that's attached to a different graphics device. // When a graphics device is removed or reset, the desktop resolution often changes, resulting in a window size change. // But here we just return the error, because we are handling device lost scenarios elsewhere. unsafe { - self.resources + resources .swap_chain .ResizeBuffers( BUFFER_COUNT as u32, @@ -346,12 +373,12 @@ impl DirectXRenderer { .context("Failed to resize swap chain")?; } - self.resources - .recreate_resources(&self.devices, width, height)?; + resources.recreate_resources(devices, width, height)?; + unsafe { - self.devices + devices .device_context - .OMSetRenderTargets(Some(&self.resources.render_target_view), None); + .OMSetRenderTargets(Some(slice::from_ref(&resources.render_target_view)), None); } Ok(()) @@ -361,15 +388,22 @@ impl DirectXRenderer { if shadows.is_empty() { return Ok(()); } + let devices = self.devices.as_ref().context("devices missing")?; self.pipelines.shadow_pipeline.update_buffer( - &self.devices.device, - &self.devices.device_context, + &devices.device, + &devices.device_context, shadows, )?; self.pipelines.shadow_pipeline.draw( - &self.devices.device_context, - &self.resources.viewport, - &self.globals.global_params_buffer, + &devices.device_context, + slice::from_ref( + &self + .resources + .as_ref() + .context("resources missing")? + .viewport, + ), + slice::from_ref(&self.globals.global_params_buffer), D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, 4, shadows.len() as u32, @@ -380,15 +414,22 @@ impl DirectXRenderer { if quads.is_empty() { return Ok(()); } + let devices = self.devices.as_ref().context("devices missing")?; self.pipelines.quad_pipeline.update_buffer( - &self.devices.device, - &self.devices.device_context, + &devices.device, + &devices.device_context, quads, )?; self.pipelines.quad_pipeline.draw( - &self.devices.device_context, - &self.resources.viewport, - &self.globals.global_params_buffer, + &devices.device_context, + slice::from_ref( + &self + .resources + .as_ref() + .context("resources missing")? + .viewport, + ), + slice::from_ref(&self.globals.global_params_buffer), D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, 4, quads.len() as u32, @@ -400,18 +441,19 @@ impl DirectXRenderer { return Ok(()); } + let devices = self.devices.as_ref().context("devices missing")?; + let resources = self.resources.as_ref().context("resources missing")?; // Clear intermediate MSAA texture unsafe { - self.devices.device_context.ClearRenderTargetView( - self.resources.path_intermediate_msaa_view[0] - .as_ref() - .unwrap(), + devices.device_context.ClearRenderTargetView( + resources.path_intermediate_msaa_view.as_ref().unwrap(), &[0.0; 4], ); // Set intermediate MSAA texture as render target - self.devices - .device_context - .OMSetRenderTargets(Some(&self.resources.path_intermediate_msaa_view), None); + devices.device_context.OMSetRenderTargets( + Some(slice::from_ref(&resources.path_intermediate_msaa_view)), + None, + ); } // Collect all vertices and sprites for a single draw call @@ -427,14 +469,15 @@ impl DirectXRenderer { } self.pipelines.path_rasterization_pipeline.update_buffer( - &self.devices.device, - &self.devices.device_context, + &devices.device, + &devices.device_context, &vertices, )?; + self.pipelines.path_rasterization_pipeline.draw( - &self.devices.device_context, - &self.resources.viewport, - &self.globals.global_params_buffer, + &devices.device_context, + slice::from_ref(&resources.viewport), + slice::from_ref(&self.globals.global_params_buffer), D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, vertices.len() as u32, 1, @@ -442,17 +485,17 @@ impl DirectXRenderer { // Resolve MSAA to non-MSAA intermediate texture unsafe { - self.devices.device_context.ResolveSubresource( - &self.resources.path_intermediate_texture, + devices.device_context.ResolveSubresource( + &resources.path_intermediate_texture, 0, - &self.resources.path_intermediate_msaa_texture, + &resources.path_intermediate_msaa_texture, 0, RENDER_TARGET_FORMAT, ); // Restore main render target - self.devices + devices .device_context - .OMSetRenderTargets(Some(&self.resources.render_target_view), None); + .OMSetRenderTargets(Some(slice::from_ref(&resources.render_target_view)), None); } Ok(()) @@ -485,19 +528,21 @@ impl DirectXRenderer { vec![PathSprite { bounds }] }; + let devices = self.devices.as_ref().context("devices missing")?; + let resources = self.resources.as_ref().context("resources missing")?; self.pipelines.path_sprite_pipeline.update_buffer( - &self.devices.device, - &self.devices.device_context, + &devices.device, + &devices.device_context, &sprites, )?; // Draw the sprites with the path texture self.pipelines.path_sprite_pipeline.draw_with_texture( - &self.devices.device_context, - &self.resources.path_intermediate_srv, - &self.resources.viewport, - &self.globals.global_params_buffer, - &self.globals.sampler, + &devices.device_context, + slice::from_ref(&resources.path_intermediate_srv), + slice::from_ref(&resources.viewport), + slice::from_ref(&self.globals.global_params_buffer), + slice::from_ref(&self.globals.sampler), sprites.len() as u32, ) } @@ -506,15 +551,17 @@ impl DirectXRenderer { if underlines.is_empty() { return Ok(()); } + let devices = self.devices.as_ref().context("devices missing")?; + let resources = self.resources.as_ref().context("resources missing")?; self.pipelines.underline_pipeline.update_buffer( - &self.devices.device, - &self.devices.device_context, + &devices.device, + &devices.device_context, underlines, )?; self.pipelines.underline_pipeline.draw( - &self.devices.device_context, - &self.resources.viewport, - &self.globals.global_params_buffer, + &devices.device_context, + slice::from_ref(&resources.viewport), + slice::from_ref(&self.globals.global_params_buffer), D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, 4, underlines.len() as u32, @@ -529,18 +576,20 @@ impl DirectXRenderer { if sprites.is_empty() { return Ok(()); } + let devices = self.devices.as_ref().context("devices missing")?; + let resources = self.resources.as_ref().context("resources missing")?; self.pipelines.mono_sprites.update_buffer( - &self.devices.device, - &self.devices.device_context, + &devices.device, + &devices.device_context, sprites, )?; let texture_view = self.atlas.get_texture_view(texture_id); self.pipelines.mono_sprites.draw_with_texture( - &self.devices.device_context, + &devices.device_context, &texture_view, - &self.resources.viewport, - &self.globals.global_params_buffer, - &self.globals.sampler, + slice::from_ref(&resources.viewport), + slice::from_ref(&self.globals.global_params_buffer), + slice::from_ref(&self.globals.sampler), sprites.len() as u32, ) } @@ -553,18 +602,21 @@ impl DirectXRenderer { if sprites.is_empty() { return Ok(()); } + + let devices = self.devices.as_ref().context("devices missing")?; + let resources = self.resources.as_ref().context("resources missing")?; self.pipelines.poly_sprites.update_buffer( - &self.devices.device, - &self.devices.device_context, + &devices.device, + &devices.device_context, sprites, )?; let texture_view = self.atlas.get_texture_view(texture_id); self.pipelines.poly_sprites.draw_with_texture( - &self.devices.device_context, + &devices.device_context, &texture_view, - &self.resources.viewport, - &self.globals.global_params_buffer, - &self.globals.sampler, + slice::from_ref(&resources.viewport), + slice::from_ref(&self.globals.global_params_buffer), + slice::from_ref(&self.globals.sampler), sprites.len() as u32, ) } @@ -577,7 +629,8 @@ impl DirectXRenderer { } pub(crate) fn gpu_specs(&self) -> Result { - let desc = unsafe { self.devices.adapter.GetDesc1() }?; + let devices = self.devices.as_ref().context("devices missing")?; + let desc = unsafe { devices.adapter.GetDesc1() }?; let is_software_emulated = (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32) != 0; let device_name = String::from_utf16_lossy(&desc.Description) .trim_matches(char::from(0)) @@ -592,7 +645,7 @@ impl DirectXRenderer { 0x10DE => nvidia::get_driver_version(), 0x1002 => amd::get_driver_version(), // For Intel and other vendors, we use the DXGI API to get the driver version. - _ => dxgi::get_driver_version(&self.devices.adapter), + _ => dxgi::get_driver_version(&devices.adapter), } .context("Failed to get gpu driver info") .log_err() @@ -612,43 +665,14 @@ impl DirectXRenderer { let render_params: IDWriteRenderingParams1 = factory.CreateRenderingParams().unwrap().cast().unwrap(); FontInfo { - gamma_ratios: Self::get_gamma_ratios(render_params.GetGamma()), + gamma_ratios: get_gamma_correction_ratios(render_params.GetGamma()), grayscale_enhanced_contrast: render_params.GetGrayscaleEnhancedContrast(), } }) } - // Gamma ratios for brightening/darkening edges for better contrast - // https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.cpp#L50 - fn get_gamma_ratios(gamma: f32) -> [f32; 4] { - const GAMMA_INCORRECT_TARGET_RATIOS: [[f32; 4]; 13] = [ - [0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0], // gamma = 1.0 - [0.0166 / 4.0, -0.0807 / 4.0, 0.2227 / 4.0, -0.0751 / 4.0], // gamma = 1.1 - [0.0350 / 4.0, -0.1760 / 4.0, 0.4325 / 4.0, -0.1370 / 4.0], // gamma = 1.2 - [0.0543 / 4.0, -0.2821 / 4.0, 0.6302 / 4.0, -0.1876 / 4.0], // gamma = 1.3 - [0.0739 / 4.0, -0.3963 / 4.0, 0.8167 / 4.0, -0.2287 / 4.0], // gamma = 1.4 - [0.0933 / 4.0, -0.5161 / 4.0, 0.9926 / 4.0, -0.2616 / 4.0], // gamma = 1.5 - [0.1121 / 4.0, -0.6395 / 4.0, 1.1588 / 4.0, -0.2877 / 4.0], // gamma = 1.6 - [0.1300 / 4.0, -0.7649 / 4.0, 1.3159 / 4.0, -0.3080 / 4.0], // gamma = 1.7 - [0.1469 / 4.0, -0.8911 / 4.0, 1.4644 / 4.0, -0.3234 / 4.0], // gamma = 1.8 - [0.1627 / 4.0, -1.0170 / 4.0, 1.6051 / 4.0, -0.3347 / 4.0], // gamma = 1.9 - [0.1773 / 4.0, -1.1420 / 4.0, 1.7385 / 4.0, -0.3426 / 4.0], // gamma = 2.0 - [0.1908 / 4.0, -1.2652 / 4.0, 1.8650 / 4.0, -0.3476 / 4.0], // gamma = 2.1 - [0.2031 / 4.0, -1.3864 / 4.0, 1.9851 / 4.0, -0.3501 / 4.0], // gamma = 2.2 - ]; - - const NORM13: f32 = ((0x10000 as f64) / (255.0 * 255.0) * 4.0) as f32; - const NORM24: f32 = ((0x100 as f64) / (255.0) * 4.0) as f32; - - let index = ((gamma * 10.0).round() as usize).clamp(10, 22) - 10; - let ratios = GAMMA_INCORRECT_TARGET_RATIOS[index]; - - [ - ratios[0] * NORM13, - ratios[1] * NORM24, - ratios[2] * NORM13, - ratios[3] * NORM24, - ] + pub(crate) fn mark_drawable(&mut self) { + self.skip_draws = false; } } @@ -659,7 +683,7 @@ impl DirectXResources { height: u32, hwnd: HWND, disable_direct_composition: bool, - ) -> Result> { + ) -> Result { let swap_chain = if disable_direct_composition { create_swap_chain(&devices.dxgi_factory, &devices.device, hwnd, width, height)? } else { @@ -682,18 +706,16 @@ impl DirectXResources { ) = create_resources(devices, &swap_chain, width, height)?; set_rasterizer_state(&devices.device, &devices.device_context)?; - Ok(ManuallyDrop::new(Self { + Ok(Self { swap_chain, - render_target, + render_target: Some(render_target), render_target_view, path_intermediate_texture, path_intermediate_msaa_texture, path_intermediate_msaa_view, path_intermediate_srv, viewport, - width, - height, - })) + }) } #[inline] @@ -712,7 +734,7 @@ impl DirectXResources { path_intermediate_msaa_view, viewport, ) = create_resources(devices, &self.swap_chain, width, height)?; - self.render_target = render_target; + self.render_target = Some(render_target); self.render_target_view = render_target_view; self.path_intermediate_texture = path_intermediate_texture; self.path_intermediate_msaa_texture = path_intermediate_msaa_texture; @@ -822,7 +844,7 @@ impl DirectXGlobalElements { }; let mut buffer = None; device.CreateBuffer(&desc, None, Some(&mut buffer))?; - [buffer] + buffer }; let sampler = unsafe { @@ -840,7 +862,7 @@ impl DirectXGlobalElements { }; let mut output = None; device.CreateSamplerState(&desc, Some(&mut output))?; - [output] + output }; Ok(Self { @@ -865,7 +887,7 @@ struct PipelineState { fragment: ID3D11PixelShader, buffer: ID3D11Buffer, buffer_size: usize, - view: [Option; 1], + view: Option, blend_state: ID3D11BlendState, _marker: std::marker::PhantomData, } @@ -935,7 +957,7 @@ impl PipelineState { ) -> Result<()> { set_pipeline_state( device_context, - &self.view, + slice::from_ref(&self.view), topology, viewport, &self.vertex, @@ -960,7 +982,7 @@ impl PipelineState { ) -> Result<()> { set_pipeline_state( device_context, - &self.view, + slice::from_ref(&self.view), D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, viewport, &self.vertex, @@ -997,18 +1019,8 @@ struct PathSprite { impl Drop for DirectXRenderer { fn drop(&mut self) { #[cfg(debug_assertions)] - report_live_objects(&self.devices.device).ok(); - unsafe { - ManuallyDrop::drop(&mut self.devices); - ManuallyDrop::drop(&mut self.resources); - } - } -} - -impl Drop for DirectXResources { - fn drop(&mut self) { - unsafe { - ManuallyDrop::drop(&mut self.render_target); + if let Some(devices) = &self.devices { + report_live_objects(&devices.device).ok(); } } } @@ -1082,13 +1094,13 @@ fn create_resources( width: u32, height: u32, ) -> Result<( - ManuallyDrop, - [Option; 1], ID3D11Texture2D, - [Option; 1], + Option, ID3D11Texture2D, - [Option; 1], - [D3D11_VIEWPORT; 1], + Option, + ID3D11Texture2D, + Option, + D3D11_VIEWPORT, )> { let (render_target, render_target_view) = create_render_target_and_its_view(swap_chain, &devices.device)?; @@ -1112,17 +1124,11 @@ fn create_resources( fn create_render_target_and_its_view( swap_chain: &IDXGISwapChain1, device: &ID3D11Device, -) -> Result<( - ManuallyDrop, - [Option; 1], -)> { +) -> Result<(ID3D11Texture2D, Option)> { let render_target: ID3D11Texture2D = unsafe { swap_chain.GetBuffer(0) }?; let mut render_target_view = None; unsafe { device.CreateRenderTargetView(&render_target, None, Some(&mut render_target_view))? }; - Ok(( - ManuallyDrop::new(render_target), - [Some(render_target_view.unwrap())], - )) + Ok((render_target, render_target_view)) } #[inline] @@ -1130,7 +1136,7 @@ fn create_path_intermediate_texture( device: &ID3D11Device, width: u32, height: u32, -) -> Result<(ID3D11Texture2D, [Option; 1])> { +) -> Result<(ID3D11Texture2D, Option)> { let texture = unsafe { let mut output = None; let desc = D3D11_TEXTURE2D_DESC { @@ -1155,7 +1161,7 @@ fn create_path_intermediate_texture( let mut shader_resource_view = None; unsafe { device.CreateShaderResourceView(&texture, None, Some(&mut shader_resource_view))? }; - Ok((texture, [Some(shader_resource_view.unwrap())])) + Ok((texture, Some(shader_resource_view.unwrap()))) } #[inline] @@ -1163,7 +1169,7 @@ fn create_path_intermediate_msaa_texture_and_view( device: &ID3D11Device, width: u32, height: u32, -) -> Result<(ID3D11Texture2D, [Option; 1])> { +) -> Result<(ID3D11Texture2D, Option)> { let msaa_texture = unsafe { let mut output = None; let desc = D3D11_TEXTURE2D_DESC { @@ -1186,15 +1192,11 @@ fn create_path_intermediate_msaa_texture_and_view( }; let mut msaa_view = None; unsafe { device.CreateRenderTargetView(&msaa_texture, None, Some(&mut msaa_view))? }; - Ok((msaa_texture, [Some(msaa_view.unwrap())])) + Ok((msaa_texture, Some(msaa_view.unwrap()))) } #[inline] -fn set_viewport( - device_context: &ID3D11DeviceContext, - width: f32, - height: f32, -) -> [D3D11_VIEWPORT; 1] { +fn set_viewport(device_context: &ID3D11DeviceContext, width: f32, height: f32) -> D3D11_VIEWPORT { let viewport = [D3D11_VIEWPORT { TopLeftX: 0.0, TopLeftY: 0.0, @@ -1204,7 +1206,7 @@ fn set_viewport( MaxDepth: 1.0, }]; unsafe { device_context.RSSetViewports(Some(&viewport)) }; - viewport + viewport[0] } #[inline] @@ -1332,10 +1334,10 @@ fn create_buffer( fn create_buffer_view( device: &ID3D11Device, buffer: &ID3D11Buffer, -) -> Result<[Option; 1]> { +) -> Result> { let mut view = None; unsafe { device.CreateShaderResourceView(buffer, None, Some(&mut view)) }?; - Ok([view]) + Ok(view) } #[inline] diff --git a/crates/gpui/src/platform/windows/dispatcher.rs b/crates/gpui/src/platform/windows/dispatcher.rs index 3707a69047..0720d414c9 100644 --- a/crates/gpui/src/platform/windows/dispatcher.rs +++ b/crates/gpui/src/platform/windows/dispatcher.rs @@ -1,12 +1,10 @@ use std::{ + sync::atomic::{AtomicBool, Ordering}, thread::{ThreadId, current}, - time::Duration, + time::{Duration, Instant}, }; -use async_task::Runnable; -use flume::Sender; -use parking::Parker; -use parking_lot::Mutex; +use anyhow::Context; use util::ResultExt; use windows::{ System::Threading::{ @@ -14,87 +12,161 @@ use windows::{ }, Win32::{ Foundation::{LPARAM, WPARAM}, + System::Threading::{ + GetCurrentThread, HIGH_PRIORITY_CLASS, SetPriorityClass, SetThreadPriority, + THREAD_PRIORITY_HIGHEST, THREAD_PRIORITY_TIME_CRITICAL, + }, UI::WindowsAndMessaging::PostMessageW, }, }; use crate::{ - HWND, PlatformDispatcher, SafeHwnd, TaskLabel, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, + GLOBAL_THREAD_TIMINGS, HWND, PlatformDispatcher, Priority, PriorityQueueSender, + RealtimePriority, RunnableVariant, SafeHwnd, THREAD_TIMINGS, TaskLabel, TaskTiming, + ThreadTaskTimings, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, profiler, }; pub(crate) struct WindowsDispatcher { - main_sender: Sender, - parker: Mutex, + pub(crate) wake_posted: AtomicBool, + main_sender: PriorityQueueSender, main_thread_id: ThreadId, - platform_window_handle: SafeHwnd, + pub(crate) platform_window_handle: SafeHwnd, validation_number: usize, } impl WindowsDispatcher { pub(crate) fn new( - main_sender: Sender, + main_sender: PriorityQueueSender, platform_window_handle: HWND, validation_number: usize, ) -> Self { - let parker = Mutex::new(Parker::new()); let main_thread_id = current().id(); let platform_window_handle = platform_window_handle.into(); WindowsDispatcher { main_sender, - parker, main_thread_id, platform_window_handle, validation_number, + wake_posted: AtomicBool::new(false), } } - fn dispatch_on_threadpool(&self, runnable: Runnable) { + fn dispatch_on_threadpool(&self, priority: WorkItemPriority, runnable: RunnableVariant) { let handler = { let mut task_wrapper = Some(runnable); WorkItemHandler::new(move |_| { - task_wrapper.take().unwrap().run(); + Self::execute_runnable(task_wrapper.take().unwrap()); Ok(()) }) }; - ThreadPool::RunWithPriorityAsync(&handler, WorkItemPriority::High).log_err(); + + ThreadPool::RunWithPriorityAsync(&handler, priority).log_err(); } - fn dispatch_on_threadpool_after(&self, runnable: Runnable, duration: Duration) { + fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) { let handler = { let mut task_wrapper = Some(runnable); TimerElapsedHandler::new(move |_| { - task_wrapper.take().unwrap().run(); + Self::execute_runnable(task_wrapper.take().unwrap()); Ok(()) }) }; ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err(); } + + #[inline(always)] + pub(crate) fn execute_runnable(runnable: RunnableVariant) { + let start = Instant::now(); + + let mut timing = match runnable { + RunnableVariant::Meta(runnable) => { + let location = runnable.metadata().location; + let timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + + timing + } + RunnableVariant::Compat(runnable) => { + let timing = TaskTiming { + location: core::panic::Location::caller(), + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + + timing + } + }; + + let end = Instant::now(); + timing.end = Some(end); + + profiler::add_task_timing(timing); + } } impl PlatformDispatcher for WindowsDispatcher { + fn get_all_timings(&self) -> Vec { + let global_thread_timings = GLOBAL_THREAD_TIMINGS.lock(); + ThreadTaskTimings::convert(&global_thread_timings) + } + + fn get_current_thread_timings(&self) -> Vec { + THREAD_TIMINGS.with(|timings| { + let timings = timings.lock(); + let timings = &timings.timings; + + let mut vec = Vec::with_capacity(timings.len()); + + let (s1, s2) = timings.as_slices(); + vec.extend_from_slice(s1); + vec.extend_from_slice(s2); + vec + }) + } + fn is_main_thread(&self) -> bool { current().id() == self.main_thread_id } - fn dispatch(&self, runnable: Runnable, label: Option) { - self.dispatch_on_threadpool(runnable); + fn dispatch(&self, runnable: RunnableVariant, label: Option, priority: Priority) { + let priority = match priority { + Priority::Realtime(_) => unreachable!(), + Priority::High => WorkItemPriority::High, + Priority::Medium => WorkItemPriority::Normal, + Priority::Low => WorkItemPriority::Low, + }; + self.dispatch_on_threadpool(priority, runnable); + if let Some(label) = label { log::debug!("TaskLabel: {label:?}"); } } - fn dispatch_on_main_thread(&self, runnable: Runnable) { - match self.main_sender.send(runnable) { - Ok(_) => unsafe { - PostMessageW( - Some(self.platform_window_handle.as_raw()), - WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, - WPARAM(self.validation_number), - LPARAM(0), - ) - .log_err(); - }, + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + match self.main_sender.send(priority, runnable) { + Ok(_) => { + if !self.wake_posted.swap(true, Ordering::AcqRel) { + unsafe { + PostMessageW( + Some(self.platform_window_handle.as_raw()), + WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, + WPARAM(self.validation_number), + LPARAM(0), + ) + .log_err(); + } + } + } Err(runnable) => { // NOTE: Runnable may wrap a Future that is !Send. // @@ -109,20 +181,31 @@ impl PlatformDispatcher for WindowsDispatcher { } } - fn dispatch_after(&self, duration: Duration, runnable: Runnable) { + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { self.dispatch_on_threadpool_after(runnable, duration); } - fn park(&self, timeout: Option) -> bool { - if let Some(timeout) = timeout { - self.parker.lock().park_timeout(timeout) - } else { - self.parker.lock().park(); - true - } - } + fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + std::thread::spawn(move || { + // SAFETY: always safe to call + let thread_handle = unsafe { GetCurrentThread() }; - fn unparker(&self) -> parking::Unparker { - self.parker.lock().unparker() + let thread_priority = match priority { + RealtimePriority::Audio => THREAD_PRIORITY_TIME_CRITICAL, + RealtimePriority::Other => THREAD_PRIORITY_HIGHEST, + }; + + // SAFETY: thread_handle is a valid handle to a thread + unsafe { SetPriorityClass(thread_handle, HIGH_PRIORITY_CLASS) } + .context("thread priority class") + .log_err(); + + // SAFETY: thread_handle is a valid handle to a thread + unsafe { SetThreadPriority(thread_handle, thread_priority) } + .context("thread priority") + .log_err(); + + f(); + }); } } diff --git a/crates/gpui/src/platform/windows/display.rs b/crates/gpui/src/platform/windows/display.rs index 79716c951d..720d459c1c 100644 --- a/crates/gpui/src/platform/windows/display.rs +++ b/crates/gpui/src/platform/windows/display.rs @@ -23,6 +23,7 @@ pub(crate) struct WindowsDisplay { pub display_id: DisplayId, scale_factor: f32, bounds: Bounds, + visible_bounds: Bounds, physical_bounds: Bounds, uuid: Uuid, } @@ -36,6 +37,7 @@ impl WindowsDisplay { let screen = available_monitors().into_iter().nth(display_id.0 as _)?; let info = get_monitor_info(screen).log_err()?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); let scale_factor = get_scale_factor_for_monitor(screen).log_err()?; let physical_size = size( @@ -55,6 +57,14 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, @@ -63,22 +73,22 @@ impl WindowsDisplay { }) } - pub fn new_with_handle(monitor: HMONITOR) -> Self { - let info = get_monitor_info(monitor).expect("unable to get monitor info"); + pub fn new_with_handle(monitor: HMONITOR) -> anyhow::Result { + let info = get_monitor_info(monitor)?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); let display_id = available_monitors() .iter() .position(|handle| handle.0 == monitor.0) .unwrap(); - let scale_factor = - get_scale_factor_for_monitor(monitor).expect("unable to get scale factor for monitor"); + let scale_factor = get_scale_factor_for_monitor(monitor)?; let physical_size = size( (monitor_size.right - monitor_size.left).into(), (monitor_size.bottom - monitor_size.top).into(), ); - WindowsDisplay { + Ok(WindowsDisplay { handle: monitor, display_id: DisplayId(display_id as _), scale_factor, @@ -90,26 +100,34 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, }, uuid, - } + }) } - fn new_with_handle_and_id(handle: HMONITOR, display_id: DisplayId) -> Self { - let info = get_monitor_info(handle).expect("unable to get monitor info"); + fn new_with_handle_and_id(handle: HMONITOR, display_id: DisplayId) -> anyhow::Result { + let info = get_monitor_info(handle)?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); - let scale_factor = - get_scale_factor_for_monitor(handle).expect("unable to get scale factor for monitor"); + let scale_factor = get_scale_factor_for_monitor(handle)?; let physical_size = size( (monitor_size.right - monitor_size.left).into(), (monitor_size.bottom - monitor_size.top).into(), ); - WindowsDisplay { + Ok(WindowsDisplay { handle, display_id, scale_factor, @@ -121,12 +139,20 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, }, uuid, - } + }) } pub fn primary_monitor() -> Option { @@ -140,7 +166,7 @@ impl WindowsDisplay { ); return None; } - Some(WindowsDisplay::new_with_handle(monitor)) + WindowsDisplay::new_with_handle(monitor).log_err() } /// Check if the center point of given bounds is inside this monitor @@ -154,7 +180,9 @@ impl WindowsDisplay { if monitor.is_invalid() { false } else { - let display = WindowsDisplay::new_with_handle(monitor); + let Ok(display) = WindowsDisplay::new_with_handle(monitor) else { + return false; + }; display.uuid == self.uuid } } @@ -163,11 +191,10 @@ impl WindowsDisplay { available_monitors() .into_iter() .enumerate() - .map(|(id, handle)| { - Rc::new(WindowsDisplay::new_with_handle_and_id( - handle, - DisplayId(id as _), - )) as Rc + .filter_map(|(id, handle)| { + Some(Rc::new( + WindowsDisplay::new_with_handle_and_id(handle, DisplayId(id as _)).ok()?, + ) as Rc) }) .collect() } @@ -194,6 +221,10 @@ impl PlatformDisplay for WindowsDisplay { fn bounds(&self) -> Bounds { self.bounds } + + fn visible_bounds(&self) -> Bounds { + self.visible_bounds + } } fn available_monitors() -> SmallVec<[HMONITOR; 4]> { diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index a9873c109c..f648f45cf4 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -26,6 +26,7 @@ pub(crate) const WM_GPUI_DOCK_MENU_ACTION: u32 = WM_USER + 4; pub(crate) const WM_GPUI_FORCE_UPDATE_WINDOW: u32 = WM_USER + 5; pub(crate) const WM_GPUI_KEYBOARD_LAYOUT_CHANGED: u32 = WM_USER + 6; pub(crate) const WM_GPUI_GPU_DEVICE_LOST: u32 = WM_USER + 7; +pub(crate) const WM_GPUI_KEYDOWN: u32 = WM_USER + 8; const SIZE_MOVE_LOOP_TIMER_ID: usize = 1; const AUTO_HIDE_TASKBAR_THICKNESS_PX: i32 = 1; @@ -50,7 +51,7 @@ impl WindowsWindowInner { WM_NCCALCSIZE => self.handle_calc_client_size(handle, wparam, lparam), WM_DPICHANGED => self.handle_dpi_changed_msg(handle, wparam, lparam), WM_DISPLAYCHANGE => self.handle_display_change_msg(handle), - WM_NCHITTEST => self.handle_hit_test_msg(handle, msg, wparam, lparam), + WM_NCHITTEST => self.handle_hit_test_msg(handle, lparam), WM_PAINT => self.handle_paint_msg(handle), WM_CLOSE => self.handle_close_msg(), WM_DESTROY => self.handle_destroy_msg(handle), @@ -92,13 +93,10 @@ impl WindowsWindowInner { } WM_MOUSEWHEEL => self.handle_mouse_wheel_msg(handle, wparam, lparam), WM_MOUSEHWHEEL => self.handle_mouse_horizontal_wheel_msg(handle, wparam, lparam), - WM_SYSKEYDOWN => self.handle_syskeydown_msg(handle, wparam, lparam), - WM_SYSKEYUP => self.handle_syskeyup_msg(handle, wparam, lparam), - WM_SYSCOMMAND => self.handle_system_command(wparam), - WM_KEYDOWN => self.handle_keydown_msg(handle, wparam, lparam), - WM_KEYUP => self.handle_keyup_msg(handle, wparam, lparam), + WM_SYSKEYUP => self.handle_syskeyup_msg(wparam, lparam), + WM_KEYUP => self.handle_keyup_msg(wparam, lparam), + WM_GPUI_KEYDOWN => self.handle_keydown_msg(wparam, lparam), WM_CHAR => self.handle_char_msg(wparam), - WM_DEADCHAR => self.handle_dead_char_msg(wparam), WM_IME_STARTCOMPOSITION => self.handle_ime_position(handle), WM_IME_COMPOSITION => self.handle_ime_composition(handle, lparam), WM_SETCURSOR => self.handle_set_cursor(handle, lparam), @@ -118,17 +116,16 @@ impl WindowsWindowInner { } fn handle_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); let origin = logical_point( lparam.signed_loword() as f32, lparam.signed_hiword() as f32, - lock.scale_factor, + self.state.scale_factor.get(), ); - lock.origin = origin; - let size = lock.logical_size; + self.state.origin.set(origin); + let size = self.state.logical_size.get(); let center_x = origin.x.0 + size.width.0 / 2.; let center_y = origin.y.0 + size.height.0 / 2.; - let monitor_bounds = lock.display.bounds(); + let monitor_bounds = self.state.display.get().bounds(); if center_x < monitor_bounds.left().0 || center_x > monitor_bounds.right().0 || center_y < monitor_bounds.top().0 @@ -138,42 +135,42 @@ impl WindowsWindowInner { let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONULL) }; // minimize the window can trigger this event too, in this case, // monitor is invalid, we do nothing. - if !monitor.is_invalid() && lock.display.handle != monitor { + if !monitor.is_invalid() && self.state.display.get().handle != monitor { // we will get the same monitor if we only have one - lock.display = WindowsDisplay::new_with_handle(monitor); + self.state + .display + .set(WindowsDisplay::new_with_handle(monitor).log_err()?); } } - if let Some(mut callback) = lock.callbacks.moved.take() { - drop(lock); + if let Some(mut callback) = self.state.callbacks.moved.take() { callback(); - self.state.borrow_mut().callbacks.moved = Some(callback); + self.state.callbacks.moved.set(Some(callback)); } Some(0) } fn handle_get_min_max_info_msg(&self, lparam: LPARAM) -> Option { - let lock = self.state.borrow(); - let min_size = lock.min_size?; - let scale_factor = lock.scale_factor; - let boarder_offset = lock.border_offset; - drop(lock); + let min_size = self.state.min_size?; + let scale_factor = self.state.scale_factor.get(); + let boarder_offset = &self.state.border_offset; + unsafe { let minmax_info = &mut *(lparam.0 as *mut MINMAXINFO); minmax_info.ptMinTrackSize.x = - min_size.width.scale(scale_factor).0 as i32 + boarder_offset.width_offset; + min_size.width.scale(scale_factor).0 as i32 + boarder_offset.width_offset.get(); minmax_info.ptMinTrackSize.y = - min_size.height.scale(scale_factor).0 as i32 + boarder_offset.height_offset; + min_size.height.scale(scale_factor).0 as i32 + boarder_offset.height_offset.get(); } Some(0) } fn handle_size_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); - // Don't resize the renderer when the window is minimized, but record that it was minimized so // that on restore the swap chain can be recreated via `update_drawable_size_even_if_unchanged`. if wparam.0 == SIZE_MINIMIZED as usize { - lock.restore_from_minimized = lock.callbacks.request_frame.take(); + self.state + .restore_from_minimized + .set(self.state.callbacks.request_frame.take()); return Some(0); } @@ -181,14 +178,16 @@ impl WindowsWindowInner { let height = lparam.hiword().max(1) as i32; let new_size = size(DevicePixels(width), DevicePixels(height)); - let scale_factor = lock.scale_factor; + let scale_factor = self.state.scale_factor.get(); let mut should_resize_renderer = false; - if lock.restore_from_minimized.is_some() { - lock.callbacks.request_frame = lock.restore_from_minimized.take(); + if let Some(restore_from_minimized) = self.state.restore_from_minimized.take() { + self.state + .callbacks + .request_frame + .set(Some(restore_from_minimized)); } else { should_resize_renderer = true; } - drop(lock); self.handle_size_change(new_size, scale_factor, should_resize_renderer); Some(0) @@ -201,15 +200,19 @@ impl WindowsWindowInner { should_resize_renderer: bool, ) { let new_logical_size = device_size.to_pixels(scale_factor); - let mut lock = self.state.borrow_mut(); - lock.logical_size = new_logical_size; - if should_resize_renderer { - lock.renderer.resize(device_size).log_err(); + + self.state.logical_size.set(new_logical_size); + if should_resize_renderer + && let Err(e) = self.state.renderer.borrow_mut().resize(device_size) + { + log::error!("Failed to resize renderer, invalidating devices: {}", e); + self.state + .invalidate_devices + .store(true, std::sync::atomic::Ordering::Release); } - if let Some(mut callback) = lock.callbacks.resize.take() { - drop(lock); + if let Some(mut callback) = self.state.callbacks.resize.take() { callback(new_logical_size, scale_factor); - self.state.borrow_mut().callbacks.resize = Some(callback); + self.state.callbacks.resize.set(Some(callback)); } } @@ -240,8 +243,9 @@ impl WindowsWindowInner { fn handle_timer_msg(&self, handle: HWND, wparam: WPARAM) -> Option { if wparam.0 == SIZE_MOVE_LOOP_TIMER_ID { - for runnable in self.main_receiver.drain() { - runnable.run(); + let mut runnables = self.main_receiver.clone().try_iter(); + while let Some(Ok(runnable)) = runnables.next() { + WindowsDispatcher::execute_runnable(runnable); } self.handle_paint_msg(handle) } else { @@ -254,17 +258,14 @@ impl WindowsWindowInner { } fn handle_close_msg(&self) -> Option { - let mut callback = self.state.borrow_mut().callbacks.should_close.take()?; + let mut callback = self.state.callbacks.should_close.take()?; let should_close = callback(); - self.state.borrow_mut().callbacks.should_close = Some(callback); + self.state.callbacks.should_close.set(Some(callback)); if should_close { None } else { Some(0) } } fn handle_destroy_msg(&self, handle: HWND) -> Option { - let callback = { - let mut lock = self.state.borrow_mut(); - lock.callbacks.close.take() - }; + let callback = { self.state.callbacks.close.take() }; if let Some(callback) = callback { callback(); } @@ -283,12 +284,10 @@ impl WindowsWindowInner { fn handle_mouse_move_msg(&self, handle: HWND, lparam: LPARAM, wparam: WPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE); - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; - let scale_factor = lock.scale_factor; - drop(lock); + let scale_factor = self.state.scale_factor.get(); let pressed_button = match MODIFIERKEYS_FLAGS(wparam.loword() as u32) { flags if flags.contains(MK_LBUTTON) => Some(MouseButton::Left), @@ -310,58 +309,32 @@ impl WindowsWindowInner { modifiers: current_modifiers(), }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } fn handle_mouse_leave_msg(&self) -> Option { - let mut lock = self.state.borrow_mut(); - lock.hovered = false; - if let Some(mut callback) = lock.callbacks.hovered_status_change.take() { - drop(lock); + self.state.hovered.set(false); + if let Some(mut callback) = self.state.callbacks.hovered_status_change.take() { callback(false); - self.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + self.state + .callbacks + .hovered_status_change + .set(Some(callback)); } Some(0) } - fn handle_syskeydown_msg(&self, handle: HWND, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); - let input = handle_key_event(handle, wparam, lparam, &mut lock, |keystroke| { - PlatformInput::KeyDown(KeyDownEvent { - keystroke, - is_held: lparam.0 & (0x1 << 30) > 0, - }) - })?; - let mut func = lock.callbacks.input.take()?; - drop(lock); - - let handled = !func(input).propagate; - - let mut lock = self.state.borrow_mut(); - lock.callbacks.input = Some(func); - - if handled { - lock.system_key_handled = true; - Some(0) - } else { - // we need to call `DefWindowProcW`, or we will lose the system-wide `Alt+F4`, `Alt+{other keys}` - // shortcuts. - None - } - } - - fn handle_syskeyup_msg(&self, handle: HWND, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); - let input = handle_key_event(handle, wparam, lparam, &mut lock, |keystroke| { + fn handle_syskeyup_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + let input = handle_key_event(wparam, lparam, &self.state, |keystroke, _| { PlatformInput::KeyUp(KeyUpEvent { keystroke }) })?; - let mut func = lock.callbacks.input.take()?; - drop(lock); + let mut func = self.state.callbacks.input.take()?; + func(input); - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); // Always return 0 to indicate that the message was handled, so we could properly handle `ModifiersChanged` event. Some(0) @@ -369,58 +342,46 @@ impl WindowsWindowInner { // It's a known bug that you can't trigger `ctrl-shift-0`. See: // https://superuser.com/questions/1455762/ctrl-shift-number-key-combination-has-stopped-working-for-a-few-numbers - fn handle_keydown_msg(&self, handle: HWND, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); - let Some(input) = handle_key_event(handle, wparam, lparam, &mut lock, |keystroke| { - PlatformInput::KeyDown(KeyDownEvent { - keystroke, - is_held: lparam.0 & (0x1 << 30) > 0, - }) - }) else { + fn handle_keydown_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + let Some(input) = handle_key_event( + wparam, + lparam, + &self.state, + |keystroke, prefer_character_input| { + PlatformInput::KeyDown(KeyDownEvent { + keystroke, + is_held: lparam.0 & (0x1 << 30) > 0, + prefer_character_input, + }) + }, + ) else { return Some(1); }; - drop(lock); - let is_composing = self - .with_input_handler(|input_handler| input_handler.marked_text_range()) - .flatten() - .is_some(); - if is_composing { - translate_message(handle, wparam, lparam); - return Some(0); - } - - let Some(mut func) = self.state.borrow_mut().callbacks.input.take() else { + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); - if handled { - Some(0) - } else { - translate_message(handle, wparam, lparam); - Some(1) - } + if handled { Some(0) } else { Some(1) } } - fn handle_keyup_msg(&self, handle: HWND, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); - let Some(input) = handle_key_event(handle, wparam, lparam, &mut lock, |keystroke| { + fn handle_keyup_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + let Some(input) = handle_key_event(wparam, lparam, &self.state, |keystroke, _| { PlatformInput::KeyUp(KeyUpEvent { keystroke }) }) else { return Some(1); }; - let Some(mut func) = lock.callbacks.input.take() else { + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; - drop(lock); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -434,14 +395,6 @@ impl WindowsWindowInner { Some(0) } - fn handle_dead_char_msg(&self, wparam: WPARAM) -> Option { - let ch = char::from_u32(wparam.0 as u32)?.to_string(); - self.with_input_handler(|input_handler| { - input_handler.replace_and_mark_text_in_range(None, &ch, None); - }); - None - } - fn handle_mouse_down_msg( &self, handle: HWND, @@ -449,16 +402,15 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { unsafe { SetCapture(handle) }; - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; let x = lparam.signed_loword(); let y = lparam.signed_hiword(); let physical_point = point(DevicePixels(x as i32), DevicePixels(y as i32)); - let click_count = lock.click_state.update(button, physical_point); - let scale_factor = lock.scale_factor; - drop(lock); + let click_count = self.state.click_state.update(button, physical_point); + let scale_factor = self.state.scale_factor.get(); let input = PlatformInput::MouseDown(MouseDownEvent { button, @@ -468,7 +420,7 @@ impl WindowsWindowInner { first_mouse: false, }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -480,15 +432,14 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { unsafe { ReleaseCapture().log_err() }; - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; let x = lparam.signed_loword() as f32; let y = lparam.signed_hiword() as f32; - let click_count = lock.click_state.current_count; - let scale_factor = lock.scale_factor; - drop(lock); + let click_count = self.state.click_state.current_count.get(); + let scale_factor = self.state.scale_factor.get(); let input = PlatformInput::MouseUp(MouseUpEvent { button, @@ -497,7 +448,7 @@ impl WindowsWindowInner { click_count, }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -524,16 +475,23 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { let modifiers = current_modifiers(); - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; - let scale_factor = lock.scale_factor; + let scale_factor = self.state.scale_factor.get(); let wheel_scroll_amount = match modifiers.shift { - true => lock.system_settings.mouse_wheel_settings.wheel_scroll_chars, - false => lock.system_settings.mouse_wheel_settings.wheel_scroll_lines, + true => self + .system_settings() + .mouse_wheel_settings + .wheel_scroll_chars + .get(), + false => self + .system_settings() + .mouse_wheel_settings + .wheel_scroll_lines + .get(), }; - drop(lock); let wheel_distance = (wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_amount as f32; @@ -558,7 +516,7 @@ impl WindowsWindowInner { touch_phase: TouchPhase::Moved, }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -569,13 +527,15 @@ impl WindowsWindowInner { wparam: WPARAM, lparam: LPARAM, ) -> Option { - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; - let scale_factor = lock.scale_factor; - let wheel_scroll_chars = lock.system_settings.mouse_wheel_settings.wheel_scroll_chars; - drop(lock); + let scale_factor = self.state.scale_factor.get(); + let wheel_scroll_chars = self + .system_settings() + .mouse_wheel_settings + .wheel_scroll_chars + .get(); let wheel_distance = (-wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_chars as f32; @@ -594,7 +554,7 @@ impl WindowsWindowInner { touch_phase: TouchPhase::Moved, }); let handled = !func(event).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -688,11 +648,11 @@ impl WindowsWindowInner { wparam: WPARAM, lparam: LPARAM, ) -> Option { - if !self.hide_title_bar || self.state.borrow().is_fullscreen() || wparam.0 == 0 { + if !self.hide_title_bar || self.state.is_fullscreen() || wparam.0 == 0 { return None; } - let is_maximized = self.state.borrow().is_maximized(); + let is_maximized = self.state.is_maximized(); let insets = get_client_area_insets(handle, is_maximized, self.windows_version); // wparam is TRUE so lparam points to an NCCALCSIZE_PARAMS structure let mut params = lparam.0 as *mut NCCALCSIZE_PARAMS; @@ -707,11 +667,7 @@ impl WindowsWindowInner { // used by Chrome. However, it may result in one row of pixels being obscured // in our client area. But as Chrome says, "there seems to be no better solution." if is_maximized - && let Some(ref taskbar_position) = self - .state - .borrow() - .system_settings - .auto_hide_taskbar_position + && let Some(taskbar_position) = self.system_settings().auto_hide_taskbar_position.get() { // For the auto-hide taskbar, adjust in by 1 pixel on taskbar edge, // so the window isn't treated as a "fullscreen app", which would cause @@ -740,11 +696,9 @@ impl WindowsWindowInner { let this = self.clone(); self.executor .spawn(async move { - let mut lock = this.state.borrow_mut(); - if let Some(mut func) = lock.callbacks.active_status_change.take() { - drop(lock); + if let Some(mut func) = this.state.callbacks.active_status_change.take() { func(activated); - this.state.borrow_mut().callbacks.active_status_change = Some(func); + this.state.callbacks.active_status_change.set(Some(func)); } }) .detach(); @@ -768,38 +722,64 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { let new_dpi = wparam.loword() as f32; - let mut lock = self.state.borrow_mut(); - let is_maximized = lock.is_maximized(); + + let is_maximized = self.state.is_maximized(); let new_scale_factor = new_dpi / USER_DEFAULT_SCREEN_DPI as f32; - lock.scale_factor = new_scale_factor; - lock.border_offset.update(handle).log_err(); - drop(lock); + self.state.scale_factor.set(new_scale_factor); + self.state.border_offset.update(handle).log_err(); - let rect = unsafe { &*(lparam.0 as *const RECT) }; - let width = rect.right - rect.left; - let height = rect.bottom - rect.top; - // this will emit `WM_SIZE` and `WM_MOVE` right here - // even before this function returns - // the new size is handled in `WM_SIZE` - unsafe { - SetWindowPos( - handle, - None, - rect.left, - rect.top, - width, - height, - SWP_NOZORDER | SWP_NOACTIVATE, - ) - .context("unable to set window position after dpi has changed") - .log_err(); - } - - // When maximized, SetWindowPos doesn't send WM_SIZE, so we need to manually - // update the size and call the resize callback if is_maximized { - let device_size = size(DevicePixels(width), DevicePixels(height)); - self.handle_size_change(device_size, new_scale_factor, true); + // Get the monitor and its work area at the new DPI + let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST) }; + let mut monitor_info: MONITORINFO = unsafe { std::mem::zeroed() }; + monitor_info.cbSize = std::mem::size_of::() as u32; + if unsafe { GetMonitorInfoW(monitor, &mut monitor_info) }.as_bool() { + let work_area = monitor_info.rcWork; + let width = work_area.right - work_area.left; + let height = work_area.bottom - work_area.top; + + // Update the window size to match the new monitor work area + // This will trigger WM_SIZE which will handle the size change + unsafe { + SetWindowPos( + handle, + None, + work_area.left, + work_area.top, + width, + height, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED, + ) + .context("unable to set maximized window position after dpi has changed") + .log_err(); + } + + // SetWindowPos may not send WM_SIZE for maximized windows in some cases, + // so we manually update the size to ensure proper rendering + let device_size = size(DevicePixels(width), DevicePixels(height)); + self.handle_size_change(device_size, new_scale_factor, true); + } + } else { + // For non-maximized windows, use the suggested RECT from the system + let rect = unsafe { &*(lparam.0 as *const RECT) }; + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + // this will emit `WM_SIZE` and `WM_MOVE` right here + // even before this function returns + // the new size is handled in `WM_SIZE` + unsafe { + SetWindowPos( + handle, + None, + rect.left, + rect.top, + width, + height, + SWP_NOZORDER | SWP_NOACTIVATE, + ) + .context("unable to set window position after dpi has changed") + .log_err(); + } } Some(0) @@ -820,7 +800,7 @@ impl WindowsWindowInner { // Because WM_DPICHANGED, WM_MOVE, WM_SIZE will come first, window reposition and resize // are handled there. // So we only care about if monitor is disconnected. - let previous_monitor = self.state.borrow().display; + let previous_monitor = self.state.display.get(); if WindowsDisplay::is_connected(previous_monitor.handle) { // we are fine, other display changed return None; @@ -837,87 +817,79 @@ impl WindowsWindowInner { log::error!("No monitor detected!"); return None; } - let new_display = WindowsDisplay::new_with_handle(new_monitor); - self.state.borrow_mut().display = new_display; + let new_display = WindowsDisplay::new_with_handle(new_monitor).log_err()?; + self.state.display.set(new_display); Some(0) } - fn handle_hit_test_msg( - &self, - handle: HWND, - msg: u32, - wparam: WPARAM, - lparam: LPARAM, - ) -> Option { - if !self.is_movable || self.state.borrow().is_fullscreen() { + fn handle_hit_test_msg(&self, handle: HWND, lparam: LPARAM) -> Option { + if !self.is_movable || self.state.is_fullscreen() { return None; } - let mut lock = self.state.borrow_mut(); - if let Some(mut callback) = lock.callbacks.hit_test_window_control.take() { - drop(lock); + let callback = self.state.callbacks.hit_test_window_control.take(); + let drag_area = if let Some(mut callback) = callback { let area = callback(); - self.state.borrow_mut().callbacks.hit_test_window_control = Some(callback); + self.state + .callbacks + .hit_test_window_control + .set(Some(callback)); if let Some(area) = area { - return match area { + match area { WindowControlArea::Drag => Some(HTCAPTION as _), - WindowControlArea::Close => Some(HTCLOSE as _), - WindowControlArea::Max => Some(HTMAXBUTTON as _), - WindowControlArea::Min => Some(HTMINBUTTON as _), - }; + WindowControlArea::Close => return Some(HTCLOSE as _), + WindowControlArea::Max => return Some(HTMAXBUTTON as _), + WindowControlArea::Min => return Some(HTMINBUTTON as _), + } + } else { + None } } else { - drop(lock); - } + None + }; if !self.hide_title_bar { // If the OS draws the title bar, we don't need to handle hit test messages. - return None; - } - - // default handler for resize areas - let hit = unsafe { DefWindowProcW(handle, msg, wparam, lparam) }; - if matches!( - hit.0 as u32, - HTNOWHERE - | HTRIGHT - | HTLEFT - | HTTOPLEFT - | HTTOP - | HTTOPRIGHT - | HTBOTTOMRIGHT - | HTBOTTOM - | HTBOTTOMLEFT - ) { - return Some(hit.0); - } - - if self.state.borrow().is_fullscreen() { - return Some(HTCLIENT as _); + return drag_area; } let dpi = unsafe { GetDpiForWindow(handle) }; - let frame_y = unsafe { GetSystemMetricsForDpi(SM_CYFRAME, dpi) }; - + // We do not use the OS title bar, so the default `DefWindowProcW` will only register a 1px edge for resizes + // We need to calculate the frame thickness ourselves and do the hit test manually. + let frame_y = get_frame_thicknessx(dpi); + let frame_x = get_frame_thicknessy(dpi); let mut cursor_point = POINT { x: lparam.signed_loword().into(), y: lparam.signed_hiword().into(), }; + unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() }; - if !self.state.borrow().is_maximized() && cursor_point.y >= 0 && cursor_point.y <= frame_y { - return Some(HTTOP as _); + if !self.state.is_maximized() && 0 <= cursor_point.y && cursor_point.y <= frame_y { + // x-axis actually goes from -frame_x to 0 + return Some(if cursor_point.x <= 0 { + HTTOPLEFT + } else { + let mut rect = Default::default(); + unsafe { GetWindowRect(handle, &mut rect) }.log_err(); + // right and bottom bounds of RECT are exclusive, thus `-1` + let right = rect.right - rect.left - 1; + // the bounds include the padding frames, so accomodate for both of them + if right - 2 * frame_x <= cursor_point.x { + HTTOPRIGHT + } else { + HTTOP + } + } as _); } - Some(HTCLIENT as _) + drag_area } fn handle_nc_mouse_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE | TME_NONCLIENT); - let mut lock = self.state.borrow_mut(); - let mut func = lock.callbacks.input.take()?; - let scale_factor = lock.scale_factor; - drop(lock); + let mut func = self.state.callbacks.input.take()?; + let scale_factor = self.state.scale_factor.get(); let mut cursor_point = POINT { x: lparam.signed_loword().into(), @@ -930,7 +902,7 @@ impl WindowsWindowInner { modifiers: current_modifiers(), }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { None } } @@ -942,17 +914,15 @@ impl WindowsWindowInner { wparam: WPARAM, lparam: LPARAM, ) -> Option { - let mut lock = self.state.borrow_mut(); - if let Some(mut func) = lock.callbacks.input.take() { - let scale_factor = lock.scale_factor; + if let Some(mut func) = self.state.callbacks.input.take() { + let scale_factor = self.state.scale_factor.get(); let mut cursor_point = POINT { x: lparam.signed_loword().into(), y: lparam.signed_hiword().into(), }; unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() }; let physical_point = point(DevicePixels(cursor_point.x), DevicePixels(cursor_point.y)); - let click_count = lock.click_state.update(button, physical_point); - drop(lock); + let click_count = self.state.click_state.update(button, physical_point); let input = PlatformInput::MouseDown(MouseDownEvent { button, @@ -963,21 +933,20 @@ impl WindowsWindowInner { }); let result = func(input); let handled = !result.propagate || result.default_prevented; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { return Some(0); } } else { - drop(lock); }; // Since these are handled in handle_nc_mouse_up_msg we must prevent the default window proc if button == MouseButton::Left { match wparam.0 as u32 { - HTMINBUTTON => self.state.borrow_mut().nc_button_pressed = Some(HTMINBUTTON), - HTMAXBUTTON => self.state.borrow_mut().nc_button_pressed = Some(HTMAXBUTTON), - HTCLOSE => self.state.borrow_mut().nc_button_pressed = Some(HTCLOSE), + HTMINBUTTON => self.state.nc_button_pressed.set(Some(HTMINBUTTON)), + HTMAXBUTTON => self.state.nc_button_pressed.set(Some(HTMAXBUTTON)), + HTCLOSE => self.state.nc_button_pressed.set(Some(HTCLOSE)), _ => return None, }; Some(0) @@ -993,10 +962,8 @@ impl WindowsWindowInner { wparam: WPARAM, lparam: LPARAM, ) -> Option { - let mut lock = self.state.borrow_mut(); - if let Some(mut func) = lock.callbacks.input.take() { - let scale_factor = lock.scale_factor; - drop(lock); + if let Some(mut func) = self.state.callbacks.input.take() { + let scale_factor = self.state.scale_factor.get(); let mut cursor_point = POINT { x: lparam.signed_loword().into(), @@ -1010,16 +977,15 @@ impl WindowsWindowInner { click_count: 1, }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { return Some(0); } } else { - drop(lock); } - let last_pressed = self.state.borrow_mut().nc_button_pressed.take(); + let last_pressed = self.state.nc_button_pressed.take(); if button == MouseButton::Left && let Some(last_pressed) = last_pressed { @@ -1029,7 +995,7 @@ impl WindowsWindowInner { true } (HTMAXBUTTON, HTMAXBUTTON) => { - if self.state.borrow().is_maximized() { + if self.state.is_maximized() { unsafe { ShowWindowAsync(handle, SW_NORMAL).ok().log_err() }; } else { unsafe { ShowWindowAsync(handle, SW_MAXIMIZE).ok().log_err() }; @@ -1054,17 +1020,16 @@ impl WindowsWindowInner { } fn handle_cursor_changed(&self, lparam: LPARAM) -> Option { - let mut state = self.state.borrow_mut(); - let had_cursor = state.current_cursor.is_some(); + let had_cursor = self.state.current_cursor.get().is_some(); - state.current_cursor = if lparam.0 == 0 { + self.state.current_cursor.set(if lparam.0 == 0 { None } else { Some(HCURSOR(lparam.0 as _)) - }; + }); - if had_cursor != state.current_cursor.is_some() { - unsafe { SetCursor(state.current_cursor) }; + if had_cursor != self.state.current_cursor.get().is_some() { + unsafe { SetCursor(self.state.current_cursor.get()) }; } Some(0) @@ -1087,9 +1052,9 @@ impl WindowsWindowInner { return None; } unsafe { - SetCursor(self.state.borrow().current_cursor); + SetCursor(self.state.current_cursor.get()); }; - Some(1) + Some(0) } fn handle_system_settings_changed( @@ -1099,11 +1064,12 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { if wparam.0 != 0 { - let mut lock = self.state.borrow_mut(); - let display = lock.display; - lock.system_settings.update(display, wparam.0); - lock.click_state.system_update(wparam.0); - lock.border_offset.update(handle).log_err(); + let display = self.state.display.get(); + self.state.click_state.system_update(wparam.0); + self.state.border_offset.update(handle).log_err(); + // system settings may emit a window message which wants to take the refcell self.state, so drop it + + self.system_settings().update(display, wparam.0); } else { self.handle_system_theme_changed(handle, lparam)?; }; @@ -1114,17 +1080,6 @@ impl WindowsWindowInner { Some(0) } - fn handle_system_command(&self, wparam: WPARAM) -> Option { - if wparam.0 == SC_KEYMENU as usize { - let mut lock = self.state.borrow_mut(); - if lock.system_key_handled { - lock.system_key_handled = false; - return Some(0); - } - } - None - } - fn handle_system_theme_changed(&self, handle: HWND, lparam: LPARAM) -> Option { // lParam is a pointer to a string that indicates the area containing the system parameter // that was changed. @@ -1137,13 +1092,13 @@ impl WindowsWindowInner { let new_appearance = system_appearance() .context("unable to get system appearance when handling ImmersiveColorSet") .log_err()?; - let mut lock = self.state.borrow_mut(); - if new_appearance != lock.appearance { - lock.appearance = new_appearance; - let mut callback = lock.callbacks.appearance_changed.take()?; - drop(lock); + + if new_appearance != self.state.appearance.get() { + self.state.appearance.set(new_appearance); + let mut callback = self.state.callbacks.appearance_changed.take()?; + callback(); - self.state.borrow_mut().callbacks.appearance_changed = Some(callback); + self.state.callbacks.appearance_changed.set(Some(callback)); configure_dwm_dark_mode(handle, new_appearance); } } @@ -1172,38 +1127,51 @@ impl WindowsWindowInner { } fn handle_device_lost(&self, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); let devices = lparam.0 as *const DirectXDevices; let devices = unsafe { &*devices }; - lock.renderer.handle_device_lost(&devices); + if let Err(err) = self + .state + .renderer + .borrow_mut() + .handle_device_lost(&devices) + { + panic!("Device lost: {err}"); + } Some(0) } #[inline] fn draw_window(&self, handle: HWND, force_render: bool) -> Option { - let mut request_frame = self.state.borrow_mut().callbacks.request_frame.take()?; + let mut request_frame = self.state.callbacks.request_frame.take()?; + + // we are instructing gpui to force render a frame, this will + // re-populate all the gpu textures for us so we can resume drawing in + // case we disabled drawing earlier due to a device loss + self.state.renderer.borrow_mut().mark_drawable(); request_frame(RequestFrameOptions { require_presentation: false, force_render, }); - self.state.borrow_mut().callbacks.request_frame = Some(request_frame); + + self.state.callbacks.request_frame.set(Some(request_frame)); unsafe { ValidateRect(Some(handle), None).ok().log_err() }; + Some(0) } #[inline] fn parse_char_message(&self, wparam: WPARAM) -> Option { let code_point = wparam.loword(); - let mut lock = self.state.borrow_mut(); + // https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G2630 match code_point { 0xD800..=0xDBFF => { // High surrogate, wait for low surrogate - lock.pending_surrogate = Some(code_point); + self.state.pending_surrogate.set(Some(code_point)); None } 0xDC00..=0xDFFF => { - if let Some(high_surrogate) = lock.pending_surrogate.take() { + if let Some(high_surrogate) = self.state.pending_surrogate.take() { // Low surrogate, combine with pending high surrogate String::from_utf16(&[high_surrogate, code_point]).ok() } else { @@ -1215,7 +1183,7 @@ impl WindowsWindowInner { } } _ => { - lock.pending_surrogate = None; + self.state.pending_surrogate.set(None); char::from_u32(code_point as u32) .filter(|c| !c.is_control()) .map(|c| c.to_string()) @@ -1224,9 +1192,8 @@ impl WindowsWindowInner { } fn start_tracking_mouse(&self, handle: HWND, flags: TRACKMOUSEEVENT_FLAGS) { - let mut lock = self.state.borrow_mut(); - if !lock.hovered { - lock.hovered = true; + if !self.state.hovered.get() { + self.state.hovered.set(true); unsafe { TrackMouseEvent(&mut TRACKMOUSEEVENT { cbSize: std::mem::size_of::() as u32, @@ -1236,10 +1203,12 @@ impl WindowsWindowInner { }) .log_err() }; - if let Some(mut callback) = lock.callbacks.hovered_status_change.take() { - drop(lock); + if let Some(mut callback) = self.state.callbacks.hovered_status_change.take() { callback(true); - self.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + self.state + .callbacks + .hovered_status_change + .set(Some(callback)); } } } @@ -1248,9 +1217,9 @@ impl WindowsWindowInner { where F: FnOnce(&mut PlatformInputHandler) -> R, { - let mut input_handler = self.state.borrow_mut().input_handler.take()?; + let mut input_handler = self.state.input_handler.take()?; let result = f(&mut input_handler); - self.state.borrow_mut().input_handler = Some(input_handler); + self.state.input_handler.set(Some(input_handler)); Some(result) } @@ -1258,84 +1227,61 @@ impl WindowsWindowInner { where F: FnOnce(&mut PlatformInputHandler, f32) -> Option, { - let mut lock = self.state.borrow_mut(); - let mut input_handler = lock.input_handler.take()?; - let scale_factor = lock.scale_factor; - drop(lock); + let mut input_handler = self.state.input_handler.take()?; + let scale_factor = self.state.scale_factor.get(); + let result = f(&mut input_handler, scale_factor); - self.state.borrow_mut().input_handler = Some(input_handler); + self.state.input_handler.set(Some(input_handler)); result } } -#[inline] -fn translate_message(handle: HWND, wparam: WPARAM, lparam: LPARAM) { - let msg = MSG { - hwnd: handle, - message: WM_KEYDOWN, - wParam: wparam, - lParam: lparam, - // It seems like leaving the following two parameters empty doesn't break key events, they still work as expected. - // But if any bugs pop up after this PR, this is probably the place to look first. - time: 0, - pt: POINT::default(), - }; - unsafe { TranslateMessage(&msg).ok().log_err() }; -} - fn handle_key_event( - handle: HWND, wparam: WPARAM, lparam: LPARAM, - state: &mut WindowsWindowState, + state: &WindowsWindowState, f: F, ) -> Option where - F: FnOnce(Keystroke) -> PlatformInput, + F: FnOnce(Keystroke, bool) -> PlatformInput, { let virtual_key = VIRTUAL_KEY(wparam.loword()); - let mut modifiers = current_modifiers(); + let modifiers = current_modifiers(); match virtual_key { - VK_SHIFT | VK_CONTROL | VK_MENU | VK_LWIN | VK_RWIN => { + VK_SHIFT | VK_CONTROL | VK_MENU | VK_LMENU | VK_RMENU | VK_LWIN | VK_RWIN => { if state .last_reported_modifiers + .get() .is_some_and(|prev_modifiers| prev_modifiers == modifiers) { return None; } - state.last_reported_modifiers = Some(modifiers); + state.last_reported_modifiers.set(Some(modifiers)); Some(PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers, capslock: current_capslock(), })) } - VK_PACKET => { - translate_message(handle, wparam, lparam); - None - } + VK_PACKET => None, VK_CAPITAL => { let capslock = current_capslock(); if state .last_reported_capslock + .get() .is_some_and(|prev_capslock| prev_capslock == capslock) { return None; } - state.last_reported_capslock = Some(capslock); + state.last_reported_capslock.set(Some(capslock)); Some(PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers, capslock, })) } vkey => { - let vkey = if vkey == VK_PROCESSKEY { - VIRTUAL_KEY(unsafe { ImmGetVirtualKey(handle) } as u16) - } else { - vkey - }; let keystroke = parse_normal_key(vkey, lparam, modifiers)?; - Some(f(keystroke)) + Some(f(keystroke.0, keystroke.1)) } } } @@ -1395,24 +1341,98 @@ fn parse_normal_key( vkey: VIRTUAL_KEY, lparam: LPARAM, mut modifiers: Modifiers, -) -> Option { - let mut key_char = None; +) -> Option<(Keystroke, bool)> { + let (key_char, prefer_character_input) = process_key(vkey, lparam.hiword()); + let key = parse_immutable(vkey).or_else(|| { let scan_code = lparam.hiword() & 0xFF; - key_char = generate_key_char( - vkey, - scan_code as u32, - modifiers.control, - modifiers.shift, - modifiers.alt, - ); get_keystroke_key(vkey, scan_code as u32, &mut modifiers) })?; - Some(Keystroke { - modifiers, - key, + + Some(( + Keystroke { + modifiers, + key, + key_char, + }, + prefer_character_input, + )) +} + +fn process_key(vkey: VIRTUAL_KEY, scan_code: u16) -> (Option, bool) { + let mut keyboard_state = [0u8; 256]; + unsafe { + if GetKeyboardState(&mut keyboard_state).is_err() { + return (None, false); + } + } + + let mut buffer_c = [0u16; 8]; + let result_c = unsafe { + ToUnicode( + vkey.0 as u32, + scan_code as u32, + Some(&keyboard_state), + &mut buffer_c, + 0x4, + ) + }; + + if result_c == 0 { + return (None, false); + } + + let c = &buffer_c[..result_c.unsigned_abs() as usize]; + let key_char = String::from_utf16(c) + .ok() + .filter(|s| !s.is_empty() && !s.chars().next().unwrap().is_control()); + + if result_c < 0 { + return (key_char, true); + } + + if key_char.is_none() { + return (None, false); + } + + // Workaround for some bug that makes the compiler think keyboard_state is still zeroed out + let keyboard_state = std::hint::black_box(keyboard_state); + let ctrl_down = (keyboard_state[VK_CONTROL.0 as usize] & 0x80) != 0; + let alt_down = (keyboard_state[VK_MENU.0 as usize] & 0x80) != 0; + let win_down = (keyboard_state[VK_LWIN.0 as usize] & 0x80) != 0 + || (keyboard_state[VK_RWIN.0 as usize] & 0x80) != 0; + + let has_modifiers = ctrl_down || alt_down || win_down; + if !has_modifiers { + return (key_char, false); + } + + let mut state_no_modifiers = keyboard_state; + state_no_modifiers[VK_CONTROL.0 as usize] = 0; + state_no_modifiers[VK_LCONTROL.0 as usize] = 0; + state_no_modifiers[VK_RCONTROL.0 as usize] = 0; + state_no_modifiers[VK_MENU.0 as usize] = 0; + state_no_modifiers[VK_LMENU.0 as usize] = 0; + state_no_modifiers[VK_RMENU.0 as usize] = 0; + state_no_modifiers[VK_LWIN.0 as usize] = 0; + state_no_modifiers[VK_RWIN.0 as usize] = 0; + + let mut buffer_c_no_modifiers = [0u16; 8]; + let result_c_no_modifiers = unsafe { + ToUnicode( + vkey.0 as u32, + scan_code as u32, + Some(&state_no_modifiers), + &mut buffer_c_no_modifiers, + 0x4, + ) + }; + + let c_no_modifiers = &buffer_c_no_modifiers[..result_c_no_modifiers.unsigned_abs() as usize]; + ( key_char, - }) + result_c != result_c_no_modifiers || c != c_no_modifiers, + ) } fn parse_ime_composition_string(ctx: HIMC, comp_type: IME_COMPOSITION_STRING) -> Option { @@ -1449,11 +1469,9 @@ fn is_virtual_key_pressed(vkey: VIRTUAL_KEY) -> bool { #[inline] pub(crate) fn current_modifiers() -> Modifiers { - let altgr = is_virtual_key_pressed(VK_RMENU) && is_virtual_key_pressed(VK_LCONTROL); - Modifiers { - control: is_virtual_key_pressed(VK_CONTROL) && !altgr, - alt: is_virtual_key_pressed(VK_MENU) && !altgr, + control: is_virtual_key_pressed(VK_CONTROL), + alt: is_virtual_key_pressed(VK_MENU), shift: is_virtual_key_pressed(VK_SHIFT), platform: is_virtual_key_pressed(VK_LWIN) || is_virtual_key_pressed(VK_RWIN), function: false, @@ -1487,7 +1505,7 @@ fn get_client_area_insets( // The top inset is calculated using an empirical formula that I derived through various // tests. Without this, the top 1-2 rows of pixels in our window would be obscured. let dpi = unsafe { GetDpiForWindow(handle) }; - let frame_thickness = get_frame_thickness(dpi); + let frame_thickness = get_frame_thicknessx(dpi); let top_insets = if is_maximized { frame_thickness } else { @@ -1508,12 +1526,18 @@ fn get_client_area_insets( // borders on Windows: // - SM_CXSIZEFRAME: The resize handle. // - SM_CXPADDEDBORDER: Additional border space that isn't part of the resize handle. -fn get_frame_thickness(dpi: u32) -> i32 { +fn get_frame_thicknessx(dpi: u32) -> i32 { let resize_frame_thickness = unsafe { GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) }; let padding_thickness = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) }; resize_frame_thickness + padding_thickness } +fn get_frame_thicknessy(dpi: u32) -> i32 { + let resize_frame_thickness = unsafe { GetSystemMetricsForDpi(SM_CYSIZEFRAME, dpi) }; + let padding_thickness = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) }; + resize_frame_thickness + padding_thickness +} + fn notify_frame_changed(handle: HWND) { unsafe { SetWindowPos( diff --git a/crates/gpui/src/platform/windows/keyboard.rs b/crates/gpui/src/platform/windows/keyboard.rs index 259ebaebff..627988be57 100644 --- a/crates/gpui/src/platform/windows/keyboard.rs +++ b/crates/gpui/src/platform/windows/keyboard.rs @@ -9,7 +9,6 @@ use windows::Win32::UI::{ }, WindowsAndMessaging::KL_NAMELENGTH, }; -use windows_core::HSTRING; use crate::{ KeybindingKeystroke, Keystroke, Modifiers, PlatformKeyboardLayout, PlatformKeyboardMapper, @@ -93,14 +92,13 @@ impl PlatformKeyboardMapper for WindowsKeyboardMapper { impl WindowsKeyboardLayout { pub(crate) fn new() -> Result { - let mut buffer = [0u16; KL_NAMELENGTH as usize]; + let mut buffer = [0u16; KL_NAMELENGTH as usize]; // KL_NAMELENGTH includes the null terminator unsafe { GetKeyboardLayoutNameW(&mut buffer)? }; - let id = HSTRING::from_wide(&buffer).to_string(); + let id = String::from_utf16_lossy(&buffer[..buffer.len() - 1]); // Remove the null terminator let entry = windows_registry::LOCAL_MACHINE.open(format!( - "System\\CurrentControlSet\\Control\\Keyboard Layouts\\{}", - id + "System\\CurrentControlSet\\Control\\Keyboard Layouts\\{id}" ))?; - let name = entry.get_hstring("Layout Text")?.to_string(); + let name = entry.get_string("Layout Text")?; Ok(Self { id, name }) } @@ -227,7 +225,7 @@ pub(crate) fn generate_key_char( } let mut buffer = [0; 8]; - let len = unsafe { ToUnicode(vkey.0 as u32, scan_code, Some(&state), &mut buffer, 1 << 2) }; + let len = unsafe { ToUnicode(vkey.0 as u32, scan_code, Some(&state), &mut buffer, 0x5) }; match len { len if len > 0 => String::from_utf16(&buffer[..len as usize]) diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 5219d8c817..fa847bca6b 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -1,15 +1,16 @@ use std::{ - cell::RefCell, + cell::{Cell, RefCell}, ffi::OsStr, - mem::ManuallyDrop, path::{Path, PathBuf}, rc::{Rc, Weak}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; use ::util::{ResultExt, paths::SanitizedPath}; use anyhow::{Context as _, Result, anyhow}; -use async_task::Runnable; use futures::channel::oneshot::{self, Receiver}; use itertools::Itertools; use parking_lot::RwLock; @@ -38,36 +39,40 @@ pub(crate) struct WindowsPlatform { text_system: Arc, windows_version: WindowsVersion, drop_target_helper: IDropTargetHelper, + /// Flag to instruct the `VSyncProvider` thread to invalidate the directx devices + /// as resizing them has failed, causing us to have lost at least the render target. + invalidate_devices: Arc, handle: HWND, disable_direct_composition: bool, } struct WindowsPlatformInner { - state: RefCell, + state: WindowsPlatformState, raw_window_handles: std::sync::Weak>>, // The below members will never change throughout the entire lifecycle of the app. validation_number: usize, - main_receiver: flume::Receiver, + main_receiver: PriorityQueueReceiver, + dispatcher: Arc, } pub(crate) struct WindowsPlatformState { callbacks: PlatformCallbacks, - menus: Vec, - jump_list: JumpList, + menus: RefCell>, + jump_list: RefCell, // NOTE: standard cursor handles don't need to close. - pub(crate) current_cursor: Option, - directx_devices: ManuallyDrop, + pub(crate) current_cursor: Cell>, + directx_devices: RefCell>, } #[derive(Default)] struct PlatformCallbacks { - open_urls: Option)>>, - quit: Option>, - reopen: Option>, - app_menu_action: Option>, - will_open_app_menu: Option>, - validate_app_menu_command: Option bool>>, - keyboard_layout_change: Option>, + open_urls: Cell)>>>, + quit: Cell>>, + reopen: Cell>>, + app_menu_action: Cell>>, + will_open_app_menu: Cell>>, + validate_app_menu_command: Cell bool>>>, + keyboard_layout_change: Cell>>, } impl WindowsPlatformState { @@ -75,14 +80,14 @@ impl WindowsPlatformState { let callbacks = PlatformCallbacks::default(); let jump_list = JumpList::new(); let current_cursor = load_cursor(CursorStyle::Arrow); - let directx_devices = ManuallyDrop::new(directx_devices); + let directx_devices = Some(directx_devices); Self { callbacks, - jump_list, - current_cursor, - directx_devices, - menus: Vec::new(), + jump_list: RefCell::new(jump_list), + current_cursor: Cell::new(current_cursor), + directx_devices: RefCell::new(directx_devices), + menus: RefCell::new(Vec::new()), } } } @@ -93,7 +98,7 @@ impl WindowsPlatform { OleInitialize(None).context("unable to initialize Windows OLE")?; } let directx_devices = DirectXDevices::new().context("Creating DirectX devices")?; - let (main_sender, main_receiver) = flume::unbounded::(); + let (main_sender, main_receiver) = PriorityQueueReceiver::new(); let validation_number = if usize::BITS == 64 { rand::random::() as usize } else { @@ -109,8 +114,10 @@ impl WindowsPlatform { inner: None, raw_window_handles: Arc::downgrade(&raw_window_handles), validation_number, + main_sender: Some(main_sender), main_receiver: Some(main_receiver), directx_devices: Some(directx_devices), + dispatcher: None, }; let result = unsafe { CreateWindowExW( @@ -125,16 +132,19 @@ impl WindowsPlatform { Some(HWND_MESSAGE), None, None, - Some(&context as *const _ as *const _), + Some(&raw const context as *const _), ) }; - let inner = context.inner.take().unwrap()?; + let inner = context + .inner + .take() + .context("CreateWindowExW did not run correctly")??; + let dispatcher = context + .dispatcher + .take() + .context("CreateWindowExW did not run correctly")?; let handle = result?; - let dispatcher = Arc::new(WindowsDispatcher::new( - main_sender, - handle, - validation_number, - )); + let disable_direct_composition = std::env::var(DISABLE_DIRECT_COMPOSITION) .is_ok_and(|value| value == "true" || value == "1"); let background_executor = BackgroundExecutor::new(dispatcher.clone()); @@ -158,6 +168,7 @@ impl WindowsPlatform { disable_direct_composition, windows_version, drop_target_helper, + invalidate_devices: Arc::new(AtomicBool::new(false)), }) } @@ -183,14 +194,15 @@ impl WindowsPlatform { WindowCreationInfo { icon: self.icon, executor: self.foreground_executor.clone(), - current_cursor: self.inner.state.borrow().current_cursor, + current_cursor: self.inner.state.current_cursor.get(), windows_version: self.windows_version, drop_target_helper: self.drop_target_helper.clone(), validation_number: self.inner.validation_number, main_receiver: self.inner.main_receiver.clone(), platform_window_handle: self.handle, disable_direct_composition: self.disable_direct_composition, - directx_devices: (*self.inner.state.borrow().directx_devices).clone(), + directx_devices: self.inner.state.directx_devices.borrow().clone().unwrap(), + invalidate_devices: self.invalidate_devices.clone(), } } @@ -201,9 +213,8 @@ impl WindowsPlatform { actions.push(dock_menu); } }); - let mut lock = self.inner.state.borrow_mut(); - lock.jump_list.dock_menus = actions; - update_jump_list(&lock.jump_list).log_err(); + self.inner.state.jump_list.borrow_mut().dock_menus = actions; + update_jump_list(&self.inner.state.jump_list.borrow()).log_err(); } fn update_jump_list( @@ -217,12 +228,10 @@ impl WindowsPlatform { actions.push(dock_menu); } }); - let mut lock = self.inner.state.borrow_mut(); - lock.jump_list.dock_menus = actions; - lock.jump_list.recent_workspaces = entries; - update_jump_list(&lock.jump_list) - .log_err() - .unwrap_or_default() + let mut jump_list = self.inner.state.jump_list.borrow_mut(); + jump_list.dock_menus = actions; + jump_list.recent_workspaces = entries; + update_jump_list(&jump_list).log_err().unwrap_or_default() } fn find_current_active_window(&self) -> Option { @@ -238,25 +247,31 @@ impl WindowsPlatform { } fn begin_vsync_thread(&self) { - let mut directx_device = (*self.inner.state.borrow().directx_devices).clone(); + let mut directx_device = self.inner.state.directx_devices.borrow().clone().unwrap(); let platform_window: SafeHwnd = self.handle.into(); let validation_number = self.inner.validation_number; let all_windows = Arc::downgrade(&self.raw_window_handles); let text_system = Arc::downgrade(&self.text_system); + let invalidate_devices = self.invalidate_devices.clone(); + std::thread::Builder::new() .name("VSyncProvider".to_owned()) .spawn(move || { let vsync_provider = VSyncProvider::new(); loop { vsync_provider.wait_for_vsync(); - if check_device_lost(&directx_device.device) { - handle_gpu_device_lost( + if check_device_lost(&directx_device.device) + || invalidate_devices.fetch_and(false, Ordering::Acquire) + { + if let Err(err) = handle_gpu_device_lost( &mut directx_device, platform_window.as_raw(), validation_number, &all_windows, &text_system, - ); + ) { + panic!("Device lost: {err}"); + } } let Some(all_windows) = all_windows.upgrade() else { break; @@ -272,6 +287,22 @@ impl WindowsPlatform { } } +fn translate_accelerator(msg: &MSG) -> Option<()> { + if msg.message != WM_KEYDOWN && msg.message != WM_SYSKEYDOWN { + return None; + } + + let result = unsafe { + SendMessageW( + msg.hwnd, + WM_GPUI_KEYDOWN, + Some(msg.wParam), + Some(msg.lParam), + ) + }; + (result.0 == 0).then_some(()) +} + impl Platform for WindowsPlatform { fn background_executor(&self) -> BackgroundExecutor { self.background_executor.clone() @@ -300,9 +331,9 @@ impl Platform for WindowsPlatform { fn on_keyboard_layout_change(&self, callback: Box) { self.inner .state - .borrow_mut() .callbacks - .keyboard_layout_change = Some(callback); + .keyboard_layout_change + .set(Some(callback)); } fn run(&self, on_finish_launching: Box) { @@ -312,13 +343,15 @@ impl Platform for WindowsPlatform { let mut msg = MSG::default(); unsafe { while GetMessageW(&mut msg, None, 0, 0).as_bool() { - DispatchMessageW(&msg); + if translate_accelerator(&msg).is_none() { + _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } } } - if let Some(ref mut callback) = self.inner.state.borrow_mut().callbacks.quit { - callback(); - } + self.inner + .with_callback(|callbacks| &callbacks.quit, |callback| callback()); } fn quit(&self) { @@ -353,11 +386,12 @@ impl Platform for WindowsPlatform { #[allow( clippy::disallowed_methods, reason = "We are restarting ourselves, using std command thus is fine" - )] - let restart_process = util::command::new_std_command("powershell.exe") - .arg("-command") - .arg(script) - .spawn(); + )] // todo(shell): There might be no powershell on the system + let restart_process = + util::command::new_std_command(util::shell::get_windows_system_shell()) + .arg("-command") + .arg(script) + .spawn(); match restart_process { Ok(_) => self.quit(), @@ -436,7 +470,7 @@ impl Platform for WindowsPlatform { } fn on_open_urls(&self, callback: Box)>) { - self.inner.state.borrow_mut().callbacks.open_urls = Some(callback); + self.inner.state.callbacks.open_urls.set(Some(callback)); } fn prompt_for_paths( @@ -506,19 +540,19 @@ impl Platform for WindowsPlatform { } fn on_quit(&self, callback: Box) { - self.inner.state.borrow_mut().callbacks.quit = Some(callback); + self.inner.state.callbacks.quit.set(Some(callback)); } fn on_reopen(&self, callback: Box) { - self.inner.state.borrow_mut().callbacks.reopen = Some(callback); + self.inner.state.callbacks.reopen.set(Some(callback)); } fn set_menus(&self, menus: Vec, _keymap: &Keymap) { - self.inner.state.borrow_mut().menus = menus.into_iter().map(|menu| menu.owned()).collect(); + *self.inner.state.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect(); } fn get_menus(&self) -> Option> { - Some(self.inner.state.borrow().menus.clone()) + Some(self.inner.state.menus.borrow().clone()) } fn set_dock_menu(&self, menus: Vec, _keymap: &Keymap) { @@ -526,19 +560,27 @@ impl Platform for WindowsPlatform { } fn on_app_menu_action(&self, callback: Box) { - self.inner.state.borrow_mut().callbacks.app_menu_action = Some(callback); + self.inner + .state + .callbacks + .app_menu_action + .set(Some(callback)); } fn on_will_open_app_menu(&self, callback: Box) { - self.inner.state.borrow_mut().callbacks.will_open_app_menu = Some(callback); + self.inner + .state + .callbacks + .will_open_app_menu + .set(Some(callback)); } fn on_validate_app_menu_command(&self, callback: Box bool>) { self.inner .state - .borrow_mut() .callbacks - .validate_app_menu_command = Some(callback); + .validate_app_menu_command + .set(Some(callback)); } fn app_path(&self) -> Result { @@ -552,14 +594,13 @@ impl Platform for WindowsPlatform { fn set_cursor_style(&self, style: CursorStyle) { let hcursor = load_cursor(style); - let mut lock = self.inner.state.borrow_mut(); - if lock.current_cursor.map(|c| c.0) != hcursor.map(|c| c.0) { + if self.inner.state.current_cursor.get().map(|c| c.0) != hcursor.map(|c| c.0) { self.post_message( WM_GPUI_CURSOR_STYLE_CHANGED, WPARAM(0), LPARAM(hcursor.map_or(0, |c| c.0 as isize)), ); - lock.current_cursor = hcursor; + self.inner.state.current_cursor.set(hcursor); } } @@ -606,15 +647,24 @@ impl Platform for WindowsPlatform { .collect_vec(); self.foreground_executor().spawn(async move { let mut credentials: *mut CREDENTIALW = std::ptr::null_mut(); - unsafe { + let result = unsafe { CredReadW( PCWSTR::from_raw(target_name.as_ptr()), CRED_TYPE_GENERIC, None, &mut credentials, - )? + ) }; + if let Err(err) = result { + // ERROR_NOT_FOUND means the credential doesn't exist. + // Return Ok(None) to match macOS and Linux behavior. + if err.code().0 == ERROR_NOT_FOUND.0 as i32 { + return Ok(None); + } + return Err(err.into()); + } + if credentials.is_null() { Ok(None) } else { @@ -676,17 +726,41 @@ impl Platform for WindowsPlatform { impl WindowsPlatformInner { fn new(context: &mut PlatformWindowCreateContext) -> Result> { - let state = RefCell::new(WindowsPlatformState::new( - context.directx_devices.take().unwrap(), - )); + let state = WindowsPlatformState::new( + context + .directx_devices + .take() + .context("missing directx devices")?, + ); Ok(Rc::new(Self { state, raw_window_handles: context.raw_window_handles.clone(), + dispatcher: context + .dispatcher + .as_ref() + .context("missing dispatcher")? + .clone(), validation_number: context.validation_number, - main_receiver: context.main_receiver.take().unwrap(), + main_receiver: context + .main_receiver + .take() + .context("missing main receiver")?, })) } + /// Calls `project` to project to the corresponding callback field, removes it from callbacks, calls `f` with the callback and then puts the callback back. + fn with_callback( + &self, + project: impl Fn(&PlatformCallbacks) -> &Cell>, + f: impl FnOnce(&mut T), + ) { + let callback = project(&self.state.callbacks).take(); + if let Some(mut callback) = callback { + f(&mut callback); + project(&self.state.callbacks).set(Some(callback)); + } + } + fn handle_msg( self: &Rc, handle: HWND, @@ -716,9 +790,7 @@ impl WindowsPlatformInner { } match message { WM_GPUI_CLOSE_ONE_WINDOW => { - if self.close_one_window(HWND(lparam.0 as _)) { - unsafe { PostQuitMessage(0) }; - } + self.close_one_window(HWND(lparam.0 as _)); Some(0) } WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD => self.run_foreground_task(), @@ -746,51 +818,101 @@ impl WindowsPlatformInner { #[inline] fn run_foreground_task(&self) -> Option { - for runnable in self.main_receiver.drain() { - runnable.run(); + const MAIN_TASK_TIMEOUT: u128 = 10; + + let start = std::time::Instant::now(); + 'tasks: loop { + 'timeout_loop: loop { + if start.elapsed().as_millis() >= MAIN_TASK_TIMEOUT { + log::debug!("foreground task timeout reached"); + // we spent our budget on gpui tasks, we likely have a lot of work queued so drain system events first to stay responsive + // then quit out of foreground work to allow us to process other gpui events first before returning back to foreground task work + // if we don't we might not for example process window quit events + let mut msg = MSG::default(); + let process_message = |msg: &_| { + if translate_accelerator(msg).is_none() { + _ = unsafe { TranslateMessage(msg) }; + unsafe { DispatchMessageW(msg) }; + } + }; + let peek_msg = |msg: &mut _, msg_kind| unsafe { + PeekMessageW(msg, None, 0, 0, PM_REMOVE | msg_kind).as_bool() + }; + if peek_msg(&mut msg, PM_QS_PAINT) { + process_message(&msg); + } + while peek_msg(&mut msg, PM_QS_INPUT) { + process_message(&msg); + } + // Allow the main loop to process other gpui events before going back into `run_foreground_task` + unsafe { + if let Err(_) = PostMessageW( + Some(self.dispatcher.platform_window_handle.as_raw()), + WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, + WPARAM(self.validation_number), + LPARAM(0), + ) { + self.dispatcher.wake_posted.store(false, Ordering::Release); + }; + } + break 'tasks; + } + let mut main_receiver = self.main_receiver.clone(); + match main_receiver.try_pop() { + Ok(Some(runnable)) => WindowsDispatcher::execute_runnable(runnable), + _ => break 'timeout_loop, + } + } + + // Someone could enqueue a Runnable here. The flag is still true, so they will not PostMessage. + // We need to check for those Runnables after we clear the flag. + self.dispatcher.wake_posted.store(false, Ordering::Release); + let mut main_receiver = self.main_receiver.clone(); + match main_receiver.try_pop() { + Ok(Some(runnable)) => { + self.dispatcher.wake_posted.store(true, Ordering::Release); + + WindowsDispatcher::execute_runnable(runnable); + } + _ => break 'tasks, + } } + Some(0) } fn handle_dock_action_event(&self, action_idx: usize) -> Option { - let mut lock = self.state.borrow_mut(); - let mut callback = lock.callbacks.app_menu_action.take()?; - let Some(action) = lock + let Some(action) = self + .state .jump_list + .borrow() .dock_menus .get(action_idx) .map(|dock_menu| dock_menu.action.boxed_clone()) else { - lock.callbacks.app_menu_action = Some(callback); log::error!("Dock menu for index {action_idx} not found"); return Some(1); }; - drop(lock); - callback(&*action); - self.state.borrow_mut().callbacks.app_menu_action = Some(callback); + self.with_callback( + |callbacks| &callbacks.app_menu_action, + |callback| callback(&*action), + ); Some(0) } fn handle_keyboard_layout_change(&self) -> Option { - let mut callback = self - .state - .borrow_mut() - .callbacks - .keyboard_layout_change - .take()?; - callback(); - self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); + self.with_callback( + |callbacks| &callbacks.keyboard_layout_change, + |callback| callback(), + ); Some(0) } fn handle_device_lost(&self, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); let directx_devices = lparam.0 as *const DirectXDevices; let directx_devices = unsafe { &*directx_devices }; - unsafe { - ManuallyDrop::drop(&mut lock.directx_devices); - } - lock.directx_devices = ManuallyDrop::new(directx_devices.clone()); + self.state.directx_devices.borrow_mut().take(); + *self.state.directx_devices.borrow_mut() = Some(directx_devices.clone()); Some(0) } @@ -807,14 +929,6 @@ impl Drop for WindowsPlatform { } } -impl Drop for WindowsPlatformState { - fn drop(&mut self) { - unsafe { - ManuallyDrop::drop(&mut self.directx_devices); - } - } -} - pub(crate) struct WindowCreationInfo { pub(crate) icon: HICON, pub(crate) executor: ForegroundExecutor, @@ -822,18 +936,23 @@ pub(crate) struct WindowCreationInfo { pub(crate) windows_version: WindowsVersion, pub(crate) drop_target_helper: IDropTargetHelper, pub(crate) validation_number: usize, - pub(crate) main_receiver: flume::Receiver, + pub(crate) main_receiver: PriorityQueueReceiver, pub(crate) platform_window_handle: HWND, pub(crate) disable_direct_composition: bool, pub(crate) directx_devices: DirectXDevices, + /// Flag to instruct the `VSyncProvider` thread to invalidate the directx devices + /// as resizing them has failed, causing us to have lost at least the render target. + pub(crate) invalidate_devices: Arc, } struct PlatformWindowCreateContext { inner: Option>>, raw_window_handles: std::sync::Weak>>, validation_number: usize, - main_receiver: Option>, + main_sender: Option>, + main_receiver: Option>, directx_devices: Option, + dispatcher: Option>, } fn open_target(target: impl AsRef) -> Result<()> { @@ -951,17 +1070,30 @@ fn file_save_dialog( ) -> Result> { let dialog: IFileSaveDialog = unsafe { CoCreateInstance(&FileSaveDialog, None, CLSCTX_ALL)? }; if !directory.to_string_lossy().is_empty() - && let Some(full_path) = directory.canonicalize().log_err() + && let Some(full_path) = directory + .canonicalize() + .context("failed to canonicalize directory") + .log_err() { let full_path = SanitizedPath::new(&full_path); let full_path_string = full_path.to_string(); let path_item: IShellItem = unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_string), None)? }; - unsafe { dialog.SetFolder(&path_item).log_err() }; + unsafe { + dialog + .SetFolder(&path_item) + .context("failed to set dialog folder") + .log_err() + }; } if let Some(suggested_name) = suggested_name { - unsafe { dialog.SetFileName(&HSTRING::from(suggested_name)).log_err() }; + unsafe { + dialog + .SetFileName(&HSTRING::from(suggested_name)) + .context("failed to set file name") + .log_err() + }; } unsafe { @@ -1023,37 +1155,28 @@ fn handle_gpu_device_lost( validation_number: usize, all_windows: &std::sync::Weak>>, text_system: &std::sync::Weak, -) { +) -> Result<()> { // Here we wait a bit to ensure the system has time to recover from the device lost state. // If we don't wait, the final drawing result will be blank. std::thread::sleep(std::time::Duration::from_millis(350)); - try_to_recover_from_device_lost( - || { - DirectXDevices::new() - .context("Failed to recreate new DirectX devices after device lost") - }, - |new_devices| *directx_devices = new_devices, - || { - log::error!("Failed to recover DirectX devices after multiple attempts."); - // Do something here? - // At this point, the device loss is considered unrecoverable. - // std::process::exit(1); - }, - ); + *directx_devices = try_to_recover_from_device_lost(|| { + DirectXDevices::new().context("Failed to recreate new DirectX devices after device lost") + })?; log::info!("DirectX devices successfully recreated."); + let lparam = LPARAM(directx_devices as *const _ as _); unsafe { SendMessageW( platform_window, WM_GPUI_GPU_DEVICE_LOST, Some(WPARAM(validation_number)), - Some(LPARAM(directx_devices as *const _ as _)), + Some(lparam), ); } if let Some(text_system) = text_system.upgrade() { - text_system.handle_gpu_lost(&directx_devices); + text_system.handle_gpu_lost(&directx_devices)?; } if let Some(all_windows) = all_windows.upgrade() { for window in all_windows.read().iter() { @@ -1062,7 +1185,7 @@ fn handle_gpu_device_lost( window.as_raw(), WM_GPUI_GPU_DEVICE_LOST, Some(WPARAM(validation_number)), - Some(LPARAM(directx_devices as *const _ as _)), + Some(lparam), ); } } @@ -1078,6 +1201,7 @@ fn handle_gpu_device_lost( } } } + Ok(()) } const PLATFORM_WINDOW_CLASS_NAME: PCWSTR = w!("Zed::PlatformWindow"); @@ -1098,10 +1222,20 @@ unsafe extern "system" fn window_procedure( lparam: LPARAM, ) -> LRESULT { if msg == WM_NCCREATE { - let params = lparam.0 as *const CREATESTRUCTW; - let params = unsafe { &*params }; + let params = unsafe { &*(lparam.0 as *const CREATESTRUCTW) }; let creation_context = params.lpCreateParams as *mut PlatformWindowCreateContext; let creation_context = unsafe { &mut *creation_context }; + + let Some(main_sender) = creation_context.main_sender.take() else { + creation_context.inner = Some(Err(anyhow!("missing main sender"))); + return LRESULT(0); + }; + creation_context.dispatcher = Some(Arc::new(WindowsDispatcher::new( + main_sender, + hwnd, + creation_context.validation_number, + ))); + return match WindowsPlatformInner::new(creation_context) { Ok(inner) => { let weak = Box::new(Rc::downgrade(&inner)); diff --git a/crates/gpui/src/platform/windows/shaders.hlsl b/crates/gpui/src/platform/windows/shaders.hlsl index 2cef54ae61..d6168eea09 100644 --- a/crates/gpui/src/platform/windows/shaders.hlsl +++ b/crates/gpui/src/platform/windows/shaders.hlsl @@ -107,6 +107,12 @@ float4 distance_from_clip_rect(float2 unit_vertex, Bounds bounds, Bounds clip_bo return distance_from_clip_rect_impl(position, clip_bounds); } +float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds bounds, Bounds clip_bounds, TransformationMatrix transformation) { + float2 position = unit_vertex * bounds.size + bounds.origin; + float2 transformed = mul(position, transformation.rotation_scale) + transformation.translation; + return distance_from_clip_rect_impl(transformed, clip_bounds); +} + // Convert linear RGB to sRGB float3 linear_to_srgb(float3 color) { return pow(color, float3(2.2, 2.2, 2.2)); @@ -384,7 +390,7 @@ float4 gradient_color(Background background, float pattern_period = pattern_height * sin(stripe_angle); float2x2 rotation = rotate2d(stripe_angle); float2 relative_position = position - bounds.origin; - float2 rotated_point = mul(rotation, relative_position); + float2 rotated_point = mul(relative_position, rotation); float pattern = fmod(rotated_point.x, pattern_period); float distance = min(pattern, pattern_period - pattern) - pattern_period * (pattern_width / pattern_height) / 2.0f; color = solid_color; @@ -654,7 +660,14 @@ float4 quad_fragment(QuadFragmentInput input): SV_Target { // out on each straight line, rather than around the whole // perimeter. This way each line starts and ends with a dash. bool is_horizontal = corner_center_to_point.x < corner_center_to_point.y; - float border_width = is_horizontal ? border.x : border.y; + // Choosing the right border width for dashed borders. + // TODO: A better solution exists taking a look at the whole file. + // this does not fix single dashed borders at the corners + float2 dashed_border = float2( + max(quad.border_widths.bottom, quad.border_widths.top), + max(quad.border_widths.right, quad.border_widths.left) + ); + float border_width = is_horizontal ? dashed_border.x : dashed_border.y; dash_velocity = dv_numerator / border_width; t = is_horizontal ? the_point.x : the_point.y; t *= dash_velocity; @@ -1088,7 +1101,7 @@ MonochromeSpriteVertexOutput monochrome_sprite_vertex(uint vertex_id: SV_VertexI MonochromeSprite sprite = mono_sprites[sprite_id]; float4 device_position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation); - float4 clip_distance = distance_from_clip_rect(unit_vertex, sprite.bounds, sprite.content_mask); + float4 clip_distance = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); float2 tile_position = to_tile_position(unit_vertex, sprite.tile); float4 color = hsla_to_rgba(sprite.color); diff --git a/crates/gpui/src/platform/windows/system_settings.rs b/crates/gpui/src/platform/windows/system_settings.rs index b2bd289cd0..f5ef5ce31e 100644 --- a/crates/gpui/src/platform/windows/system_settings.rs +++ b/crates/gpui/src/platform/windows/system_settings.rs @@ -1,4 +1,7 @@ -use std::ffi::{c_uint, c_void}; +use std::{ + cell::Cell, + ffi::{c_uint, c_void}, +}; use ::util::ResultExt; use windows::Win32::UI::{ @@ -15,18 +18,18 @@ use super::WindowsDisplay; /// Windows settings pulled from SystemParametersInfo /// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-systemparametersinfow -#[derive(Default, Debug, Clone, Copy)] +#[derive(Default, Debug, Clone)] pub(crate) struct WindowsSystemSettings { pub(crate) mouse_wheel_settings: MouseWheelSettings, - pub(crate) auto_hide_taskbar_position: Option, + pub(crate) auto_hide_taskbar_position: Cell>, } -#[derive(Default, Debug, Clone, Copy)] +#[derive(Default, Debug, Clone)] pub(crate) struct MouseWheelSettings { /// SEE: SPI_GETWHEELSCROLLCHARS - pub(crate) wheel_scroll_chars: u32, + pub(crate) wheel_scroll_chars: Cell, /// SEE: SPI_GETWHEELSCROLLLINES - pub(crate) wheel_scroll_lines: u32, + pub(crate) wheel_scroll_lines: Cell, } impl WindowsSystemSettings { @@ -36,12 +39,13 @@ impl WindowsSystemSettings { settings } - fn init(&mut self, display: WindowsDisplay) { + fn init(&self, display: WindowsDisplay) { self.mouse_wheel_settings.update(); - self.auto_hide_taskbar_position = AutoHideTaskbarPosition::new(display).log_err().flatten(); + self.auto_hide_taskbar_position + .set(AutoHideTaskbarPosition::new(display).log_err().flatten()); } - pub(crate) fn update(&mut self, display: WindowsDisplay, wparam: usize) { + pub(crate) fn update(&self, display: WindowsDisplay, wparam: usize) { match wparam { // SPI_SETWORKAREA 47 => self.update_taskbar_position(display), @@ -51,22 +55,23 @@ impl WindowsSystemSettings { } } - fn update_mouse_wheel_settings(&mut self) { + fn update_mouse_wheel_settings(&self) { self.mouse_wheel_settings.update(); } - fn update_taskbar_position(&mut self, display: WindowsDisplay) { - self.auto_hide_taskbar_position = AutoHideTaskbarPosition::new(display).log_err().flatten(); + fn update_taskbar_position(&self, display: WindowsDisplay) { + self.auto_hide_taskbar_position + .set(AutoHideTaskbarPosition::new(display).log_err().flatten()); } } impl MouseWheelSettings { - fn update(&mut self) { + fn update(&self) { self.update_wheel_scroll_chars(); self.update_wheel_scroll_lines(); } - fn update_wheel_scroll_chars(&mut self) { + fn update_wheel_scroll_chars(&self) { let mut value = c_uint::default(); let result = unsafe { SystemParametersInfoW( @@ -77,12 +82,12 @@ impl MouseWheelSettings { ) }; - if result.log_err() != None && self.wheel_scroll_chars != value { - self.wheel_scroll_chars = value; + if result.log_err() != None && self.wheel_scroll_chars.get() != value { + self.wheel_scroll_chars.set(value); } } - fn update_wheel_scroll_lines(&mut self) { + fn update_wheel_scroll_lines(&self) { let mut value = c_uint::default(); let result = unsafe { SystemParametersInfoW( @@ -93,8 +98,8 @@ impl MouseWheelSettings { ) }; - if result.log_err() != None && self.wheel_scroll_lines != value { - self.wheel_scroll_lines = value; + if result.log_err() != None && self.wheel_scroll_lines.get() != value { + self.wheel_scroll_lines.set(value); } } } diff --git a/crates/gpui/src/platform/windows/vsync.rs b/crates/gpui/src/platform/windows/vsync.rs index 5cbcb8e99e..73c32cf9b9 100644 --- a/crates/gpui/src/platform/windows/vsync.rs +++ b/crates/gpui/src/platform/windows/vsync.rs @@ -5,23 +5,10 @@ use std::{ use anyhow::{Context, Result}; use util::ResultExt; -use windows::{ - Win32::{ - Foundation::{HANDLE, HWND}, - Graphics::{ - DirectComposition::{ - COMPOSITION_FRAME_ID_COMPLETED, COMPOSITION_FRAME_ID_TYPE, COMPOSITION_FRAME_STATS, - COMPOSITION_TARGET_ID, - }, - Dwm::{DWM_TIMING_INFO, DwmFlush, DwmGetCompositionTimingInfo}, - }, - System::{ - LibraryLoader::{GetModuleHandleA, GetProcAddress}, - Performance::QueryPerformanceFrequency, - Threading::INFINITE, - }, - }, - core::{HRESULT, s}, +use windows::Win32::{ + Foundation::HWND, + Graphics::Dwm::{DWM_TIMING_INFO, DwmFlush, DwmGetCompositionTimingInfo}, + System::Performance::QueryPerformanceFrequency, }; static QPC_TICKS_PER_SECOND: LazyLock = LazyLock::new(|| { @@ -35,20 +22,6 @@ static QPC_TICKS_PER_SECOND: LazyLock = LazyLock::new(|| { const VSYNC_INTERVAL_THRESHOLD: Duration = Duration::from_millis(1); const DEFAULT_VSYNC_INTERVAL: Duration = Duration::from_micros(16_666); // ~60Hz -// Here we are using dynamic loading of DirectComposition functions, -// or the app will refuse to start on windows systems that do not support DirectComposition. -type DCompositionGetFrameId = - unsafe extern "system" fn(frameidtype: COMPOSITION_FRAME_ID_TYPE, frameid: *mut u64) -> HRESULT; -type DCompositionGetStatistics = unsafe extern "system" fn( - frameid: u64, - framestats: *mut COMPOSITION_FRAME_STATS, - targetidcount: u32, - targetids: *mut COMPOSITION_TARGET_ID, - actualtargetidcount: *mut u32, -) -> HRESULT; -type DCompositionWaitForCompositorClock = - unsafe extern "system" fn(count: u32, handles: *const HANDLE, timeoutinms: u32) -> u32; - pub(crate) struct VSyncProvider { interval: Duration, f: Box bool>, @@ -56,35 +29,12 @@ pub(crate) struct VSyncProvider { impl VSyncProvider { pub(crate) fn new() -> Self { - if let Some((get_frame_id, get_statistics, wait_for_comp_clock)) = - initialize_direct_composition() - .context("Retrieving DirectComposition functions") - .log_with_level(log::Level::Warn) - { - let interval = get_dwm_interval_from_direct_composition(get_frame_id, get_statistics) - .context("Failed to get DWM interval from DirectComposition") - .log_err() - .unwrap_or(DEFAULT_VSYNC_INTERVAL); - log::info!( - "DirectComposition is supported for VSync, interval: {:?}", - interval - ); - let f = Box::new(move || unsafe { - wait_for_comp_clock(0, std::ptr::null(), INFINITE) == 0 - }); - Self { interval, f } - } else { - let interval = get_dwm_interval() - .context("Failed to get DWM interval") - .log_err() - .unwrap_or(DEFAULT_VSYNC_INTERVAL); - log::info!( - "DirectComposition is not supported for VSync, falling back to DWM, interval: {:?}", - interval - ); - let f = Box::new(|| unsafe { DwmFlush().is_ok() }); - Self { interval, f } - } + let interval = get_dwm_interval() + .context("Failed to get DWM interval") + .log_err() + .unwrap_or(DEFAULT_VSYNC_INTERVAL); + let f = Box::new(|| unsafe { DwmFlush().is_ok() }); + Self { interval, f } } pub(crate) fn wait_for_vsync(&self) { @@ -105,49 +55,6 @@ impl VSyncProvider { } } -fn initialize_direct_composition() -> Result<( - DCompositionGetFrameId, - DCompositionGetStatistics, - DCompositionWaitForCompositorClock, -)> { - unsafe { - // Load DLL at runtime since older Windows versions don't have dcomp. - let hmodule = GetModuleHandleA(s!("dcomp.dll")).context("Loading dcomp.dll")?; - let get_frame_id_addr = GetProcAddress(hmodule, s!("DCompositionGetFrameId")) - .context("Function DCompositionGetFrameId not found")?; - let get_statistics_addr = GetProcAddress(hmodule, s!("DCompositionGetStatistics")) - .context("Function DCompositionGetStatistics not found")?; - let wait_for_compositor_clock_addr = - GetProcAddress(hmodule, s!("DCompositionWaitForCompositorClock")) - .context("Function DCompositionWaitForCompositorClock not found")?; - let get_frame_id: DCompositionGetFrameId = std::mem::transmute(get_frame_id_addr); - let get_statistics: DCompositionGetStatistics = std::mem::transmute(get_statistics_addr); - let wait_for_compositor_clock: DCompositionWaitForCompositorClock = - std::mem::transmute(wait_for_compositor_clock_addr); - Ok((get_frame_id, get_statistics, wait_for_compositor_clock)) - } -} - -fn get_dwm_interval_from_direct_composition( - get_frame_id: DCompositionGetFrameId, - get_statistics: DCompositionGetStatistics, -) -> Result { - let mut frame_id = 0; - unsafe { get_frame_id(COMPOSITION_FRAME_ID_COMPLETED, &mut frame_id) }.ok()?; - let mut stats = COMPOSITION_FRAME_STATS::default(); - unsafe { - get_statistics( - frame_id, - &mut stats, - 0, - std::ptr::null_mut(), - std::ptr::null_mut(), - ) - } - .ok()?; - Ok(retrieve_duration(stats.framePeriod, *QPC_TICKS_PER_SECOND)) -} - fn get_dwm_interval() -> Result { let mut timing_info = DWM_TIMING_INFO { cbSize: std::mem::size_of::() as u32, diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 7abb4ee21a..0cfa812b28 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -1,24 +1,24 @@ #![deny(unsafe_op_in_unsafe_fn)] use std::{ - cell::RefCell, + cell::{Cell, RefCell}, num::NonZeroIsize, path::PathBuf, rc::{Rc, Weak}, str::FromStr, - sync::{Arc, Once}, + sync::{Arc, Once, atomic::AtomicBool}, time::{Duration, Instant}, }; use ::util::ResultExt; use anyhow::{Context as _, Result}; -use async_task::Runnable; use futures::channel::oneshot::{self, Receiver}; use raw_window_handle as rwh; use smallvec::SmallVec; use windows::{ Win32::{ Foundation::*, + Graphics::Dwm::*, Graphics::Gdi::*, System::{Com::*, LibraryLoader::*, Ole::*, SystemServices::*}, UI::{Controls::*, HiDpi::*, Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*}, @@ -30,49 +30,58 @@ use crate::*; pub(crate) struct WindowsWindow(pub Rc); +impl std::ops::Deref for WindowsWindow { + type Target = WindowsWindowInner; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + pub struct WindowsWindowState { - pub origin: Point, - pub logical_size: Size, + pub origin: Cell>, + pub logical_size: Cell>, pub min_size: Option>, - pub fullscreen_restore_bounds: Bounds, + pub fullscreen_restore_bounds: Cell>, pub border_offset: WindowBorderOffset, - pub appearance: WindowAppearance, - pub scale_factor: f32, - pub restore_from_minimized: Option>, + pub appearance: Cell, + pub scale_factor: Cell, + pub restore_from_minimized: Cell>>, pub callbacks: Callbacks, - pub input_handler: Option, - pub pending_surrogate: Option, - pub last_reported_modifiers: Option, - pub last_reported_capslock: Option, - pub system_key_handled: bool, - pub hovered: bool, + pub input_handler: Cell>, + pub pending_surrogate: Cell>, + pub last_reported_modifiers: Cell>, + pub last_reported_capslock: Cell>, + pub hovered: Cell, - pub renderer: DirectXRenderer, + pub renderer: RefCell, pub click_state: ClickState, - pub system_settings: WindowsSystemSettings, - pub current_cursor: Option, - pub nc_button_pressed: Option, + pub current_cursor: Cell>, + pub nc_button_pressed: Cell>, - pub display: WindowsDisplay, - fullscreen: Option, - initial_placement: Option, + pub display: Cell, + /// Flag to instruct the `VSyncProvider` thread to invalidate the directx devices + /// as resizing them has failed, causing us to have lost at least the render target. + pub invalidate_devices: Arc, + fullscreen: Cell>, + initial_placement: Cell>, hwnd: HWND, } pub(crate) struct WindowsWindowInner { hwnd: HWND, - pub(super) this: Weak, drop_target_helper: IDropTargetHelper, - pub(crate) state: RefCell, + pub(crate) state: WindowsWindowState, + system_settings: WindowsSystemSettings, pub(crate) handle: AnyWindowHandle, pub(crate) hide_title_bar: bool, pub(crate) is_movable: bool, pub(crate) executor: ForegroundExecutor, pub(crate) windows_version: WindowsVersion, pub(crate) validation_number: usize, - pub(crate) main_receiver: flume::Receiver, + pub(crate) main_receiver: PriorityQueueReceiver, pub(crate) platform_window_handle: HWND, } @@ -86,6 +95,7 @@ impl WindowsWindowState { min_size: Option>, appearance: WindowAppearance, disable_direct_composition: bool, + invalidate_devices: Arc, ) -> Result { let scale_factor = { let monitor_dpi = unsafe { GetDpiForWindow(hwnd) } as f32; @@ -112,45 +122,42 @@ impl WindowsWindowState { let pending_surrogate = None; let last_reported_modifiers = None; let last_reported_capslock = None; - let system_key_handled = false; let hovered = false; let click_state = ClickState::new(); - let system_settings = WindowsSystemSettings::new(display); let nc_button_pressed = None; let fullscreen = None; let initial_placement = None; Ok(Self { - origin, - logical_size, - fullscreen_restore_bounds, + origin: Cell::new(origin), + logical_size: Cell::new(logical_size), + fullscreen_restore_bounds: Cell::new(fullscreen_restore_bounds), border_offset, - appearance, - scale_factor, - restore_from_minimized, + appearance: Cell::new(appearance), + scale_factor: Cell::new(scale_factor), + restore_from_minimized: Cell::new(restore_from_minimized), min_size, callbacks, - input_handler, - pending_surrogate, - last_reported_modifiers, - last_reported_capslock, - system_key_handled, - hovered, - renderer, + input_handler: Cell::new(input_handler), + pending_surrogate: Cell::new(pending_surrogate), + last_reported_modifiers: Cell::new(last_reported_modifiers), + last_reported_capslock: Cell::new(last_reported_capslock), + hovered: Cell::new(hovered), + renderer: RefCell::new(renderer), click_state, - system_settings, - current_cursor, - nc_button_pressed, - display, - fullscreen, - initial_placement, + current_cursor: Cell::new(current_cursor), + nc_button_pressed: Cell::new(nc_button_pressed), + display: Cell::new(display), + fullscreen: Cell::new(fullscreen), + initial_placement: Cell::new(initial_placement), hwnd, + invalidate_devices, }) } #[inline] pub(crate) fn is_fullscreen(&self) -> bool { - self.fullscreen.is_some() + self.fullscreen.get().is_some() } pub(crate) fn is_maximized(&self) -> bool { @@ -159,8 +166,8 @@ impl WindowsWindowState { fn bounds(&self) -> Bounds { Bounds { - origin: self.origin, - size: self.logical_size, + origin: self.origin.get(), + size: self.logical_size.get(), } } @@ -171,14 +178,16 @@ impl WindowsWindowState { length: std::mem::size_of::() as u32, ..Default::default() }; - GetWindowPlacement(self.hwnd, &mut placement).log_err(); + GetWindowPlacement(self.hwnd, &mut placement) + .context("failed to get window placement") + .log_err(); placement }; ( calculate_client_rect( placement.rcNormalPosition, - self.border_offset, - self.scale_factor, + &self.border_offset, + self.scale_factor.get(), ), placement.showCmd == SW_SHOWMAXIMIZED.0 as u32, ) @@ -188,7 +197,7 @@ impl WindowsWindowState { let (bounds, maximized) = self.calculate_window_bounds(); if self.is_fullscreen() { - WindowBounds::Fullscreen(self.fullscreen_restore_bounds) + WindowBounds::Fullscreen(self.fullscreen_restore_bounds.get()) } else if maximized { WindowBounds::Maximized(bounds) } else { @@ -201,13 +210,13 @@ impl WindowsWindowState { /// Currently, GPUI uses the logical size of the app to handle mouse interactions (such as /// whether the mouse collides with other elements of GPUI). fn content_size(&self) -> Size { - self.logical_size + self.logical_size.get() } } impl WindowsWindowInner { fn new(context: &mut WindowCreateContext, hwnd: HWND, cs: &CREATESTRUCTW) -> Result> { - let state = RefCell::new(WindowsWindowState::new( + let state = WindowsWindowState::new( hwnd, &context.directx_devices, cs, @@ -216,11 +225,11 @@ impl WindowsWindowInner { context.min_size, context.appearance, context.disable_direct_composition, - )?); + context.invalidate_devices.clone(), + )?; - Ok(Rc::new_cyclic(|this| Self { + Ok(Rc::new(Self { hwnd, - this: this.clone(), drop_target_helper: context.drop_target_helper.clone(), state, handle: context.handle, @@ -231,54 +240,55 @@ impl WindowsWindowInner { validation_number: context.validation_number, main_receiver: context.main_receiver.clone(), platform_window_handle: context.platform_window_handle, + system_settings: WindowsSystemSettings::new(context.display), })) } - fn toggle_fullscreen(&self) { - let Some(this) = self.this.upgrade() else { - log::error!("Unable to toggle fullscreen: window has been dropped"); - return; - }; + fn toggle_fullscreen(self: &Rc) { + let this = self.clone(); self.executor .spawn(async move { - let mut lock = this.state.borrow_mut(); let StyleAndBounds { style, x, y, cx, cy, - } = if let Some(state) = lock.fullscreen.take() { - state - } else { - let (window_bounds, _) = lock.calculate_window_bounds(); - lock.fullscreen_restore_bounds = window_bounds; - let style = WINDOW_STYLE(unsafe { get_window_long(this.hwnd, GWL_STYLE) } as _); - let mut rc = RECT::default(); - unsafe { GetWindowRect(this.hwnd, &mut rc) }.log_err(); - let _ = lock.fullscreen.insert(StyleAndBounds { - style, - x: rc.left, - y: rc.top, - cx: rc.right - rc.left, - cy: rc.bottom - rc.top, - }); - let style = style - & !(WS_THICKFRAME - | WS_SYSMENU - | WS_MAXIMIZEBOX - | WS_MINIMIZEBOX - | WS_CAPTION); - let physical_bounds = lock.display.physical_bounds(); - StyleAndBounds { - style, - x: physical_bounds.left().0, - y: physical_bounds.top().0, - cx: physical_bounds.size.width.0, - cy: physical_bounds.size.height.0, + } = match this.state.fullscreen.take() { + Some(state) => state, + None => { + let (window_bounds, _) = this.state.calculate_window_bounds(); + this.state.fullscreen_restore_bounds.set(window_bounds); + + let style = + WINDOW_STYLE(unsafe { get_window_long(this.hwnd, GWL_STYLE) } as _); + let mut rc = RECT::default(); + unsafe { GetWindowRect(this.hwnd, &mut rc) } + .context("failed to get window rect") + .log_err(); + let _ = this.state.fullscreen.set(Some(StyleAndBounds { + style, + x: rc.left, + y: rc.top, + cx: rc.right - rc.left, + cy: rc.bottom - rc.top, + })); + let style = style + & !(WS_THICKFRAME + | WS_SYSMENU + | WS_MAXIMIZEBOX + | WS_MINIMIZEBOX + | WS_CAPTION); + let physical_bounds = this.state.display.get().physical_bounds(); + StyleAndBounds { + style, + x: physical_bounds.left().0, + y: physical_bounds.top().0, + cx: physical_bounds.size.width.0, + cy: physical_bounds.size.height.0, + } } }; - drop(lock); unsafe { set_window_long(this.hwnd, GWL_STYLE, style.0 as isize) }; unsafe { SetWindowPos( @@ -296,39 +306,48 @@ impl WindowsWindowInner { .detach(); } - fn set_window_placement(&self) -> Result<()> { - let Some(open_status) = self.state.borrow_mut().initial_placement.take() else { + fn set_window_placement(self: &Rc) -> Result<()> { + let Some(open_status) = self.state.initial_placement.take() else { return Ok(()); }; match open_status.state { WindowOpenState::Maximized => unsafe { - SetWindowPlacement(self.hwnd, &open_status.placement)?; + SetWindowPlacement(self.hwnd, &open_status.placement) + .context("failed to set window placement")?; ShowWindowAsync(self.hwnd, SW_MAXIMIZE).ok()?; }, WindowOpenState::Fullscreen => { - unsafe { SetWindowPlacement(self.hwnd, &open_status.placement)? }; + unsafe { + SetWindowPlacement(self.hwnd, &open_status.placement) + .context("failed to set window placement")? + }; self.toggle_fullscreen(); } WindowOpenState::Windowed => unsafe { - SetWindowPlacement(self.hwnd, &open_status.placement)?; + SetWindowPlacement(self.hwnd, &open_status.placement) + .context("failed to set window placement")?; }, } Ok(()) } + + pub(crate) fn system_settings(&self) -> &WindowsSystemSettings { + &self.system_settings + } } #[derive(Default)] pub(crate) struct Callbacks { - pub(crate) request_frame: Option>, - pub(crate) input: Option DispatchEventResult>>, - pub(crate) active_status_change: Option>, - pub(crate) hovered_status_change: Option>, - pub(crate) resize: Option, f32)>>, - pub(crate) moved: Option>, - pub(crate) should_close: Option bool>>, - pub(crate) close: Option>, - pub(crate) hit_test_window_control: Option Option>>, - pub(crate) appearance_changed: Option>, + pub(crate) request_frame: Cell>>, + pub(crate) input: Cell DispatchEventResult>>>, + pub(crate) active_status_change: Cell>>, + pub(crate) hovered_status_change: Cell>>, + pub(crate) resize: Cell, f32)>>>, + pub(crate) moved: Cell>>, + pub(crate) should_close: Cell bool>>>, + pub(crate) close: Cell>>, + pub(crate) hit_test_window_control: Cell Option>>>, + pub(crate) appearance_changed: Cell>>, } struct WindowCreateContext { @@ -343,11 +362,12 @@ struct WindowCreateContext { windows_version: WindowsVersion, drop_target_helper: IDropTargetHelper, validation_number: usize, - main_receiver: flume::Receiver, + main_receiver: PriorityQueueReceiver, platform_window_handle: HWND, appearance: WindowAppearance, disable_direct_composition: bool, directx_devices: DirectXDevices, + invalidate_devices: Arc, } impl WindowsWindow { @@ -367,6 +387,7 @@ impl WindowsWindow { platform_window_handle, disable_direct_composition, directx_devices, + invalidate_devices, } = creation_info; register_window_class(icon); let hide_title_bar = params @@ -427,6 +448,7 @@ impl WindowsWindow { appearance, disable_direct_composition, directx_devices, + invalidate_devices, }; let creation_result = unsafe { CreateWindowExW( @@ -447,26 +469,27 @@ impl WindowsWindow { // Failure to create a `WindowsWindowState` can cause window creation to fail, // so check the inner result first. - let this = context.inner.take().unwrap()?; + let this = context.inner.take().transpose()?; let hwnd = creation_result?; + let this = this.unwrap(); register_drag_drop(&this)?; configure_dwm_dark_mode(hwnd, appearance); - this.state.borrow_mut().border_offset.update(hwnd)?; + this.state.border_offset.update(hwnd)?; let placement = retrieve_window_placement( hwnd, display, params.bounds, - this.state.borrow().scale_factor, - this.state.borrow().border_offset, + this.state.scale_factor.get(), + &this.state.border_offset, )?; if params.show { unsafe { SetWindowPlacement(hwnd, &placement)? }; } else { - this.state.borrow_mut().initial_placement = Some(WindowOpenStatus { + this.state.initial_placement.set(Some(WindowOpenStatus { placement, state: WindowOpenState::Windowed, - }); + })); } Ok(Self(this)) @@ -509,15 +532,15 @@ impl Drop for WindowsWindow { impl PlatformWindow for WindowsWindow { fn bounds(&self) -> Bounds { - self.0.state.borrow().bounds() + self.state.bounds() } fn is_maximized(&self) -> bool { - self.0.state.borrow().is_maximized() + self.state.is_maximized() } fn window_bounds(&self) -> WindowBounds { - self.0.state.borrow().window_bounds() + self.state.window_bounds() } /// get the logical size of the app's drawable area. @@ -525,14 +548,14 @@ impl PlatformWindow for WindowsWindow { /// Currently, GPUI uses the logical size of the app to handle mouse interactions (such as /// whether the mouse collides with other elements of GPUI). fn content_size(&self) -> Size { - self.0.state.borrow().content_size() + self.state.content_size() } fn resize(&mut self, size: Size) { let hwnd = self.0.hwnd; let bounds = crate::bounds(self.bounds().origin, size).to_device_pixels(self.scale_factor()); - let rect = calculate_window_rect(bounds, self.0.state.borrow().border_offset); + let rect = calculate_window_rect(bounds, &self.state.border_offset); self.0 .executor @@ -555,15 +578,15 @@ impl PlatformWindow for WindowsWindow { } fn scale_factor(&self) -> f32 { - self.0.state.borrow().scale_factor + self.state.scale_factor.get() } fn appearance(&self) -> WindowAppearance { - self.0.state.borrow().appearance + self.state.appearance.get() } fn display(&self) -> Option> { - Some(Rc::new(self.0.state.borrow().display)) + Some(Rc::new(self.state.display.get())) } fn mouse_position(&self) -> Point { @@ -588,11 +611,11 @@ impl PlatformWindow for WindowsWindow { } fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { - self.0.state.borrow_mut().input_handler = Some(input_handler); + self.state.input_handler.set(Some(input_handler)); } fn take_input_handler(&mut self) -> Option { - self.0.state.borrow_mut().input_handler.take() + self.state.input_handler.take() } fn prompt( @@ -644,10 +667,12 @@ impl PlatformWindow for WindowsWindow { let mut btn_encoded = Vec::new(); for (index, btn) in answers.iter().enumerate() { let encoded = HSTRING::from(btn.label().as_ref()); - let button_id = if btn.is_cancel() { - IDCANCEL.0 - } else { - index as i32 - 100 + let button_id = match btn { + PromptButton::Ok(_) => IDOK.0, + PromptButton::Cancel(_) => IDCANCEL.0, + // the first few low integer values are reserved for known buttons + // so for simplicity we just go backwards from -1 + PromptButton::Other(_) => -(index as i32) - 1, }; button_id_map.push(button_id); buttons.push(TASKDIALOG_BUTTON { @@ -665,11 +690,11 @@ impl PlatformWindow for WindowsWindow { .context("unable to create task dialog") .log_err(); - let clicked = button_id_map - .iter() - .position(|&button_id| button_id == res) - .unwrap(); - let _ = done_tx.send(clicked); + if let Some(clicked) = + button_id_map.iter().position(|&button_id| button_id == res) + { + let _ = done_tx.send(clicked); + } } }) .detach(); @@ -736,7 +761,7 @@ impl PlatformWindow for WindowsWindow { } fn is_hovered(&self) -> bool { - self.0.state.borrow().hovered + self.state.hovered.get() } fn set_title(&mut self, title: &str) { @@ -748,20 +773,26 @@ impl PlatformWindow for WindowsWindow { fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) { let hwnd = self.0.hwnd; + // using Dwm APIs for Mica and MicaAlt backdrops. + // others follow the set_window_composition_attribute approach match background_appearance { WindowBackgroundAppearance::Opaque => { - // ACCENT_DISABLED set_window_composition_attribute(hwnd, None, 0); } WindowBackgroundAppearance::Transparent => { - // Use ACCENT_ENABLE_TRANSPARENTGRADIENT for transparent background set_window_composition_attribute(hwnd, None, 2); } WindowBackgroundAppearance::Blurred => { - // Enable acrylic blur - // ACCENT_ENABLE_ACRYLICBLURBEHIND set_window_composition_attribute(hwnd, Some((0, 0, 0, 0)), 4); } + WindowBackgroundAppearance::MicaBackdrop => { + // DWMSBT_MAINWINDOW => MicaBase + dwm_set_window_composition_attribute(hwnd, 2); + } + WindowBackgroundAppearance::MicaAltBackdrop => { + // DWMSBT_TABBEDWINDOW => MicaAlt + dwm_set_window_composition_attribute(hwnd, 4); + } } } @@ -773,8 +804,9 @@ impl PlatformWindow for WindowsWindow { unsafe { if IsWindowVisible(self.0.hwnd).as_bool() { ShowWindowAsync(self.0.hwnd, SW_MAXIMIZE).ok().log_err(); - } else if let Some(status) = self.0.state.borrow_mut().initial_placement.as_mut() { + } else if let Some(mut status) = self.state.initial_placement.take() { status.state = WindowOpenState::Maximized; + self.state.initial_placement.set(Some(status)); } } } @@ -782,61 +814,78 @@ impl PlatformWindow for WindowsWindow { fn toggle_fullscreen(&self) { if unsafe { IsWindowVisible(self.0.hwnd).as_bool() } { self.0.toggle_fullscreen(); - } else if let Some(status) = self.0.state.borrow_mut().initial_placement.as_mut() { + } else if let Some(mut status) = self.state.initial_placement.take() { status.state = WindowOpenState::Fullscreen; + self.state.initial_placement.set(Some(status)); } } fn is_fullscreen(&self) -> bool { - self.0.state.borrow().is_fullscreen() + self.state.is_fullscreen() } fn on_request_frame(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.request_frame = Some(callback); + self.state.callbacks.request_frame.set(Some(callback)); } fn on_input(&self, callback: Box DispatchEventResult>) { - self.0.state.borrow_mut().callbacks.input = Some(callback); + self.state.callbacks.input.set(Some(callback)); } fn on_active_status_change(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.active_status_change = Some(callback); + self.0 + .state + .callbacks + .active_status_change + .set(Some(callback)); } fn on_hover_status_change(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + self.0 + .state + .callbacks + .hovered_status_change + .set(Some(callback)); } fn on_resize(&self, callback: Box, f32)>) { - self.0.state.borrow_mut().callbacks.resize = Some(callback); + self.state.callbacks.resize.set(Some(callback)); } fn on_moved(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.moved = Some(callback); + self.state.callbacks.moved.set(Some(callback)); } fn on_should_close(&self, callback: Box bool>) { - self.0.state.borrow_mut().callbacks.should_close = Some(callback); + self.state.callbacks.should_close.set(Some(callback)); } fn on_close(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.close = Some(callback); + self.state.callbacks.close.set(Some(callback)); } fn on_hit_test_window_control(&self, callback: Box Option>) { - self.0.state.borrow_mut().callbacks.hit_test_window_control = Some(callback); + self.0 + .state + .callbacks + .hit_test_window_control + .set(Some(callback)); } fn on_appearance_changed(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.appearance_changed = Some(callback); + self.0 + .state + .callbacks + .appearance_changed + .set(Some(callback)); } fn draw(&self, scene: &Scene) { - self.0.state.borrow_mut().renderer.draw(scene).log_err(); + self.state.renderer.borrow_mut().draw(scene).log_err(); } fn sprite_atlas(&self) -> Arc { - self.0.state.borrow().renderer.sprite_atlas() + self.state.renderer.borrow().sprite_atlas() } fn get_raw_handle(&self) -> HWND { @@ -844,7 +893,7 @@ impl PlatformWindow for WindowsWindow { } fn gpu_specs(&self) -> Option { - self.0.state.borrow().renderer.gpu_specs().log_err() + self.state.renderer.borrow().gpu_specs().log_err() } fn update_ime_position(&self, _bounds: Bounds) { @@ -857,11 +906,9 @@ struct WindowsDragDropHandler(pub Rc); impl WindowsDragDropHandler { fn handle_drag_drop(&self, input: PlatformInput) { - let mut lock = self.0.state.borrow_mut(); - if let Some(mut func) = lock.callbacks.input.take() { - drop(lock); + if let Some(mut func) = self.0.state.callbacks.input.take() { func(input); - self.0.state.borrow_mut().callbacks.input = Some(func); + self.0.state.callbacks.input.set(Some(func)); } } } @@ -893,9 +940,9 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { if idata.u.hGlobal.is_invalid() { return Ok(()); } - let hdrop = idata.u.hGlobal.0 as *mut HDROP; + let hdrop = HDROP(idata.u.hGlobal.0); let mut paths = SmallVec::<[PathBuf; 2]>::new(); - with_file_names(*hdrop, |file_name| { + with_file_names(hdrop, |file_name| { if let Some(path) = PathBuf::from_str(&file_name).log_err() { paths.push(path); } @@ -905,7 +952,7 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { ScreenToClient(self.0.hwnd, &mut cursor_position) .ok() .log_err(); - let scale_factor = self.0.state.borrow().scale_factor; + let scale_factor = self.0.state.scale_factor.get(); let input = PlatformInput::FileDrop(FileDropEvent::Entered { position: logical_point( cursor_position.x as f32, @@ -943,7 +990,7 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { .ok() .log_err(); } - let scale_factor = self.0.state.borrow().scale_factor; + let scale_factor = self.0.state.scale_factor.get(); let input = PlatformInput::FileDrop(FileDropEvent::Pending { position: logical_point( cursor_position.x as f32, @@ -985,7 +1032,7 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { .ok() .log_err(); } - let scale_factor = self.0.state.borrow().scale_factor; + let scale_factor = self.0.state.scale_factor.get(); let input = PlatformInput::FileDrop(FileDropEvent::Submit { position: logical_point( cursor_position.x as f32, @@ -999,15 +1046,15 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct ClickState { - button: MouseButton, - last_click: Instant, - last_position: Point, - double_click_spatial_tolerance_width: i32, - double_click_spatial_tolerance_height: i32, - double_click_interval: Duration, - pub(crate) current_count: usize, + button: Cell, + last_click: Cell, + last_position: Cell>, + double_click_spatial_tolerance_width: Cell, + double_click_spatial_tolerance_height: Cell, + double_click_interval: Cell, + pub(crate) current_count: Cell, } impl ClickState { @@ -1017,61 +1064,59 @@ impl ClickState { let double_click_interval = Duration::from_millis(unsafe { GetDoubleClickTime() } as u64); ClickState { - button: MouseButton::Left, - last_click: Instant::now(), - last_position: Point::default(), - double_click_spatial_tolerance_width, - double_click_spatial_tolerance_height, - double_click_interval, - current_count: 0, + button: Cell::new(MouseButton::Left), + last_click: Cell::new(Instant::now()), + last_position: Cell::new(Point::default()), + double_click_spatial_tolerance_width: Cell::new(double_click_spatial_tolerance_width), + double_click_spatial_tolerance_height: Cell::new(double_click_spatial_tolerance_height), + double_click_interval: Cell::new(double_click_interval), + current_count: Cell::new(0), } } /// update self and return the needed click count - pub fn update(&mut self, button: MouseButton, new_position: Point) -> usize { - if self.button == button && self.is_double_click(new_position) { - self.current_count += 1; + pub fn update(&self, button: MouseButton, new_position: Point) -> usize { + if self.button.get() == button && self.is_double_click(new_position) { + self.current_count.update(|it| it + 1); } else { - self.current_count = 1; + self.current_count.set(1); } - self.last_click = Instant::now(); - self.last_position = new_position; - self.button = button; + self.last_click.set(Instant::now()); + self.last_position.set(new_position); + self.button.set(button); - self.current_count + self.current_count.get() } - pub fn system_update(&mut self, wparam: usize) { + pub fn system_update(&self, wparam: usize) { match wparam { // SPI_SETDOUBLECLKWIDTH - 29 => { - self.double_click_spatial_tolerance_width = - unsafe { GetSystemMetrics(SM_CXDOUBLECLK) } - } + 29 => self + .double_click_spatial_tolerance_width + .set(unsafe { GetSystemMetrics(SM_CXDOUBLECLK) }), // SPI_SETDOUBLECLKHEIGHT - 30 => { - self.double_click_spatial_tolerance_height = - unsafe { GetSystemMetrics(SM_CYDOUBLECLK) } - } + 30 => self + .double_click_spatial_tolerance_height + .set(unsafe { GetSystemMetrics(SM_CYDOUBLECLK) }), // SPI_SETDOUBLECLICKTIME - 32 => { - self.double_click_interval = - Duration::from_millis(unsafe { GetDoubleClickTime() } as u64) - } + 32 => self + .double_click_interval + .set(Duration::from_millis(unsafe { GetDoubleClickTime() } as u64)), _ => {} } } #[inline] fn is_double_click(&self, new_position: Point) -> bool { - let diff = self.last_position - new_position; + let diff = self.last_position.get() - new_position; - self.last_click.elapsed() < self.double_click_interval - && diff.x.0.abs() <= self.double_click_spatial_tolerance_width - && diff.y.0.abs() <= self.double_click_spatial_tolerance_height + self.last_click.get().elapsed() < self.double_click_interval.get() + && diff.x.0.abs() <= self.double_click_spatial_tolerance_width.get() + && diff.y.0.abs() <= self.double_click_spatial_tolerance_height.get() } } +#[derive(Copy, Clone)] struct StyleAndBounds { style: WINDOW_STYLE, x: i32, @@ -1097,14 +1142,14 @@ struct AccentPolicy { type Color = (u8, u8, u8, u8); -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone)] pub(crate) struct WindowBorderOffset { - pub(crate) width_offset: i32, - pub(crate) height_offset: i32, + pub(crate) width_offset: Cell, + pub(crate) height_offset: Cell, } impl WindowBorderOffset { - pub(crate) fn update(&mut self, hwnd: HWND) -> anyhow::Result<()> { + pub(crate) fn update(&self, hwnd: HWND) -> anyhow::Result<()> { let window_rect = unsafe { let mut rect = std::mem::zeroed(); GetWindowRect(hwnd, &mut rect)?; @@ -1115,19 +1160,21 @@ impl WindowBorderOffset { GetClientRect(hwnd, &mut rect)?; rect }; - self.width_offset = - (window_rect.right - window_rect.left) - (client_rect.right - client_rect.left); - self.height_offset = - (window_rect.bottom - window_rect.top) - (client_rect.bottom - client_rect.top); + self.width_offset + .set((window_rect.right - window_rect.left) - (client_rect.right - client_rect.left)); + self.height_offset + .set((window_rect.bottom - window_rect.top) - (client_rect.bottom - client_rect.top)); Ok(()) } } +#[derive(Clone)] struct WindowOpenStatus { placement: WINDOWPLACEMENT, state: WindowOpenState, } +#[derive(Clone, Copy)] enum WindowOpenState { Maximized, Fullscreen, @@ -1159,8 +1206,7 @@ unsafe extern "system" fn window_procedure( lparam: LPARAM, ) -> LRESULT { if msg == WM_NCCREATE { - let window_params = lparam.0 as *const CREATESTRUCTW; - let window_params = unsafe { &*window_params }; + let window_params = unsafe { &*(lparam.0 as *const CREATESTRUCTW) }; let window_creation_context = window_params.lpCreateParams as *mut WindowCreateContext; let window_creation_context = unsafe { &mut *window_creation_context }; return match WindowsWindowInner::new(window_creation_context, hwnd, window_params) { @@ -1238,7 +1284,7 @@ fn register_drag_drop(window: &Rc) -> Result<()> { Ok(()) } -fn calculate_window_rect(bounds: Bounds, border_offset: WindowBorderOffset) -> RECT { +fn calculate_window_rect(bounds: Bounds, border_offset: &WindowBorderOffset) -> RECT { // NOTE: // The reason we're not using `AdjustWindowRectEx()` here is // that the size reported by this function is incorrect. @@ -1252,10 +1298,10 @@ fn calculate_window_rect(bounds: Bounds, border_offset: WindowBord right: bounds.right().0, bottom: bounds.bottom().0, }; - let left_offset = border_offset.width_offset / 2; - let top_offset = border_offset.height_offset / 2; - let right_offset = border_offset.width_offset - left_offset; - let bottom_offset = border_offset.height_offset - top_offset; + let left_offset = border_offset.width_offset.get() / 2; + let top_offset = border_offset.height_offset.get() / 2; + let right_offset = border_offset.width_offset.get() - left_offset; + let bottom_offset = border_offset.height_offset.get() - top_offset; rect.left -= left_offset; rect.top -= top_offset; rect.right += right_offset; @@ -1265,13 +1311,13 @@ fn calculate_window_rect(bounds: Bounds, border_offset: WindowBord fn calculate_client_rect( rect: RECT, - border_offset: WindowBorderOffset, + border_offset: &WindowBorderOffset, scale_factor: f32, ) -> Bounds { - let left_offset = border_offset.width_offset / 2; - let top_offset = border_offset.height_offset / 2; - let right_offset = border_offset.width_offset - left_offset; - let bottom_offset = border_offset.height_offset - top_offset; + let left_offset = border_offset.width_offset.get() / 2; + let top_offset = border_offset.height_offset.get() / 2; + let right_offset = border_offset.width_offset.get() - left_offset; + let bottom_offset = border_offset.height_offset.get() - top_offset; let left = rect.left + left_offset; let top = rect.top + top_offset; let right = rect.right - right_offset; @@ -1288,7 +1334,7 @@ fn retrieve_window_placement( display: WindowsDisplay, initial_bounds: Bounds, scale_factor: f32, - border_offset: WindowBorderOffset, + border_offset: &WindowBorderOffset, ) -> Result { let mut placement = WINDOWPLACEMENT { length: std::mem::size_of::() as u32, @@ -1306,9 +1352,34 @@ fn retrieve_window_placement( Ok(placement) } +fn dwm_set_window_composition_attribute(hwnd: HWND, backdrop_type: u32) { + let mut version = unsafe { std::mem::zeroed() }; + let status = unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut version) }; + + // DWMWA_SYSTEMBACKDROP_TYPE is available only on version 22621 or later + // using SetWindowCompositionAttributeType as a fallback + if !status.is_ok() || version.dwBuildNumber < 22621 { + return; + } + + unsafe { + let result = DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &backdrop_type as *const _ as *const _, + std::mem::size_of_val(&backdrop_type) as u32, + ); + + if !result.is_ok() { + return; + } + } +} + fn set_window_composition_attribute(hwnd: HWND, color: Option, state: u32) { let mut version = unsafe { std::mem::zeroed() }; let status = unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut version) }; + if !status.is_ok() || version.dwBuildNumber < 17763 { return; } @@ -1373,7 +1444,9 @@ mod tests { state.update(MouseButton::Left, point(DevicePixels(0), DevicePixels(0))), 2 ); - state.last_click -= Duration::from_millis(700); + state + .last_click + .update(|it| it - Duration::from_millis(700)); assert_eq!( state.update(MouseButton::Left, point(DevicePixels(0), DevicePixels(0))), 1 diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs new file mode 100644 index 0000000000..73f435d7e7 --- /dev/null +++ b/crates/gpui/src/profiler.rs @@ -0,0 +1,234 @@ +use std::{ + cell::LazyCell, + hash::Hasher, + hash::{DefaultHasher, Hash}, + sync::Arc, + thread::ThreadId, + time::Instant, +}; + +use serde::{Deserialize, Serialize}; + +#[doc(hidden)] +#[derive(Debug, Copy, Clone)] +pub struct TaskTiming { + pub location: &'static core::panic::Location<'static>, + pub start: Instant, + pub end: Option, +} + +#[doc(hidden)] +#[derive(Debug, Clone)] +pub struct ThreadTaskTimings { + pub thread_name: Option, + pub thread_id: ThreadId, + pub timings: Vec, +} + +impl ThreadTaskTimings { + pub(crate) fn convert(timings: &[GlobalThreadTimings]) -> Vec { + timings + .iter() + .filter_map(|t| match t.timings.upgrade() { + Some(timings) => Some((t.thread_id, timings)), + _ => None, + }) + .map(|(thread_id, timings)| { + let timings = timings.lock(); + let thread_name = timings.thread_name.clone(); + let timings = &timings.timings; + + let mut vec = Vec::with_capacity(timings.len()); + + let (s1, s2) = timings.as_slices(); + vec.extend_from_slice(s1); + vec.extend_from_slice(s2); + + ThreadTaskTimings { + thread_name, + thread_id, + timings: vec, + } + }) + .collect() + } +} + +/// Serializable variant of [`core::panic::Location`] +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +pub struct SerializedLocation<'a> { + /// Name of the source file + pub file: &'a str, + /// Line in the source file + pub line: u32, + /// Column in the source file + pub column: u32, +} + +impl<'a> From<&'a core::panic::Location<'a>> for SerializedLocation<'a> { + fn from(value: &'a core::panic::Location<'a>) -> Self { + SerializedLocation { + file: value.file(), + line: value.line(), + column: value.column(), + } + } +} + +/// Serializable variant of [`TaskTiming`] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedTaskTiming<'a> { + /// Location of the timing + #[serde(borrow)] + pub location: SerializedLocation<'a>, + /// Time at which the measurement was reported in nanoseconds + pub start: u128, + /// Duration of the measurement in nanoseconds + pub duration: u128, +} + +impl<'a> SerializedTaskTiming<'a> { + /// Convert an array of [`TaskTiming`] into their serializable format + /// + /// # Params + /// + /// `anchor` - [`Instant`] that should be earlier than all timings to use as base anchor + pub fn convert(anchor: Instant, timings: &[TaskTiming]) -> Vec> { + let serialized = timings + .iter() + .map(|timing| { + let start = timing.start.duration_since(anchor).as_nanos(); + let duration = timing + .end + .unwrap_or_else(|| Instant::now()) + .duration_since(timing.start) + .as_nanos(); + SerializedTaskTiming { + location: timing.location.into(), + start, + duration, + } + }) + .collect::>(); + + serialized + } +} + +/// Serializable variant of [`ThreadTaskTimings`] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedThreadTaskTimings<'a> { + /// Thread name + pub thread_name: Option, + /// Hash of the thread id + pub thread_id: u64, + /// Timing records for this thread + #[serde(borrow)] + pub timings: Vec>, +} + +impl<'a> SerializedThreadTaskTimings<'a> { + /// Convert [`ThreadTaskTimings`] into their serializable format + /// + /// # Params + /// + /// `anchor` - [`Instant`] that should be earlier than all timings to use as base anchor + pub fn convert( + anchor: Instant, + timings: ThreadTaskTimings, + ) -> SerializedThreadTaskTimings<'static> { + let serialized_timings = SerializedTaskTiming::convert(anchor, &timings.timings); + + let mut hasher = DefaultHasher::new(); + timings.thread_id.hash(&mut hasher); + let thread_id = hasher.finish(); + + SerializedThreadTaskTimings { + thread_name: timings.thread_name, + thread_id, + timings: serialized_timings, + } + } +} + +// Allow 20mb of task timing entries +const MAX_TASK_TIMINGS: usize = (20 * 1024 * 1024) / core::mem::size_of::(); + +pub(crate) type TaskTimings = circular_buffer::CircularBuffer; +pub(crate) type GuardedTaskTimings = spin::Mutex; + +pub(crate) struct GlobalThreadTimings { + pub thread_id: ThreadId, + pub timings: std::sync::Weak, +} + +pub(crate) static GLOBAL_THREAD_TIMINGS: spin::Mutex> = + spin::Mutex::new(Vec::new()); + +thread_local! { + pub(crate) static THREAD_TIMINGS: LazyCell> = LazyCell::new(|| { + let current_thread = std::thread::current(); + let thread_name = current_thread.name(); + let thread_id = current_thread.id(); + let timings = ThreadTimings::new(thread_name.map(|e| e.to_string()), thread_id); + let timings = Arc::new(spin::Mutex::new(timings)); + + { + let timings = Arc::downgrade(&timings); + let global_timings = GlobalThreadTimings { + thread_id: std::thread::current().id(), + timings, + }; + GLOBAL_THREAD_TIMINGS.lock().push(global_timings); + } + + timings + }); +} + +pub(crate) struct ThreadTimings { + pub thread_name: Option, + pub thread_id: ThreadId, + pub timings: Box, +} + +impl ThreadTimings { + pub(crate) fn new(thread_name: Option, thread_id: ThreadId) -> Self { + ThreadTimings { + thread_name, + thread_id, + timings: TaskTimings::boxed(), + } + } +} + +impl Drop for ThreadTimings { + fn drop(&mut self) { + let mut thread_timings = GLOBAL_THREAD_TIMINGS.lock(); + + let Some((index, _)) = thread_timings + .iter() + .enumerate() + .find(|(_, t)| t.thread_id == self.thread_id) + else { + return; + }; + thread_timings.swap_remove(index); + } +} + +pub(crate) fn add_task_timing(timing: TaskTiming) { + THREAD_TIMINGS.with(|timings| { + let mut timings = timings.lock(); + let timings = &mut timings.timings; + + if let Some(last_timing) = timings.iter_mut().rev().next() { + if last_timing.location == timing.location { + last_timing.end = timing.end; + return; + } + } + + timings.push_back(timing); + }); +} diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs new file mode 100644 index 0000000000..9e9da71097 --- /dev/null +++ b/crates/gpui/src/queue.rs @@ -0,0 +1,328 @@ +use std::{ + fmt, + iter::FusedIterator, + sync::{Arc, atomic::AtomicUsize}, +}; + +use rand::{Rng, SeedableRng, rngs::SmallRng}; + +use crate::Priority; + +struct PriorityQueues { + high_priority: Vec, + medium_priority: Vec, + low_priority: Vec, +} + +impl PriorityQueues { + fn is_empty(&self) -> bool { + self.high_priority.is_empty() + && self.medium_priority.is_empty() + && self.low_priority.is_empty() + } +} + +struct PriorityQueueState { + queues: parking_lot::Mutex>, + condvar: parking_lot::Condvar, + receiver_count: AtomicUsize, + sender_count: AtomicUsize, +} + +impl PriorityQueueState { + fn send(&self, priority: Priority, item: T) -> Result<(), SendError> { + if self + .receiver_count + .load(std::sync::atomic::Ordering::Relaxed) + == 0 + { + return Err(SendError(item)); + } + + let mut queues = self.queues.lock(); + match priority { + Priority::Realtime(_) => unreachable!(), + Priority::High => queues.high_priority.push(item), + Priority::Medium => queues.medium_priority.push(item), + Priority::Low => queues.low_priority.push(item), + }; + self.condvar.notify_one(); + Ok(()) + } + + fn recv<'a>(&'a self) -> Result>, RecvError> { + let mut queues = self.queues.lock(); + + let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); + if queues.is_empty() && sender_count == 0 { + return Err(crate::queue::RecvError); + } + + while queues.is_empty() { + self.condvar.wait(&mut queues); + } + + Ok(queues) + } + + fn try_recv<'a>( + &'a self, + ) -> Result>>, RecvError> { + let mut queues = self.queues.lock(); + + let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); + if queues.is_empty() && sender_count == 0 { + return Err(crate::queue::RecvError); + } + + if queues.is_empty() { + Ok(None) + } else { + Ok(Some(queues)) + } + } +} + +pub(crate) struct PriorityQueueSender { + state: Arc>, +} + +impl PriorityQueueSender { + fn new(state: Arc>) -> Self { + Self { state } + } + + pub(crate) fn send(&self, priority: Priority, item: T) -> Result<(), SendError> { + self.state.send(priority, item)?; + Ok(()) + } +} + +impl Drop for PriorityQueueSender { + fn drop(&mut self) { + self.state + .sender_count + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + +pub(crate) struct PriorityQueueReceiver { + state: Arc>, + rand: SmallRng, + disconnected: bool, +} + +impl Clone for PriorityQueueReceiver { + fn clone(&self) -> Self { + self.state + .receiver_count + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + Self { + state: Arc::clone(&self.state), + rand: SmallRng::seed_from_u64(0), + disconnected: self.disconnected, + } + } +} + +pub(crate) struct SendError(T); + +impl fmt::Debug for SendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SendError").field(&self.0).finish() + } +} + +#[derive(Debug)] +pub(crate) struct RecvError; + +#[allow(dead_code)] +impl PriorityQueueReceiver { + pub(crate) fn new() -> (PriorityQueueSender, Self) { + let state = PriorityQueueState { + queues: parking_lot::Mutex::new(PriorityQueues { + high_priority: Vec::new(), + medium_priority: Vec::new(), + low_priority: Vec::new(), + }), + condvar: parking_lot::Condvar::new(), + receiver_count: AtomicUsize::new(1), + sender_count: AtomicUsize::new(1), + }; + let state = Arc::new(state); + + let sender = PriorityQueueSender::new(Arc::clone(&state)); + + let receiver = PriorityQueueReceiver { + state, + rand: SmallRng::seed_from_u64(0), + disconnected: false, + }; + + (sender, receiver) + } + + /// Tries to pop one element from the priority queue without blocking. + /// + /// This will early return if there are no elements in the queue. + /// + /// This method is best suited if you only intend to pop one element, for better performance + /// on large queues see [`Self::try_iter`] + /// + /// # Errors + /// + /// If the sender was dropped + pub(crate) fn try_pop(&mut self) -> Result, RecvError> { + self.pop_inner(false) + } + + /// Pops an element from the priority queue blocking if necessary. + /// + /// This method is best suited if you only intend to pop one element, for better performance + /// on large queues see [`Self::iter``] + /// + /// # Errors + /// + /// If the sender was dropped + pub(crate) fn pop(&mut self) -> Result { + self.pop_inner(true).map(|e| e.unwrap()) + } + + /// Returns an iterator over the elements of the queue + /// this iterator will end when all elements have been consumed and will not wait for new ones. + pub(crate) fn try_iter(self) -> TryIter { + TryIter { + receiver: self, + ended: false, + } + } + + /// Returns an iterator over the elements of the queue + /// this iterator will wait for new elements if the queue is empty. + pub(crate) fn iter(self) -> Iter { + Iter(self) + } + + #[inline(always)] + // algorithm is the loaded die from biased coin from + // https://www.keithschwarz.com/darts-dice-coins/ + fn pop_inner(&mut self, block: bool) -> Result, RecvError> { + use Priority as P; + + let mut queues = if !block { + let Some(queues) = self.state.try_recv()? else { + return Ok(None); + }; + queues + } else { + self.state.recv()? + }; + + let high = P::High.probability() * !queues.high_priority.is_empty() as u32; + let medium = P::Medium.probability() * !queues.medium_priority.is_empty() as u32; + let low = P::Low.probability() * !queues.low_priority.is_empty() as u32; + let mut mass = high + medium + low; //% + + if !queues.high_priority.is_empty() { + let flip = self.rand.random_ratio(P::High.probability(), mass); + if flip { + return Ok(queues.high_priority.pop()); + } + mass -= P::High.probability(); + } + + if !queues.medium_priority.is_empty() { + let flip = self.rand.random_ratio(P::Medium.probability(), mass); + if flip { + return Ok(queues.medium_priority.pop()); + } + mass -= P::Medium.probability(); + } + + if !queues.low_priority.is_empty() { + let flip = self.rand.random_ratio(P::Low.probability(), mass); + if flip { + return Ok(queues.low_priority.pop()); + } + } + + Ok(None) + } +} + +impl Drop for PriorityQueueReceiver { + fn drop(&mut self) { + self.state + .receiver_count + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + +/// If None is returned the sender disconnected +pub(crate) struct Iter(PriorityQueueReceiver); +impl Iterator for Iter { + type Item = T; + + fn next(&mut self) -> Option { + self.0.pop().ok() + } +} +impl FusedIterator for Iter {} + +/// If None is returned there are no more elements in the queue +pub(crate) struct TryIter { + receiver: PriorityQueueReceiver, + ended: bool, +} +impl Iterator for TryIter { + type Item = Result; + + fn next(&mut self) -> Option { + if self.ended { + return None; + } + + let res = self.receiver.try_pop(); + self.ended = res.is_err(); + + res.transpose() + } +} +impl FusedIterator for TryIter {} + +#[cfg(test)] +mod tests { + use collections::HashSet; + + use super::*; + + #[test] + fn all_tasks_get_yielded() { + let (tx, mut rx) = PriorityQueueReceiver::new(); + tx.send(Priority::Medium, 20).unwrap(); + tx.send(Priority::High, 30).unwrap(); + tx.send(Priority::Low, 10).unwrap(); + tx.send(Priority::Medium, 21).unwrap(); + tx.send(Priority::High, 31).unwrap(); + + drop(tx); + + assert_eq!( + rx.iter().collect::>(), + [30, 31, 20, 21, 10].into_iter().collect::>() + ) + } + + #[test] + fn new_high_prio_task_get_scheduled_quickly() { + let (tx, mut rx) = PriorityQueueReceiver::new(); + for _ in 0..100 { + tx.send(Priority::Low, 1).unwrap(); + } + + assert_eq!(rx.pop().unwrap(), 1); + tx.send(Priority::High, 3).unwrap(); + assert_eq!(rx.pop().unwrap(), 3); + assert_eq!(rx.pop().unwrap(), 1); + } +} diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 8afb4e4eb8..446c3ad2a3 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -252,6 +252,7 @@ pub struct Style { pub box_shadow: Vec, /// The text style of this element + #[refineable] pub text: TextStyleRefinement, /// The mouse cursor style shown when the mouse pointer is over an element. @@ -403,13 +404,7 @@ impl Default for TextStyle { TextStyle { color: black(), // todo(linux) make this configurable or choose better default - font_family: if cfg!(any(target_os = "linux", target_os = "freebsd")) { - "FreeMono".into() - } else if cfg!(target_os = "windows") { - "Segoe UI".into() - } else { - "Helvetica".into() - }, + font_family: ".SystemUIFont".into(), font_features: FontFeatures::default(), font_fallbacks: None, font_size: rems(1.).into(), @@ -1475,4 +1470,21 @@ mod tests { ] ); } + + #[perf] + fn test_text_style_refinement() { + let mut style = Style::default(); + style.refine(&StyleRefinement::default().text_size(px(20.0))); + style.refine(&StyleRefinement::default().font_weight(FontWeight::SEMIBOLD)); + + assert_eq!( + Some(AbsoluteLength::from(px(20.0))), + style.text_style().unwrap().font_size + ); + + assert_eq!( + Some(FontWeight::SEMIBOLD), + style.text_style().unwrap().font_weight + ); + } } diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index c714cac14f..e01649be48 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -1,21 +1,22 @@ use crate::{ self as gpui, AbsoluteLength, AlignContent, AlignItems, BorderStyle, CursorStyle, - DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontStyle, FontWeight, - GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, StyleRefinement, - TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, relative, rems, + DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle, + FontWeight, GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, + StyleRefinement, TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, + relative, rems, }; pub use gpui_macros::{ border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods, overflow_style_methods, padding_style_methods, position_style_methods, visibility_style_methods, }; - const ELLIPSIS: SharedString = SharedString::new_static("…"); /// A trait for elements that can be styled. /// Use this to opt-in to a utility CSS-like styling API. +// gate on rust-analyzer so rust-analyzer never needs to expand this macro, it takes up to 10 seconds to expand due to inefficiencies in rust-analyzers proc-macro srv #[cfg_attr( - any(feature = "inspector", debug_assertions), + all(any(feature = "inspector", debug_assertions), not(rust_analyzer)), gpui_macros::derive_inspector_reflection )] pub trait Styled: Sized { @@ -53,46 +54,43 @@ pub trait Styled: Sized { self } + /// Sets the display type of the element to `none`. + /// [Docs](https://tailwindcss.com/docs/display) + fn hidden(mut self) -> Self { + self.style().display = Some(Display::None); + self + } + /// Sets the whitespace of the element to `normal`. /// [Docs](https://tailwindcss.com/docs/whitespace#normal) fn whitespace_normal(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Normal); + self.text_style().white_space = Some(WhiteSpace::Normal); self } /// Sets the whitespace of the element to `nowrap`. /// [Docs](https://tailwindcss.com/docs/whitespace#nowrap) fn whitespace_nowrap(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Nowrap); + self.text_style().white_space = Some(WhiteSpace::Nowrap); self } /// Sets the truncate overflowing text with an ellipsis (…) if needed. /// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis) fn text_ellipsis(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); + self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); self } /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(overflow); + self.text_style().text_overflow = Some(overflow); self } /// Set the text alignment of the element. fn text_align(mut self, align: TextAlign) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_align = Some(align); + self.text_style().text_align = Some(align); self } @@ -120,7 +118,7 @@ pub trait Styled: Sized { /// Sets number of lines to show before truncating the text. /// [Docs](https://tailwindcss.com/docs/line-clamp) fn line_clamp(mut self, lines: usize) -> Self { - let mut text_style = self.text_style().get_or_insert_with(Default::default); + let mut text_style = self.text_style(); text_style.line_clamp = Some(lines); self.overflow_hidden() } @@ -302,6 +300,16 @@ pub trait Styled: Sized { self } + /// Sets the element to justify items along the container's main axis such + /// that there is an equal amount of space around each item, but also + /// accounting for the doubling of space you would normally see between + /// each item when using justify-around. + /// [Docs](https://tailwindcss.com/docs/justify-content#space-evenly) + fn justify_evenly(mut self) -> Self { + self.style().justify_content = Some(JustifyContent::SpaceEvenly); + self + } + /// Sets the element to pack content items in their default position as if no align-content value was set. /// [Docs](https://tailwindcss.com/docs/align-content#normal) fn content_normal(mut self) -> Self { @@ -378,7 +386,7 @@ pub trait Styled: Sized { } /// Returns a mutable reference to the text style that has been configured on this element. - fn text_style(&mut self) -> &mut Option { + fn text_style(&mut self) -> &mut TextStyleRefinement { let style: &mut StyleRefinement = self.style(); &mut style.text } @@ -387,7 +395,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_color(mut self, color: impl Into) -> Self { - self.text_style().get_or_insert_with(Default::default).color = Some(color.into()); + self.text_style().color = Some(color.into()); self } @@ -395,9 +403,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn font_weight(mut self, weight: FontWeight) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_weight = Some(weight); + self.text_style().font_weight = Some(weight); self } @@ -405,9 +411,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_bg(mut self, bg: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .background_color = Some(bg.into()); + self.text_style().background_color = Some(bg.into()); self } @@ -415,97 +419,77 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_size(mut self, size: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(size.into()); + self.text_style().font_size = Some(size.into()); self } /// Sets the text size to 'extra small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xs(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.75).into()); + self.text_style().font_size = Some(rems(0.75).into()); self } /// Sets the text size to 'small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_sm(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.875).into()); + self.text_style().font_size = Some(rems(0.875).into()); self } /// Sets the text size to 'base'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_base(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.0).into()); + self.text_style().font_size = Some(rems(1.0).into()); self } /// Sets the text size to 'large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_lg(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.125).into()); + self.text_style().font_size = Some(rems(1.125).into()); self } /// Sets the text size to 'extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.25).into()); + self.text_style().font_size = Some(rems(1.25).into()); self } /// Sets the text size to 'extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_2xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.5).into()); + self.text_style().font_size = Some(rems(1.5).into()); self } /// Sets the text size to 'extra extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_3xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.875).into()); + self.text_style().font_size = Some(rems(1.875).into()); self } /// Sets the font style of the element to italic. /// [Docs](https://tailwindcss.com/docs/font-style#italicizing-text) fn italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + self.text_style().font_style = Some(FontStyle::Italic); self } /// Sets the font style of the element to normal (not italic). /// [Docs](https://tailwindcss.com/docs/font-style#displaying-text-normally) fn not_italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Normal); + self.text_style().font_style = Some(FontStyle::Normal); self } /// Sets the text decoration to underline. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#underling-text) fn underline(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.underline = Some(UnderlineStyle { thickness: px(1.), ..Default::default() @@ -516,7 +500,7 @@ pub trait Styled: Sized { /// Sets the decoration of the text to have a line through it. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#adding-a-line-through-text) fn line_through(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.strikethrough = Some(StrikethroughStyle { thickness: px(1.), ..Default::default() @@ -528,15 +512,13 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_decoration_none(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .underline = None; + self.text_style().underline = None; self } /// Sets the color for the underline on this element fn text_decoration_color(mut self, color: impl Into) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.color = Some(color.into()); self @@ -545,7 +527,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a solid line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_solid(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = false; self @@ -554,7 +536,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a wavy line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_wavy(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = true; self @@ -563,7 +545,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 0px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_0(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(0.); self @@ -572,7 +554,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 1px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_1(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(1.); self @@ -581,7 +563,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 2px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_2(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(2.); self @@ -590,7 +572,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 4px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_4(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(4.); self @@ -599,7 +581,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 8px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_8(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(8.); self @@ -607,9 +589,13 @@ pub trait Styled: Sized { /// Sets the font family of this element and its children. fn font_family(mut self, family_name: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_family = Some(family_name.into()); + self.text_style().font_family = Some(family_name.into()); + self + } + + /// Sets the font features of this element and its children. + fn font_features(mut self, features: FontFeatures) -> Self { + self.text_style().font_features = Some(features); self } @@ -623,7 +609,7 @@ pub trait Styled: Sized { style, } = font; - let text_style = self.text_style().get_or_insert_with(Default::default); + let text_style = self.text_style(); text_style.font_family = Some(family); text_style.font_features = Some(features); text_style.font_weight = Some(weight); @@ -635,9 +621,7 @@ pub trait Styled: Sized { /// Sets the line height of this element and its children. fn line_height(mut self, line_height: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .line_height = Some(line_height.into()); + self.text_style().line_height = Some(line_height.into()); self } diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index 0107624bc8..cae1b5d423 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -1,5 +1,10 @@ -use crate::{AssetSource, DevicePixels, IsZero, Result, SharedString, Size}; +use crate::{ + AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size, + swap_rgba_pa_to_bgra, +}; +use image::Frame; use resvg::tiny_skia::Pixmap; +use smallvec::SmallVec; use std::{ hash::Hash, sync::{Arc, LazyLock}, @@ -15,17 +20,22 @@ pub(crate) struct RenderSvgParams { } #[derive(Clone)] +/// A struct holding everything necessary to render SVGs. pub struct SvgRenderer { asset_source: Arc, usvg_options: Arc>, } +/// The size in which to render the SVG. pub enum SvgSize { + /// An absolute size in device pixels. Size(Size), + /// A scaling factor to apply to the size provided by the SVG. ScaleFactor(f32), } impl SvgRenderer { + /// Creates a new SVG renderer with the provided asset source. pub fn new(asset_source: Arc) -> Self { static FONT_DB: LazyLock> = LazyLock::new(|| { let mut db = usvg::fontdb::Database::new(); @@ -54,41 +64,82 @@ impl SvgRenderer { } } - pub(crate) fn render(&self, params: &RenderSvgParams) -> Result>> { - anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size"); + /// Renders the given bytes into an image buffer. + pub fn render_single_frame( + &self, + bytes: &[u8], + scale_factor: f32, + to_brga: bool, + ) -> Result, usvg::Error> { + self.render_pixmap( + bytes, + SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR), + ) + .map(|pixmap| { + let mut buffer = + image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()) + .unwrap(); - // Load the tree. - let Some(bytes) = self.asset_source.load(¶ms.path)? else { - return Ok(None); - }; + if to_brga { + for pixel in buffer.chunks_exact_mut(4) { + swap_rgba_pa_to_bgra(pixel); + } + } - let pixmap = self.render_pixmap(&bytes, SvgSize::Size(params.size))?; - - // Convert the pixmap's pixels into an alpha mask. - let alpha_mask = pixmap - .pixels() - .iter() - .map(|p| p.alpha()) - .collect::>(); - Ok(Some(alpha_mask)) + let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)])); + image.scale_factor = SMOOTH_SVG_SCALE_FACTOR; + Arc::new(image) + }) } - pub fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result { - let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?; + pub(crate) fn render_alpha_mask( + &self, + params: &RenderSvgParams, + bytes: Option<&[u8]>, + ) -> Result, Vec)>> { + anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size"); - let size = match size { - SvgSize::Size(size) => size, - SvgSize::ScaleFactor(scale) => crate::size( - DevicePixels((tree.size().width() * scale) as i32), - DevicePixels((tree.size().height() * scale) as i32), - ), + let render_pixmap = |bytes| { + let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?; + + // Convert the pixmap's pixels into an alpha mask. + let size = Size::new( + DevicePixels(pixmap.width() as i32), + DevicePixels(pixmap.height() as i32), + ); + let alpha_mask = pixmap + .pixels() + .iter() + .map(|p| p.alpha()) + .collect::>(); + + Ok(Some((size, alpha_mask))) + }; + + if let Some(bytes) = bytes { + render_pixmap(bytes) + } else if let Some(bytes) = self.asset_source.load(¶ms.path)? { + render_pixmap(&bytes) + } else { + Ok(None) + } + } + + fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result { + let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?; + let svg_size = tree.size(); + let scale = match size { + SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(), + SvgSize::ScaleFactor(scale) => scale, }; // Render the SVG to a pixmap with the specified width and height. - let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width.into(), size.height.into()) - .ok_or(usvg::Error::InvalidSize)?; + let mut pixmap = resvg::tiny_skia::Pixmap::new( + (svg_size.width() * scale) as u32, + (svg_size.height() * scale) as u32, + ) + .ok_or(usvg::Error::InvalidSize)?; - let scale = size.width.0 as f32 / tree.size().width(); let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); resvg::render(&tree, transform, &mut pixmap.as_mut()); diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs index 8a95a3975a..a205005963 100644 --- a/crates/gpui/src/tab_stop.rs +++ b/crates/gpui/src/tab_stop.rs @@ -320,7 +320,7 @@ mod tests { let focus_map = Arc::new(FocusMap::default()); let mut tab_index_map = TabStopMap::default(); - let focus_handles = vec![ + let focus_handles = [ FocusHandle::new(&focus_map).tab_stop(true).tab_index(0), FocusHandle::new(&focus_map).tab_stop(true).tab_index(1), FocusHandle::new(&focus_map).tab_stop(true).tab_index(1), diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 288726d379..11cb087286 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -1,8 +1,8 @@ use crate::{ AbsoluteLength, App, Bounds, DefiniteLength, Edges, Length, Pixels, Point, Size, Style, Window, + point, size, }; use collections::{FxHashMap, FxHashSet}; -use smallvec::SmallVec; use stacksafe::{StackSafe, stacksafe}; use std::{fmt::Debug, ops::Range}; use taffy::{ @@ -30,6 +30,7 @@ pub struct TaffyLayoutEngine { taffy: TaffyTree, absolute_layout_bounds: FxHashMap>, computed_layouts: FxHashSet, + layout_bounds_scratch_space: Vec, } const EXPECT_MESSAGE: &str = "we should avoid taffy layout errors by construction if possible"; @@ -37,11 +38,12 @@ const EXPECT_MESSAGE: &str = "we should avoid taffy layout errors by constructio impl TaffyLayoutEngine { pub fn new() -> Self { let mut taffy = TaffyTree::new(); - taffy.disable_rounding(); + taffy.enable_rounding(); TaffyLayoutEngine { taffy, absolute_layout_bounds: FxHashMap::default(), computed_layouts: FxHashSet::default(), + layout_bounds_scratch_space: Vec::new(), } } @@ -55,9 +57,10 @@ impl TaffyLayoutEngine { &mut self, style: Style, rem_size: Pixels, + scale_factor: f32, children: &[LayoutId], ) -> LayoutId { - let taffy_style = style.to_taffy(rem_size); + let taffy_style = style.to_taffy(rem_size, scale_factor); if children.is_empty() { self.taffy @@ -67,9 +70,7 @@ impl TaffyLayoutEngine { } else { self.taffy // This is safe because LayoutId is repr(transparent) to taffy::tree::NodeId. - .new_with_children(taffy_style, unsafe { - std::mem::transmute::<&[LayoutId], &[taffy::NodeId]>(children) - }) + .new_with_children(taffy_style, LayoutId::to_taffy_slice(children)) .expect(EXPECT_MESSAGE) .into() } @@ -79,6 +80,7 @@ impl TaffyLayoutEngine { &mut self, style: Style, rem_size: Pixels, + scale_factor: f32, measure: impl FnMut( Size>, Size, @@ -87,7 +89,7 @@ impl TaffyLayoutEngine { ) -> Size + 'static, ) -> LayoutId { - let taffy_style = style.to_taffy(rem_size); + let taffy_style = style.to_taffy(rem_size, scale_factor); self.taffy .new_leaf_with_context( @@ -167,7 +169,7 @@ impl TaffyLayoutEngine { // if !self.computed_layouts.insert(id) { - let mut stack = SmallVec::<[LayoutId; 64]>::new(); + let mut stack = &mut self.layout_bounds_scratch_space; stack.push(id); while let Some(id) = stack.pop() { self.absolute_layout_bounds.remove(&id); @@ -176,12 +178,25 @@ impl TaffyLayoutEngine { .children(id.into()) .expect(EXPECT_MESSAGE) .into_iter() - .map(Into::into), + .map(LayoutId::from), ); } } - // let started_at = std::time::Instant::now(); + let scale_factor = window.scale_factor(); + + let transform = |v: AvailableSpace| match v { + AvailableSpace::Definite(pixels) => { + AvailableSpace::Definite(Pixels(pixels.0 * scale_factor)) + } + AvailableSpace::MinContent => AvailableSpace::MinContent, + AvailableSpace::MaxContent => AvailableSpace::MaxContent, + }; + let available_space = size( + transform(available_space.width), + transform(available_space.height), + ); + self.taffy .compute_layout_with_measure( id.into(), @@ -192,32 +207,50 @@ impl TaffyLayoutEngine { }; let known_dimensions = Size { - width: known_dimensions.width.map(Pixels), - height: known_dimensions.height.map(Pixels), + width: known_dimensions.width.map(|e| Pixels(e / scale_factor)), + height: known_dimensions.height.map(|e| Pixels(e / scale_factor)), }; - (node_context.measure)(known_dimensions, available_space.into(), window, cx) - .into() + let available_space: Size = available_space.into(); + let untransform = |ev: AvailableSpace| match ev { + AvailableSpace::Definite(pixels) => { + AvailableSpace::Definite(Pixels(pixels.0 / scale_factor)) + } + AvailableSpace::MinContent => AvailableSpace::MinContent, + AvailableSpace::MaxContent => AvailableSpace::MaxContent, + }; + let available_space = size( + untransform(available_space.width), + untransform(available_space.height), + ); + + let a: Size = + (node_context.measure)(known_dimensions, available_space, window, cx); + size(a.width.0 * scale_factor, a.height.0 * scale_factor).into() }, ) .expect(EXPECT_MESSAGE); - - // println!("compute_layout took {:?}", started_at.elapsed()); } - pub fn layout_bounds(&mut self, id: LayoutId) -> Bounds { + pub fn layout_bounds(&mut self, id: LayoutId, scale_factor: f32) -> Bounds { if let Some(layout) = self.absolute_layout_bounds.get(&id).cloned() { return layout; } let layout = self.taffy.layout(id.into()).expect(EXPECT_MESSAGE); let mut bounds = Bounds { - origin: layout.location.into(), - size: layout.size.into(), + origin: point( + Pixels(layout.location.x / scale_factor), + Pixels(layout.location.y / scale_factor), + ), + size: size( + Pixels(layout.size.width / scale_factor), + Pixels(layout.size.height / scale_factor), + ), }; if let Some(parent_id) = self.taffy.parent(id.0) { - let parent_bounds = self.layout_bounds(parent_id.into()); + let parent_bounds = self.layout_bounds(parent_id.into(), scale_factor); bounds.origin += parent_bounds.origin; } self.absolute_layout_bounds.insert(id, bounds); @@ -231,6 +264,13 @@ impl TaffyLayoutEngine { #[repr(transparent)] pub struct LayoutId(NodeId); +impl LayoutId { + fn to_taffy_slice(node_ids: &[Self]) -> &[taffy::NodeId] { + // SAFETY: LayoutId is repr(transparent) to taffy::tree::NodeId. + unsafe { std::mem::transmute::<&[LayoutId], &[taffy::NodeId]>(node_ids) } + } +} + impl std::hash::Hash for LayoutId { fn hash(&self, state: &mut H) { u64::from(self.0).hash(state); @@ -250,11 +290,11 @@ impl From for NodeId { } trait ToTaffy { - fn to_taffy(&self, rem_size: Pixels) -> Output; + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> Output; } impl ToTaffy for Style { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::Style { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::Style { use taffy::style_helpers::{fr, length, minmax, repeat}; fn to_grid_line( @@ -277,24 +317,24 @@ impl ToTaffy for Style { taffy::style::Style { display: self.display.into(), overflow: self.overflow.into(), - scrollbar_width: self.scrollbar_width.to_taffy(rem_size), + scrollbar_width: self.scrollbar_width.to_taffy(rem_size, scale_factor), position: self.position.into(), - inset: self.inset.to_taffy(rem_size), - size: self.size.to_taffy(rem_size), - min_size: self.min_size.to_taffy(rem_size), - max_size: self.max_size.to_taffy(rem_size), + inset: self.inset.to_taffy(rem_size, scale_factor), + size: self.size.to_taffy(rem_size, scale_factor), + min_size: self.min_size.to_taffy(rem_size, scale_factor), + max_size: self.max_size.to_taffy(rem_size, scale_factor), aspect_ratio: self.aspect_ratio, - margin: self.margin.to_taffy(rem_size), - padding: self.padding.to_taffy(rem_size), - border: self.border_widths.to_taffy(rem_size), + margin: self.margin.to_taffy(rem_size, scale_factor), + padding: self.padding.to_taffy(rem_size, scale_factor), + border: self.border_widths.to_taffy(rem_size, scale_factor), align_items: self.align_items.map(|x| x.into()), align_self: self.align_self.map(|x| x.into()), align_content: self.align_content.map(|x| x.into()), justify_content: self.justify_content.map(|x| x.into()), - gap: self.gap.to_taffy(rem_size), + gap: self.gap.to_taffy(rem_size, scale_factor), flex_direction: self.flex_direction.into(), flex_wrap: self.flex_wrap.into(), - flex_basis: self.flex_basis.to_taffy(rem_size), + flex_basis: self.flex_basis.to_taffy(rem_size, scale_factor), flex_grow: self.flex_grow, flex_shrink: self.flex_shrink, grid_template_rows: to_grid_repeat(&self.grid_rows), @@ -315,41 +355,53 @@ impl ToTaffy for Style { } impl ToTaffy for AbsoluteLength { - fn to_taffy(&self, rem_size: Pixels) -> f32 { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> f32 { match self { - AbsoluteLength::Pixels(pixels) => pixels.into(), - AbsoluteLength::Rems(rems) => (*rems * rem_size).into(), + AbsoluteLength::Pixels(pixels) => { + let pixels: f32 = pixels.into(); + pixels * scale_factor + } + AbsoluteLength::Rems(rems) => { + let pixels: f32 = (*rems * rem_size).into(); + pixels * scale_factor + } } } } impl ToTaffy for Length { - fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::LengthPercentageAuto { + fn to_taffy( + &self, + rem_size: Pixels, + scale_factor: f32, + ) -> taffy::prelude::LengthPercentageAuto { match self { - Length::Definite(length) => length.to_taffy(rem_size), + Length::Definite(length) => length.to_taffy(rem_size, scale_factor), Length::Auto => taffy::prelude::LengthPercentageAuto::auto(), } } } impl ToTaffy for Length { - fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::Dimension { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::prelude::Dimension { match self { - Length::Definite(length) => length.to_taffy(rem_size), + Length::Definite(length) => length.to_taffy(rem_size, scale_factor), Length::Auto => taffy::prelude::Dimension::auto(), } } } impl ToTaffy for DefiniteLength { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentage { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentage { match self { DefiniteLength::Absolute(length) => match length { AbsoluteLength::Pixels(pixels) => { - taffy::style::LengthPercentage::length(pixels.into()) + let pixels: f32 = pixels.into(); + taffy::style::LengthPercentage::length(pixels * scale_factor) } AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentage::length((*rems * rem_size).into()) + let pixels: f32 = (*rems * rem_size).into(); + taffy::style::LengthPercentage::length(pixels * scale_factor) } }, DefiniteLength::Fraction(fraction) => { @@ -360,14 +412,16 @@ impl ToTaffy for DefiniteLength { } impl ToTaffy for DefiniteLength { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentageAuto { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentageAuto { match self { DefiniteLength::Absolute(length) => match length { AbsoluteLength::Pixels(pixels) => { - taffy::style::LengthPercentageAuto::length(pixels.into()) + let pixels: f32 = pixels.into(); + taffy::style::LengthPercentageAuto::length(pixels * scale_factor) } AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentageAuto::length((*rems * rem_size).into()) + let pixels: f32 = (*rems * rem_size).into(); + taffy::style::LengthPercentageAuto::length(pixels * scale_factor) } }, DefiniteLength::Fraction(fraction) => { @@ -378,12 +432,15 @@ impl ToTaffy for DefiniteLength { } impl ToTaffy for DefiniteLength { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::Dimension { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::Dimension { match self { DefiniteLength::Absolute(length) => match length { - AbsoluteLength::Pixels(pixels) => taffy::style::Dimension::length(pixels.into()), + AbsoluteLength::Pixels(pixels) => { + let pixels: f32 = pixels.into(); + taffy::style::Dimension::length(pixels * scale_factor) + } AbsoluteLength::Rems(rems) => { - taffy::style::Dimension::length((*rems * rem_size).into()) + taffy::style::Dimension::length((*rems * rem_size * scale_factor).into()) } }, DefiniteLength::Fraction(fraction) => taffy::style::Dimension::percent(*fraction), @@ -392,11 +449,15 @@ impl ToTaffy for DefiniteLength { } impl ToTaffy for AbsoluteLength { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentage { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentage { match self { - AbsoluteLength::Pixels(pixels) => taffy::style::LengthPercentage::length(pixels.into()), + AbsoluteLength::Pixels(pixels) => { + let pixels: f32 = pixels.into(); + taffy::style::LengthPercentage::length(pixels * scale_factor) + } AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentage::length((*rems * rem_size).into()) + let pixels: f32 = (*rems * rem_size).into(); + taffy::style::LengthPercentage::length(pixels * scale_factor) } } } @@ -431,10 +492,10 @@ impl ToTaffy> for Size where T: ToTaffy + Clone + Debug + Default + PartialEq, { - fn to_taffy(&self, rem_size: Pixels) -> TaffySize { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> TaffySize { TaffySize { - width: self.width.to_taffy(rem_size), - height: self.height.to_taffy(rem_size), + width: self.width.to_taffy(rem_size, scale_factor), + height: self.height.to_taffy(rem_size, scale_factor), } } } @@ -443,12 +504,12 @@ impl ToTaffy> for Edges where T: ToTaffy + Clone + Debug + Default + PartialEq, { - fn to_taffy(&self, rem_size: Pixels) -> TaffyRect { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> TaffyRect { TaffyRect { - top: self.top.to_taffy(rem_size), - right: self.right.to_taffy(rem_size), - bottom: self.bottom.to_taffy(rem_size), - left: self.left.to_taffy(rem_size), + top: self.top.to_taffy(rem_size, scale_factor), + right: self.right.to_taffy(rem_size, scale_factor), + bottom: self.bottom.to_taffy(rem_size, scale_factor), + left: self.left.to_taffy(rem_size, scale_factor), } } } diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index efac008738..070e434dc9 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -73,12 +73,15 @@ impl TextSystem { fallback_font_stack: smallvec![ // TODO: Remove this when Linux have implemented setting fallbacks. font(".ZedMono"), + font(".ZedSans"), font("Helvetica"), - font("Segoe UI"), // Windows - font("Cantarell"), // Gnome - font("Ubuntu"), // Gnome (Ubuntu) - font("Noto Sans"), // KDE - font("DejaVu Sans") + font("Segoe UI"), // Windows + font("Ubuntu"), // Gnome (Ubuntu) + font("Adwaita Sans"), // Gnome 47 + font("Cantarell"), // Gnome + font("Noto Sans"), // KDE + font("DejaVu Sans"), + font("Arial"), // macOS, Windows ], } } @@ -415,40 +418,30 @@ impl WindowTextSystem { let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); let mut lines = SmallVec::new(); - let mut line_start = 0; - let mut max_wrap_lines = line_clamp.unwrap_or(usize::MAX); + let mut max_wrap_lines = line_clamp; let mut wrapped_lines = 0; - let mut process_line = |line_text: SharedString| { - let line_end = line_start + line_text.len(); + let mut process_line = |line_text: SharedString, line_start, line_end| { + font_runs.clear(); - let mut last_font: Option = None; let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new(); let mut run_start = line_start; while run_start < line_end { let Some(run) = runs.peek_mut() else { + log::warn!("`TextRun`s do not cover the entire to be shaped text"); break; }; - let run_len_within_line = cmp::min(line_end, run_start + run.len) - run_start; + let run_len_within_line = cmp::min(line_end - run_start, run.len); - if last_font == Some(run.font.clone()) { - font_runs.last_mut().unwrap().len += run_len_within_line; - } else { - last_font = Some(run.font.clone()); - font_runs.push(FontRun { - len: run_len_within_line, - font_id: self.resolve_font(&run.font), - }); - } - - if decoration_runs.last().is_some_and(|last_run| { - last_run.color == run.color - && last_run.underline == run.underline - && last_run.strikethrough == run.strikethrough - && last_run.background_color == run.background_color - }) { - decoration_runs.last_mut().unwrap().len += run_len_within_line as u32; + let decoration_changed = if let Some(last_run) = decoration_runs.last_mut() + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + && last_run.background_color == run.background_color + { + last_run.len += run_len_within_line as u32; + false } else { decoration_runs.push(DecorationRun { len: run_len_within_line as u32, @@ -457,13 +450,26 @@ impl WindowTextSystem { underline: run.underline, strikethrough: run.strikethrough, }); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run_len_within_line; + } else { + font_runs.push(FontRun { + len: run_len_within_line, + font_id, + }); } - if run_len_within_line == run.len { + // Preserve the remainder of the run for the next line + run.len -= run_len_within_line; + if run.len == 0 { runs.next(); - } else { - // Preserve the remainder of the run for the next line - run.len -= run_len_within_line; } run_start += run_len_within_line; } @@ -473,7 +479,7 @@ impl WindowTextSystem { font_size, &font_runs, wrap_width, - Some(max_wrap_lines - wrapped_lines), + max_wrap_lines.map(|max| max.saturating_sub(wrapped_lines)), ); wrapped_lines += layout.wrap_boundaries.len(); @@ -484,33 +490,43 @@ impl WindowTextSystem { }); // Skip `\n` character. - line_start = line_end + 1; if let Some(run) = runs.peek_mut() { run.len -= 1; if run.len == 0 { runs.next(); } } - - font_runs.clear(); }; let mut split_lines = text.split('\n'); - let mut processed = false; + // Special case single lines to prevent allocating a sharedstring if let Some(first_line) = split_lines.next() && let Some(second_line) = split_lines.next() { - processed = true; - process_line(first_line.to_string().into()); - process_line(second_line.to_string().into()); + let mut line_start = 0; + process_line( + SharedString::new(first_line), + line_start, + line_start + first_line.len(), + ); + line_start += first_line.len() + '\n'.len_utf8(); + process_line( + SharedString::new(second_line), + line_start, + line_start + second_line.len(), + ); for line_text in split_lines { - process_line(line_text.to_string().into()); + line_start += line_text.len() + '\n'.len_utf8(); + process_line( + SharedString::new(line_text), + line_start, + line_start + line_text.len(), + ); } - } - - if !processed { - process_line(text); + } else { + let end = text.len(); + process_line(text, 0, end); } self.font_runs_pool.lock().push(font_runs); @@ -526,37 +542,52 @@ impl WindowTextSystem { /// Subsets of the line can be styled independently with the `runs` parameter. /// Generally, you should prefer to use [`Self::shape_line`] instead, which /// can be painted directly. - pub fn layout_line( + pub fn layout_line( &self, - text: Text, + text: &str, font_size: Pixels, runs: &[TextRun], force_width: Option, - ) -> Arc - where - Text: AsRef, - SharedString: From, - { + ) -> Arc { + let mut last_run = None::<&TextRun>; let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); + font_runs.clear(); + for run in runs.iter() { - let font_id = self.resolve_font(&run.font); - if let Some(last_run) = font_runs.last_mut() - && last_run.font_id == font_id + let decoration_changed = if let Some(last_run) = last_run + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + // we do not consider differing background color relevant, as it does not affect glyphs + // && last_run.background_color == run.background_color { - last_run.len += run.len; - continue; + false + } else { + last_run = Some(run); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run.len; + } else { + font_runs.push(FontRun { + len: run.len, + font_id, + }); } - font_runs.push(FontRun { - len: run.len, - font_id, - }); } - let layout = - self.line_layout_cache - .layout_line_internal(text, font_size, &font_runs, force_width); + let layout = self.line_layout_cache.layout_line( + &SharedString::new(text), + font_size, + &font_runs, + force_width, + ); - font_runs.clear(); self.font_runs_pool.lock().push(font_runs); layout @@ -706,7 +737,7 @@ impl Display for FontStyle { } /// A styled run of text, for use in [`crate::TextLayout`]. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct TextRun { /// A number of utf8 bytes pub len: usize, @@ -780,6 +811,12 @@ pub struct Font { pub style: FontStyle, } +impl Default for Font { + fn default() -> Self { + font(".SystemUIFont") + } +} + /// Get a [`Font`] for a given name. pub fn font(family: impl Into) -> Font { Font { diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index 189a3e85c6..84618eccc4 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -369,16 +369,17 @@ fn paint_line( let content_mask = window.content_mask(); if max_glyph_bounds.intersects(&content_mask.bounds) { + let vertical_offset = point(px(0.0), glyph.position.y); if glyph.is_emoji { window.paint_emoji( - glyph_origin + baseline_offset, + glyph_origin + baseline_offset + vertical_offset, run.font_id, glyph.id, layout.font_size, )?; } else { window.paint_glyph( - glyph_origin + baseline_offset, + glyph_origin + baseline_offset + vertical_offset, run.font_id, glyph.id, layout.font_size, diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index eff4e640ef..375a9bdc7b 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -501,7 +501,7 @@ impl LineLayoutCache { } else { drop(current_frame); let text = SharedString::from(text); - let unwrapped_layout = self.layout_line::<&SharedString>(&text, font_size, runs); + let unwrapped_layout = self.layout_line::<&SharedString>(&text, font_size, runs, None); let wrap_boundaries = if let Some(wrap_width) = wrap_width { unwrapped_layout.compute_wrap_boundaries(text.as_ref(), wrap_width, max_lines) } else { @@ -535,19 +535,6 @@ impl LineLayoutCache { text: Text, font_size: Pixels, runs: &[FontRun], - ) -> Arc - where - Text: AsRef, - SharedString: From, - { - self.layout_line_internal(text, font_size, runs, None) - } - - pub fn layout_line_internal( - &self, - text: Text, - font_size: Pixels, - runs: &[FontRun], force_width: Option, ) -> Arc where diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index d499d78551..45159313b4 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -1,6 +1,6 @@ use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, px}; use collections::HashMap; -use std::{iter, sync::Arc}; +use std::{borrow::Cow, iter, sync::Arc}; /// The GPUI line wrapper, used to wrap lines of text to a given width. pub struct LineWrapper { @@ -129,13 +129,13 @@ impl LineWrapper { } /// Truncate a line of text to the given width with this wrapper's font and font size. - pub fn truncate_line( + pub fn truncate_line<'a>( &mut self, line: SharedString, truncate_width: Pixels, truncation_suffix: &str, - runs: &mut Vec, - ) -> SharedString { + runs: &'a [TextRun], + ) -> (SharedString, Cow<'a, [TextRun]>) { let mut width = px(0.); let mut suffix_width = truncation_suffix .chars() @@ -154,15 +154,18 @@ impl LineWrapper { if width.floor() > truncate_width { let result = SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix)); - update_runs_after_truncation(&result, truncation_suffix, runs); + let mut runs = runs.to_vec(); + update_runs_after_truncation(&result, truncation_suffix, &mut runs); - return result; + return (result, Cow::Owned(runs)); } } - line + (line, Cow::Borrowed(runs)) } + /// Any character in this list should be treated as a word character, + /// meaning it can be part of a word that should not be wrapped. pub(crate) fn is_word_char(c: char) -> bool { // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc. c.is_ascii_alphanumeric() || @@ -180,10 +183,9 @@ impl LineWrapper { // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode matches!(c, '\u{0400}'..='\u{04FF}') || // Some other known special characters that should be treated as word characters, - // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, `2^3`, `a~b`, etc. - matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '!' | ';' | '*') || - // Characters that used in URL, e.g. `https://github.com/zed-industries/zed?a=1&b=2` for better wrapping a long URL. - matches!(c, '/' | ':' | '?' | '&' | '=') || + // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, + // `2^3`, `a~b`, `a=1`, `Self::new`, etc. + matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':') || // `⋯` character is special used in Zed, to keep this at the end of the line. matches!(c, '⋯') } @@ -225,19 +227,15 @@ impl LineWrapper { fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec) { let mut truncate_at = result.len() - ellipsis.len(); - let mut run_end = None; for (run_index, run) in runs.iter_mut().enumerate() { if run.len <= truncate_at { truncate_at -= run.len; } else { run.len = truncate_at + ellipsis.len(); - run_end = Some(run_index + 1); + runs.truncate(run_index + 1); break; } } - if let Some(run_end) = run_end { - runs.truncate(run_end); - } } /// A fragment of a line that can be wrapped. @@ -317,9 +315,7 @@ impl Boundary { #[cfg(test)] mod tests { use super::*; - use crate::{ - Font, FontFeatures, FontStyle, FontWeight, Hsla, TestAppContext, TestDispatcher, font, - }; + use crate::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font}; #[cfg(target_os = "macos")] use crate::{TextRun, WindowTextSystem, WrapBoundary}; use rand::prelude::*; @@ -343,10 +339,7 @@ mod tests { weight: FontWeight::default(), style: FontStyle::Normal, }, - color: Hsla::default(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }) .collect() } @@ -496,15 +489,14 @@ mod tests { fn perform_test( wrapper: &mut LineWrapper, text: &'static str, - result: &'static str, + expected: &'static str, ellipsis: &str, ) { let dummy_run_lens = vec![text.len()]; - let mut dummy_runs = generate_test_runs(&dummy_run_lens); - assert_eq!( - wrapper.truncate_line(text.into(), px(220.), ellipsis, &mut dummy_runs), - result - ); + let dummy_runs = generate_test_runs(&dummy_run_lens); + let (result, dummy_runs) = + wrapper.truncate_line(text.into(), px(220.), ellipsis, &dummy_runs); + assert_eq!(result, expected); assert_eq!(dummy_runs.first().unwrap().len, result.len()); } @@ -535,16 +527,15 @@ mod tests { fn perform_test( wrapper: &mut LineWrapper, text: &'static str, - result: &str, + expected: &str, run_lens: &[usize], result_run_len: &[usize], line_width: Pixels, ) { - let mut dummy_runs = generate_test_runs(run_lens); - assert_eq!( - wrapper.truncate_line(text.into(), line_width, "…", &mut dummy_runs), - result - ); + let dummy_runs = generate_test_runs(run_lens); + let (result, dummy_runs) = + wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs); + assert_eq!(result, expected); for (run, result_len) in dummy_runs.iter().zip(result_run_len) { assert_eq!(run.len, *result_len); } @@ -648,15 +639,19 @@ mod tests { assert_word("@mention"); assert_word("#hashtag"); assert_word("$variable"); + assert_word("a=1"); + assert_word("Self::is_word_char"); assert_word("more⋯"); // Space assert_not_word("foo bar"); // URL case - assert_word("https://github.com/zed-industries/zed/"); assert_word("github.com"); - assert_word("a=1&b=2"); + assert_not_word("zed-industries/zed"); + assert_not_word("zed-industries\\zed"); + assert_not_word("a=1&b=2"); + assert_not_word("foo?b=2"); // Latin-1 Supplement assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ"); @@ -691,16 +686,12 @@ mod tests { font: font("Helvetica"), color: Default::default(), underline: Default::default(), - strikethrough: None, - background_color: None, + ..Default::default() }; let bold = TextRun { len: 0, font: font("Helvetica").bold(), - color: Default::default(), - underline: Default::default(), - strikethrough: None, - background_color: None, + ..Default::default() }; let text = "aa bbb cccc ddddd eeee".into(); diff --git a/crates/gpui/src/util.rs b/crates/gpui/src/util.rs index badb680082..92c86810c5 100644 --- a/crates/gpui/src/util.rs +++ b/crates/gpui/src/util.rs @@ -83,8 +83,11 @@ impl FutureExt for T { } } +#[pin_project::pin_project] pub struct WithTimeout { + #[pin] future: T, + #[pin] timer: Task<()>, } @@ -97,15 +100,11 @@ impl Future for WithTimeout { type Output = Result; fn poll(self: Pin<&mut Self>, cx: &mut task::Context) -> task::Poll { - // SAFETY: the fields of Timeout are private and we never move the future ourselves - // And its already pinned since we are being polled (all futures need to be pinned to be polled) - let this = unsafe { &raw mut *self.get_unchecked_mut() }; - let future = unsafe { Pin::new_unchecked(&mut (*this).future) }; - let timer = unsafe { Pin::new_unchecked(&mut (*this).timer) }; + let this = self.project(); - if let task::Poll::Ready(output) = future.poll(cx) { + if let task::Poll::Ready(output) = this.future.poll(cx) { task::Poll::Ready(Ok(output)) - } else if timer.poll(cx).is_ready() { + } else if this.timer.poll(cx).is_ready() { task::Poll::Ready(Err(Timeout)) } else { task::Poll::Pending diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 62020cc178..36e46f6961 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -9,14 +9,15 @@ use crate::{ KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, - PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad, - Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, - SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, - SharedString, Size, StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab, - SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, - TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, - point, prelude::*, px, rems, size, transparent_black, + PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, Priority, PromptButton, + PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, + Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, + ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, SubscriberSet, + Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, + TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -58,7 +59,14 @@ mod prompts; use crate::util::atomic_incr_if_not_zero; pub use prompts::*; -pub(crate) const DEFAULT_WINDOW_SIZE: Size = size(px(1024.), px(700.)); +pub(crate) const DEFAULT_WINDOW_SIZE: Size = size(px(1536.), px(864.)); + +/// A 6:5 aspect ratio minimum window size to be used for functional, +/// additional-to-main-Zed windows, like the settings and rules library windows. +pub const DEFAULT_ADDITIONAL_WINDOW_SIZE: Size = Size { + width: Pixels(900.), + height: Pixels(750.), +}; /// Represents the two different phases when dispatching events. #[derive(Default, Copy, Clone, Debug, Eq, PartialEq)] @@ -589,7 +597,7 @@ pub enum HitboxBehavior { /// ``` /// /// This has effects beyond event handling - any use of hitbox checking, such as hover - /// styles and tooltops. These other behaviors are the main point of this mechanism. An + /// styles and tooltips. These other behaviors are the main point of this mechanism. An /// alternative might be to not affect mouse event handling - but this would allow /// inconsistent UI where clicks and moves interact with elements that are not considered to /// be hovered. @@ -617,7 +625,7 @@ pub enum HitboxBehavior { /// desired, then a `cx.stop_propagation()` handler like the one above can be used. /// /// This has effects beyond event handling - this affects any use of `is_hovered`, such as - /// hover styles and tooltops. These other behaviors are the main point of this mechanism. + /// hover styles and tooltips. These other behaviors are the main point of this mechanism. /// An alternative might be to not affect mouse event handling - but this would allow /// inconsistent UI where clicks and moves interact with elements that are not considered to /// be hovered. @@ -815,6 +823,12 @@ impl Frame { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +enum InputModality { + Mouse, + Keyboard, +} + /// Holds the state for a specific window. pub struct Window { pub(crate) handle: AnyWindowHandle, @@ -837,7 +851,7 @@ pub struct Window { pub(crate) text_style_stack: Vec, pub(crate) rendered_entity_stack: Vec, pub(crate) element_offset_stack: Vec>, - pub(crate) element_opacity: Option, + pub(crate) element_opacity: f32, pub(crate) content_mask_stack: Vec>, pub(crate) requested_autoscroll: Option>, pub(crate) image_cache_stack: Vec, @@ -863,6 +877,7 @@ pub struct Window { hovered: Rc>, pub(crate) needs_present: Rc>, pub(crate) last_input_timestamp: Rc>, + last_input_modality: InputModality, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -895,6 +910,7 @@ struct PendingInput { keystrokes: SmallVec<[Keystroke; 1]>, focus: Option, timer: Option>, + needs_timeout: bool, } pub(crate) struct ElementStateBox { @@ -903,27 +919,69 @@ pub(crate) struct ElementStateBox { pub(crate) type_name: &'static str, } -fn default_bounds(display_id: Option, cx: &mut App) -> Bounds { - const DEFAULT_WINDOW_OFFSET: Point = point(px(0.), px(35.)); - +fn default_bounds(display_id: Option, cx: &mut App) -> WindowBounds { // TODO, BUG: if you open a window with the currently active window - // on the stack, this will erroneously select the 'unwrap_or_else' - // code path - cx.active_window() - .and_then(|w| w.update(cx, |_, window, _| window.bounds()).ok()) - .map(|mut bounds| { - bounds.origin += DEFAULT_WINDOW_OFFSET; - bounds - }) - .unwrap_or_else(|| { - let display = display_id - .map(|id| cx.find_display(id)) - .unwrap_or_else(|| cx.primary_display()); + // on the stack, this will erroneously fallback to `None` + // + // TODO these should be the initial window bounds not considering maximized/fullscreen + let active_window_bounds = cx + .active_window() + .and_then(|w| w.update(cx, |_, window, _| window.window_bounds()).ok()); + const CASCADE_OFFSET: f32 = 25.0; + + let display = display_id + .map(|id| cx.find_display(id)) + .unwrap_or_else(|| cx.primary_display()); + + let default_placement = || Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE); + + // Use visible_bounds to exclude taskbar/dock areas + let display_bounds = display + .as_ref() + .map(|d| d.visible_bounds()) + .unwrap_or_else(default_placement); + + let ( + Bounds { + origin: base_origin, + size: base_size, + }, + window_bounds_ctor, + ): (_, fn(Bounds) -> WindowBounds) = match active_window_bounds { + Some(bounds) => match bounds { + WindowBounds::Windowed(bounds) => (bounds, WindowBounds::Windowed), + WindowBounds::Maximized(bounds) => (bounds, WindowBounds::Maximized), + WindowBounds::Fullscreen(bounds) => (bounds, WindowBounds::Fullscreen), + }, + None => ( display - .map(|display| display.default_bounds()) - .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)) - }) + .as_ref() + .map(|d| d.default_bounds()) + .unwrap_or_else(default_placement), + WindowBounds::Windowed, + ), + }; + + let cascade_offset = point(px(CASCADE_OFFSET), px(CASCADE_OFFSET)); + let proposed_origin = base_origin + cascade_offset; + let proposed_bounds = Bounds::new(proposed_origin, base_size); + + let display_right = display_bounds.origin.x + display_bounds.size.width; + let display_bottom = display_bounds.origin.y + display_bounds.size.height; + let window_right = proposed_bounds.origin.x + proposed_bounds.size.width; + let window_bottom = proposed_bounds.origin.y + proposed_bounds.size.height; + + let fits_horizontally = window_right <= display_right; + let fits_vertically = window_bottom <= display_bottom; + + let final_origin = match (fits_horizontally, fits_vertically) { + (true, true) => proposed_origin, + (false, true) => point(display_bounds.origin.x, base_origin.y), + (true, false) => point(base_origin.x, display_bounds.origin.y), + (false, false) => display_bounds.origin, + }; + window_bounds_ctor(Bounds::new(final_origin, base_size)) } impl Window { @@ -950,13 +1008,11 @@ impl Window { tabbing_identifier, } = options; - let bounds = window_bounds - .map(|bounds| bounds.get_bounds()) - .unwrap_or_else(|| default_bounds(display_id, cx)); + let window_bounds = window_bounds.unwrap_or_else(|| default_bounds(display_id, cx)); let mut platform_window = cx.platform.open_window( handle, WindowParams { - bounds, + bounds: window_bounds.get_bounds(), titlebar, kind, is_movable, @@ -997,12 +1053,10 @@ impl Window { .request_decorations(window_decorations.unwrap_or(WindowDecorations::Server)); platform_window.set_background_appearance(window_background); - if let Some(ref window_open_state) = window_bounds { - match window_open_state { - WindowBounds::Fullscreen(_) => platform_window.toggle_fullscreen(), - WindowBounds::Maximized(_) => platform_window.zoom(), - WindowBounds::Windowed(_) => {} - } + match window_bounds { + WindowBounds::Fullscreen(_) => platform_window.toggle_fullscreen(), + WindowBounds::Maximized(_) => platform_window.zoom(), + WindowBounds::Windowed(_) => {} } platform_window.on_close(Box::new({ @@ -1222,7 +1276,7 @@ impl Window { rendered_entity_stack: Vec::new(), element_offset_stack: Vec::new(), content_mask_stack: Vec::new(), - element_opacity: None, + element_opacity: 1.0, requested_autoscroll: None, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), @@ -1246,6 +1300,7 @@ impl Window { hovered, needs_present, last_input_timestamp, + last_input_modality: InputModality::Mouse, refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -1307,9 +1362,7 @@ impl Window { for view_id in self .rendered_frame .dispatch_tree - .view_path(view_id) - .into_iter() - .rev() + .view_path_reversed(view_id) { if !self.dirty_views.insert(view_id) { break; @@ -1445,7 +1498,8 @@ impl Window { style } - /// Check if the platform window is maximized + /// Check if the platform window is maximized. + /// /// On some platforms (namely Windows) this is different than the bounds being the size of the display pub fn is_maximized(&self) -> bool { self.platform_window.is_maximized() @@ -1672,6 +1726,27 @@ impl Window { }) } + /// Spawn the future returned by the given closure on the application thread + /// pool, with the given priority. The closure is provided a handle to the + /// current window and an `AsyncWindowContext` for use within your future. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + cx: &App, + f: AsyncFn, + ) -> Task + where + R: 'static, + AsyncFn: AsyncFnOnce(&mut AsyncWindowContext) -> R + 'static, + { + let handle = self.handle; + cx.spawn_with_priority(priority, async move |app| { + let mut async_window_cx = AsyncWindowContext::new_context(app.clone(), handle); + f(&mut async_window_cx).await + }) + } + fn bounds_changed(&mut self, cx: &mut App) { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); @@ -1747,6 +1822,7 @@ impl Window { self.platform_window.show_window_menu(position) } + /// Handle window movement for Linux and macOS. /// Tells the compositor to take control of window movement (Wayland and X11) /// /// Events may not be received during a move operation. @@ -1839,7 +1915,8 @@ impl Window { f: impl FnOnce(&GlobalElementId, &mut Self) -> R, ) -> R { self.element_id_stack.push(element_id); - let global_id = GlobalElementId(self.element_id_stack.clone()); + let global_id = GlobalElementId(Arc::from(&*self.element_id_stack)); + let result = f(&global_id, self); self.element_id_stack.pop(); result @@ -1848,6 +1925,9 @@ impl Window { /// Executes the provided function with the specified rem size. /// /// This method must only be called as part of element drawing. + // This function is called in a highly recursive manner in editor + // prepainting, make sure its inlined to reduce the stack burden + #[inline] pub fn with_rem_size(&mut self, rem_size: Option>, f: F) -> R where F: FnOnce(&mut Self) -> R, @@ -1899,6 +1979,12 @@ impl Window { self.modifiers } + /// Returns true if the last input event was keyboard-based (key press, tab navigation, etc.) + /// This is used for focus-visible styling to show focus indicators only for keyboard navigation. + pub fn last_input_was_keyboard(&self) -> bool { + self.last_input_modality == InputModality::Keyboard + } + /// The current state of the keyboard's capslock pub fn capslock(&self) -> Capslock { self.capslock @@ -1922,7 +2008,9 @@ impl Window { if let Some(input_handler) = self.platform_window.take_input_handler() { self.rendered_frame.input_handlers.push(Some(input_handler)); } - self.draw_roots(cx); + if !cx.mode.skip_drawing() { + self.draw_roots(cx); + } self.dirty_views.clear(); self.next_frame.window_active = self.active.get(); @@ -2245,7 +2333,7 @@ impl Window { self.rendered_frame.accessed_element_states[range.start.accessed_element_states_index ..range.end.accessed_element_states_index] .iter() - .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), + .map(|(id, type_id)| (id.clone(), *type_id)), ); self.text_system .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); @@ -2313,7 +2401,7 @@ impl Window { self.rendered_frame.accessed_element_states[range.start.accessed_element_states_index ..range.end.accessed_element_states_index] .iter() - .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), + .map(|(id, type_id)| (id.clone(), *type_id)), ); self.next_frame.tab_stops.replay( &self.rendered_frame.tab_stops.insertion_history @@ -2331,6 +2419,9 @@ impl Window { /// Push a text style onto the stack, and call a function with that style active. /// Use [`Window::text_style`] to get the current, combined text style. This method /// should only be called as part of element drawing. + // This function is called in a highly recursive manner in editor + // prepainting, make sure its inlined to reduce the stack burden + #[inline] pub fn with_text_style(&mut self, style: Option, f: F) -> R where F: FnOnce(&mut Self) -> R, @@ -2347,7 +2438,7 @@ impl Window { } /// Updates the cursor style at the platform level. This method should only be called - /// during the prepaint phase of element drawing. + /// during the paint phase of element drawing. pub fn set_cursor_style(&mut self, style: CursorStyle, hitbox: &Hitbox) { self.invalidator.debug_assert_paint(); self.next_frame.cursor_styles.push(CursorStyleRequest { @@ -2358,7 +2449,7 @@ impl Window { /// Updates the cursor style for the entire window at the platform level. A cursor /// style using this method will have precedence over any cursor style set using - /// `set_cursor_style`. This method should only be called during the prepaint + /// `set_cursor_style`. This method should only be called during the paint /// phase of element drawing. pub fn set_window_cursor_style(&mut self, style: CursorStyle) { self.invalidator.debug_assert_paint(); @@ -2381,6 +2472,9 @@ impl Window { /// Invoke the given function with the given content mask after intersecting it /// with the current mask. This method should only be called during element drawing. + // This function is called in a highly recursive manner in editor + // prepainting, make sure its inlined to reduce the stack burden + #[inline] pub fn with_content_mask( &mut self, mask: Option>, @@ -2435,14 +2529,16 @@ impl Window { opacity: Option, f: impl FnOnce(&mut Self) -> R, ) -> R { - if opacity.is_none() { - return f(self); - } - self.invalidator.debug_assert_paint_or_prepaint(); - self.element_opacity = opacity; + + let Some(opacity) = opacity else { + return f(self); + }; + + let previous_opacity = self.element_opacity; + self.element_opacity = previous_opacity * opacity; let result = f(self); - self.element_opacity = None; + self.element_opacity = previous_opacity; result } @@ -2539,9 +2635,10 @@ impl Window { /// Obtain the current element opacity. This method should only be called during the /// prepaint phase of element drawing. + #[inline] pub(crate) fn element_opacity(&self) -> f32 { self.invalidator.debug_assert_paint_or_prepaint(); - self.element_opacity.unwrap_or(1.0) + self.element_opacity } /// Obtain the current content mask. This method should only be called during element drawing. @@ -2632,10 +2729,8 @@ impl Window { { self.invalidator.debug_assert_paint_or_prepaint(); - let key = (GlobalElementId(global_id.0.clone()), TypeId::of::()); - self.next_frame - .accessed_element_states - .push((GlobalElementId(key.0.clone()), TypeId::of::())); + let key = (global_id.clone(), TypeId::of::()); + self.next_frame.accessed_element_states.push(key.clone()); if let Some(any) = self .next_frame @@ -3063,6 +3158,7 @@ impl Window { &mut self, bounds: Bounds, path: SharedString, + mut data: Option<&[u8]>, transformation: TransformationMatrix, color: Hsla, cx: &App, @@ -3071,6 +3167,7 @@ impl Window { let element_opacity = self.element_opacity(); let scale_factor = self.scale_factor(); + let bounds = bounds.scale(scale_factor); let params = RenderSvgParams { path, @@ -3082,21 +3179,33 @@ impl Window { let Some(tile) = self.sprite_atlas .get_or_insert_with(¶ms.clone().into(), &mut || { - let Some(bytes) = cx.svg_renderer.render(¶ms)? else { + let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(¶ms, data)? + else { return Ok(None); }; - Ok(Some((params.size, Cow::Owned(bytes)))) + Ok(Some((size, Cow::Owned(bytes)))) })? else { return Ok(()); }; let content_mask = self.content_mask().scale(scale_factor); + let svg_bounds = Bounds { + origin: bounds.center() + - Point::new( + ScaledPixels(tile.bounds.size.width.0 as f32 / SMOOTH_SVG_SCALE_FACTOR / 2.), + ScaledPixels(tile.bounds.size.height.0 as f32 / SMOOTH_SVG_SCALE_FACTOR / 2.), + ), + size: tile + .bounds + .size + .map(|value| ScaledPixels(value.0 as f32 / SMOOTH_SVG_SCALE_FACTOR)), + }; self.next_frame.scene.insert_primitive(MonochromeSprite { order: 0, pad: 0, - bounds: bounds - .map_origin(|origin| origin.floor()) + bounds: svg_bounds + .map_origin(|origin| origin.round()) .map_size(|size| size.ceil()), content_mask, color: color.opacity(element_opacity), @@ -3210,11 +3319,14 @@ impl Window { cx.layout_id_buffer.clear(); cx.layout_id_buffer.extend(children); let rem_size = self.rem_size(); + let scale_factor = self.scale_factor(); - self.layout_engine - .as_mut() - .unwrap() - .request_layout(style, rem_size, &cx.layout_id_buffer) + self.layout_engine.as_mut().unwrap().request_layout( + style, + rem_size, + scale_factor, + &cx.layout_id_buffer, + ) } /// Add a node to the layout tree for the current frame. Instead of taking a `Style` and children, @@ -3225,21 +3337,19 @@ impl Window { /// returns a `Size`. /// /// This method should only be called as part of the request_layout or prepaint phase of element drawing. - pub fn request_measured_layout< - F: FnMut(Size>, Size, &mut Window, &mut App) -> Size + pub fn request_measured_layout(&mut self, style: Style, measure: F) -> LayoutId + where + F: Fn(Size>, Size, &mut Window, &mut App) -> Size + 'static, - >( - &mut self, - style: Style, - measure: F, - ) -> LayoutId { + { self.invalidator.debug_assert_prepaint(); let rem_size = self.rem_size(); + let scale_factor = self.scale_factor(); self.layout_engine .as_mut() .unwrap() - .request_measured_layout(style, rem_size, measure) + .request_measured_layout(style, rem_size, scale_factor, measure) } /// Compute the layout for the given id within the given available space. @@ -3267,11 +3377,12 @@ impl Window { pub fn layout_bounds(&mut self, layout_id: LayoutId) -> Bounds { self.invalidator.debug_assert_prepaint(); + let scale_factor = self.scale_factor(); let mut bounds = self .layout_engine .as_mut() .unwrap() - .layout_bounds(layout_id) + .layout_bounds(layout_id, scale_factor) .map(Into::into); bounds.origin += self.element_offset(); bounds @@ -3400,25 +3511,25 @@ impl Window { /// This method should only be called as part of the paint phase of element drawing. pub fn on_mouse_event( &mut self, - mut handler: impl FnMut(&Event, DispatchPhase, &mut Window, &mut App) + 'static, + mut listener: impl FnMut(&Event, DispatchPhase, &mut Window, &mut App) + 'static, ) { self.invalidator.debug_assert_paint(); self.next_frame.mouse_listeners.push(Some(Box::new( move |event: &dyn Any, phase: DispatchPhase, window: &mut Window, cx: &mut App| { if let Some(event) = event.downcast_ref() { - handler(event, phase, window, cx) + listener(event, phase, window, cx) } }, ))); } - /// Register a key event listener on the window for the next frame. The type of event + /// Register a key event listener on this node for the next frame. The type of event /// is determined by the first parameter of the given listener. When the next frame is rendered /// the listener will be cleared. /// /// This is a fairly low-level method, so prefer using event handlers on elements unless you have - /// a specific need to register a global listener. + /// a specific need to register a listener yourself. /// /// This method should only be called as part of the paint phase of element drawing. pub fn on_key_event( @@ -3523,6 +3634,7 @@ impl Window { PlatformInput::KeyDown(KeyDownEvent { keystroke: keystroke.clone(), is_held: false, + prefer_character_input: false, }), cx, ); @@ -3560,6 +3672,16 @@ impl Window { #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { self.last_input_timestamp.set(Instant::now()); + + // Track whether this input was keyboard-based for focus-visible styling + self.last_input_modality = match &event { + PlatformInput::KeyDown(_) | PlatformInput::ModifiersChanged(_) => { + InputModality::Keyboard + } + PlatformInput::MouseDown(e) if e.is_focusing() => InputModality::Mouse, + _ => self.last_input_modality, + }; + // Handlers may set this to false by calling `stop_propagation`. cx.propagate_event = true; // Handlers may set this to true by calling `prevent_default`. @@ -3583,6 +3705,9 @@ impl Window { self.modifiers = mouse_up.modifiers; PlatformInput::MouseUp(mouse_up) } + PlatformInput::MousePressure(mouse_pressure) => { + PlatformInput::MousePressure(mouse_pressure) + } PlatformInput::MouseExited(mouse_exited) => { self.modifiers = mouse_exited.modifiers; PlatformInput::MouseExited(mouse_exited) @@ -3779,49 +3904,87 @@ impl Window { } if !match_result.pending.is_empty() { + currently_pending.timer.take(); currently_pending.keystrokes = match_result.pending; currently_pending.focus = self.focus; - currently_pending.timer = Some(self.spawn(cx, async move |cx| { - cx.background_executor.timer(Duration::from_secs(1)).await; - cx.update(move |window, cx| { - let Some(currently_pending) = window - .pending_input - .take() - .filter(|pending| pending.focus == window.focus) - else { - return; - }; - let node_id = window.focus_node_id_in_rendered_frame(window.focus); - let dispatch_path = window.rendered_frame.dispatch_tree.dispatch_path(node_id); + let text_input_requires_timeout = event + .downcast_ref::() + .filter(|key_down| key_down.keystroke.key_char.is_some()) + .and_then(|_| self.platform_window.take_input_handler()) + .map_or(false, |mut input_handler| { + let accepts = input_handler.accepts_text_input(self, cx); + self.platform_window.set_input_handler(input_handler); + accepts + }); - let to_replay = window - .rendered_frame - .dispatch_tree - .flush_dispatch(currently_pending.keystrokes, &dispatch_path); + currently_pending.needs_timeout |= + match_result.pending_has_binding || text_input_requires_timeout; - window.pending_input_changed(cx); - window.replay_pending_input(to_replay, cx) - }) - .log_err(); - })); + if currently_pending.needs_timeout { + currently_pending.timer = Some(self.spawn(cx, async move |cx| { + cx.background_executor.timer(Duration::from_secs(1)).await; + cx.update(move |window, cx| { + let Some(currently_pending) = window + .pending_input + .take() + .filter(|pending| pending.focus == window.focus) + else { + return; + }; + + let node_id = window.focus_node_id_in_rendered_frame(window.focus); + let dispatch_path = + window.rendered_frame.dispatch_tree.dispatch_path(node_id); + + let to_replay = window + .rendered_frame + .dispatch_tree + .flush_dispatch(currently_pending.keystrokes, &dispatch_path); + + window.pending_input_changed(cx); + window.replay_pending_input(to_replay, cx) + }) + .log_err(); + })); + } else { + currently_pending.timer = None; + } self.pending_input = Some(currently_pending); self.pending_input_changed(cx); cx.propagate_event = false; return; } - for binding in match_result.bindings { - self.dispatch_action_on_node(node_id, binding.action.as_ref(), cx); - if !cx.propagate_event { - self.dispatch_keystroke_observers( - event, - Some(binding.action), - match_result.context_stack, - cx, - ); - self.pending_input_changed(cx); - return; + let skip_bindings = event + .downcast_ref::() + .filter(|key_down_event| key_down_event.prefer_character_input) + .map(|_| { + self.platform_window + .take_input_handler() + .map_or(false, |mut input_handler| { + let accepts = input_handler.accepts_text_input(self, cx); + self.platform_window.set_input_handler(input_handler); + // If modifiers are not excessive (e.g. AltGr), and the input handler is accepting text input, + // we prefer the text input over bindings. + accepts + }) + }) + .unwrap_or(false); + + if !skip_bindings { + for binding in match_result.bindings { + self.dispatch_action_on_node(node_id, binding.action.as_ref(), cx); + if !cx.propagate_event { + self.dispatch_keystroke_observers( + event, + Some(binding.action), + match_result.context_stack, + cx, + ); + self.pending_input_changed(cx); + return; + } } } @@ -3930,6 +4093,7 @@ impl Window { let event = KeyDownEvent { keystroke: replay.keystroke.clone(), is_held: false, + prefer_character_input: true, }; cx.propagate_event = true; @@ -4281,10 +4445,10 @@ impl Window { } /// Returns a generic event listener that invokes the given listener with the view and context associated with the given view handle. - pub fn listener_for( + pub fn listener_for( &self, - view: &Entity, - f: impl Fn(&mut V, &E, &mut Window, &mut Context) + 'static, + view: &Entity, + f: impl Fn(&mut T, &E, &mut Window, &mut Context) + 'static, ) -> impl Fn(&E, &mut Window, &mut App) + 'static { let view = view.downgrade(); move |e: &E, window: &mut Window, cx: &mut App| { @@ -4293,14 +4457,14 @@ impl Window { } /// Returns a generic handler that invokes the given handler with the view and context associated with the given view handle. - pub fn handler_for) + 'static>( + pub fn handler_for) + 'static>( &self, - view: &Entity, + entity: &Entity, f: Callback, - ) -> impl Fn(&mut Window, &mut App) + use { - let view = view.downgrade(); + ) -> impl Fn(&mut Window, &mut App) + 'static { + let entity = entity.downgrade(); move |window: &mut Window, cx: &mut App| { - view.update(cx, |view, cx| f(view, window, cx)).ok(); + entity.update(cx, |entity, cx| f(entity, window, cx)).ok(); } } @@ -4317,34 +4481,42 @@ impl Window { })) } - /// Register an action listener on the window for the next frame. The type of action + /// Register an action listener on this node for the next frame. The type of action /// is determined by the first parameter of the given listener. When the next frame is rendered /// the listener will be cleared. /// /// This is a fairly low-level method, so prefer using action handlers on elements unless you have - /// a specific need to register a global listener. + /// a specific need to register a listener yourself. + /// + /// This method should only be called as part of the paint phase of element drawing. pub fn on_action( &mut self, action_type: TypeId, listener: impl Fn(&dyn Any, DispatchPhase, &mut Window, &mut App) + 'static, ) { + self.invalidator.debug_assert_paint(); + self.next_frame .dispatch_tree .on_action(action_type, Rc::new(listener)); } - /// Register an action listener on the window for the next frame if the condition is true. - /// The type of action is determined by the first parameter of the given listener. - /// When the next frame is rendered the listener will be cleared. + /// Register a capturing action listener on this node for the next frame if the condition is true. + /// The type of action is determined by the first parameter of the given listener. When the next + /// frame is rendered the listener will be cleared. /// /// This is a fairly low-level method, so prefer using action handlers on elements unless you have - /// a specific need to register a global listener. + /// a specific need to register a listener yourself. + /// + /// This method should only be called as part of the paint phase of element drawing. pub fn on_action_when( &mut self, condition: bool, action_type: TypeId, listener: impl Fn(&dyn Any, DispatchPhase, &mut Window, &mut App) + 'static, ) { + self.invalidator.debug_assert_paint(); + if condition { self.next_frame .dispatch_tree @@ -4630,7 +4802,15 @@ pub struct WindowHandle { #[deref] #[deref_mut] pub(crate) any_handle: AnyWindowHandle, - state_type: PhantomData, + state_type: PhantomData V>, +} + +impl Debug for WindowHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WindowHandle") + .field("any_handle", &self.any_handle.id.as_u64()) + .finish() + } } impl WindowHandle { @@ -4690,7 +4870,7 @@ impl WindowHandle { .get(self.id) .and_then(|window| { window - .as_ref() + .as_deref() .and_then(|window| window.root.clone()) .map(|root_view| root_view.downcast::()) }) @@ -4758,9 +4938,6 @@ impl From> for AnyWindowHandle { } } -unsafe impl Send for WindowHandle {} -unsafe impl Sync for WindowHandle {} - /// A handle to a window with any root view type, which can be downcast to a window with a specific root view type. #[derive(Copy, Clone, PartialEq, Eq, Hash)] pub struct AnyWindowHandle { @@ -4854,7 +5031,7 @@ pub enum ElementId { /// A code location. CodeLocation(core::panic::Location<'static>), /// A labeled child of an element. - NamedChild(Box, SharedString), + NamedChild(Arc, SharedString), } impl ElementId { @@ -4912,6 +5089,18 @@ impl From for ElementId { } } +impl From for ElementId { + fn from(name: String) -> Self { + ElementId::Name(name.into()) + } +} + +impl From> for ElementId { + fn from(name: Arc) -> Self { + ElementId::Name(name.into()) + } +} + impl From> for ElementId { fn from(path: Arc) -> Self { ElementId::Path(path) @@ -4968,7 +5157,7 @@ impl From<(&'static str, u32)> for ElementId { impl> From<(ElementId, T)> for ElementId { fn from((id, name): (ElementId, T)) -> Self { - ElementId::NamedChild(Box::new(id), name.into()) + ElementId::NamedChild(Arc::new(id), name.into()) } } diff --git a/crates/gpui/src/window/prompts.rs b/crates/gpui/src/window/prompts.rs index 778ee1dab0..63ad1668be 100644 --- a/crates/gpui/src/window/prompts.rs +++ b/crates/gpui/src/window/prompts.rs @@ -142,6 +142,7 @@ impl Render for FallbackPromptRenderer { .id(ix) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(PromptResponse(ix)); + cx.stop_propagation(); })) })); diff --git a/crates/gpui_macros/Cargo.toml b/crates/gpui_macros/Cargo.toml index 0722d8d229..2ee8da52fb 100644 --- a/crates/gpui_macros/Cargo.toml +++ b/crates/gpui_macros/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "gpui-macros" +name = "gpui_macros" version = "0.1.0" edition.workspace = true -publish = true +publish = false license = "Apache-2.0" description = "Macros used by gpui" @@ -22,7 +22,6 @@ heck.workspace = true proc-macro2.workspace = true quote.workspace = true syn.workspace = true -workspace-hack.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["inspector"] } diff --git a/crates/gpui_macros/tests/derive_inspector_reflection.rs b/crates/gpui_macros/tests/derive_inspector_reflection.rs index a0adcb7801..92f4e56e9c 100644 --- a/crates/gpui_macros/tests/derive_inspector_reflection.rs +++ b/crates/gpui_macros/tests/derive_inspector_reflection.rs @@ -1,8 +1,7 @@ //! This code was generated using Zed Agent with Claude Opus 4. -use gpui_macros::derive_inspector_reflection; - -#[derive_inspector_reflection] +// gate on rust-analyzer so rust-analyzer never needs to expand this macro, it takes up to 10 seconds to expand due to inefficiencies in rust-analyzers proc-macro srv +#[cfg_attr(not(rust_analyzer), gpui_macros::derive_inspector_reflection)] trait Transform: Clone { /// Doubles the value fn double(self) -> Self; diff --git a/crates/gpui_tokio/Cargo.toml b/crates/gpui_tokio/Cargo.toml index 2d4abf4063..e9d72b8ec2 100644 --- a/crates/gpui_tokio/Cargo.toml +++ b/crates/gpui_tokio/Cargo.toml @@ -17,4 +17,3 @@ anyhow.workspace = true util.workspace = true gpui.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } -workspace-hack.workspace = true diff --git a/crates/gpui_tokio/src/gpui_tokio.rs b/crates/gpui_tokio/src/gpui_tokio.rs index 8384f2a88e..9cfa1493af 100644 --- a/crates/gpui_tokio/src/gpui_tokio.rs +++ b/crates/gpui_tokio/src/gpui_tokio.rs @@ -1,28 +1,52 @@ use std::future::Future; use gpui::{App, AppContext, Global, ReadGlobal, Task}; -use tokio::task::JoinError; use util::defer; +pub use tokio::task::JoinError; + +/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads. +/// +/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime +/// yourself and pass a Handle to `init_from_handle`. pub fn init(cx: &mut App) { - cx.set_global(GlobalTokio::new()); + let runtime = tokio::runtime::Builder::new_multi_thread() + // Since we now have two executors, let's try to keep our footprint small + .worker_threads(2) + .enable_all() + .build() + .expect("Failed to initialize Tokio"); + + cx.set_global(GlobalTokio::new(RuntimeHolder::Owned(runtime))); +} + +/// Initializes the Tokio wrapper using a Tokio runtime handle. +pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) { + cx.set_global(GlobalTokio::new(RuntimeHolder::Shared(handle))); +} + +enum RuntimeHolder { + Owned(tokio::runtime::Runtime), + Shared(tokio::runtime::Handle), +} + +impl RuntimeHolder { + pub fn handle(&self) -> &tokio::runtime::Handle { + match self { + RuntimeHolder::Owned(runtime) => runtime.handle(), + RuntimeHolder::Shared(handle) => handle, + } + } } struct GlobalTokio { - runtime: tokio::runtime::Runtime, + runtime: RuntimeHolder, } impl Global for GlobalTokio {} impl GlobalTokio { - fn new() -> Self { - let runtime = tokio::runtime::Builder::new_multi_thread() - // Since we now have two executors, let's try to keep our footprint small - .worker_threads(2) - .enable_all() - .build() - .expect("Failed to initialize Tokio"); - + fn new(runtime: RuntimeHolder) -> Self { Self { runtime } } } @@ -39,7 +63,7 @@ impl Tokio { R: Send + 'static, { cx.read_global(|tokio: &GlobalTokio, cx| { - let join_handle = tokio.runtime.spawn(f); + let join_handle = tokio.runtime.handle().spawn(f); let abort_handle = join_handle.abort_handle(); let cancel = defer(move || { abort_handle.abort(); @@ -61,7 +85,7 @@ impl Tokio { R: Send + 'static, { cx.read_global(|tokio: &GlobalTokio, cx| { - let join_handle = tokio.runtime.spawn(f); + let join_handle = tokio.runtime.handle().spawn(f); let abort_handle = join_handle.abort_handle(); let cancel = defer(move || { abort_handle.abort(); diff --git a/crates/html_to_markdown/Cargo.toml b/crates/html_to_markdown/Cargo.toml index 16f10d0cbc..70ff3b3555 100644 --- a/crates/html_to_markdown/Cargo.toml +++ b/crates/html_to_markdown/Cargo.toml @@ -20,7 +20,6 @@ anyhow.workspace = true html5ever.workspace = true markup5ever_rcdom.workspace = true regex.workspace = true -workspace-hack.workspace = true [dev-dependencies] indoc.workspace = true diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index 3a4d875f6a..177f8639ca 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "zed-http-client" +name = "http_client" version = "0.1.0" edition.workspace = true -publish = true +publish = false license = "Apache-2.0" description = "A HTTP client library for Zed and GPUI" @@ -28,11 +28,10 @@ http-body.workspace = true http.workspace = true log.workspace = true parking_lot.workspace = true -reqwest.workspace = true serde.workspace = true serde_json.workspace = true +serde_urlencoded.workspace = true sha2.workspace = true tempfile.workspace = true url.workspace = true util.workspace = true -workspace-hack.workspace = true diff --git a/crates/http_client/src/async_body.rs b/crates/http_client/src/async_body.rs index 6b99a54a7d..8fb49f2185 100644 --- a/crates/http_client/src/async_body.rs +++ b/crates/http_client/src/async_body.rs @@ -88,17 +88,6 @@ impl From<&'static str> for AsyncBody { } } -impl TryFrom for AsyncBody { - type Error = anyhow::Error; - - fn try_from(value: reqwest::Body) -> Result { - value - .as_bytes() - .ok_or_else(|| anyhow::anyhow!("Underlying data is a stream")) - .map(|bytes| Self::from_bytes(Bytes::copy_from_slice(bytes))) - } -} - impl> From> for AsyncBody { fn from(body: Option) -> Self { match body { diff --git a/crates/http_client/src/github.rs b/crates/http_client/src/github.rs index 32efed8e72..e52e2f1d25 100644 --- a/crates/http_client/src/github.rs +++ b/crates/http_client/src/github.rs @@ -1,10 +1,13 @@ -use crate::HttpClient; +use crate::{HttpClient, HttpRequestExt}; use anyhow::{Context as _, Result, anyhow, bail}; use futures::AsyncReadExt; +use http::Request; use serde::Deserialize; use std::sync::Arc; use url::Url; +const GITHUB_API_URL: &str = "https://api.github.com"; + pub struct GitHubLspBinaryVersion { pub name: String, pub url: String, @@ -34,12 +37,17 @@ pub async fn latest_github_release( pre_release: bool, http: Arc, ) -> anyhow::Result { + let url = format!("{GITHUB_API_URL}/repos/{repo_name_with_owner}/releases"); + + let request = Request::get(&url) + .follow_redirects(crate::RedirectPolicy::FollowAll) + .when_some(std::env::var("GITHUB_TOKEN").ok(), |builder, token| { + builder.header("Authorization", format!("Bearer {}", token)) + }) + .body(Default::default())?; + let mut response = http - .get( - format!("https://api.github.com/repos/{repo_name_with_owner}/releases").as_str(), - Default::default(), - true, - ) + .send(request) .await .context("error fetching latest release")?; @@ -91,12 +99,17 @@ pub async fn get_release_by_tag_name( tag: &str, http: Arc, ) -> anyhow::Result { + let url = format!("{GITHUB_API_URL}/repos/{repo_name_with_owner}/releases/tags/{tag}"); + + let request = Request::get(&url) + .follow_redirects(crate::RedirectPolicy::FollowAll) + .when_some(std::env::var("GITHUB_TOKEN").ok(), |builder, token| { + builder.header("Authorization", format!("Bearer {}", token)) + }) + .body(Default::default())?; + let mut response = http - .get( - &format!("https://api.github.com/repos/{repo_name_with_owner}/releases/tags/{tag}"), - Default::default(), - true, - ) + .send(request) .await .context("error fetching latest release")?; diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index 76bf0b905d..1182ef74ca 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -6,18 +6,15 @@ pub use anyhow::{Result, anyhow}; pub use async_body::{AsyncBody, Inner}; use derive_more::Deref; use http::HeaderValue; -pub use http::{self, Method, Request, Response, StatusCode, Uri}; +pub use http::{self, Method, Request, Response, StatusCode, Uri, request::Builder}; -use futures::{ - FutureExt as _, - future::{self, BoxFuture}, -}; -use http::request::Builder; +use futures::future::BoxFuture; use parking_lot::Mutex; +use serde::Serialize; +use std::sync::Arc; #[cfg(feature = "test-support")] -use std::fmt; -use std::{any::type_name, sync::Arc}; -pub use url::Url; +use std::{any::type_name, fmt}; +pub use url::{Host, Url}; #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] pub enum RedirectPolicy { @@ -59,10 +56,10 @@ impl HttpRequestExt for http::request::Builder { } pub trait HttpClient: 'static + Send + Sync { - fn type_name(&self) -> &'static str; - fn user_agent(&self) -> Option<&HeaderValue>; + fn proxy(&self) -> Option<&Url>; + fn send( &self, req: http::Request, @@ -106,20 +103,10 @@ pub trait HttpClient: 'static + Send + Sync { } } - fn proxy(&self) -> Option<&Url>; - #[cfg(feature = "test-support")] fn as_fake(&self) -> &FakeHttpClient { panic!("called as_fake on {}", type_name::()) } - - fn send_multipart_form<'a>( - &'a self, - _url: &str, - _request: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - future::ready(Err(anyhow!("not implemented"))).boxed() - } } /// An [`HttpClient`] that may have a proxy. @@ -163,38 +150,20 @@ impl HttpClient for HttpClientWithProxy { self.proxy.as_ref() } - fn type_name(&self) -> &'static str { - self.client.type_name() - } - #[cfg(feature = "test-support")] fn as_fake(&self) -> &FakeHttpClient { self.client.as_fake() } - - fn send_multipart_form<'a>( - &'a self, - url: &str, - form: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - self.client.send_multipart_form(url, form) - } } /// An [`HttpClient`] that has a base URL. +#[derive(Deref)] pub struct HttpClientWithUrl { base_url: Mutex, + #[deref] client: HttpClientWithProxy, } -impl std::ops::Deref for HttpClientWithUrl { - type Target = HttpClientWithProxy; - - fn deref(&self) -> &Self::Target { - &self.client - } -} - impl HttpClientWithUrl { /// Returns a new [`HttpClientWithUrl`] with the given base URL. pub fn new( @@ -256,7 +225,7 @@ impl HttpClientWithUrl { } /// Builds a Zed Cloud URL using the given path. - pub fn build_zed_cloud_url(&self, path: &str, query: &[(&str, &str)]) -> Result { + pub fn build_zed_cloud_url(&self, path: &str) -> Result { let base_url = self.base_url(); let base_api_url = match base_url.as_ref() { "https://zed.dev" => "https://cloud.zed.dev", @@ -265,10 +234,20 @@ impl HttpClientWithUrl { other => other, }; - Ok(Url::parse_with_params( - &format!("{}{}", base_api_url, path), - query, - )?) + Ok(Url::parse(&format!("{}{}", base_api_url, path))?) + } + + /// Builds a Zed Cloud URL using the given path and query params. + pub fn build_zed_cloud_url_with_query(&self, path: &str, query: impl Serialize) -> Result { + let base_url = self.base_url(); + let base_api_url = match base_url.as_ref() { + "https://zed.dev" => "https://cloud.zed.dev", + "https://staging.zed.dev" => "https://cloud.zed.dev", + "http://localhost:3000" => "http://localhost:8787", + other => other, + }; + let query = serde_urlencoded::to_string(&query)?; + Ok(Url::parse(&format!("{}{}?{}", base_api_url, path, query))?) } /// Builds a Zed LLM URL using the given path. @@ -304,22 +283,10 @@ impl HttpClient for HttpClientWithUrl { self.client.proxy.as_ref() } - fn type_name(&self) -> &'static str { - self.client.type_name() - } - #[cfg(feature = "test-support")] fn as_fake(&self) -> &FakeHttpClient { self.client.as_fake() } - - fn send_multipart_form<'a>( - &'a self, - url: &str, - request: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - self.client.send_multipart_form(url, request) - } } pub fn read_proxy_from_env() -> Option { @@ -374,10 +341,6 @@ impl HttpClient for BlockedHttpClient { None } - fn type_name(&self) -> &'static str { - type_name::() - } - #[cfg(feature = "test-support")] fn as_fake(&self) -> &FakeHttpClient { panic!("called as_fake on {}", type_name::()) @@ -418,6 +381,7 @@ impl FakeHttpClient { } pub fn with_404_response() -> Arc { + log::warn!("Using fake HTTP client with 404 response"); Self::create(|_| async move { Ok(Response::builder() .status(404) @@ -427,6 +391,7 @@ impl FakeHttpClient { } pub fn with_200_response() -> Arc { + log::warn!("Using fake HTTP client with 200 response"); Self::create(|_| async move { Ok(Response::builder() .status(200) @@ -472,10 +437,6 @@ impl HttpClient for FakeHttpClient { None } - fn type_name(&self) -> &'static str { - type_name::() - } - fn as_fake(&self) -> &FakeHttpClient { self } diff --git a/crates/http_client_tls/Cargo.toml b/crates/http_client_tls/Cargo.toml index d0b45d7034..a55268ac31 100644 --- a/crates/http_client_tls/Cargo.toml +++ b/crates/http_client_tls/Cargo.toml @@ -18,4 +18,3 @@ doctest = true [dependencies] rustls.workspace = true rustls-platform-verifier.workspace = true -workspace-hack.workspace = true diff --git a/crates/icons/Cargo.toml b/crates/icons/Cargo.toml index c2574014ea..fc00165843 100644 --- a/crates/icons/Cargo.toml +++ b/crates/icons/Cargo.toml @@ -14,4 +14,3 @@ path = "src/icons.rs" [dependencies] serde.workspace = true strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 4c026bc2b8..23ae7a6d92 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -34,6 +34,7 @@ pub enum IconName { ArrowRightLeft, ArrowUp, ArrowUpRight, + AtSign, Attach, AudioOff, AudioOn, @@ -44,15 +45,17 @@ pub enum IconName { BellRing, Binary, Blocks, - BoltOutlined, BoltFilled, + BoltOutlined, Book, BookCopy, + Box, CaseSensitive, Chat, Check, CheckDouble, ChevronDown, + ChevronDownUp, ChevronLeft, ChevronRight, ChevronUp, @@ -78,13 +81,12 @@ pub enum IconName { Debug, DebugBreakpoint, DebugContinue, + DebugDetach, DebugDisabledBreakpoint, DebugDisabledLogBreakpoint, - DebugDetach, DebugIgnoreBreakpoints, DebugLogBreakpoint, DebugPause, - DebugStepBack, DebugStepInto, DebugStepOut, DebugStepOver, @@ -135,16 +137,20 @@ pub enum IconName { GenericRestore, GitBranch, GitBranchAlt, + GitBranchPlus, Github, Hash, HistoryRerun, Image, + Inception, Indicator, Info, Json, Keyboard, Library, LineHeight, + Link, + Linux, ListCollapse, ListFilter, ListTodo, @@ -170,8 +176,8 @@ pub enum IconName { PencilUnavailable, Person, Pin, - PlayOutlined, PlayFilled, + PlayOutlined, Plus, Power, Public, @@ -214,6 +220,7 @@ pub enum IconName { SupermavenError, SupermavenInit, SwatchBook, + SweepAi, Tab, Terminal, TerminalAlt, @@ -253,18 +260,18 @@ pub enum IconName { XCircle, XCircleFilled, ZedAgent, + ZedAgentTwo, ZedAssistant, ZedBurnMode, ZedBurnModeOn, - ZedMcpCustom, - ZedMcpExtension, ZedPredict, ZedPredictDisabled, ZedPredictDown, ZedPredictError, ZedPredictUp, + ZedSrcCustom, + ZedSrcExtension, ZedXCopilot, - Linux, } impl IconName { diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 1afa2c5f9d..92386e8ba8 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -30,7 +30,6 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/image_viewer/src/image_info.rs b/crates/image_viewer/src/image_info.rs index 70a92736aa..6eedb13ed1 100644 --- a/crates/image_viewer/src/image_info.rs +++ b/crates/image_viewer/src/image_info.rs @@ -47,7 +47,7 @@ impl Render for ImageInfo { let settings = ImageViewerSettings::get_global(cx); let Some(metadata) = self.metadata.as_ref() else { - return div(); + return div().hidden(); }; let mut components = Vec::new(); @@ -77,9 +77,7 @@ impl Render for ImageInfo { .to_string(), ); - div().child( - Button::new("image-metadata", components.join(" • ")).label_size(LabelSize::Small), - ) + div().child(Label::new(components.join(" • ")).size(LabelSize::Small)) } } diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 8a4f2ebfe2..d7c2341723 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -1,6 +1,8 @@ mod image_info; mod image_viewer_settings; +use std::path::Path; + use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; @@ -13,11 +15,12 @@ use language::{DiskState, File as _}; use persistence::IMAGE_VIEWER; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; -use theme::Theme; +use theme::{Theme, ThemeSettings}; use ui::prelude::*; use util::paths::PathExt; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, + invalid_item_view::InvalidItemView, item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, }; @@ -162,32 +165,41 @@ impl Item for ImageView { fn breadcrumbs(&self, _theme: &Theme, cx: &App) -> Option> { let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx); + let settings = ThemeSettings::get_global(cx); + Some(vec![BreadcrumbText { text, highlights: None, - font: None, + font: Some(settings.buffer_font.clone()), }]) } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, _: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| Self { + Task::ready(Some(cx.new(|cx| Self { image_item: self.image_item.clone(), project: self.project.clone(), focus_handle: cx.focus_handle(), - })) + }))) } fn has_deleted_file(&self, cx: &App) -> bool { self.image_item.read(cx).file.disk_state() == DiskState::Deleted } + fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind { + workspace::item::ItemBufferKind::Singleton + } } fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String { @@ -293,72 +305,79 @@ impl Focusable for ImageView { impl Render for ImageView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let image = self.image_item.read(cx).image.clone(); - let checkered_background = |bounds: Bounds, - _, - window: &mut Window, - _cx: &mut App| { - let square_size = 32.0; + let checkered_background = + |bounds: Bounds, _, window: &mut Window, _cx: &mut App| { + let square_size: f32 = 32.0; - let start_y = bounds.origin.y.into(); - let height: f32 = bounds.size.height.into(); - let start_x = bounds.origin.x.into(); - let width: f32 = bounds.size.width.into(); + let start_y = bounds.origin.y.into(); + let height: f32 = bounds.size.height.into(); + let start_x = bounds.origin.x.into(); + let width: f32 = bounds.size.width.into(); - let mut y = start_y; - let mut x = start_x; - let mut color_swapper = true; - // draw checkerboard pattern - while y <= start_y + height { - // Keeping track of the grid in order to be resilient to resizing - let start_swap = color_swapper; - while x <= start_x + width { - let rect = - Bounds::new(point(px(x), px(y)), size(px(square_size), px(square_size))); + let mut y = start_y; + let mut x = start_x; + let mut color_swapper = true; + // draw checkerboard pattern + while y < start_y + height { + // Keeping track of the grid in order to be resilient to resizing + let start_swap = color_swapper; + while x < start_x + width { + // Clamp square dimensions to not exceed bounds + let square_width = square_size.min(start_x + width - x); + let square_height = square_size.min(start_y + height - y); - let color = if color_swapper { - opaque_grey(0.6, 0.4) - } else { - opaque_grey(0.7, 0.4) - }; + let rect = Bounds::new( + point(px(x), px(y)), + size(px(square_width), px(square_height)), + ); - window.paint_quad(fill(rect, color)); - color_swapper = !color_swapper; - x += square_size; + let color = if color_swapper { + opaque_grey(0.6, 0.4) + } else { + opaque_grey(0.7, 0.4) + }; + + window.paint_quad(fill(rect, color)); + color_swapper = !color_swapper; + x += square_size; + } + x = start_x; + color_swapper = !start_swap; + y += square_size; } - x = start_x; - color_swapper = !start_swap; - y += square_size; - } - }; + }; - let checkered_background = canvas(|_, _, _| (), checkered_background) - .border_2() - .border_color(cx.theme().styles.colors.border) - .size_full() - .absolute() - .top_0() - .left_0(); - - div() - .track_focus(&self.focus_handle(cx)) - .size_full() - .child(checkered_background) - .child( - div() - .flex() - .justify_center() - .items_center() - .w_full() - // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full - .h_full() - .child( - img(image) - .object_fit(ObjectFit::ScaleDown) - .max_w_full() - .max_h_full() - .id("img"), - ), - ) + div().track_focus(&self.focus_handle(cx)).size_full().child( + div() + .flex() + .justify_center() + .items_center() + .w_full() + // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full + .h_full() + .child( + div() + .relative() + .max_w_full() + .max_h_full() + .child( + canvas(|_, _, _| (), checkered_background) + .border_2() + .border_color(cx.theme().styles.colors.border) + .size_full() + .absolute() + .top_0() + .left_0(), + ) + .child( + img(image) + .object_fit(ObjectFit::ScaleDown) + .max_w_full() + .max_h_full() + .id("img"), + ), + ), + ) } } @@ -377,10 +396,22 @@ impl ProjectItem for ImageView { { Self::new(item, project, window, cx) } + + fn for_broken_project_item( + abs_path: &Path, + is_local: bool, + e: &anyhow::Error, + window: &mut Window, + cx: &mut App, + ) -> Option + where + Self: Sized, + { + Some(InvalidItemView::new(abs_path, is_local, e, window, cx)) + } } pub fn init(cx: &mut App) { - ImageViewerSettings::register(cx); workspace::register_project_item::(cx); workspace::register_serializable_item::(cx); } diff --git a/crates/image_viewer/src/image_viewer_settings.rs b/crates/image_viewer/src/image_viewer_settings.rs index 64f2e49482..c490d1c46f 100644 --- a/crates/image_viewer/src/image_viewer_settings.rs +++ b/crates/image_viewer/src/image_viewer_settings.rs @@ -1,9 +1,8 @@ -use gpui::App; pub use settings::ImageFileSizeUnit; -use settings::Settings; +use settings::{RegisterSetting, Settings}; /// The settings for the image viewer. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, RegisterSetting)] pub struct ImageViewerSettings { /// The unit to use for displaying image file sizes. /// @@ -12,7 +11,7 @@ pub struct ImageViewerSettings { } impl Settings for ImageViewerSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { Self { unit: content.image_viewer.clone().unwrap().unit.unwrap(), } diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml index cefe888974..aaf40b2f8d 100644 --- a/crates/inspector_ui/Cargo.toml +++ b/crates/inspector_ui/Cargo.toml @@ -22,9 +22,9 @@ project.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true theme.workspace = true +title_bar.workspace = true ui.workspace = true util.workspace = true util_macros.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index 5f0786c885..9b145e920e 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -87,7 +87,7 @@ impl DivInspector { // Rust Analyzer doesn't get started for it. let rust_language_result = languages.language_for_name("Rust").await; let rust_style_buffer = rust_language_result.and_then(|rust_language| { - cx.new(|cx| Buffer::local("", cx).with_language(rust_language, cx)) + cx.new(|cx| Buffer::local("", cx).with_language_async(rust_language, cx)) }); match json_style_buffer.and_then(|json_style_buffer| { @@ -576,7 +576,12 @@ fn render_layout_state(inspector_state: &DivInspectorState, cx: &App) -> Div { .child( div() .text_ui(cx) - .child(format!("Bounds: {}", inspector_state.bounds)), + .child(format!( + "Bounds: ⌜{} - {}⌟", + inspector_state.bounds.origin, + inspector_state.bounds.bottom_right() + )) + .child(format!("Size: {}", inspector_state.bounds.size)), ) .child( div() @@ -659,6 +664,8 @@ impl CompletionProvider for RustStyleCompletionProvider { replace_range: replace_range.clone(), new_text: format!(".{}()", method.name), label: CodeLabel::plain(method.name.to_string(), None), + match_start: None, + snippet_deduplication_key: None, icon_path: None, documentation: method.documentation.map(|documentation| { CompletionDocumentation::MultiLineMarkdown(documentation.into()) @@ -679,7 +686,6 @@ impl CompletionProvider for RustStyleCompletionProvider { position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some() diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index 8d24b93fa9..7f7985df9b 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, anyhow}; use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window}; use std::{cell::OnceCell, path::Path, sync::Arc}; +use title_bar::platform_title_bar::PlatformTitleBar; use ui::{Label, Tooltip, prelude::*}; use util::{ResultExt as _, command::new_smol_command}; use workspace::AppState; @@ -56,6 +57,8 @@ fn render_inspector( let ui_font = theme::setup_ui_font(window, cx); let colors = cx.theme().colors(); let inspector_id = inspector.active_element_id(); + let toolbar_height = PlatformTitleBar::height(window); + v_flex() .size_full() .bg(colors.panel_background) @@ -65,7 +68,11 @@ fn render_inspector( .border_color(colors.border) .child( h_flex() - .p_2() + .justify_between() + .pr_2() + .pl_1() + .mt_px() + .h(toolbar_height) .border_b_1() .border_color(colors.border_variant) .child( @@ -78,18 +85,14 @@ fn render_inspector( window.refresh(); })), ) - .child( - h_flex() - .w_full() - .justify_end() - .child(Label::new("GPUI Inspector").size(LabelSize::Large)), - ), + .child(h_flex().justify_end().child(Label::new("GPUI Inspector"))), ) .child( v_flex() .id("gpui-inspector-content") .overflow_y_scroll() - .p_2() + .px_2() + .py_0p5() .gap_2() .when_some(inspector_id, |this, inspector_id| { this.child(render_inspector_id(inspector_id, cx)) @@ -110,15 +113,19 @@ fn render_inspector_id(inspector_id: &InspectorElementId, cx: &App) -> Div { .unwrap_or(source_location_string); v_flex() - .child(Label::new("Element ID").size(LabelSize::Large)) .child( - div() - .id("instance-id") - .text_ui(cx) - .tooltip(Tooltip::text( - "Disambiguates elements from the same source location", - )) - .child(format!("Instance {}", inspector_id.instance_id)), + h_flex() + .justify_between() + .child(Label::new("Element ID").size(LabelSize::Large)) + .child( + div() + .id("instance-id") + .text_ui(cx) + .tooltip(Tooltip::text( + "Disambiguates elements from the same source location", + )) + .child(format!("Instance {}", inspector_id.instance_id)), + ), ) .child( div() @@ -126,8 +133,10 @@ fn render_inspector_id(inspector_id: &InspectorElementId, cx: &App) -> Div { .text_ui(cx) .bg(cx.theme().colors().editor_foreground.opacity(0.025)) .underline() + .font_buffer(cx) + .text_xs() .child(source_location_string) - .tooltip(Tooltip::text("Click to open by running zed cli")) + .tooltip(Tooltip::text("Click to open by running Zed CLI")) .on_click(move |_, _window, cx| { cx.background_spawn(open_zed_source_location(source_location)) .detach_and_log_err(cx); diff --git a/crates/install_cli/Cargo.toml b/crates/install_cli/Cargo.toml index 4679f9e54f..1eede025e5 100644 --- a/crates/install_cli/Cargo.toml +++ b/crates/install_cli/Cargo.toml @@ -21,5 +21,4 @@ gpui.workspace = true release_channel.workspace = true smol.workspace = true util.workspace = true -workspace-hack.workspace = true workspace.workspace = true diff --git a/crates/journal/Cargo.toml b/crates/journal/Cargo.toml index 1b32c9cdbb..a78a2cc3b2 100644 --- a/crates/journal/Cargo.toml +++ b/crates/journal/Cargo.toml @@ -22,7 +22,6 @@ serde.workspace = true settings.workspace = true shellexpand.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index 9dc724f123..f43949c005 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -3,7 +3,7 @@ use editor::scroll::Autoscroll; use editor::{Editor, SelectionEffects}; use gpui::{App, AppContext as _, Context, Window, actions}; pub use settings::HourFormat; -use settings::Settings; +use settings::{RegisterSetting, Settings}; use std::{ fs::OpenOptions, path::{Path, PathBuf}, @@ -20,7 +20,7 @@ actions!( ); /// Settings specific to journaling -#[derive(Clone, Debug)] +#[derive(Clone, Debug, RegisterSetting)] pub struct JournalSettings { /// The path of the directory where journal entries are stored. /// @@ -33,7 +33,7 @@ pub struct JournalSettings { } impl settings::Settings for JournalSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let journal = content.journal.clone().unwrap(); Self { @@ -44,8 +44,6 @@ impl settings::Settings for JournalSettings { } pub fn init(_: Arc, cx: &mut App) { - JournalSettings::register(cx); - cx.observe_new( |workspace: &mut Workspace, _window, _cx: &mut Context| { workspace.register_action(|workspace, _: &NewJournalEntry, window, cx| { @@ -161,7 +159,7 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap cx, |s| s.select_ranges([len..len]), ); - if len > 0 { + if len.0 > 0 { editor.insert("\n\n", window, cx); } editor.insert(&entry_heading, window, cx); @@ -175,9 +173,15 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap } fn journal_dir(path: &str) -> Option { - shellexpand::full(path) //TODO handle this better - .ok() - .map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal")) + let expanded = shellexpand::full(path).ok()?; + let base_path = Path::new(expanded.as_ref()); + let absolute_path = if base_path.is_absolute() { + base_path.to_path_buf() + } else { + log::warn!("Invalid journal path {path:?} (not absolute), falling back to home directory",); + std::env::home_dir()? + }; + Some(absolute_path.join("journal")) } fn heading_entry(now: NaiveTime, hour_format: &HourFormat) -> String { @@ -226,4 +230,65 @@ mod tests { assert_eq!(actual_heading_entry, expected_heading_entry); } } + + mod journal_dir_tests { + use super::super::*; + + #[test] + #[cfg(target_family = "unix")] + fn test_absolute_unix_path() { + let result = journal_dir("/home/user"); + assert!(result.is_some()); + let path = result.unwrap(); + assert!(path.is_absolute()); + assert_eq!(path, PathBuf::from("/home/user/journal")); + } + + #[test] + fn test_tilde_expansion() { + let result = journal_dir("~/documents"); + assert!(result.is_some()); + let path = result.unwrap(); + + assert!(path.is_absolute(), "Tilde should expand to absolute path"); + + if let Some(home) = std::env::home_dir() { + assert_eq!(path, home.join("documents").join("journal")); + } + } + + #[test] + fn test_relative_path_falls_back_to_home() { + for relative_path in ["relative/path", "NONEXT/some/path", "../some/path"] { + let result = journal_dir(relative_path); + assert!(result.is_some(), "Failed for path: {}", relative_path); + let path = result.unwrap(); + + assert!( + path.is_absolute(), + "Path should be absolute for input '{}', got: {:?}", + relative_path, + path + ); + + if let Some(home) = std::env::home_dir() { + assert_eq!( + path, + home.join("journal"), + "Should fall back to home directory for input '{}'", + relative_path + ); + } + } + } + + #[test] + #[cfg(target_os = "windows")] + fn test_absolute_path_windows_style() { + let result = journal_dir("C:\\Users\\user\\Documents"); + assert!(result.is_some()); + let path = result.unwrap(); + assert_eq!(path, PathBuf::from("C:\\Users\\user\\Documents\\journal")); + } + } } diff --git a/crates/json_schema_store/Cargo.toml b/crates/json_schema_store/Cargo.toml index 05c8cbfd9d..efb1b36e79 100644 --- a/crates/json_schema_store/Cargo.toml +++ b/crates/json_schema_store/Cargo.toml @@ -30,7 +30,6 @@ snippet_provider.workspace = true task.workspace = true theme.workspace = true util.workspace = true -workspace-hack.workspace = true diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index 87c4203047..18041545cc 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -3,8 +3,9 @@ use std::{str::FromStr, sync::Arc}; use anyhow::{Context as _, Result}; use gpui::{App, AsyncApp, BorrowAppContext as _, Entity, WeakEntity}; -use language::LanguageRegistry; +use language::{LanguageRegistry, language_settings::all_language_settings}; use project::LspStore; +use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields}; // Origin: https://github.com/SchemaStore/schemastore const TSCONFIG_SCHEMA: &str = include_str!("schemas/tsconfig.json"); @@ -61,7 +62,9 @@ impl SchemaStore { return false; }; project::lsp_store::json_language_server_ext::notify_schema_changed( - lsp_store, &uri, cx, + lsp_store, + uri.clone(), + cx, ); true }) @@ -157,14 +160,35 @@ pub fn resolve_schema_request_inner( } } "snippets" => snippet_provider::format::VsSnippetsFile::generate_json_schema(), + "jsonc" => jsonc_schema(), _ => { - anyhow::bail!("Unrecognized builtin JSON schema: {}", schema_name); + anyhow::bail!("Unrecognized builtin JSON schema: {schema_name}"); } }; Ok(schema) } -pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value { +const JSONC_LANGUAGE_NAME: &str = "JSONC"; + +pub fn all_schema_file_associations( + languages: &Arc, + cx: &mut App, +) -> serde_json::Value { + let extension_globs = languages + .available_language_for_name(JSONC_LANGUAGE_NAME) + .map(|language| language.matcher().path_suffixes.clone()) + .into_iter() + .flatten() + // Path suffixes can be entire file names or just their extensions. + .flat_map(|path_suffix| [format!("*.{path_suffix}"), path_suffix]); + let override_globs = all_language_settings(None, cx) + .file_types + .get(JSONC_LANGUAGE_NAME) + .into_iter() + .flat_map(|(_, glob_strings)| glob_strings) + .cloned(); + let jsonc_globs = extension_globs.chain(override_globs).collect::>(); + let mut file_associations = serde_json::json!([ { "fileMatch": [ @@ -209,6 +233,10 @@ pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value { "fileMatch": ["package.json"], "url": "zed://schemas/package_json" }, + { + "fileMatch": &jsonc_globs, + "url": "zed://schemas/jsonc" + }, ]); #[cfg(debug_assertions)] @@ -231,7 +259,7 @@ pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value { let file_name = normalized_action_name_to_file_name(normalized_name.clone()); serde_json::json!({ "fileMatch": [file_name], - "url": format!("zed://schemas/action/{}", normalized_name) + "url": format!("zed://schemas/action/{normalized_name}") }) }), ); @@ -247,6 +275,26 @@ fn package_json_schema() -> serde_json::Value { serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap() } +fn jsonc_schema() -> serde_json::Value { + let generator = schemars::generate::SchemaSettings::draft2019_09() + .with_transform(DefaultDenyUnknownFields) + .with_transform(AllowTrailingCommas) + .into_generator(); + let meta_schema = generator + .settings() + .meta_schema + .as_ref() + .expect("meta_schema should be present in schemars settings") + .to_string(); + let defs = generator.definitions(); + let schema = schemars::json_schema!({ + "$schema": meta_schema, + "allowTrailingCommas": true, + "$defs": defs, + }); + serde_json::to_value(schema).unwrap() +} + fn generate_inspector_style_schema() -> serde_json::Value { let schema = schemars::generate::SchemaSettings::draft2019_09() .with_transform(util::schemars::DefaultDenyUnknownFields) diff --git a/crates/json_schema_store/src/schemas/package.json b/crates/json_schema_store/src/schemas/package.json index a24583fa88..0906dcf36e 100644 --- a/crates/json_schema_store/src/schemas/package.json +++ b/crates/json_schema_store/src/schemas/package.json @@ -1030,22 +1030,22 @@ "$ref": "#" }, "eslintConfig": { - "$ref": "https://json.schemastore.org/eslintrc.json" + "$ref": "https://www.schemastore.org/eslintrc.json" }, "prettier": { - "$ref": "https://json.schemastore.org/prettierrc.json" + "$ref": "https://www.schemastore.org/prettierrc.json" }, "stylelint": { - "$ref": "https://json.schemastore.org/stylelintrc.json" + "$ref": "https://www.schemastore.org/stylelintrc.json" }, "ava": { - "$ref": "https://json.schemastore.org/ava.json" + "$ref": "https://www.schemastore.org/ava.json" }, "release": { - "$ref": "https://json.schemastore.org/semantic-release.json" + "$ref": "https://www.schemastore.org/semantic-release.json" }, "jscpd": { - "$ref": "https://json.schemastore.org/jscpd.json" + "$ref": "https://www.schemastore.org/jscpd.json" }, "pnpm": { "description": "Defines pnpm specific configuration.", @@ -1305,5 +1305,5 @@ ] } ], - "$id": "https://json.schemastore.org/package.json" + "$id": "https://www.schemastore.org/package.json" } diff --git a/crates/json_schema_store/src/schemas/tsconfig.json b/crates/json_schema_store/src/schemas/tsconfig.json index 4b90887254..9484c027df 100644 --- a/crates/json_schema_store/src/schemas/tsconfig.json +++ b/crates/json_schema_store/src/schemas/tsconfig.json @@ -1466,7 +1466,7 @@ } } }, - "id": "https://json.schemastore.org/tsconfig", + "id": "https://www.schemastore.org/tsconfig", "title": "JSON schema for the TypeScript compiler's configuration file", "type": "object" } diff --git a/crates/keymap_editor/Cargo.toml b/crates/keymap_editor/Cargo.toml index ccd42dfa01..33ba95ddd6 100644 --- a/crates/keymap_editor/Cargo.toml +++ b/crates/keymap_editor/Cargo.toml @@ -41,8 +41,6 @@ tree-sitter-rust.workspace = true ui_input.workspace = true ui.workspace = true util.workspace = true -vim.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 106b50d798..9e243d3215 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1,15 +1,17 @@ use std::{ + cell::RefCell, cmp::{self}, ops::{Not as _, Range}, + rc::Rc, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; mod ui_components; use anyhow::{Context as _, anyhow}; use collections::{HashMap, HashSet}; -use editor::{CompletionProvider, Editor, EditorEvent}; +use editor::{CompletionProvider, Editor, EditorEvent, EditorMode, SizingBehavior}; use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ @@ -23,14 +25,16 @@ use gpui::{ use language::{Language, LanguageConfig, ToOffset as _}; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{CompletionDisplayOptions, Project}; -use settings::{BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets}; +use settings::{ + BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size, +}; use ui::{ ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, Indicator, - Modal, ModalFooter, ModalHeader, ParentElement as _, Render, Section, SharedString, - Styled as _, Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, Tooltip, - Window, prelude::*, right_click_menu, + Modal, ModalFooter, ModalHeader, ParentElement as _, PopoverMenu, Render, Section, + SharedString, Styled as _, Table, TableColumnWidths, TableInteractionState, + TableResizeBehavior, Tooltip, Window, prelude::*, }; -use ui_input::SingleLineInput; +use ui_input::InputField; use util::ResultExt; use workspace::{ Item, ModalView, SerializableItem, Workspace, notifications::NotifyTaskExt as _, @@ -38,7 +42,7 @@ use workspace::{ }; pub use ui_components::*; -use zed_actions::OpenKeymapEditor; +use zed_actions::{ChangeKeybinding, OpenKeymap}; use crate::{ persistence::KEYBINDING_EDITORS, @@ -77,37 +81,88 @@ pub fn init(cx: &mut App) { let keymap_event_channel = KeymapEventChannel::new(); cx.set_global(keymap_event_channel); - cx.on_action(|_: &OpenKeymapEditor, cx| { - workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| { - workspace - .with_local_workspace(window, cx, |workspace, window, cx| { - let existing = workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()); + fn open_keymap_editor( + filter: Option, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + workspace + .with_local_workspace(window, cx, |workspace, window, cx| { + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); - if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - } else { - let keymap_editor = - cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); - workspace.add_item_to_active_pane( - Box::new(keymap_editor), - None, - true, - window, - cx, - ); - } - }) - .detach(); - }) - }); + let keymap_editor = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let keymap_editor = + cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); + workspace.add_item_to_active_pane( + Box::new(keymap_editor.clone()), + None, + true, + window, + cx, + ); + keymap_editor + }; + + if let Some(filter) = filter { + keymap_editor.update(cx, |editor, cx| { + editor.filter_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.insert(&filter, window, cx); + }); + if !editor.has_binding_for(&filter) { + open_binding_modal_after_loading(cx) + } + }) + } + }) + .detach_and_log_err(cx); + } + + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace + .register_action(|workspace, _: &OpenKeymap, window, cx| { + open_keymap_editor(None, workspace, window, cx); + }) + .register_action(|workspace, action: &ChangeKeybinding, window, cx| { + open_keymap_editor(Some(action.action.clone()), workspace, window, cx); + }); + }) + .detach(); register_serializable_item::(cx); } +fn open_binding_modal_after_loading(cx: &mut Context) { + let started_at = Instant::now(); + let observer = Rc::new(RefCell::new(None)); + let handle = { + let observer = Rc::clone(&observer); + cx.observe(&cx.entity(), move |editor, _, cx| { + let subscription = observer.borrow_mut().take(); + + if started_at.elapsed().as_secs() > 10 { + return; + } + if !editor.matches.is_empty() { + editor.selected_index = Some(0); + cx.dispatch_action(&CreateBinding); + return; + } + + *observer.borrow_mut() = subscription; + }) + }; + *observer.borrow_mut() = Some(handle); +} + pub struct KeymapEventChannel {} impl Global for KeymapEventChannel {} @@ -140,7 +195,7 @@ enum SearchMode { impl SearchMode { fn invert(&self) -> Self { match self { - SearchMode::Normal => SearchMode::KeyStroke { exact_match: false }, + SearchMode::Normal => SearchMode::KeyStroke { exact_match: true }, SearchMode::KeyStroke { .. } => SearchMode::Normal, } } @@ -171,7 +226,7 @@ impl FilterState { #[derive(Debug, Default, PartialEq, Eq, Clone, Hash)] struct ActionMapping { - keystrokes: Vec, + keystrokes: Rc<[KeybindingKeystroke]>, context: Option, } @@ -233,7 +288,7 @@ struct ConflictState { } type ConflictKeybindMapping = HashMap< - Vec, + Rc<[KeybindingKeystroke]>, Vec<( Option, Vec, @@ -255,7 +310,7 @@ impl ConflictState { .context .and_then(|ctx| gpui::KeyBindingContextPredicate::parse(&ctx).ok()); let entry = action_keybind_mapping - .entry(mapping.keystrokes) + .entry(mapping.keystrokes.clone()) .or_default(); let origin = ConflictOrigin::new(binding.source, index); if let Some((_, origins)) = @@ -518,6 +573,11 @@ impl KeymapEditor { } } + fn clear_action_query(&self, window: &mut Window, cx: &mut Context) { + self.filter_editor + .update(cx, |editor, cx| editor.clear(window, cx)) + } + fn on_query_changed(&mut self, cx: &mut Context) { let action_query = self.current_action_query(cx); let keystroke_query = self.current_keystroke_query(cx); @@ -683,8 +743,7 @@ impl KeymapEditor { .unwrap_or(KeybindSource::Unknown); let keystroke_text = ui::text_for_keybinding_keystrokes(key_binding.keystrokes(), cx); - let ui_key_binding = ui::KeyBinding::new_from_gpui(key_binding.clone(), cx) - .vim_mode(source == KeybindSource::Vim); + let binding = KeyBinding::new(key_binding, source); let context = key_binding .predicate() @@ -715,7 +774,7 @@ impl KeymapEditor { StringMatchCandidate::new(index, &action_information.humanized_name); processed_bindings.push(ProcessedBinding::new_mapped( keystroke_text, - ui_key_binding, + binding, context, source, action_information, @@ -910,12 +969,14 @@ impl KeymapEditor { let context_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { menu.context(self.focus_handle.clone()) + .when(selected_binding_is_unbound, |this| { + this.action("Create", Box::new(CreateBinding)) + }) .action_disabled_when( selected_binding_is_unbound, "Edit", Box::new(EditBinding), ) - .action("Create", Box::new(CreateBinding)) .action_disabled_when( selected_binding_is_unbound, "Delete", @@ -973,12 +1034,11 @@ impl KeymapEditor { if conflict.is_user_keybind_conflict() { base_button_style(index, IconName::Warning) .icon_color(Color::Warning) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "View conflicts", Some(&ToggleConflictFilter), "Use alt+click to show all conflicts", - window, cx, ) }) @@ -993,12 +1053,11 @@ impl KeymapEditor { })) } else if self.search_mode.exact_match() { base_button_style(index, IconName::Info) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Edit this binding", Some(&ShowMatchingKeybinds), "This binding is overridden by other bindings.", - window, cx, ) }) @@ -1009,12 +1068,11 @@ impl KeymapEditor { })) } else { base_button_style(index, IconName::Info) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Show matching keybinds", Some(&ShowMatchingKeybinds), "This binding is overridden by other bindings.\nUse alt+click to edit this binding", - window, cx, ) }) @@ -1198,13 +1256,12 @@ impl KeymapEditor { else { return; }; - let tab_size = cx.global::().json_tab_size(); self.previous_edit = Some(PreviousEdit::ScrollBarOffset( self.table_interaction_state.read(cx).scroll_offset(), )); let keyboard_mapper = cx.keyboard_mapper().clone(); cx.spawn(async move |_, _| { - remove_keybinding(to_remove, &fs, tab_size, keyboard_mapper.as_ref()).await + remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await }) .detach_and_notify_err(window, cx); } @@ -1322,6 +1379,13 @@ impl KeymapEditor { editor.set_keystrokes(keystrokes, cx); }); } + + fn has_binding_for(&self, action_name: &str) -> bool { + self.keybindings + .iter() + .filter(|kb| kb.keystrokes().is_some()) + .any(|kb| kb.action().name == action_name) + } } struct HumanizedActionNameCache { @@ -1347,10 +1411,25 @@ impl HumanizedActionNameCache { } } +#[derive(Clone)] +struct KeyBinding { + keystrokes: Rc<[KeybindingKeystroke]>, + source: KeybindSource, +} + +impl KeyBinding { + fn new(binding: &gpui::KeyBinding, source: KeybindSource) -> Self { + Self { + keystrokes: Rc::from(binding.keystrokes()), + source, + } + } +} + #[derive(Clone)] struct KeybindInformation { keystroke_text: SharedString, - ui_binding: ui::KeyBinding, + binding: KeyBinding, context: KeybindContextString, source: KeybindSource, } @@ -1358,7 +1437,7 @@ struct KeybindInformation { impl KeybindInformation { fn get_action_mapping(&self) -> ActionMapping { ActionMapping { - keystrokes: self.ui_binding.keystrokes.clone(), + keystrokes: self.binding.keystrokes.clone(), context: self.context.local().cloned(), } } @@ -1400,7 +1479,7 @@ enum ProcessedBinding { impl ProcessedBinding { fn new_mapped( keystroke_text: impl Into, - ui_key_binding: ui::KeyBinding, + binding: KeyBinding, context: KeybindContextString, source: KeybindSource, action_information: ActionInformation, @@ -1408,7 +1487,7 @@ impl ProcessedBinding { Self::Mapped( KeybindInformation { keystroke_text: keystroke_text.into(), - ui_binding: ui_key_binding, + binding, context, source, }, @@ -1426,8 +1505,8 @@ impl ProcessedBinding { } fn keystrokes(&self) -> Option<&[KeybindingKeystroke]> { - self.ui_key_binding() - .map(|binding| binding.keystrokes.as_slice()) + self.key_binding() + .map(|binding| binding.keystrokes.as_ref()) } fn keybind_information(&self) -> Option<&KeybindInformation> { @@ -1445,9 +1524,8 @@ impl ProcessedBinding { self.keybind_information().map(|keybind| &keybind.context) } - fn ui_key_binding(&self) -> Option<&ui::KeyBinding> { - self.keybind_information() - .map(|keybind| &keybind.ui_binding) + fn key_binding(&self) -> Option<&KeyBinding> { + self.keybind_information().map(|keybind| &keybind.binding) } fn keystroke_text(&self) -> Option<&SharedString> { @@ -1533,9 +1611,33 @@ impl Item for KeymapEditor { impl Render for KeymapEditor { fn render(&mut self, _window: &mut Window, cx: &mut ui::Context) -> impl ui::IntoElement { + if let SearchMode::KeyStroke { exact_match } = self.search_mode { + let button = IconButton::new("keystrokes-exact-match", IconName::CaseSensitive) + .tooltip(move |_window, cx| { + Tooltip::for_action( + "Toggle Exact Match Mode", + &ToggleExactKeystrokeMatching, + cx, + ) + }) + .shape(IconButtonShape::Square) + .toggle_state(exact_match) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action(ToggleExactKeystrokeMatching.boxed_clone(), cx); + })); + + self.keystroke_editor.update(cx, |editor, _| { + editor.actions_slot = Some(button.into_any_element()); + }); + } else { + self.keystroke_editor.update(cx, |editor, _| { + editor.actions_slot = None; + }); + } + let row_count = self.matches.len(); - let theme = cx.theme(); let focus_handle = &self.focus_handle; + let theme = cx.theme(); v_flex() .id("keymap-editor") @@ -1598,12 +1700,11 @@ impl Render for KeymapEditor { .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Search by Keystroke", &ToggleKeystrokeSearch, &focus_handle.clone(), - window, cx, ) } @@ -1635,7 +1736,7 @@ impl Render for KeymapEditor { let filter_state = self.filter_state; let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( match filter_state { FilterState::All => "Show Conflicts", @@ -1645,7 +1746,6 @@ impl Render for KeymapEditor { }, &ToggleConflictFilter, &focus_handle.clone(), - window, cx, ) } @@ -1663,104 +1763,72 @@ impl Render for KeymapEditor { }), ) .child( - div() - .ml_1() + h_flex() + .w_full() .pl_2() - .border_l_1() - .border_color(cx.theme().colors().border_variant) + .gap_1() + .justify_end() .child( - right_click_menu("open-keymap-menu") - .menu(|window, cx| { - ContextMenu::build(window, cx, |menu, _, _| { - menu.header("Open Keymap JSON") + PopoverMenu::new("open-keymap-menu") + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _, _| { + menu.header("View Default...") .action( - "User", - zed_actions::OpenKeymap.boxed_clone(), - ) - .action( - "Zed Default", + "Zed Key Bindings", zed_actions::OpenDefaultKeymap .boxed_clone(), ) .action( - "Vim Default", - vim::OpenDefaultKeymap.boxed_clone(), + "Vim Bindings", + zed_actions::vim::OpenDefaultKeymap.boxed_clone(), ) - }) + })) }) - .anchor(gpui::Corner::TopLeft) - .trigger(|open, _, _| { + .anchor(gpui::Corner::TopRight) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }) + .trigger_with_tooltip( IconButton::new( "OpenKeymapJsonButton", - IconName::Json, + IconName::Ellipsis, ) - .icon_size(IconSize::Small) - .when(!open, |this| { - this.tooltip(move |window, cx| { - Tooltip::with_meta( - "Open keymap.json", - Some(&zed_actions::OpenKeymap), - "Right click to view more options", - window, + .icon_size(IconSize::Small), + { + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "View Default...", + &zed_actions::OpenKeymapFile, + &focus_handle, cx, ) - }) - }) - .on_click(|_, window, cx| { - window.dispatch_action( - zed_actions::OpenKeymap.boxed_clone(), - cx, - ); - }) - }), + } + }, + ), + ) + .child( + Button::new("edit-in-json", "Edit in keymap.json") + .style(ButtonStyle::Outlined) + .on_click(|_, window, cx| { + window.dispatch_action( + zed_actions::OpenKeymapFile.boxed_clone(), + cx, + ); + }) ), ) ), ) - .when_some( - match self.search_mode { - SearchMode::Normal => None, - SearchMode::KeyStroke { exact_match } => Some(exact_match), - }, - |this, exact_match| { + .when( + matches!(self.search_mode, SearchMode::KeyStroke { .. }), + |this| { this.child( h_flex() .gap_2() .child(self.keystroke_editor.clone()) - .child( - h_flex() - .min_w_64() - .child( - IconButton::new( - "keystrokes-exact-match", - IconName::CaseSensitive, - ) - .tooltip({ - let keystroke_focus_handle = - self.keystroke_editor.read(cx).focus_handle(cx); - - move |window, cx| { - Tooltip::for_action_in( - "Toggle Exact Match Mode", - &ToggleExactKeystrokeMatching, - &keystroke_focus_handle, - window, - cx, - ) - } - }) - .shape(IconButtonShape::Square) - .toggle_state(exact_match) - .on_click( - cx.listener(|_, _, window, cx| { - window.dispatch_action( - ToggleExactKeystrokeMatching.boxed_clone(), - cx, - ); - }), - ), - ), - ) + .child(div().min_w_64()), // Spacer div to align with the search input ) }, ), @@ -1850,13 +1918,13 @@ impl Render for KeymapEditor { ) .into_any_element(); - let keystrokes = binding.ui_key_binding().cloned().map_or( + let keystrokes = binding.key_binding().map_or( binding .keystroke_text() .cloned() .unwrap_or_default() .into_any_element(), - IntoElement::into_any_element, + |binding| ui::KeyBinding::from_keystrokes(binding.keystrokes.clone(), binding.source).into_any_element() ); let action_arguments = match binding.action().arguments.clone() @@ -2108,7 +2176,7 @@ struct KeybindingEditorModal { editing_keybind: ProcessedBinding, editing_keybind_idx: usize, keybind_editor: Entity, - context_editor: Entity, + context_editor: Entity, action_arguments_editor: Option>, fs: Arc, error: Option, @@ -2142,8 +2210,8 @@ impl KeybindingEditorModal { let keybind_editor = cx .new(|cx| KeystrokeInput::new(editing_keybind.keystrokes().map(Vec::from), window, cx)); - let context_editor: Entity = cx.new(|cx| { - let input = SingleLineInput::new(window, cx, "Keybinding Context") + let context_editor: Entity = cx.new(|cx| { + let input = InputField::new(window, cx, "Keybinding Context") .label("Edit Context") .label_size(LabelSize::Default); @@ -2283,7 +2351,6 @@ impl KeybindingEditorModal { fn save(&mut self, cx: &mut Context) -> Result<(), InputError> { let existing_keybind = self.editing_keybind.clone(); let fs = self.fs.clone(); - let tab_size = cx.global::().json_tab_size(); let mut new_keystrokes = self.validate_keystrokes(cx).map_err(InputError::error)?; new_keystrokes @@ -2296,7 +2363,7 @@ impl KeybindingEditorModal { .map_err(InputError::error)?; let action_mapping = ActionMapping { - keystrokes: new_keystrokes, + keystrokes: Rc::from(new_keystrokes.as_slice()), context: new_context.map(SharedString::from), }; @@ -2362,7 +2429,6 @@ impl KeybindingEditorModal { &action_mapping, new_action_args.as_deref(), &fs, - tab_size, keyboard_mapper.as_ref(), ) .await @@ -2436,7 +2502,7 @@ impl KeybindingEditorModal { } fn get_matching_bindings_count(&self, cx: &Context) -> usize { - let current_keystrokes = self.keybind_editor.read(cx).keystrokes().to_vec(); + let current_keystrokes = self.keybind_editor.read(cx).keystrokes(); if current_keystrokes.is_empty() { return 0; @@ -2453,17 +2519,20 @@ impl KeybindingEditorModal { return false; } - binding - .keystrokes() - .map(|keystrokes| keystrokes_match_exactly(keystrokes, ¤t_keystrokes)) - .unwrap_or(false) + binding.keystrokes().is_some_and(|keystrokes| { + keystrokes_match_exactly(keystrokes, current_keystrokes) + }) }) .count() } - fn show_matching_bindings(&mut self, _window: &mut Window, cx: &mut Context) { + fn show_matching_bindings(&mut self, window: &mut Window, cx: &mut Context) { let keystrokes = self.keybind_editor.read(cx).keystrokes().to_vec(); + self.keymap_editor.update(cx, |keymap_editor, cx| { + keymap_editor.clear_action_query(window, cx) + }); + // Dismiss the modal cx.emit(DismissEvent); @@ -2721,10 +2790,10 @@ impl ActionArgumentsEditor { let editor = cx.new_window_entity(|window, cx| { let multi_buffer = cx.new(|cx| editor::MultiBuffer::singleton(buffer, cx)); let mut editor = Editor::new( - editor::EditorMode::Full { + EditorMode::Full { scale_ui_elements_with_buffer_font_size: true, show_active_line_background: false, - sized_by_content: true, + sizing_behavior: SizingBehavior::SizeByContent, }, multi_buffer, project.upgrade(), @@ -2926,6 +2995,8 @@ impl CompletionProvider for KeyContextCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: None, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: None, }) @@ -2941,7 +3012,6 @@ impl CompletionProvider for KeyContextCompletionProvider { _position: language::Anchor, text: &str, _trigger_in_words: bool, - _menu_is_open: bool, _cx: &mut Context, ) -> bool { text.chars() @@ -3014,13 +3084,14 @@ async fn save_keybinding_update( action_mapping: &ActionMapping, new_args: Option<&str>, fs: &Arc, - tab_size: usize, keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> anyhow::Result<()> { let keymap_contents = settings::KeymapFile::load_keymap_file(fs) .await .context("Failed to load keymap file")?; + let tab_size = infer_json_indent_size(&keymap_contents); + let existing_keystrokes = existing.keystrokes().unwrap_or_default(); let existing_context = existing.context().and_then(KeybindContextString::local_str); let existing_args = existing @@ -3084,7 +3155,6 @@ async fn save_keybinding_update( async fn remove_keybinding( existing: ProcessedBinding, fs: &Arc, - tab_size: usize, keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> anyhow::Result<()> { let Some(keystrokes) = existing.keystrokes() else { @@ -3093,6 +3163,7 @@ async fn remove_keybinding( let keymap_contents = settings::KeymapFile::load_keymap_file(fs) .await .context("Failed to load keymap file")?; + let tab_size = infer_json_indent_size(&keymap_contents); let operation = settings::KeybindUpdateOperation::Remove { target: settings::KeybindUpdateTarget { diff --git a/crates/keymap_editor/src/ui_components/keystroke_input.rs b/crates/keymap_editor/src/ui_components/keystroke_input.rs index e264df3b62..6936de784f 100644 --- a/crates/keymap_editor/src/ui_components/keystroke_input.rs +++ b/crates/keymap_editor/src/ui_components/keystroke_input.rs @@ -64,6 +64,7 @@ pub struct KeystrokeInput { clear_close_keystrokes_timer: Option>, #[cfg(test)] recording: bool, + pub actions_slot: Option, } impl KeystrokeInput { @@ -94,6 +95,7 @@ impl KeystrokeInput { clear_close_keystrokes_timer: None, #[cfg(test)] recording: false, + actions_slot: None, } } @@ -445,6 +447,11 @@ impl KeystrokeInput { // not get de-synced self.inner_focus_handle.is_focused(window) } + + pub fn actions_slot(mut self, action: impl IntoElement) -> Self { + self.actions_slot = Some(action.into_any_element()); + self + } } impl EventEmitter<()> for KeystrokeInput {} @@ -586,7 +593,7 @@ impl Render for KeystrokeInput { .min_w_0() .justify_center() .flex_wrap() - .gap(ui::DynamicSpacing::Base04.rems(cx)) + .gap_1() .children(self.render_keystrokes(is_recording)), ) .child( @@ -636,18 +643,25 @@ impl Render for KeystrokeInput { ) } }) - .child( - IconButton::new("clear-btn", IconName::Backspace) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::for_action_title( - "Clear Keystrokes", - &ClearKeystrokes, - )) - .when(!is_focused, |this| this.icon_color(Color::Muted)) - .on_click(cx.listener(|this, _event, window, cx| { - this.clear_keystrokes(&ClearKeystrokes, window, cx); - })), - ), + .when_some(self.actions_slot.take(), |this, action| this.child(action)) + .when(is_recording, |this| { + this.child( + IconButton::new("clear-btn", IconName::Backspace) + .shape(IconButtonShape::Square) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Clear Keystrokes", + Some(&ClearKeystrokes), + "Hit it three times to execute", + cx, + ) + }) + .when(!is_focused, |this| this.icon_color(Color::Muted)) + .on_click(cx.listener(|this, _event, window, cx| { + this.clear_keystrokes(&ClearKeystrokes, window, cx); + })), + ) + }), ) } } @@ -1102,9 +1116,6 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); - project::Project::init_settings(cx); - workspace::init_settings(cx); }); let fs = FakeFs::new(cx.executor()); diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 4a4f51a58b..49ea681290 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -20,8 +20,8 @@ test-support = [ "text/test-support", "tree-sitter-rust", "tree-sitter-python", - "tree-sitter-rust", "tree-sitter-typescript", + "tree-sitter-md", "settings/test-support", "util/test-support", ] @@ -60,6 +60,7 @@ sum_tree.workspace = true task.workspace = true text.workspace = true theme.workspace = true +tree-sitter-md = { workspace = true, optional = true } tree-sitter-python = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } @@ -67,7 +68,7 @@ tree-sitter.workspace = true unicase = "2.6" util.workspace = true watch.workspace = true -workspace-hack.workspace = true +zlog.workspace = true diffy = "0.4.2" [dev-dependencies] diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 1605eea051..22fcbf5ee8 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1,15 +1,19 @@ +pub mod row_chunk; + use crate::{ - DebuggerTextObject, LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, - TextObject, TreeSitterOptions, + DebuggerTextObject, LanguageScope, Outline, OutlineConfig, PLAIN_TEXT, RunnableCapture, + RunnableTag, TextObject, TreeSitterOptions, diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}, language_settings::{LanguageSettings, language_settings}, outline::OutlineItem, + row_chunk::RowChunks, syntax_map::{ SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatch, SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, }, task_context::RunnableRange, text_diff::text_diff, + unified_diff, }; pub use crate::{ Grammar, Language, LanguageRegistry, @@ -18,9 +22,9 @@ pub use crate::{ proto, }; use anyhow::{Context as _, Result}; +use clock::Lamport; pub use clock::ReplicaId; -use clock::{AGENT_REPLICA_ID, Lamport}; -use collections::HashMap; +use collections::{HashMap, HashSet}; use fs::MTime; use futures::channel::oneshot; use gpui::{ @@ -126,6 +130,37 @@ pub struct Buffer { has_unsaved_edits: Cell<(clock::Global, bool)>, change_bits: Vec>>, _subscriptions: Vec, + tree_sitter_data: Arc, +} + +#[derive(Debug)] +pub struct TreeSitterData { + chunks: RowChunks, + brackets_by_chunks: Mutex>>>>, +} + +const MAX_ROWS_IN_A_CHUNK: u32 = 50; + +impl TreeSitterData { + fn clear(&mut self, snapshot: text::BufferSnapshot) { + self.chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK); + self.brackets_by_chunks.get_mut().clear(); + self.brackets_by_chunks + .get_mut() + .resize(self.chunks.len(), None); + } + + fn new(snapshot: text::BufferSnapshot) -> Self { + let chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK); + Self { + brackets_by_chunks: Mutex::new(vec![None; chunks.len()]), + chunks, + } + } + + fn version(&self) -> &clock::Global { + self.chunks.version() + } } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -149,6 +184,7 @@ pub struct BufferSnapshot { remote_selections: TreeMap, language: Option>, non_text_state_update_count: usize, + tree_sitter_data: Arc, } /// The kind and amount of indentation in a particular line. For now, @@ -209,6 +245,8 @@ struct SelectionSet { pub struct Diagnostic { /// The name of the service that produced this diagnostic. pub source: Option, + /// The ID provided by the dynamic registration that produced this diagnostic. + pub registration_id: Option, /// A machine-readable code that identifies this diagnostic. pub code: Option, pub code_description: Option, @@ -323,7 +361,8 @@ pub enum BufferEvent { /// The buffer is in need of a reload ReloadNeeded, /// The buffer's language was changed. - LanguageChanged, + /// The boolean indicates whether this buffer did not have a language before, but does now. + LanguageChanged(bool), /// The buffer's syntax trees were updated. Reparsed, /// The buffer's diagnostics were updated. @@ -506,15 +545,15 @@ pub struct Chunk<'a> { pub highlight_style: Option, /// The severity of diagnostic associated with this chunk, if any. pub diagnostic_severity: Option, - /// Whether this chunk of text is marked as unnecessary. - pub is_unnecessary: bool, - /// Whether this chunk of text was originally a tab character. - pub is_tab: bool, /// A bitset of which characters are tabs in this string. pub tabs: u128, /// Bitmap of character indices in this chunk pub chars: u128, + /// Whether this chunk of text is marked as unnecessary. + pub is_unnecessary: bool, /// Whether this chunk of text was originally a tab character. + pub is_tab: bool, + /// Whether this chunk of text was originally an inlay. pub is_inlay: bool, /// Whether to underline the corresponding text range in the editor. pub underline: bool, @@ -717,10 +756,37 @@ pub struct EditPreview { } impl EditPreview { + pub fn as_unified_diff(&self, edits: &[(Range, impl AsRef)]) -> Option { + let (first, _) = edits.first()?; + let (last, _) = edits.last()?; + + let start = first.start.to_point(&self.old_snapshot); + let old_end = last.end.to_point(&self.old_snapshot); + let new_end = last + .end + .bias_right(&self.old_snapshot) + .to_point(&self.applied_edits_snapshot); + + let start = Point::new(start.row.saturating_sub(3), 0); + let old_end = Point::new(old_end.row + 4, 0).min(self.old_snapshot.max_point()); + let new_end = Point::new(new_end.row + 4, 0).min(self.applied_edits_snapshot.max_point()); + + Some(unified_diff( + &self + .old_snapshot + .text_for_range(start..old_end) + .collect::(), + &self + .applied_edits_snapshot + .text_for_range(start..new_end) + .collect::(), + )) + } + pub fn highlight_edits( &self, current_snapshot: &BufferSnapshot, - edits: &[(Range, String)], + edits: &[(Range, impl AsRef)], include_deletions: bool, cx: &App, ) -> HighlightedText { @@ -730,6 +796,8 @@ impl EditPreview { let mut highlighted_text = HighlightedTextBuilder::default(); + let visible_range_in_preview_snapshot = + visible_range_in_preview_snapshot.to_offset(&self.applied_edits_snapshot); let mut offset_in_preview_snapshot = visible_range_in_preview_snapshot.start; let insertion_highlight_style = HighlightStyle { @@ -747,7 +815,8 @@ impl EditPreview { .end .bias_right(&self.old_snapshot) .to_offset(&self.applied_edits_snapshot); - let edit_start_in_preview_snapshot = edit_new_end_in_preview_snapshot - edit_text.len(); + let edit_start_in_preview_snapshot = + edit_new_end_in_preview_snapshot - edit_text.as_ref().len(); let unchanged_range_in_preview_snapshot = offset_in_preview_snapshot..edit_start_in_preview_snapshot; @@ -772,7 +841,7 @@ impl EditPreview { ); } - if !edit_text.is_empty() { + if !edit_text.as_ref().is_empty() { highlighted_text.add_text_from_buffer_range( edit_start_in_preview_snapshot..edit_new_end_in_preview_snapshot, &self.applied_edits_snapshot, @@ -796,7 +865,19 @@ impl EditPreview { highlighted_text.build() } - fn compute_visible_range(&self, edits: &[(Range, String)]) -> Option> { + pub fn build_result_buffer(&self, cx: &mut App) -> Entity { + cx.new(|cx| { + let mut buffer = Buffer::local_normalized( + self.applied_edits_snapshot.as_rope().clone(), + self.applied_edits_snapshot.line_ending(), + cx, + ); + buffer.set_language_async(self.syntax_snapshot.root_language(), cx); + buffer + }) + } + + pub fn compute_visible_range(&self, edits: &[(Range, T)]) -> Option> { let (first, _) = edits.first()?; let (last, _) = edits.last()?; @@ -813,22 +894,34 @@ impl EditPreview { let range = Point::new(start.row, 0) ..Point::new(end.row, self.applied_edits_snapshot.line_len(end.row)); - Some(range.to_offset(&self.applied_edits_snapshot)) + Some(range) } } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct BracketMatch { - pub open_range: Range, - pub close_range: Range, +pub struct BracketMatch { + pub open_range: Range, + pub close_range: Range, pub newline_only: bool, + pub syntax_layer_depth: usize, + pub color_index: Option, +} + +impl BracketMatch { + pub fn bracket_ranges(self) -> (Range, Range) { + (self.open_range, self.close_range) + } } impl Buffer { /// Create a new buffer with the given base text. pub fn local>(base_text: T, cx: &Context) -> Self { Self::build( - TextBuffer::new(0, cx.entity_id().as_non_zero_u64().into(), base_text.into()), + TextBuffer::new( + ReplicaId::LOCAL, + cx.entity_id().as_non_zero_u64().into(), + base_text.into(), + ), None, Capability::ReadWrite, ) @@ -842,7 +935,7 @@ impl Buffer { ) -> Self { Self::build( TextBuffer::new_normalized( - 0, + ReplicaId::LOCAL, cx.entity_id().as_non_zero_u64().into(), line_ending, base_text_normalized, @@ -948,6 +1041,12 @@ impl Buffer { } /// Assign a language to the buffer, returning the buffer. + pub fn with_language_async(mut self, language: Arc, cx: &mut Context) -> Self { + self.set_language_async(Some(language), cx); + self + } + + /// Assign a language to the buffer, blocking for up to 1ms to reparse the buffer, returning the buffer. pub fn with_language(mut self, language: Arc, cx: &mut Context) -> Self { self.set_language(Some(language), cx); self @@ -968,8 +1067,10 @@ impl Buffer { let saved_mtime = file.as_ref().and_then(|file| file.disk_state().mtime()); let snapshot = buffer.snapshot(); let syntax_map = Mutex::new(SyntaxMap::new(&snapshot)); + let tree_sitter_data = TreeSitterData::new(snapshot); Self { saved_mtime, + tree_sitter_data: Arc::new(tree_sitter_data), saved_version: buffer.version(), preview_version: buffer.version(), reload_task: None, @@ -991,10 +1092,10 @@ impl Buffer { language: None, remote_selections: Default::default(), diagnostics: Default::default(), - diagnostics_timestamp: Default::default(), + diagnostics_timestamp: Lamport::MIN, completion_triggers: Default::default(), completion_triggers_per_language_server: Default::default(), - completion_triggers_timestamp: Default::default(), + completion_triggers_timestamp: Lamport::MIN, deferred_ops: OperationQueue::new(), has_conflict: false, change_bits: Default::default(), @@ -1012,18 +1113,21 @@ impl Buffer { let buffer_id = entity_id.as_non_zero_u64().into(); async move { let text = - TextBuffer::new_normalized(0, buffer_id, Default::default(), text).snapshot(); + TextBuffer::new_normalized(ReplicaId::LOCAL, buffer_id, Default::default(), text) + .snapshot(); let mut syntax = SyntaxMap::new(&text).snapshot(); if let Some(language) = language.clone() { let language_registry = language_registry.clone(); syntax.reparse(&text, language_registry, language); } + let tree_sitter_data = TreeSitterData::new(text.clone()); BufferSnapshot { text, syntax, file: None, diagnostics: Default::default(), remote_selections: Default::default(), + tree_sitter_data: Arc::new(tree_sitter_data), language, non_text_state_update_count: 0, } @@ -1033,12 +1137,19 @@ impl Buffer { pub fn build_empty_snapshot(cx: &mut App) -> BufferSnapshot { let entity_id = cx.reserve_entity::().entity_id(); let buffer_id = entity_id.as_non_zero_u64().into(); - let text = - TextBuffer::new_normalized(0, buffer_id, Default::default(), Rope::new()).snapshot(); + let text = TextBuffer::new_normalized( + ReplicaId::LOCAL, + buffer_id, + Default::default(), + Rope::new(), + ) + .snapshot(); let syntax = SyntaxMap::new(&text).snapshot(); + let tree_sitter_data = TreeSitterData::new(text.clone()); BufferSnapshot { text, syntax, + tree_sitter_data: Arc::new(tree_sitter_data), file: None, diagnostics: Default::default(), remote_selections: Default::default(), @@ -1056,14 +1167,18 @@ impl Buffer { ) -> BufferSnapshot { let entity_id = cx.reserve_entity::().entity_id(); let buffer_id = entity_id.as_non_zero_u64().into(); - let text = TextBuffer::new_normalized(0, buffer_id, Default::default(), text).snapshot(); + let text = + TextBuffer::new_normalized(ReplicaId::LOCAL, buffer_id, Default::default(), text) + .snapshot(); let mut syntax = SyntaxMap::new(&text).snapshot(); if let Some(language) = language.clone() { syntax.reparse(&text, language_registry, language); } + let tree_sitter_data = TreeSitterData::new(text.clone()); BufferSnapshot { text, syntax, + tree_sitter_data: Arc::new(tree_sitter_data), file: None, diagnostics: Default::default(), remote_selections: Default::default(), @@ -1080,9 +1195,16 @@ impl Buffer { syntax_map.interpolate(&text); let syntax = syntax_map.snapshot(); + let tree_sitter_data = if self.text.version() != *self.tree_sitter_data.version() { + Arc::new(TreeSitterData::new(text.clone())) + } else { + self.tree_sitter_data.clone() + }; + BufferSnapshot { text, syntax, + tree_sitter_data, file: self.file.clone(), remote_selections: self.remote_selections.clone(), diagnostics: self.diagnostics.clone(), @@ -1110,7 +1232,7 @@ impl Buffer { } // Reparse the branch buffer so that we get syntax highlighting immediately. - branch.reparse(cx); + branch.reparse(cx, true); branch }) @@ -1118,7 +1240,7 @@ impl Buffer { pub fn preview_edits( &self, - edits: Arc<[(Range, String)]>, + edits: Arc<[(Range, Arc)]>, cx: &App, ) -> Task { let registry = self.language_registry(); @@ -1262,13 +1384,29 @@ impl Buffer { } /// Assign a language to the buffer. + pub fn set_language_async(&mut self, language: Option>, cx: &mut Context) { + self.set_language_(language, cfg!(any(test, feature = "test-support")), cx); + } + + /// Assign a language to the buffer, blocking for up to 1ms to reparse the buffer. pub fn set_language(&mut self, language: Option>, cx: &mut Context) { + self.set_language_(language, true, cx); + } + + fn set_language_( + &mut self, + language: Option>, + may_block: bool, + cx: &mut Context, + ) { self.non_text_state_update_count += 1; self.syntax_map.lock().clear(&self.text); - self.language = language; + let old_language = std::mem::replace(&mut self.language, language); self.was_changed(); - self.reparse(cx); - cx.emit(BufferEvent::LanguageChanged); + self.reparse(cx, may_block); + let has_fresh_language = + self.language.is_some() && old_language.is_none_or(|old| old == *PLAIN_TEXT); + cx.emit(BufferEvent::LanguageChanged(has_fresh_language)); } /// Assign a language registry to the buffer. This allows the buffer to retrieve @@ -1500,6 +1638,16 @@ impl Buffer { self.sync_parse_timeout = timeout; } + fn invalidate_tree_sitter_data(&mut self, snapshot: text::BufferSnapshot) { + match Arc::get_mut(&mut self.tree_sitter_data) { + Some(tree_sitter_data) => tree_sitter_data.clear(snapshot), + None => { + let tree_sitter_data = TreeSitterData::new(snapshot); + self.tree_sitter_data = Arc::new(tree_sitter_data) + } + } + } + /// Called after an edit to synchronize the buffer's main parse tree with /// the buffer's new underlying state. /// @@ -1510,9 +1658,9 @@ impl Buffer { /// The snapshot with the interpolated edits is sent to a background thread, /// where we ask Tree-sitter to perform an incremental parse. /// - /// Meanwhile, in the foreground, we block the main thread for up to 1ms - /// waiting on the parse to complete. As soon as it completes, we proceed - /// synchronously, unless a 1ms timeout elapses. + /// Meanwhile, in the foreground if `may_block` is true, we block the main + /// thread for up to 1ms waiting on the parse to complete. As soon as it + /// completes, we proceed synchronously, unless a 1ms timeout elapses. /// /// If we time out waiting on the parse, we spawn a second task waiting /// until the parse does complete and return with the interpolated tree still @@ -1523,7 +1671,10 @@ impl Buffer { /// initiate an additional reparse recursively. To avoid concurrent parses /// for the same buffer, we only initiate a new parse if we are not already /// parsing in the background. - pub fn reparse(&mut self, cx: &mut Context) { + pub fn reparse(&mut self, cx: &mut Context, may_block: bool) { + if self.text.version() != *self.tree_sitter_data.version() { + self.invalidate_tree_sitter_data(self.text.snapshot()); + } if self.reparse.is_some() { return; } @@ -1552,39 +1703,70 @@ impl Buffer { }); self.parse_status.0.send(ParseStatus::Parsing).unwrap(); - match cx - .background_executor() - .block_with_timeout(self.sync_parse_timeout, parse_task) - { - Ok(new_syntax_snapshot) => { - self.did_finish_parsing(new_syntax_snapshot, cx); - self.reparse = None; + if may_block { + match cx + .background_executor() + .block_with_timeout(self.sync_parse_timeout, parse_task) + { + Ok(new_syntax_snapshot) => { + self.did_finish_parsing(new_syntax_snapshot, cx); + self.reparse = None; + } + Err(parse_task) => { + self.reparse = Some(cx.spawn(async move |this, cx| { + let new_syntax_map = cx.background_spawn(parse_task).await; + this.update(cx, move |this, cx| { + let grammar_changed = || { + this.language.as_ref().is_none_or(|current_language| { + !Arc::ptr_eq(&language, current_language) + }) + }; + let language_registry_changed = || { + new_syntax_map.contains_unknown_injections() + && language_registry.is_some_and(|registry| { + registry.version() + != new_syntax_map.language_registry_version() + }) + }; + let parse_again = this.version.changed_since(&parsed_version) + || language_registry_changed() + || grammar_changed(); + this.did_finish_parsing(new_syntax_map, cx); + this.reparse = None; + if parse_again { + this.reparse(cx, false); + } + }) + .ok(); + })); + } } - Err(parse_task) => { - self.reparse = Some(cx.spawn(async move |this, cx| { - let new_syntax_map = parse_task.await; - this.update(cx, move |this, cx| { - let grammar_changed = - this.language.as_ref().is_none_or(|current_language| { - !Arc::ptr_eq(&language, current_language) - }); - let language_registry_changed = new_syntax_map - .contains_unknown_injections() + } else { + self.reparse = Some(cx.spawn(async move |this, cx| { + let new_syntax_map = cx.background_spawn(parse_task).await; + this.update(cx, move |this, cx| { + let grammar_changed = || { + this.language.as_ref().is_none_or(|current_language| { + !Arc::ptr_eq(&language, current_language) + }) + }; + let language_registry_changed = || { + new_syntax_map.contains_unknown_injections() && language_registry.is_some_and(|registry| { registry.version() != new_syntax_map.language_registry_version() - }); - let parse_again = language_registry_changed - || grammar_changed - || this.version.changed_since(&parsed_version); - this.did_finish_parsing(new_syntax_map, cx); - this.reparse = None; - if parse_again { - this.reparse(cx); - } - }) - .ok(); - })); - } + }) + }; + let parse_again = this.version.changed_since(&parsed_version) + || language_registry_changed() + || grammar_changed(); + this.did_finish_parsing(new_syntax_map, cx); + this.reparse = None; + if parse_again { + this.reparse(cx, false); + } + }) + .ok(); + })); } } @@ -1594,6 +1776,9 @@ impl Buffer { self.syntax_map.lock().did_parse(syntax_snapshot); self.request_autoindent(cx); self.parse_status.0.send(ParseStatus::Idle).unwrap(); + if self.text.version() != *self.tree_sitter_data.version() { + self.invalidate_tree_sitter_data(self.text.snapshot()); + } cx.emit(BufferEvent::Reparsed); cx.notify(); } @@ -1602,6 +1787,18 @@ impl Buffer { self.parse_status.1.clone() } + /// Wait until the buffer is no longer parsing + pub fn parsing_idle(&self) -> impl Future + use<> { + let mut parse_status = self.parse_status(); + async move { + while *parse_status.borrow() != ParseStatus::Idle { + if parse_status.changed().await.is_err() { + break; + } + } + } + } + /// Assign to the buffer a set of diagnostics created by a given language server. pub fn update_diagnostics( &mut self, @@ -1996,7 +2193,7 @@ impl Buffer { self.end_transaction(cx) } - fn has_unsaved_edits(&self) -> bool { + pub fn has_unsaved_edits(&self) -> bool { let (last_version, has_unsaved_edits) = self.has_unsaved_edits.take(); if last_version == self.version { @@ -2027,6 +2224,11 @@ impl Buffer { } } + /// Marks the buffer as having a conflict regardless of current buffer state. + pub fn set_conflict(&mut self) { + self.has_conflict = true; + } + /// Checks if the buffer and its file have both changed since the buffer /// was last saved or reloaded. pub fn has_conflict(&self) -> bool { @@ -2049,7 +2251,7 @@ impl Buffer { } /// Gets a [`Subscription`] that tracks all of the changes to the buffer's text. - pub fn subscribe(&mut self) -> Subscription { + pub fn subscribe(&mut self) -> Subscription { self.text.subscribe() } @@ -2066,12 +2268,15 @@ impl Buffer { } } + /// Set the change bit for all "listeners". fn was_changed(&mut self) { self.change_bits.retain(|change_bit| { - change_bit.upgrade().is_some_and(|bit| { - bit.replace(true); - true - }) + change_bit + .upgrade() + .inspect(|bit| { + _ = bit.replace(true); + }) + .is_some() }); } @@ -2260,7 +2465,7 @@ impl Buffer { ) { let lamport_timestamp = self.text.lamport_clock.tick(); self.remote_selections.insert( - AGENT_REPLICA_ID, + ReplicaId::AGENT, SelectionSet { selections, lamport_timestamp, @@ -2464,7 +2669,7 @@ impl Buffer { return; } - self.reparse(cx); + self.reparse(cx, true); cx.emit(BufferEvent::Edited); if was_dirty != self.is_dirty() { cx.emit(BufferEvent::DirtyChanged); @@ -2917,7 +3122,7 @@ impl Buffer { edits.push((range, new_text)); } - log::info!("mutating buffer {} with {:?}", self.replica_id(), edits); + log::info!("mutating buffer {:?} with {:?}", self.replica_id(), edits); self.edit(edits, None, cx); } @@ -3348,7 +3553,19 @@ impl BufferSnapshot { pub fn syntax_layer_at(&self, position: D) -> Option> { let offset = position.to_offset(self); self.syntax_layers_for_range(offset..offset, false) - .filter(|l| l.node().end_byte() > offset) + .filter(|l| { + if let Some(ranges) = l.included_sub_ranges { + ranges.iter().any(|range| { + let start = range.start.to_offset(self); + start <= offset && { + let end = range.end.to_offset(self); + offset < end + } + }) + } else { + l.node().start_byte() <= offset && l.node().end_byte() > offset + } + }) .last() } @@ -3818,6 +4035,46 @@ impl BufferSnapshot { include_extra_context: bool, theme: Option<&SyntaxTheme>, ) -> Vec> { + self.outline_items_containing_internal( + range, + include_extra_context, + theme, + |this, range| this.anchor_after(range.start)..this.anchor_before(range.end), + ) + } + + pub fn outline_items_as_points_containing( + &self, + range: Range, + include_extra_context: bool, + theme: Option<&SyntaxTheme>, + ) -> Vec> { + self.outline_items_containing_internal(range, include_extra_context, theme, |_, range| { + range + }) + } + + pub fn outline_items_as_offsets_containing( + &self, + range: Range, + include_extra_context: bool, + theme: Option<&SyntaxTheme>, + ) -> Vec> { + self.outline_items_containing_internal( + range, + include_extra_context, + theme, + |buffer, range| range.to_offset(buffer), + ) + } + + fn outline_items_containing_internal( + &self, + range: Range, + include_extra_context: bool, + theme: Option<&SyntaxTheme>, + range_callback: fn(&Self, Range) -> Range, + ) -> Vec> { let range = range.to_offset(self); let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| { grammar.outline_config.as_ref().map(|c| &c.query) @@ -3890,19 +4147,16 @@ impl BufferSnapshot { anchor_items.push(OutlineItem { depth: item_ends_stack.len(), - range: self.anchor_after(item.range.start)..self.anchor_before(item.range.end), + range: range_callback(self, item.range.clone()), + source_range_for_text: range_callback(self, item.source_range_for_text.clone()), text: item.text, highlight_ranges: item.highlight_ranges, name_ranges: item.name_ranges, - body_range: item - .body_range - .map(|r| self.anchor_after(r.start)..self.anchor_before(r.end)), + body_range: item.body_range.map(|r| range_callback(self, r)), annotation_range: annotation_row_range.map(|annotation_range| { - self.anchor_after(Point::new(annotation_range.start, 0)) - ..self.anchor_before(Point::new( - annotation_range.end, - self.line_len(annotation_range.end), - )) + let point_range = Point::new(annotation_range.start, 0) + ..Point::new(annotation_range.end, self.line_len(annotation_range.end)); + range_callback(self, point_range) }), }); item_ends_stack.push(item.range.end); @@ -3969,14 +4223,13 @@ impl BufferSnapshot { if buffer_ranges.is_empty() { return None; } + let source_range_for_text = + buffer_ranges.first().unwrap().0.start..buffer_ranges.last().unwrap().0.end; let mut text = String::new(); let mut highlight_ranges = Vec::new(); let mut name_ranges = Vec::new(); - let mut chunks = self.chunks( - buffer_ranges.first().unwrap().0.start..buffer_ranges.last().unwrap().0.end, - true, - ); + let mut chunks = self.chunks(source_range_for_text.clone(), true); let mut last_buffer_range_end = 0; for (buffer_range, is_name) in buffer_ranges { let space_added = !text.is_empty() && buffer_range.start > last_buffer_range_end; @@ -4022,6 +4275,7 @@ impl BufferSnapshot { Some(OutlineItem { depth: 0, // We'll calculate the depth later range: item_point_range, + source_range_for_text: source_range_for_text.to_point(self), text, highlight_ranges, name_ranges, @@ -4048,24 +4302,58 @@ impl BufferSnapshot { self.syntax.matches(range, self, query) } - pub fn all_bracket_ranges( + /// Finds all [`RowChunks`] applicable to the given range, then returns all bracket pairs that intersect with those chunks. + /// Hence, may return more bracket pairs than the range contains. + /// + /// Will omit known chunks. + /// The resulting bracket match collections are not ordered. + pub fn fetch_bracket_ranges( &self, range: Range, - ) -> impl Iterator + '_ { - let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| { - grammar.brackets_config.as_ref().map(|c| &c.query) - }); - let configs = matches - .grammars() - .iter() - .map(|grammar| grammar.brackets_config.as_ref().unwrap()) - .collect::>(); + known_chunks: Option<&HashSet>>, + ) -> HashMap, Vec>> { + let mut all_bracket_matches = HashMap::default(); + + for chunk in self + .tree_sitter_data + .chunks + .applicable_chunks(&[self.anchor_before(range.start)..self.anchor_after(range.end)]) + { + if known_chunks.is_some_and(|chunks| chunks.contains(&chunk.row_range())) { + continue; + } + let Some(chunk_range) = self.tree_sitter_data.chunks.chunk_range(chunk) else { + continue; + }; + let chunk_range = chunk_range.to_offset(&self); + + if let Some(cached_brackets) = + &self.tree_sitter_data.brackets_by_chunks.lock()[chunk.id] + { + all_bracket_matches.insert(chunk.row_range(), cached_brackets.clone()); + continue; + } + + let mut all_brackets = Vec::new(); + let mut opens = Vec::new(); + let mut color_pairs = Vec::new(); + + let mut matches = self + .syntax + .matches(chunk_range.clone(), &self.text, |grammar| { + grammar.brackets_config.as_ref().map(|c| &c.query) + }); + let configs = matches + .grammars() + .iter() + .map(|grammar| grammar.brackets_config.as_ref().unwrap()) + .collect::>(); - iter::from_fn(move || { while let Some(mat) = matches.peek() { let mut open = None; let mut close = None; - let config = &configs[mat.grammar_index]; + let syntax_layer_depth = mat.depth; + let config = configs[mat.grammar_index]; let pattern = &config.patterns[mat.pattern_index]; for capture in mat.captures { if capture.index == config.open_capture_ix { @@ -4082,25 +4370,83 @@ impl BufferSnapshot { }; let bracket_range = open_range.start..=close_range.end; - if !bracket_range.overlaps(&range) { + if !bracket_range.overlaps(&chunk_range) { continue; } - return Some(BracketMatch { - open_range, - close_range, + let index = all_brackets.len(); + all_brackets.push(BracketMatch { + open_range: open_range.clone(), + close_range: close_range.clone(), newline_only: pattern.newline_only, + syntax_layer_depth, + color_index: None, }); + + // Certain languages have "brackets" that are not brackets, e.g. tags. and such + // bracket will match the entire tag with all text inside. + // For now, avoid highlighting any pair that has more than single char in each bracket. + // We need to colorize `` bracket pairs, so cannot make this check stricter. + let should_color = + !pattern.rainbow_exclude && (open_range.len() == 1 || close_range.len() == 1); + if should_color { + opens.push(open_range.clone()); + color_pairs.push((open_range, close_range, index)); + } } - None - }) + + opens.sort_by_key(|r| (r.start, r.end)); + opens.dedup_by(|a, b| a.start == b.start && a.end == b.end); + color_pairs.sort_by_key(|(_, close, _)| close.end); + + let mut open_stack = Vec::new(); + let mut open_index = 0; + for (open, close, index) in color_pairs { + while open_index < opens.len() && opens[open_index].start < close.start { + open_stack.push(opens[open_index].clone()); + open_index += 1; + } + + if open_stack.last() == Some(&open) { + let depth_index = open_stack.len() - 1; + all_brackets[index].color_index = Some(depth_index); + open_stack.pop(); + } + } + + all_brackets.sort_by_key(|bracket_match| { + (bracket_match.open_range.start, bracket_match.open_range.end) + }); + + if let empty_slot @ None = + &mut self.tree_sitter_data.brackets_by_chunks.lock()[chunk.id] + { + *empty_slot = Some(all_brackets.clone()); + } + all_bracket_matches.insert(chunk.row_range(), all_brackets); + } + + all_bracket_matches + } + + pub fn all_bracket_ranges( + &self, + range: Range, + ) -> impl Iterator> { + self.fetch_bracket_ranges(range.clone(), None) + .into_values() + .flatten() + .filter(move |bracket_match| { + let bracket_range = bracket_match.open_range.start..bracket_match.close_range.end; + bracket_range.overlaps(&range) + }) } /// Returns bracket range pairs overlapping or adjacent to `range` pub fn bracket_ranges( &self, range: Range, - ) -> impl Iterator + '_ { + ) -> impl Iterator> + '_ { // Find bracket pairs that *inclusively* contain the given range. let range = range.start.to_previous_offset(self)..range.end.to_next_offset(self); self.all_bracket_ranges(range) @@ -4246,11 +4592,19 @@ impl BufferSnapshot { pub fn enclosing_bracket_ranges( &self, range: Range, - ) -> impl Iterator + '_ { + ) -> impl Iterator> + '_ { let range = range.start.to_offset(self)..range.end.to_offset(self); - self.bracket_ranges(range.clone()).filter(move |pair| { - pair.open_range.start <= range.start && pair.close_range.end >= range.end + let result: Vec<_> = self.bracket_ranges(range.clone()).collect(); + let max_depth = result + .iter() + .map(|mat| mat.syntax_layer_depth) + .max() + .unwrap_or(0); + result.into_iter().filter(move |pair| { + pair.open_range.start <= range.start + && pair.close_range.end >= range.end + && pair.syntax_layer_depth == max_depth }) } @@ -4737,6 +5091,7 @@ impl Clone for BufferSnapshot { remote_selections: self.remote_selections.clone(), diagnostics: self.diagnostics.clone(), language: self.language.clone(), + tree_sitter_data: self.tree_sitter_data.clone(), non_text_state_update_count: self.non_text_state_update_count, } } @@ -4970,7 +5325,7 @@ impl<'a> Iterator for BufferChunks<'a> { text: chunk, chars: chars_map, tabs, - }) = self.chunks.peek_tabs() + }) = self.chunks.peek_with_bitmaps() { let chunk_start = self.range.start; let mut chunk_end = (self.chunks.offset() + chunk.len()) @@ -4983,18 +5338,14 @@ impl<'a> Iterator for BufferChunks<'a> { chunk_end = chunk_end.min(*parent_capture_end); highlight_id = Some(*parent_highlight_id); } - - let slice = - &chunk[chunk_start - self.chunks.offset()..chunk_end - self.chunks.offset()]; + let bit_start = chunk_start - self.chunks.offset(); let bit_end = chunk_end - self.chunks.offset(); - let mask = if bit_end >= 128 { - u128::MAX - } else { - (1u128 << bit_end) - 1 - }; - let tabs = (tabs >> (chunk_start - self.chunks.offset())) & mask; - let chars_map = (chars_map >> (chunk_start - self.chunks.offset())) & mask; + let slice = &chunk[bit_start..bit_end]; + + let mask = 1u128.unbounded_shl(bit_end as u32).wrapping_sub(1); + let tabs = (tabs >> bit_start) & mask; + let chars = (chars_map >> bit_start) & mask; self.range.start = chunk_end; if self.range.start == self.chunks.offset() + chunk.len() { @@ -5008,7 +5359,7 @@ impl<'a> Iterator for BufferChunks<'a> { diagnostic_severity: self.current_diagnostic_severity(), is_unnecessary: self.current_code_is_unnecessary(), tabs, - chars: chars_map, + chars, ..Chunk::default() }) } else { @@ -5055,6 +5406,7 @@ impl Default for Diagnostic { is_unnecessary: false, underline: true, data: None, + registration_id: None, } } } diff --git a/crates/language/src/buffer/row_chunk.rs b/crates/language/src/buffer/row_chunk.rs new file mode 100644 index 0000000000..e4ef5227e6 --- /dev/null +++ b/crates/language/src/buffer/row_chunk.rs @@ -0,0 +1,121 @@ +//! A row chunk is an exclusive range of rows, [`BufferRow`] within a buffer of a certain version, [`Global`]. +//! All but the last chunk are of a constant, given size. + +use std::{ops::Range, sync::Arc}; + +use clock::Global; +use text::{Anchor, OffsetRangeExt as _, Point}; +use util::RangeExt; + +use crate::BufferRow; + +/// An range of rows, exclusive as [`lsp::Range`] and +/// +/// denote. +/// +/// Represents an area in a text editor, adjacent to other ones. +/// Together, chunks form entire document at a particular version [`Global`]. +/// Each chunk is queried for inlays as `(start_row, 0)..(end_exclusive, 0)` via +/// +#[derive(Clone)] +pub struct RowChunks { + snapshot: text::BufferSnapshot, + chunks: Arc<[RowChunk]>, +} + +impl std::fmt::Debug for RowChunks { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RowChunks") + .field("version", self.snapshot.version()) + .field("chunks", &self.chunks) + .finish() + } +} + +impl RowChunks { + pub fn new(snapshot: text::BufferSnapshot, max_rows_per_chunk: u32) -> Self { + let buffer_point_range = (0..snapshot.len()).to_point(&snapshot); + let last_row = buffer_point_range.end.row; + let chunks = (buffer_point_range.start.row..=last_row) + .step_by(max_rows_per_chunk as usize) + .enumerate() + .map(|(id, chunk_start)| RowChunk { + id, + start: chunk_start, + end_exclusive: (chunk_start + max_rows_per_chunk).min(last_row), + }) + .collect::>(); + Self { + snapshot, + chunks: Arc::from(chunks), + } + } + + pub fn version(&self) -> &Global { + self.snapshot.version() + } + + pub fn len(&self) -> usize { + self.chunks.len() + } + + pub fn applicable_chunks( + &self, + ranges: &[Range], + ) -> impl Iterator { + let row_ranges = ranges + .iter() + .map(|range| range.to_point(&self.snapshot)) + // Be lenient and yield multiple chunks if they "touch" the exclusive part of the range. + // This will result in LSP hints [re-]queried for more ranges, but also more hints already visible when scrolling around. + .map(|point_range| point_range.start.row..point_range.end.row + 1) + .collect::>(); + self.chunks + .iter() + .filter(move |chunk| -> bool { + let chunk_range = chunk.row_range().to_inclusive(); + row_ranges + .iter() + .any(|row_range| chunk_range.overlaps(&row_range)) + }) + .copied() + } + + pub fn chunk_range(&self, chunk: RowChunk) -> Option> { + if !self.chunks.contains(&chunk) { + return None; + } + + let start = Point::new(chunk.start, 0); + let end = if self.chunks.last() == Some(&chunk) { + Point::new( + chunk.end_exclusive, + self.snapshot.line_len(chunk.end_exclusive), + ) + } else { + Point::new(chunk.end_exclusive, 0) + }; + Some(self.snapshot.anchor_before(start)..self.snapshot.anchor_after(end)) + } + + pub fn previous_chunk(&self, chunk: RowChunk) -> Option { + if chunk.id == 0 { + None + } else { + self.chunks.get(chunk.id - 1).copied() + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RowChunk { + pub id: usize, + pub start: BufferRow, + pub end_exclusive: BufferRow, +} + +impl RowChunk { + pub fn row_range(&self) -> Range { + self.start..self.end_exclusive + } +} diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 6c87ec5b51..54e2ef4065 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -6,6 +6,7 @@ use futures::FutureExt as _; use gpui::{App, AppContext as _, BorrowAppContext, Entity}; use gpui::{HighlightStyle, TestAppContext}; use indoc::indoc; +use pretty_assertions::assert_eq; use proto::deserialize_operation; use rand::prelude::*; use regex::RegexBuilder; @@ -46,8 +47,7 @@ fn test_line_endings(cx: &mut gpui::App) { init_settings(cx, |_| {}); cx.new(|cx| { - let mut buffer = - Buffer::local("one\r\ntwo\rthree", cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local("one\r\ntwo\rthree", cx).with_language(rust_lang(), cx); assert_eq!(buffer.text(), "one\ntwo\nthree"); assert_eq!(buffer.line_ending(), LineEnding::Windows); @@ -70,7 +70,13 @@ fn test_line_endings(cx: &mut gpui::App) { fn test_set_line_ending(cx: &mut TestAppContext) { let base = cx.new(|cx| Buffer::local("one\ntwo\nthree\n", cx)); let base_replica = cx.new(|cx| { - Buffer::from_proto(1, Capability::ReadWrite, base.read(cx).to_proto(cx), None).unwrap() + Buffer::from_proto( + ReplicaId::new(1), + Capability::ReadWrite, + base.read(cx).to_proto(cx), + None, + ) + .unwrap() }); base.update(cx, |_buffer, cx| { cx.subscribe(&base_replica, |this, _, event, cx| { @@ -145,7 +151,7 @@ fn test_select_language(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); registry.add(Arc::new(Language::new( LanguageConfig { - name: LanguageName::new("Rust"), + name: LanguageName::new_static("Rust"), matcher: LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() @@ -167,7 +173,7 @@ fn test_select_language(cx: &mut App) { ))); registry.add(Arc::new(Language::new( LanguageConfig { - name: LanguageName::new("Make"), + name: LanguageName::new_static("Make"), matcher: LanguageMatcher { path_suffixes: vec!["Makefile".to_string(), "mk".to_string()], ..Default::default() @@ -269,7 +275,7 @@ async fn test_first_line_pattern(cx: &mut TestAppContext) { async fn test_language_for_file_with_custom_file_types(cx: &mut TestAppContext) { cx.update(|cx| { init_settings(cx, |settings| { - settings.file_types.extend([ + settings.file_types.get_or_insert_default().extend([ ("TypeScript".into(), vec!["js".into()].into()), ( "JavaScript".into(), @@ -397,7 +403,7 @@ fn test_edit_events(cx: &mut gpui::App) { let buffer2 = cx.new(|cx| { Buffer::remote( BufferId::from(cx.entity_id().as_non_zero_u64()), - 1, + ReplicaId::new(1), Capability::ReadWrite, "abcdef", ) @@ -602,7 +608,7 @@ async fn test_normalize_whitespace(cx: &mut gpui::TestAppContext) { #[gpui::test] async fn test_reparse(cx: &mut gpui::TestAppContext) { let text = "fn a() {}"; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); // Wait for the initial text to parse cx.executor().run_until_parked(); @@ -729,7 +735,7 @@ async fn test_reparse(cx: &mut gpui::TestAppContext) { #[gpui::test] async fn test_resetting_language(cx: &mut gpui::TestAppContext) { let buffer = cx.new(|cx| { - let mut buffer = Buffer::local("{}", cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local("{}", cx).with_language(rust_lang(), cx); buffer.set_sync_parse_timeout(Duration::ZERO); buffer }); @@ -777,29 +783,49 @@ async fn test_outline(cx: &mut gpui::TestAppContext) { "# .unindent(); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + let outline = snapshot.outline(None); assert_eq!( outline .items .iter() - .map(|item| (item.text.as_str(), item.depth)) + .map(|item| ( + item.text.as_str(), + item.depth, + item.to_point(&snapshot).body_range(&snapshot) + .map(|range| minimize_space(&snapshot.text_for_range(range).collect::())) + )) .collect::>(), &[ - ("struct Person", 0), - ("name", 1), - ("age", 1), - ("mod module", 0), - ("enum LoginState", 1), - ("LoggedOut", 2), - ("LoggingOn", 2), - ("LoggedIn", 2), - ("person", 3), - ("time", 3), - ("impl Eq for Person", 0), - ("impl Drop for Person", 0), - ("fn drop", 1), + ("struct Person", 0, Some("name: String, age: usize,".to_string())), + ("name", 1, None), + ("age", 1, None), + ( + "mod module", + 0, + Some( + "enum LoginState { LoggedOut, LoggingOn, LoggedIn { person: Person, time: Instant, } }".to_string() + ) + ), + ( + "enum LoginState", + 1, + Some("LoggedOut, LoggingOn, LoggedIn { person: Person, time: Instant, }".to_string()) + ), + ("LoggedOut", 2, None), + ("LoggingOn", 2, None), + ("LoggedIn", 2, Some("person: Person, time: Instant,".to_string())), + ("person", 3, None), + ("time", 3, None), + ("impl Eq for Person", 0, Some("".to_string())), + ( + "impl Drop for Person", + 0, + Some("fn drop(&mut self) { println!(\"bye\"); }".to_string()) + ), + ("fn drop", 1, Some("println!(\"bye\");".to_string())), ] ); @@ -834,6 +860,11 @@ async fn test_outline(cx: &mut gpui::TestAppContext) { ] ); + fn minimize_space(text: &str) -> String { + static WHITESPACE: LazyLock = LazyLock::new(|| Regex::new("[\\n\\s]+").unwrap()); + WHITESPACE.replace_all(text, " ").trim().to_string() + } + async fn search<'a>( outline: &'a Outline, query: &'a str, @@ -859,7 +890,7 @@ async fn test_outline_nodes_with_newlines(cx: &mut gpui::TestAppContext) { "# .unindent(); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( @@ -939,7 +970,7 @@ fn test_outline_annotations(cx: &mut App) { "# .unindent(); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( @@ -987,7 +1018,7 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { "# .unindent(); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); // point is at the start of an item @@ -1062,7 +1093,7 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { " .unindent(), ); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); // note, it would be nice to actually return the method test in this @@ -1081,8 +1112,7 @@ fn test_text_objects(cx: &mut App) { false, ); - let buffer = - cx.new(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(rust_lang(), cx)); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); let matches = snapshot @@ -1099,15 +1129,24 @@ fn test_text_objects(cx: &mut App) { "fn say() -> u8 { return /* hi */ 1 }", TextObject::AroundFunction ), + ( + "fn say() -> u8 { return /* hi */ 1 }", + TextObject::InsideClass + ), + ( + "impl Hello {\n fn say() -> u8 { return /* hi */ 1 }\n}", + TextObject::AroundClass + ), ], ) } #[gpui::test] fn test_enclosing_bracket_ranges(cx: &mut App) { - let mut assert = |selection_text, range_markers| { + #[track_caller] + fn assert(selection_text: &'static str, range_markers: Vec<&'static str>, cx: &mut App) { assert_bracket_pairs(selection_text, range_markers, rust_lang(), cx) - }; + } assert( indoc! {" @@ -1124,6 +1163,7 @@ fn test_enclosing_bracket_ranges(cx: &mut App) { } «}» let foo = 1;"}], + cx, ); assert( @@ -1150,6 +1190,7 @@ fn test_enclosing_bracket_ranges(cx: &mut App) { } let foo = 1;"}, ], + cx, ); assert( @@ -1176,6 +1217,7 @@ fn test_enclosing_bracket_ranges(cx: &mut App) { } let foo = 1;"}, ], + cx, ); assert( @@ -1193,6 +1235,7 @@ fn test_enclosing_bracket_ranges(cx: &mut App) { } «}» let foo = 1;"}], + cx, ); assert( @@ -1203,7 +1246,8 @@ fn test_enclosing_bracket_ranges(cx: &mut App) { } } let fˇoo = 1;"}, - vec![], + Vec::new(), + cx, ); // Regression test: avoid crash when querying at the end of the buffer. @@ -1215,14 +1259,20 @@ fn test_enclosing_bracket_ranges(cx: &mut App) { } } let foo = 1;ˇ"}, - vec![], + Vec::new(), + cx, ); } #[gpui::test] fn test_enclosing_bracket_ranges_where_brackets_are_not_outermost_children(cx: &mut App) { let mut assert = |selection_text, bracket_pair_texts| { - assert_bracket_pairs(selection_text, bracket_pair_texts, javascript_lang(), cx) + assert_bracket_pairs( + selection_text, + bracket_pair_texts, + Arc::new(javascript_lang()), + cx, + ) }; assert( @@ -1255,7 +1305,7 @@ fn test_enclosing_bracket_ranges_where_brackets_are_not_outermost_children(cx: & fn test_range_for_syntax_ancestor(cx: &mut App) { cx.new(|cx| { let text = "fn a() { b(|c| {}) }"; - let buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); let snapshot = buffer.snapshot(); assert_eq!( @@ -1307,7 +1357,7 @@ fn test_autoindent_with_soft_tabs(cx: &mut App) { cx.new(|cx| { let text = "fn a() {}"; - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit([(8..8, "\n\n")], Some(AutoindentMode::EachLine), cx); assert_eq!(buffer.text(), "fn a() {\n \n}"); @@ -1349,7 +1399,7 @@ fn test_autoindent_with_hard_tabs(cx: &mut App) { cx.new(|cx| { let text = "fn a() {}"; - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit([(8..8, "\n\n")], Some(AutoindentMode::EachLine), cx); assert_eq!(buffer.text(), "fn a() {\n\t\n}"); @@ -1398,7 +1448,7 @@ fn test_autoindent_does_not_adjust_lines_with_unchanged_suggestion(cx: &mut App) .unindent(), cx, ) - .with_language(Arc::new(rust_lang()), cx); + .with_language(rust_lang(), cx); // Lines 2 and 3 don't match the indentation suggestion. When editing these lines, // their indentation is not adjusted. @@ -1539,7 +1589,7 @@ fn test_autoindent_does_not_adjust_lines_with_unchanged_suggestion(cx: &mut App) .unindent(), cx, ) - .with_language(Arc::new(rust_lang()), cx); + .with_language(rust_lang(), cx); // Insert a closing brace. It is outdented. buffer.edit_via_marked_text( @@ -1602,7 +1652,7 @@ fn test_autoindent_does_not_adjust_lines_within_newly_created_errors(cx: &mut Ap .unindent(), cx, ) - .with_language(Arc::new(rust_lang()), cx); + .with_language(rust_lang(), cx); // Regression test: line does not get outdented due to syntax error buffer.edit_via_marked_text( @@ -1661,7 +1711,7 @@ fn test_autoindent_adjusts_lines_when_only_text_changes(cx: &mut App) { .unindent(), cx, ) - .with_language(Arc::new(rust_lang()), cx); + .with_language(rust_lang(), cx); buffer.edit_via_marked_text( &" @@ -1711,7 +1761,7 @@ fn test_autoindent_with_edit_at_end_of_buffer(cx: &mut App) { cx.new(|cx| { let text = "a\nb"; - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit( [(0..1, "\n"), (2..3, "\n")], Some(AutoindentMode::EachLine), @@ -1737,7 +1787,7 @@ fn test_autoindent_multi_line_insertion(cx: &mut App) { " .unindent(); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit( [(Point::new(3, 0)..Point::new(3, 0), "e(\n f()\n);\n")], Some(AutoindentMode::EachLine), @@ -1774,7 +1824,7 @@ fn test_autoindent_block_mode(cx: &mut App) { } "# .unindent(); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); // When this text was copied, both of the quotation marks were at the same // indent level, but the indentation of the first line was not included in @@ -1857,7 +1907,7 @@ fn test_autoindent_block_mode_with_newline(cx: &mut App) { } "# .unindent(); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); // First line contains just '\n', it's indentation is stored in "original_indent_columns" let original_indent_columns = vec![Some(4)]; @@ -1909,7 +1959,7 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut App) { } "# .unindent(); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); // The original indent columns are not known, so this text is // auto-indented in a block as if the first line was copied in @@ -2000,7 +2050,7 @@ fn test_autoindent_block_mode_multiple_adjacent_ranges(cx: &mut App) { false, ); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit( [ @@ -2014,7 +2064,7 @@ fn test_autoindent_block_mode_multiple_adjacent_ranges(cx: &mut App) { cx, ); - pretty_assertions::assert_eq!( + assert_eq!( buffer.text(), " mod numbers { @@ -2208,7 +2258,7 @@ async fn test_async_autoindents_preserve_preview(cx: &mut TestAppContext) { // Then we request that a preview tab be preserved for the new version, even though it's edited. let buffer = cx.new(|cx| { let text = "fn a() {}"; - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); // This causes autoindent to be async. buffer.set_sync_parse_timeout(Duration::ZERO); @@ -2627,7 +2677,7 @@ fn test_language_scope_at_with_combined_injections(cx: &mut App) { buffer.set_language_registry(language_registry.clone()); buffer.set_language( language_registry - .language_for_name("ERB") + .language_for_name("HTML+ERB") .now_or_never() .unwrap() .ok(), @@ -2666,7 +2716,7 @@ fn test_language_at_with_hidden_languages(cx: &mut App) { .unindent(); let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - language_registry.add(Arc::new(markdown_lang())); + language_registry.add(markdown_lang()); language_registry.add(Arc::new(markdown_inline_lang())); let mut buffer = Buffer::local(text, cx); @@ -2708,9 +2758,9 @@ fn test_language_at_for_markdown_code_block(cx: &mut App) { .unindent(); let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - language_registry.add(Arc::new(markdown_lang())); + language_registry.add(markdown_lang()); language_registry.add(Arc::new(markdown_inline_lang())); - language_registry.add(Arc::new(rust_lang())); + language_registry.add(rust_lang()); let mut buffer = Buffer::local(text, cx); buffer.set_language_registry(language_registry.clone()); @@ -2747,6 +2797,50 @@ fn test_language_at_for_markdown_code_block(cx: &mut App) { }); } +#[gpui::test] +fn test_syntax_layer_at_for_injected_languages(cx: &mut App) { + init_settings(cx, |_| {}); + + cx.new(|cx| { + let text = r#" + ```html+erb +
Hello
+ <%= link_to "Some", "https://zed.dev" %> + ``` + "# + .unindent(); + + let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + language_registry.add(Arc::new(erb_lang())); + language_registry.add(Arc::new(html_lang())); + language_registry.add(Arc::new(ruby_lang())); + + let mut buffer = Buffer::local(text, cx); + buffer.set_language_registry(language_registry.clone()); + buffer.set_language( + language_registry + .language_for_name("HTML+ERB") + .now_or_never() + .unwrap() + .ok(), + cx, + ); + + let snapshot = buffer.snapshot(); + + // Test points in the code line + let html_point = Point::new(1, 4); + let language = snapshot.language_at(html_point).unwrap(); + assert_eq!(language.name().as_ref(), "HTML"); + + let ruby_point = Point::new(2, 6); + let language = snapshot.language_at(ruby_point).unwrap(); + assert_eq!(language.name().as_ref(), "Ruby"); + + buffer + }); +} + #[gpui::test] fn test_serialization(cx: &mut gpui::App) { let mut now = Instant::now(); @@ -2775,7 +2869,8 @@ fn test_serialization(cx: &mut gpui::App) { .background_executor() .block(buffer1.read(cx).serialize_ops(None, cx)); let buffer2 = cx.new(|cx| { - let mut buffer = Buffer::from_proto(1, Capability::ReadWrite, state, None).unwrap(); + let mut buffer = + Buffer::from_proto(ReplicaId::new(1), Capability::ReadWrite, state, None).unwrap(); buffer.apply_ops( ops.into_iter() .map(|op| proto::deserialize_operation(op).unwrap()), @@ -2794,7 +2889,13 @@ fn test_branch_and_merge(cx: &mut TestAppContext) { // Create a remote replica of the base buffer. let base_replica = cx.new(|cx| { - Buffer::from_proto(1, Capability::ReadWrite, base.read(cx).to_proto(cx), None).unwrap() + Buffer::from_proto( + ReplicaId::new(1), + Capability::ReadWrite, + base.read(cx).to_proto(cx), + None, + ) + .unwrap() }); base.update(cx, |_buffer, cx| { cx.subscribe(&base_replica, |this, _, event, cx| { @@ -3056,22 +3157,20 @@ async fn test_preview_edits(cx: &mut TestAppContext) { cx: &mut TestAppContext, assert_fn: impl Fn(HighlightedText), ) { - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let edits = buffer.read_with(cx, |buffer, _| { edits .into_iter() .map(|(range, text)| { ( buffer.anchor_before(range.start)..buffer.anchor_after(range.end), - text.to_string(), + text.into(), ) }) - .collect::>() + .collect::>() }); let edit_preview = buffer - .read_with(cx, |buffer, cx| { - buffer.preview_edits(edits.clone().into(), cx) - }) + .read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx)) .await; let highlighted_edits = cx.read(|cx| { edit_preview.highlight_edits(&buffer.read(cx).snapshot(), &edits, include_deletions, cx) @@ -3108,7 +3207,8 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { .background_executor() .block(base_buffer.read(cx).serialize_ops(None, cx)); let mut buffer = - Buffer::from_proto(i as ReplicaId, Capability::ReadWrite, state, None).unwrap(); + Buffer::from_proto(ReplicaId::new(i as u16), Capability::ReadWrite, state, None) + .unwrap(); buffer.apply_ops( ops.into_iter() .map(|op| proto::deserialize_operation(op).unwrap()), @@ -3133,9 +3233,9 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { }); buffers.push(buffer); - replica_ids.push(i as ReplicaId); - network.lock().add_peer(i as ReplicaId); - log::info!("Adding initial peer with replica id {}", i); + replica_ids.push(ReplicaId::new(i as u16)); + network.lock().add_peer(ReplicaId::new(i as u16)); + log::info!("Adding initial peer with replica id {:?}", replica_ids[i]); } log::info!("initial text: {:?}", base_text); @@ -3155,14 +3255,14 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { buffer.start_transaction_at(now); buffer.randomly_edit(&mut rng, 5, cx); buffer.end_transaction_at(now, cx); - log::info!("buffer {} text: {:?}", buffer.replica_id(), buffer.text()); + log::info!("buffer {:?} text: {:?}", buffer.replica_id(), buffer.text()); }); mutation_count -= 1; } 30..=39 if mutation_count != 0 => { buffer.update(cx, |buffer, cx| { if rng.random_bool(0.2) { - log::info!("peer {} clearing active selections", replica_id); + log::info!("peer {:?} clearing active selections", replica_id); active_selections.remove(&replica_id); buffer.remove_active_selections(cx); } else { @@ -3179,7 +3279,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { } let selections: Arc<[Selection]> = selections.into(); log::info!( - "peer {} setting active selections: {:?}", + "peer {:?} setting active selections: {:?}", replica_id, selections ); @@ -3189,7 +3289,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { }); mutation_count -= 1; } - 40..=49 if mutation_count != 0 && replica_id == 0 => { + 40..=49 if mutation_count != 0 && replica_id == ReplicaId::REMOTE_SERVER => { let entry_count = rng.random_range(1..=5); buffer.update(cx, |buffer, cx| { let diagnostics = DiagnosticSet::new( @@ -3207,7 +3307,11 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { }), buffer, ); - log::info!("peer {} setting diagnostics: {:?}", replica_id, diagnostics); + log::info!( + "peer {:?} setting diagnostics: {:?}", + replica_id, + diagnostics + ); buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx); }); mutation_count -= 1; @@ -3217,12 +3321,13 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { let old_buffer_ops = cx .background_executor() .block(buffer.read(cx).serialize_ops(None, cx)); - let new_replica_id = (0..=replica_ids.len() as ReplicaId) + let new_replica_id = (0..=replica_ids.len() as u16) + .map(ReplicaId::new) .filter(|replica_id| *replica_id != buffer.read(cx).replica_id()) .choose(&mut rng) .unwrap(); log::info!( - "Adding new replica {} (replicating from {})", + "Adding new replica {:?} (replicating from {:?})", new_replica_id, replica_id ); @@ -3241,7 +3346,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { cx, ); log::info!( - "New replica {} text: {:?}", + "New replica {:?} text: {:?}", new_buffer.replica_id(), new_buffer.text() ); @@ -3264,7 +3369,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { })); network.lock().replicate(replica_id, new_replica_id); - if new_replica_id as usize == replica_ids.len() { + if new_replica_id.as_u16() as usize == replica_ids.len() { replica_ids.push(new_replica_id); } else { let new_buffer = new_buffer.take().unwrap(); @@ -3276,7 +3381,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { .map(|op| proto::deserialize_operation(op).unwrap()); if ops.len() > 0 { log::info!( - "peer {} (version: {:?}) applying {} ops from the network. {:?}", + "peer {:?} (version: {:?}) applying {} ops from the network. {:?}", new_replica_id, buffer.read(cx).version(), ops.len(), @@ -3287,13 +3392,13 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { }); } } - buffers[new_replica_id as usize] = new_buffer; + buffers[new_replica_id.as_u16() as usize] = new_buffer; } } 60..=69 if mutation_count != 0 => { buffer.update(cx, |buffer, cx| { buffer.randomly_undo_redo(&mut rng, cx); - log::info!("buffer {} text: {:?}", buffer.replica_id(), buffer.text()); + log::info!("buffer {:?} text: {:?}", buffer.replica_id(), buffer.text()); }); mutation_count -= 1; } @@ -3305,7 +3410,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { .map(|op| proto::deserialize_operation(op).unwrap()); if ops.len() > 0 { log::info!( - "peer {} (version: {:?}) applying {} ops from the network. {:?}", + "peer {:?} (version: {:?}) applying {} ops from the network. {:?}", replica_id, buffer.read(cx).version(), ops.len(), @@ -3335,13 +3440,13 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { assert_eq!( buffer.version(), first_buffer.version(), - "Replica {} version != Replica 0 version", + "Replica {:?} version != Replica 0 version", buffer.replica_id() ); assert_eq!( buffer.text(), first_buffer.text(), - "Replica {} text != Replica 0 text", + "Replica {:?} text != Replica 0 text", buffer.replica_id() ); assert_eq!( @@ -3351,7 +3456,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { first_buffer .diagnostics_in_range::<_, usize>(0..first_buffer.len(), false) .collect::>(), - "Replica {} diagnostics != Replica 0 diagnostics", + "Replica {:?} diagnostics != Replica 0 diagnostics", buffer.replica_id() ); } @@ -3359,7 +3464,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { for buffer in &buffers { let buffer = buffer.read(cx).snapshot(); let actual_remote_selections = buffer - .selections_in_range(Anchor::MIN..Anchor::MAX, false) + .selections_in_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), false) .map(|(replica_id, _, _, selections)| (replica_id, selections.collect::>())) .collect::>(); let expected_remote_selections = active_selections @@ -3370,7 +3475,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { assert_eq!( actual_remote_selections, expected_remote_selections, - "Replica {} remote selections != expected selections", + "Replica {:?} remote selections != expected selections", buffer.replica_id() ); } @@ -3394,6 +3499,25 @@ fn test_contiguous_ranges() { ); } +#[gpui::test] +fn test_insertion_after_deletion(cx: &mut gpui::App) { + let buffer = cx.new(|cx| Buffer::local("struct Foo {\n \n}", cx)); + buffer.update(cx, |buffer, cx| { + let mut anchor = buffer.anchor_after(17); + buffer.edit([(12..18, "")], None, cx); + let snapshot = buffer.snapshot(); + assert_eq!(snapshot.text(), "struct Foo {}"); + if !anchor.is_valid(&snapshot) { + anchor = snapshot.anchor_after(snapshot.offset_for_anchor(&anchor)); + } + buffer.edit([(anchor..anchor, "\n")], None, cx); + buffer.edit([(anchor..anchor, "field1:")], None, cx); + buffer.edit([(anchor..anchor, " i32,")], None, cx); + let snapshot = buffer.snapshot(); + assert_eq!(snapshot.text(), "struct Foo {\nfield1: i32,}"); + }) +} + #[gpui::test(iterations = 500)] fn test_trailing_whitespace_ranges(mut rng: StdRng) { // Generate a random multi-line string containing @@ -3444,7 +3568,7 @@ let word=öäpple.bar你 Öäpple word2-öÄpPlE-Pizza-word ÖÄPPLE word "#; let buffer = cx.new(|cx| { - let buffer = Buffer::local(contents, cx).with_language(Arc::new(rust_lang()), cx); + let buffer = Buffer::local(contents, cx).with_language(rust_lang(), cx); assert_eq!(buffer.text(), contents); buffer.check_invariants(); buffer @@ -3604,7 +3728,7 @@ fn ruby_lang() -> Language { fn html_lang() -> Language { Language::new( LanguageConfig { - name: LanguageName::new("HTML"), + name: LanguageName::new_static("HTML"), block_comment: Some(BlockCommentConfig { start: " ‹«/👉例/Cool Spaces»›"); + test_path!(" ::: ‹«/例👈/Cool Spaces»›"); + test_path!(" --> ‹«/👉例/Cool Spaces»:«4»:«2»›"); + test_path!(" ::: ‹«/例👈/Cool Spaces»(«4»,«2»)›"); + test_path!(" panicked at ‹«/👉例/Cool Spaces»:«4»:«2»›:"); + test_path!(" panicked at ‹«/例👈/Cool Spaces»(«4»,«2»)›:"); + test_path!(" at ‹«/👉例/Cool Spaces»:«4»:«2»›"); + test_path!(" at ‹«/例👈/Cool Spaces»(«4»,«2»)›"); + // Python test_path!("‹«👉例wesome.py»›"); test_path!("‹«例👈wesome.py»›"); @@ -624,7 +806,14 @@ mod tests { } #[test] - #[should_panic(expected = "No hyperlink found")] + // + fn issue_12338_regex() { + // Issue #12338 + test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«'test file 👉1.txt'»›"); + test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«👉'test file 1.txt'»›"); + } + + #[test] // fn issue_12338() { // Issue #12338 @@ -658,30 +847,45 @@ mod tests { test_path!(" ‹File \"«/🏃👈wesome.🔥»\", line «42»›: Wat?"); } + #[test] + // + fn issue_40202() { + // Elixir + test_path!("[‹«lib/blitz_apex_👉server/stats/aggregate_rank_stats.ex»:«35»›: BlitzApexServer.Stats.AggregateRankStats.update/2] + 1 #=> 1"); + } + + #[test] + // + fn issue_28194() { + test_path!( + "‹«test/c👉ontrollers/template_items_controller_test.rb»:«20»›:in 'block (2 levels) in '" + ); + } + #[test] #[cfg_attr( not(target_os = "windows"), should_panic( - expected = "Path = «test/controllers/template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)" + expected = "Path = «/test/cool.rs:4:NotDesc», at grid cells (0, 1)..=(7, 2)" ) )] #[cfg_attr( target_os = "windows", should_panic( - expected = r#"Path = «test\\controllers\\template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"# + expected = r#"Path = «C:\\test\\cool.rs:4:NotDesc», at grid cells (0, 1)..=(8, 1)"# ) )] - // - // - // #28194 was closed, but the link includes the description part (":in" here), which - // seems wrong... - fn issue_28194() { - test_path!( - "‹«test/c👉ontrollers/template_items_controller_test.rb»:«20»›:in 'block (2 levels) in '" - ); - test_path!( - "‹«test/controllers/template_items_controller_test.rb»:«19»›:i👉n 'block in '" - ); + // PathWithPosition::parse_str considers "/test/co👉ol.rs:4:NotDesc" invalid input, but + // still succeeds and truncates the part after the position. Ideally this would be + // parsed as the path "/test/co👉ol.rs:4:NotDesc" with no position. + fn path_with_position_parse_str() { + test_path!("`‹«/test/co👉ol.rs:4:NotDesc»›`"); + test_path!("<‹«/test/co👉ol.rs:4:NotDesc»›>"); + + test_path!("'‹«(/test/co👉ol.rs:4:2)»›'"); + test_path!("'‹«(/test/co👉ol.rs(4))»›'"); + test_path!("'‹«(/test/co👉ol.rs(4,2))»›'"); } } @@ -715,35 +919,38 @@ mod tests { test_path!("‹«/👉test/cool.rs(1,618033988749)»›"); } - #[test] - #[should_panic(expected = "Path = «»")] - fn colon_suffix_succeeds_in_finding_an_empty_maybe_path() { - test_path!("‹«/test/cool.rs»:«4»:«2»›👉:", "What is this?"); - test_path!("‹«/test/cool.rs»(«4»,«2»)›👉:", "What is this?"); - } - #[test] #[cfg_attr( not(target_os = "windows"), - should_panic(expected = "Path = «/test/cool.rs»") + should_panic(expected = "Path = «/te:st/co:ol.r:s:4:2::::::»") )] #[cfg_attr( target_os = "windows", - should_panic(expected = r#"Path = «C:\\test\\cool.rs»"#) + should_panic(expected = r#"Path = «C:\\te:st\\co:ol.r:s:4:2::::::»"#) )] fn many_trailing_colons_should_be_parsed_as_part_of_the_path() { - test_path!("‹«/test/cool.rs:::👉:»›"); test_path!("‹«/te:st/👉co:ol.r:s:4:2::::::»›"); + test_path!("/test/cool.rs:::👉:"); } } - #[cfg(target_os = "windows")] mod windows { // Lots of fun to be had with long file paths (verbatim) and UNC paths on Windows. // See // See // See + #[test] + fn default_prompts() { + // Windows command prompt + test_path!(r#"‹«C:\Users\someone\👉test»›>"#); + test_path!(r#"C:\Users\someone\test👉>"#); + + // Windows PowerShell + test_path!(r#"PS ‹«C:\Users\someone\👉test\cool.rs»›>"#); + test_path!(r#"PS C:\Users\someone\test\cool.rs👉>"#); + } + #[test] fn unc() { test_path!(r#"‹«\\server\share\👉test\cool.rs»›"#); @@ -752,29 +959,122 @@ mod tests { mod issues { #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\cool.rs», at grid cells (0, 0)..=(6, 0)"# - )] fn issue_verbatim() { test_path!(r#"‹«\\?\C:\👉test\cool.rs»›"#); test_path!(r#"‹«\\?\C:\test\cool👉.rs»›"#); } #[test] - #[should_panic( - expected = r#"Path = «\\\\server\\share\\test\\cool.rs», at grid cells (0, 0)..=(10, 2)"# - )] fn issue_verbatim_unc() { test_path!(r#"‹«\\?\UNC\server\share\👉test\cool.rs»›"#); test_path!(r#"‹«\\?\UNC\server\share\test\cool👉.rs»›"#); } } } + + mod perf { + use super::super::*; + use crate::TerminalSettings; + use alacritty_terminal::{ + event::VoidListener, + grid::Dimensions, + index::{Column, Point as AlacPoint}, + term::test::mock_term, + term::{Term, search::Match}, + }; + use settings::{self, Settings, SettingsContent}; + use std::{cell::RefCell, rc::Rc}; + use util_macros::perf; + + fn build_test_term(line: &str) -> (Term, AlacPoint) { + let content = line.repeat(500); + let term = mock_term(&content); + let point = AlacPoint::new( + term.grid().bottommost_line() - 1, + Column(term.grid().last_column().0 / 2), + ); + + (term, point) + } + + #[perf] + pub fn cargo_hyperlink_benchmark() { + const LINE: &str = " Compiling terminal v0.1.0 (/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal)\r\n"; + thread_local! { + static TEST_TERM_AND_POINT: (Term, AlacPoint) = + build_test_term(LINE); + } + TEST_TERM_AND_POINT.with(|(term, point)| { + assert!( + find_from_grid_point_bench(term, *point).is_some(), + "Hyperlink should have been found" + ); + }); + } + + #[perf] + pub fn rust_hyperlink_benchmark() { + const LINE: &str = " --> /Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42\r\n"; + thread_local! { + static TEST_TERM_AND_POINT: (Term, AlacPoint) = + build_test_term(LINE); + } + TEST_TERM_AND_POINT.with(|(term, point)| { + assert!( + find_from_grid_point_bench(term, *point).is_some(), + "Hyperlink should have been found" + ); + }); + } + + #[perf] + pub fn ls_hyperlink_benchmark() { + const LINE: &str = "Cargo.toml experiments notebooks rust-toolchain.toml tooling\r\n"; + thread_local! { + static TEST_TERM_AND_POINT: (Term, AlacPoint) = + build_test_term(LINE); + } + TEST_TERM_AND_POINT.with(|(term, point)| { + assert!( + find_from_grid_point_bench(term, *point).is_some(), + "Hyperlink should have been found" + ); + }); + } + + pub fn find_from_grid_point_bench( + term: &Term, + point: AlacPoint, + ) -> Option<(String, bool, Match)> { + const PATH_HYPERLINK_TIMEOUT_MS: u64 = 1000; + + thread_local! { + static TEST_REGEX_SEARCHES: RefCell = + RefCell::new({ + let default_settings_content: Rc = + settings::parse_json_with_comments(&settings::default_settings()) + .unwrap(); + let default_terminal_settings = + TerminalSettings::from_settings(&default_settings_content); + + RegexSearches::new( + &default_terminal_settings.path_hyperlink_regexes, + PATH_HYPERLINK_TIMEOUT_MS + ) + }); + } + + TEST_REGEX_SEARCHES.with(|regex_searches| { + find_from_grid_point(&term, point, &mut regex_searches.borrow_mut()) + }) + } + } } mod file_iri { - // File IRIs have a ton of use cases, most of which we currently do not support. A few of - // those cases are documented here as tests which are expected to fail. + // File IRIs have a ton of use cases. Absolute file URIs are supported on all platforms, + // including Windows drive letters (e.g., file:///C:/path) and percent-encoded characters. + // Some cases like relative file IRIs are not supported. // See https://en.wikipedia.org/wiki/File_URI_scheme /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns** @@ -794,7 +1094,6 @@ mod tests { mod issues { #[cfg(not(target_os = "windows"))] #[test] - #[should_panic(expected = "Path = «/test/Ῥόδος/», at grid cells (0, 0)..=(15, 1)")] fn issue_file_iri_with_percent_encoded_characters() { // Non-space characters // file:///test/Ῥόδος/ @@ -821,19 +1120,14 @@ mod tests { } // See https://en.wikipedia.org/wiki/File_URI_scheme + // https://github.com/zed-industries/zed/issues/39189 #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"# - )] - fn issue_absolute_file_iri() { + fn issue_39189() { test_file_iri!("file:///C:/test/cool/index.rs"); test_file_iri!("file:///C:/test/cool/"); } #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\Ῥόδος\\», at grid cells (0, 0)..=(16, 1)"# - )] fn issue_file_iri_with_percent_encoded_characters() { // Non-space characters // file:///test/Ῥόδος/ @@ -981,7 +1275,7 @@ mod tests { let mut point = cursor.point; if !cursor.input_needs_wrap { - point.column -= 1; + point = point.sub(term, Boundary::Grid, 1); } if grid.index(point).flags.contains(Flags::WIDE_CHAR_SPACER) { @@ -1007,6 +1301,13 @@ mod tests { } } + fn process_input(term: &mut Term, c: char) { + match c { + '\t' => term.put_tab(1), + c @ _ => term.input(c), + } + } + let mut hovered_grid_point: Option = None; let mut hyperlink_match = AlacPoint::default()..=AlacPoint::default(); let mut iri_or_path = String::default(); @@ -1098,9 +1399,9 @@ mod tests { term.input('C'); prev_input_point = prev_input_point_from_term(&term); term.input(':'); - term.input(c); + process_input(&mut term, c); } else { - term.input(c); + process_input(&mut term, c); prev_input_point = prev_input_point_from_term(&term); } @@ -1130,15 +1431,6 @@ mod tests { iri_or_path = path.to_string_lossy().into_owned(); } - if cfg!(windows) { - // Handle verbatim and UNC paths for Windows - if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\UNC\"#) { - iri_or_path = format!(r#"\\{stripped}"#); - } else if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\"#) { - iri_or_path = stripped.to_string(); - } - } - let hovered_grid_point = hovered_grid_point.expect("Missing hovered point (👉 or 👈)"); let hovered_char = term.grid().index(hovered_grid_point).c; ( @@ -1161,6 +1453,7 @@ mod tests { match c { // Fullwidth unicode characters used in tests '例' | '🏃' | '🦀' | '🔥' => 2, + '\t' => 8, // it's really 0-8, use the max always _ => 1, } } @@ -1283,11 +1576,9 @@ mod tests { let mut marker_header_row = String::new(); for index in 0..self.term.columns() { let remainder = index % 10; - first_header_row.push_str( - &(index > 0 && remainder == 0) - .then_some((index / 10).to_string()) - .unwrap_or(" ".into()), - ); + if index > 0 && remainder == 0 { + first_header_row.push_str(&format!("{:>10}", (index / 10))); + } second_header_row += &remainder.to_string(); if index == self.expected_hyperlink.hovered_grid_point.column.0 { marker_header_row.push('↓'); @@ -1296,16 +1587,20 @@ mod tests { } } - result += &format!("\n [{}]\n", first_header_row); + let remainder = (self.term.columns() - 1) % 10; + if remainder != 0 { + first_header_row.push_str(&" ".repeat(remainder)); + } + + result += &format!("\n [ {}]\n", first_header_row); result += &format!(" [{}]\n", second_header_row); result += &format!(" {}", marker_header_row); - let spacers: Flags = Flags::LEADING_WIDE_CHAR_SPACER | Flags::WIDE_CHAR_SPACER; for cell in self .term .renderable_content() .display_iter - .filter(|cell| !cell.flags.intersects(spacers)) + .filter(|cell| !cell.flags.intersects(WIDE_CHAR_SPACERS)) { if cell.point.column.0 == 0 { let prefix = @@ -1317,7 +1612,10 @@ mod tests { result += &format!("\n{prefix}[{:>3}] ", cell.point.line.to_string()); } - result.push(cell.c); + match cell.c { + '\t' => result.push(' '), + c @ _ => result.push(c), + } } result @@ -1331,8 +1629,34 @@ mod tests { hyperlink_kind: HyperlinkKind, source_location: &str, ) { + const CARGO_DIR_REGEX: &str = + r#"\s+(Compiling|Checking|Documenting) [^(]+\((?(?.+))\)"#; + const RUST_DIAGNOSTIC_REGEX: &str = r#"\s+(-->|:::|at) (?(?.+?))(:$|$)"#; + const ISSUE_12338_REGEX: &str = + r#"[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2} (?(?.+))"#; + const MULTIPLE_SAME_LINE_REGEX: &str = + r#"(?(?🦀 multiple_same_line 🦀) 🚣(?[0-9]+) 🏛(?[0-9]+)):"#; + const PATH_HYPERLINK_TIMEOUT_MS: u64 = 1000; + thread_local! { - static TEST_REGEX_SEARCHES: RefCell = RefCell::new(RegexSearches::new()); + static TEST_REGEX_SEARCHES: RefCell = + RefCell::new({ + let default_settings_content: Rc = + settings::parse_json_with_comments(&settings::default_settings()).unwrap(); + let default_terminal_settings = TerminalSettings::from_settings(&default_settings_content); + + RegexSearches::new([ + RUST_DIAGNOSTIC_REGEX, + CARGO_DIR_REGEX, + ISSUE_12338_REGEX, + MULTIPLE_SAME_LINE_REGEX, + ] + .into_iter() + .chain(default_terminal_settings.path_hyperlink_regexes + .iter() + .map(AsRef::as_ref)), + PATH_HYPERLINK_TIMEOUT_MS) + }); } let term_size = TermSize::new(columns, total_cells / columns + 2); @@ -1357,12 +1681,16 @@ mod tests { Some((hyperlink_word, true, hyperlink_match)) => { check_hyperlink_match.check_iri_and_match(hyperlink_word, &hyperlink_match); } - _ => { - assert!( - false, - "No hyperlink found\n at {source_location}:\n{}", - check_hyperlink_match.format_renderable_content() - ) + None => { + if expected_hyperlink.hyperlink_match.start() + != expected_hyperlink.hyperlink_match.end() + { + assert!( + false, + "No hyperlink found\n at {source_location}:\n{}", + check_hyperlink_match.format_renderable_content() + ) + } } } } diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 91a65f386f..3d70d85f35 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -2,14 +2,15 @@ use alacritty_terminal::vte::ansi::{ CursorShape as AlacCursorShape, CursorStyle as AlacCursorStyle, }; use collections::HashMap; -use gpui::{App, FontFallbacks, FontFeatures, FontWeight, Pixels, px}; +use gpui::{FontFallbacks, FontFeatures, FontWeight, Pixels, px}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub use settings::AlternateScroll; + use settings::{ - CursorShapeContent, SettingsContent, ShowScrollbar, TerminalBlink, TerminalDockPosition, - TerminalLineHeight, TerminalSettingsContent, VenvSettings, WorkingDirectory, + PathHyperlinkRegex, RegisterSetting, ShowScrollbar, TerminalBlink, TerminalDockPosition, + TerminalLineHeight, VenvSettings, WorkingDirectory, merge_from::MergeFrom, }; use task::Shell; use theme::FontFamilyName; @@ -19,7 +20,7 @@ pub struct Toolbar { pub breadcrumbs: bool, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, RegisterSetting)] pub struct TerminalSettings { pub shell: Shell, pub working_directory: WorkingDirectory, @@ -30,7 +31,7 @@ pub struct TerminalSettings { pub font_weight: Option, pub line_height: TerminalLineHeight, pub env: HashMap, - pub cursor_shape: Option, + pub cursor_shape: CursorShape, pub blinking: TerminalBlink, pub alternate_scroll: AlternateScroll, pub option_as_meta: bool, @@ -42,9 +43,12 @@ pub struct TerminalSettings { pub default_height: Pixels, pub detect_venv: VenvSettings, pub max_scroll_history_lines: Option, + pub scroll_multiplier: f32, pub toolbar: Toolbar, pub scrollbar: ScrollbarSettings, pub minimum_contrast: f32, + pub path_hyperlink_regexes: Vec, + pub path_hyperlink_timeout_ms: u64, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -66,20 +70,23 @@ fn settings_shell_to_task_shell(shell: settings::Shell) -> Shell { } => Shell::WithArguments { program, args, - title_override, + title_override: title_override.map(Into::into), }, } } impl settings::Settings for TerminalSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { - let content = content.terminal.clone().unwrap(); + fn from_settings(content: &settings::SettingsContent) -> Self { + let user_content = content.terminal.clone().unwrap(); + // Note: we allow a subset of "terminal" settings in the project files. + let mut project_content = user_content.project.clone(); + project_content.merge_from_option(content.project.terminal.as_ref()); TerminalSettings { - shell: settings_shell_to_task_shell(content.shell.unwrap()), - working_directory: content.working_directory.unwrap(), - font_size: content.font_size.map(px), - font_family: content.font_family, - font_fallbacks: content.font_fallbacks.map(|fallbacks| { + shell: settings_shell_to_task_shell(project_content.shell.unwrap()), + working_directory: project_content.working_directory.unwrap(), + font_size: user_content.font_size.map(px), + font_family: user_content.font_family, + font_fallbacks: user_content.font_fallbacks.map(|fallbacks| { FontFallbacks::from_fonts( fallbacks .into_iter() @@ -87,102 +94,40 @@ impl settings::Settings for TerminalSettings { .collect(), ) }), - font_features: content.font_features, - font_weight: content.font_weight.map(FontWeight), - line_height: content.line_height.unwrap(), - env: content.env.unwrap(), - cursor_shape: content.cursor_shape.map(Into::into), - blinking: content.blinking.unwrap(), - alternate_scroll: content.alternate_scroll.unwrap(), - option_as_meta: content.option_as_meta.unwrap(), - copy_on_select: content.copy_on_select.unwrap(), - keep_selection_on_copy: content.keep_selection_on_copy.unwrap(), - button: content.button.unwrap(), - dock: content.dock.unwrap(), - default_width: px(content.default_width.unwrap()), - default_height: px(content.default_height.unwrap()), - detect_venv: content.detect_venv.unwrap(), - max_scroll_history_lines: content.max_scroll_history_lines, + font_features: user_content.font_features, + font_weight: user_content.font_weight, + line_height: user_content.line_height.unwrap(), + env: project_content.env.unwrap(), + cursor_shape: user_content.cursor_shape.unwrap().into(), + blinking: user_content.blinking.unwrap(), + alternate_scroll: user_content.alternate_scroll.unwrap(), + option_as_meta: user_content.option_as_meta.unwrap(), + copy_on_select: user_content.copy_on_select.unwrap(), + keep_selection_on_copy: user_content.keep_selection_on_copy.unwrap(), + button: user_content.button.unwrap(), + dock: user_content.dock.unwrap(), + default_width: px(user_content.default_width.unwrap()), + default_height: px(user_content.default_height.unwrap()), + detect_venv: project_content.detect_venv.unwrap(), + scroll_multiplier: user_content.scroll_multiplier.unwrap(), + max_scroll_history_lines: user_content.max_scroll_history_lines, toolbar: Toolbar { - breadcrumbs: content.toolbar.unwrap().breadcrumbs.unwrap(), + breadcrumbs: user_content.toolbar.unwrap().breadcrumbs.unwrap(), }, scrollbar: ScrollbarSettings { - show: content.scrollbar.unwrap().show, + show: user_content.scrollbar.unwrap().show, }, - minimum_contrast: content.minimum_contrast.unwrap(), - } - } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, content: &mut SettingsContent) { - let mut default = TerminalSettingsContent::default(); - let current = content.terminal.as_mut().unwrap_or(&mut default); - let name = |s| format!("terminal.integrated.{s}"); - - vscode.f32_setting(&name("fontSize"), &mut current.font_size); - if let Some(font_family) = vscode.read_string(&name("fontFamily")) { - current.font_family = Some(FontFamilyName(font_family.into())); - } - vscode.bool_setting(&name("copyOnSelection"), &mut current.copy_on_select); - vscode.bool_setting("macOptionIsMeta", &mut current.option_as_meta); - vscode.usize_setting("scrollback", &mut current.max_scroll_history_lines); - match vscode.read_bool(&name("cursorBlinking")) { - Some(true) => current.blinking = Some(TerminalBlink::On), - Some(false) => current.blinking = Some(TerminalBlink::Off), - None => {} - } - vscode.enum_setting( - &name("cursorStyle"), - &mut current.cursor_shape, - |s| match s { - "block" => Some(CursorShapeContent::Block), - "line" => Some(CursorShapeContent::Bar), - "underline" => Some(CursorShapeContent::Underline), - _ => None, - }, - ); - // they also have "none" and "outline" as options but just for the "Inactive" variant - if let Some(height) = vscode - .read_value(&name("lineHeight")) - .and_then(|v| v.as_f64()) - { - current.line_height = Some(TerminalLineHeight::Custom(height as f32)) - } - - #[cfg(target_os = "windows")] - let platform = "windows"; - #[cfg(target_os = "linux")] - let platform = "linux"; - #[cfg(target_os = "macos")] - let platform = "osx"; - #[cfg(target_os = "freebsd")] - let platform = "freebsd"; - - // TODO: handle arguments - let shell_name = format!("{platform}Exec"); - if let Some(s) = vscode.read_string(&name(&shell_name)) { - current.shell = Some(settings::Shell::Program(s.to_owned())) - } - - if let Some(env) = vscode - .read_value(&name(&format!("env.{platform}"))) - .and_then(|v| v.as_object()) - { - for (k, v) in env { - if v.is_null() - && let Some(zed_env) = current.env.as_mut() - { - zed_env.remove(k); - } - let Some(v) = v.as_str() else { continue }; - if let Some(zed_env) = current.env.as_mut() { - zed_env.insert(k.clone(), v.to_owned()); - } else { - current.env = Some([(k.clone(), v.to_owned())].into_iter().collect()) - } - } - } - if content.terminal.is_none() && default != TerminalSettingsContent::default() { - content.terminal = Some(default) + minimum_contrast: user_content.minimum_contrast.unwrap(), + path_hyperlink_regexes: project_content + .path_hyperlink_regexes + .unwrap() + .into_iter() + .map(|regex| match regex { + PathHyperlinkRegex::SingleLine(regex) => regex, + PathHyperlinkRegex::MultiLine(regex) => regex.join("\n"), + }) + .collect(), + path_hyperlink_timeout_ms: project_content.path_hyperlink_timeout_ms.unwrap(), } } } diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 85ee506d69..eadd00bcbb 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -39,14 +39,12 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true shellexpand.workspace = true -smol.workspace = true terminal.workspace = true theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index 14606d4ed5..8d6ef03fd7 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -214,14 +214,6 @@ async fn deserialize_pane_group( } SerializedPaneGroup::Pane(serialized_pane) => { let active = serialized_pane.active; - let new_items = deserialize_terminal_views( - workspace_id, - project.clone(), - workspace.clone(), - serialized_pane.children.as_slice(), - cx, - ) - .await; let pane = panel .update_in(cx, |terminal_panel, window, cx| { @@ -236,56 +228,71 @@ async fn deserialize_pane_group( .log_err()?; let active_item = serialized_pane.active_item; let pinned_count = serialized_pane.pinned_count; - let terminal = pane - .update_in(cx, |pane, window, cx| { - populate_pane_items(pane, new_items, active_item, window, cx); - pane.set_pinned_count(pinned_count); + let new_items = deserialize_terminal_views( + workspace_id, + project.clone(), + workspace.clone(), + serialized_pane.children.as_slice(), + cx, + ); + cx.spawn({ + let pane = pane.downgrade(); + async move |cx| { + let new_items = new_items.await; + + let items = pane.update_in(cx, |pane, window, cx| { + populate_pane_items(pane, new_items, active_item, window, cx); + pane.set_pinned_count(pinned_count); + pane.items_len() + }); // Avoid blank panes in splits - if pane.items_len() == 0 { + if items.is_ok_and(|items| items == 0) { let working_directory = workspace .update(cx, |workspace, cx| default_working_directory(workspace, cx)) .ok() .flatten(); - let terminal = project.update(cx, |project, cx| { - project.create_terminal_shell(working_directory, cx) - }); - Some(Some(terminal)) - } else { - Some(None) + let Some(terminal) = project + .update(cx, |project, cx| { + project.create_terminal_shell(working_directory, cx) + }) + .log_err() + else { + return; + }; + + let terminal = terminal.await.log_err(); + pane.update_in(cx, |pane, window, cx| { + if let Some(terminal) = terminal { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal, + workspace.clone(), + Some(workspace_id), + project.downgrade(), + window, + cx, + ) + })); + pane.add_item(terminal_view, true, false, None, window, cx); + } + }) + .ok(); } - }) - .ok() - .flatten()?; - if let Some(terminal) = terminal { - let terminal = terminal.await.ok()?; - pane.update_in(cx, |pane, window, cx| { - let terminal_view = Box::new(cx.new(|cx| { - TerminalView::new( - terminal, - workspace.clone(), - Some(workspace_id), - project.downgrade(), - window, - cx, - ) - })); - pane.add_item(terminal_view, true, false, None, window, cx); - }) - .ok()?; - } + } + }) + .await; Some((Member::Pane(pane.clone()), active.then_some(pane))) } } } -async fn deserialize_terminal_views( +fn deserialize_terminal_views( workspace_id: WorkspaceId, project: Entity, workspace: WeakEntity, item_ids: &[u64], cx: &mut AsyncWindowContext, -) -> Vec> { - let mut items = Vec::with_capacity(item_ids.len()); +) -> impl Future>> + use<> { let mut deserialized_items = item_ids .iter() .map(|item_id| { @@ -302,12 +309,15 @@ async fn deserialize_terminal_views( .unwrap_or_else(|e| Task::ready(Err(e.context("no window present")))) }) .collect::>(); - while let Some(item) = deserialized_items.next().await { - if let Some(item) = item.log_err() { - items.push(item); + async move { + let mut items = Vec::with_capacity(deserialized_items.len()); + while let Some(item) = deserialized_items.next().await { + if let Some(item) = item.log_err() { + items.push(item); + } } + items } - items } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index e06e8e9c63..fd9568b0c5 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -537,9 +537,10 @@ impl TerminalElement { // Private Use Area - Powerline separator symbols only | 0xE0B0..=0xE0B7 // Powerline separators: triangles (E0B0-E0B3) and half circles (E0B4-E0B7) - | 0xE0B8..=0xE0BF // Additional Powerline separators: angles, flames, etc. - | 0xE0C0..=0xE0C8 // Powerline separators: pixelated triangles, curves - | 0xE0CC..=0xE0D4 // Powerline separators: rounded triangles, ice/lego style + | 0xE0B8..=0xE0BF // Powerline separators: corner triangles + | 0xE0C0..=0xE0CA // Powerline separators: flames (E0C0-E0C3), pixelated (E0C4-E0C7), and ice (E0C8 & E0CA) + | 0xE0CC..=0xE0D1 // Powerline separators: honeycombs (E0CC-E0CD) and lego (E0CE-E0D1) + | 0xE0D2..=0xE0D7 // Powerline separators: trapezoid (E0D2 & E0D4) and inverted triangles (E0D6-E0D7) ) } @@ -1112,9 +1113,7 @@ impl Element for TerminalElement { len, font: text_style.font(), color: theme.colors().terminal_ansi_background, - background_color: None, - underline: Default::default(), - strikethrough: None, + ..Default::default() }], None, ) @@ -1276,7 +1275,7 @@ impl Element for TerminalElement { } for (relative_highlighted_range, color) in - layout.relative_highlighted_ranges.iter() +& layout.relative_highlighted_ranges { if let Some((start_y, highlighted_range_lines)) = to_highlighted_range_lines(relative_highlighted_range, layout, origin) @@ -1322,9 +1321,8 @@ impl Element for TerminalElement { len: text_to_mark.len(), font: ime_style.font(), color: ime_style.color, - background_color: None, underline: ime_style.underline, - strikethrough: None, + ..Default::default() }], None ); @@ -1544,11 +1542,13 @@ fn to_highlighted_range_lines( } let clamped_start_line = unclamped_start.line.0.max(0) as usize; + let clamped_end_line = unclamped_end .line .0 .min(layout.dimensions.num_lines() as i32) as usize; - //Convert the start of the range to pixels + + // Convert the start of the range to pixels let start_y = origin.y + clamped_start_line as f32 * layout.dimensions.line_height; // Step 3. Expand ranges that cross lines into a collection of single-line ranges. @@ -1558,10 +1558,11 @@ fn to_highlighted_range_lines( let mut line_start = 0; let mut line_end = layout.dimensions.columns(); - if line == clamped_start_line { + if line == clamped_start_line && unclamped_start.line.0 >= 0 { line_start = unclamped_start.column.0; } - if line == clamped_end_line { + if line == clamped_end_line && unclamped_end.line.0 <= layout.dimensions.num_lines() as i32 + { line_end = unclamped_end.column.0 + 1; // +1 for inclusive } @@ -1664,6 +1665,8 @@ mod tests { assert!(TerminalElement::is_decorative_character('\u{E0B2}')); // Powerline left triangle assert!(TerminalElement::is_decorative_character('\u{E0B4}')); // Powerline right half circle (the actual issue!) assert!(TerminalElement::is_decorative_character('\u{E0B6}')); // Powerline left half circle + assert!(TerminalElement::is_decorative_character('\u{E0CA}')); // Powerline mirrored ice waveform + assert!(TerminalElement::is_decorative_character('\u{E0D7}')); // Powerline left triangle inverted // Characters that should NOT be considered decorative assert!(!TerminalElement::is_decorative_character('A')); // Regular letter @@ -1842,27 +1845,21 @@ mod tests { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let style2 = TextRun { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let style3 = TextRun { len: 1, font: font("Helvetica"), color: Hsla::blue(), // Different color - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let font_size = AbsoluteLength::Pixels(px(12.0)); @@ -1881,9 +1878,7 @@ mod tests { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let font_size = AbsoluteLength::Pixels(px(12.0)); @@ -1912,9 +1907,7 @@ mod tests { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let font_size = AbsoluteLength::Pixels(px(12.0)); @@ -1944,9 +1937,7 @@ mod tests { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let font_size = AbsoluteLength::Pixels(px(12.0)); diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 7952eb51e8..fb660e759c 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -22,20 +22,19 @@ use settings::{Settings, TerminalDockPosition}; use task::{RevealStrategy, RevealTarget, Shell, ShellBuilder, SpawnInTerminal, TaskId}; use terminal::{Terminal, terminal_settings::TerminalSettings}; use ui::{ - ButtonCommon, Clickable, ContextMenu, FluentBuilder, PopoverMenu, Toggleable, Tooltip, - prelude::*, + ButtonLike, Clickable, ContextMenu, FluentBuilder, PopoverMenu, SplitButton, Toggleable, + Tooltip, prelude::*, }; use util::{ResultExt, TryFutureExt}; use workspace::{ ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight, ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane, - MoveItemToPaneInDirection, NewTerminal, Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, - SplitRight, SplitUp, SwapPaneDown, SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, - Workspace, + MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, NewTerminal, + Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight, SplitUp, SwapPaneDown, + SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, dock::{DockPosition, Panel, PanelEvent, PanelHandle}, item::SerializableItem, move_active_item, move_item, pane, - ui::IconName, }; use anyhow::{Result, anyhow}; @@ -168,7 +167,7 @@ impl TerminalPanel { // hence we focus that first. Otherwise, we'd end up without a focused element, as // context menu will be gone the moment we spawn the modal. .action( - "Spawn task", + "Spawn Task", zed_actions::Spawn::modal().boxed_clone(), ) }); @@ -211,11 +210,10 @@ impl TerminalPanel { .on_click(cx.listener(|pane, _, window, cx| { pane.toggle_zoom(&workspace::ToggleZoom, window, cx); })) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::for_action( if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, - window, cx, ) }) @@ -344,7 +342,7 @@ impl TerminalPanel { pane::Event::RemovedItem { .. } => self.serialize(cx), pane::Event::Remove { focus_on_pane } => { let pane_count_before_removal = self.center.panes().len(); - let _removal_result = self.center.remove(pane); + let _removal_result = self.center.remove(pane, cx); if pane_count_before_removal == 1 { self.center.first_pane().update(cx, |pane, cx| { pane.set_zoomed(false, cx); @@ -395,7 +393,10 @@ impl TerminalPanel { }; panel .update_in(cx, |panel, window, cx| { - panel.center.split(&pane, &new_pane, direction).log_err(); + panel + .center + .split(&pane, &new_pane, direction, cx) + .log_err(); window.focus(&new_pane.focus_handle(cx)); }) .ok(); @@ -417,7 +418,7 @@ impl TerminalPanel { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx); }); - self.center.split(&pane, &new_pane, direction).log_err(); + self.center.split(&pane, &new_pane, direction, cx).log_err(); window.focus(&new_pane.focus_handle(cx)); } } @@ -463,11 +464,11 @@ impl TerminalPanel { cx.spawn_in(window, async move |panel, cx| { let terminal = project .update(cx, |project, cx| match terminal_view { - Some(view) => Task::ready(project.clone_terminal( + Some(view) => project.clone_terminal( &view.read(cx).terminal.clone(), cx, working_directory, - )), + ), None => project.create_terminal_shell(working_directory, cx), }) .ok()? @@ -526,23 +527,18 @@ impl TerminalPanel { window: &mut Window, cx: &mut Context, ) -> Task>> { - let remote_client = self - .workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - if project.is_via_collab() { - Err(anyhow!("cannot spawn tasks as a guest")) - } else { - Ok(project.remote_client()) - } - }) - .flatten(); - - let remote_client = match remote_client { - Ok(remote_client) => remote_client, - Err(e) => return Task::ready(Err(e)), + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Err(anyhow!("failed to read workspace"))); }; + let project = workspace.read(cx).project().read(cx); + + if project.is_via_collab() { + return Task::ready(Err(anyhow!("cannot spawn tasks as a guest"))); + } + + let remote_client = project.remote_client(); + let is_windows = project.path_style(cx).is_windows(); let remote_shell = remote_client .as_ref() .and_then(|remote_client| remote_client.read(cx).shell()); @@ -555,9 +551,9 @@ impl TerminalPanel { task.shell.clone() }; - let builder = ShellBuilder::new(&shell); + let builder = ShellBuilder::new(&shell, is_windows); let command_label = builder.command_label(task.command.as_deref().unwrap_or("")); - let (command, args) = builder.build(task.command.clone(), &task.args); + let (command, args) = builder.build_no_quote(task.command.clone(), &task.args); let task = SpawnInTerminal { command_label, @@ -818,6 +814,7 @@ impl TerminalPanel { cx: &mut Context, ) -> Task>> { let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |terminal_panel, cx| { if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? { anyhow::bail!("terminal not yet supported for collaborative projects"); @@ -829,43 +826,59 @@ impl TerminalPanel { let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; let terminal = project .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))? - .await?; - let result = workspace.update_in(cx, |workspace, window, cx| { - let terminal_view = Box::new(cx.new(|cx| { - TerminalView::new( - terminal.clone(), - workspace.weak_handle(), - workspace.database_id(), - workspace.project().downgrade(), - window, - cx, - ) - })); + .await; - match reveal_strategy { - RevealStrategy::Always => { - workspace.focus_panel::(window, cx); - } - RevealStrategy::NoFocus => { - workspace.open_panel::(window, cx); - } - RevealStrategy::Never => {} + match terminal { + Ok(terminal) => { + let result = workspace.update_in(cx, |workspace, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.weak_handle(), + workspace.database_id(), + workspace.project().downgrade(), + window, + cx, + ) + })); + + match reveal_strategy { + RevealStrategy::Always => { + workspace.focus_panel::(window, cx); + } + RevealStrategy::NoFocus => { + workspace.open_panel::(window, cx); + } + RevealStrategy::Never => {} + } + + pane.update(cx, |pane, cx| { + let focus = pane.has_focus(window, cx) + || matches!(reveal_strategy, RevealStrategy::Always); + pane.add_item(terminal_view, true, focus, None, window, cx); + }); + + Ok(terminal.downgrade()) + })?; + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.pending_terminals_to_add = + terminal_panel.pending_terminals_to_add.saturating_sub(1); + terminal_panel.serialize(cx) + })?; + result } - - pane.update(cx, |pane, cx| { - let focus = pane.has_focus(window, cx) - || matches!(reveal_strategy, RevealStrategy::Always); - pane.add_item(terminal_view, true, focus, None, window, cx); - }); - - Ok(terminal.downgrade()) - })?; - terminal_panel.update(cx, |terminal_panel, cx| { - terminal_panel.pending_terminals_to_add = - terminal_panel.pending_terminals_to_add.saturating_sub(1); - terminal_panel.serialize(cx) - })?; - result + Err(error) => { + pane.update_in(cx, |pane, window, cx| { + let focus = pane.has_focus(window, cx); + let failed_to_spawn = cx.new(|cx| FailedToSpawnTerminal { + error: error.to_string(), + focus_handle: cx.focus_handle(), + }); + pane.add_item(Box::new(failed_to_spawn), true, focus, None, window, cx); + })?; + Err(error) + } + } }) } @@ -1056,7 +1069,17 @@ impl TerminalPanel { .find_pane_in_direction(&self.active_pane, direction, cx) .cloned() { - self.center.swap(&self.active_pane, &to); + self.center.swap(&self.active_pane, &to, cx); + cx.notify(); + } + } + + fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { + if self + .center + .move_to_border(&self.active_pane, direction, cx) + .unwrap() + { cx.notify(); } } @@ -1082,6 +1105,7 @@ pub fn new_terminal_pane( Default::default(), None, NewTerminal.boxed_clone(), + false, window, cx, ); @@ -1168,6 +1192,7 @@ pub fn new_terminal_pane( &this_pane, &new_pane, split_direction, + cx, )?; anyhow::Ok(new_pane) }) @@ -1283,6 +1308,82 @@ fn add_paths_to_terminal( } } +struct FailedToSpawnTerminal { + error: String, + focus_handle: FocusHandle, +} + +impl Focusable for FailedToSpawnTerminal { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for FailedToSpawnTerminal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let popover_menu = PopoverMenu::new("settings-popover") + .trigger( + IconButton::new("icon-button-popover", IconName::ChevronDown) + .icon_size(IconSize::XSmall), + ) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |context_menu, _, _| { + context_menu + .action("Open Settings", zed_actions::OpenSettings.boxed_clone()) + .action( + "Edit settings.json", + zed_actions::OpenSettingsFile.boxed_clone(), + ) + })) + }) + .anchor(Corner::TopRight) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }); + + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .p_4() + .items_center() + .justify_center() + .bg(cx.theme().colors().editor_background) + .child( + v_flex() + .max_w_112() + .items_center() + .justify_center() + .text_center() + .child(Label::new("Failed to spawn terminal")) + .child( + Label::new(self.error.to_string()) + .size(LabelSize::Small) + .color(Color::Muted) + .mb_4(), + ) + .child(SplitButton::new( + ButtonLike::new("open-settings-ui") + .child(Label::new("Edit Settings").size(LabelSize::Small)) + .on_click(|_, window, cx| { + window.dispatch_action(zed_actions::OpenSettings.boxed_clone(), cx); + }), + popover_menu.into_any_element(), + )), + ) + } +} + +impl EventEmitter<()> for FailedToSpawnTerminal {} + +impl workspace::Item for FailedToSpawnTerminal { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + SharedString::new_static("Failed to spawn terminal") + } +} + impl EventEmitter for TerminalPanel {} impl Render for TerminalPanel { @@ -1385,6 +1486,7 @@ impl Render for TerminalPanel { &terminal_panel.active_pane, &new_pane, SplitDirection::Right, + cx, ) .log_err(); let new_pane = new_pane.read(cx); @@ -1409,6 +1511,18 @@ impl Render for TerminalPanel { .on_action(cx.listener(|terminal_panel, _: &SwapPaneDown, _, cx| { terminal_panel.swap_pane_in_direction(SplitDirection::Down, cx); })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneLeft, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Left, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneRight, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Right, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneUp, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Up, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneDown, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Down, cx); + })) .on_action( cx.listener(|terminal_panel, action: &MoveItemToPane, window, cx| { let Some(&target_pane) = @@ -1555,6 +1669,10 @@ impl Panel for TerminalPanel { "TerminalPanel" } + fn panel_key() -> &'static str { + TERMINAL_PANEL_KEY + } + fn icon(&self, _window: &Window, cx: &App) -> Option { if (self.is_enabled(cx) || !self.has_no_terminals(cx)) && TerminalSettings::get_global(cx).button @@ -1625,22 +1743,18 @@ impl Render for InlineAssistTabBarButton { .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(InlineAssist::default().boxed_clone(), cx); })) - .tooltip(move |window, cx| { - Tooltip::for_action_in( - "Inline Assist", - &InlineAssist::default(), - &focus_handle, - window, - cx, - ) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Inline Assist", &InlineAssist::default(), &focus_handle, cx) }) } } #[cfg(test)] mod tests { + use std::num::NonZero; + use super::*; - use gpui::TestAppContext; + use gpui::{TestAppContext, UpdateGlobal as _}; use pretty_assertions::assert_eq; use project::FakeFs; use settings::SettingsStore; @@ -1695,6 +1809,46 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_bypass_max_tabs_limit(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + + let (window_handle, terminal_panel) = workspace + .update(cx, |workspace, window, cx| { + let window_handle = window.window_handle(); + let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx)); + (window_handle, terminal_panel) + }) + .unwrap(); + + set_max_tabs(cx, Some(3)); + + for _ in 0..5 { + let task = window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |panel, cx| { + panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + }) + .unwrap(); + task.await.unwrap(); + } + + cx.run_until_parked(); + + let item_count = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + assert_eq!( + item_count, 5, + "Terminal panel should bypass max_tabs limit and have all 5 terminals" + ); + } + // A complex Unix command won't be properly parsed by the Windows terminal hence omit the test there. #[cfg(unix)] #[gpui::test] @@ -1758,15 +1912,70 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn renders_error_if_default_shell_fails(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.terminal.get_or_insert_default().project.shell = + Some(settings::Shell::Program("asdf".to_owned())); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + + let (window_handle, terminal_panel) = workspace + .update(cx, |workspace, window, cx| { + let window_handle = window.window_handle(); + let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx)); + (window_handle, terminal_panel) + }) + .unwrap(); + + window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + }) + .unwrap() + .await + .unwrap_err(); + + window_handle + .update(cx, |_, _, cx| { + terminal_panel.update(cx, |terminal_panel, cx| { + assert!( + terminal_panel + .active_pane + .read(cx) + .items() + .any(|item| item.downcast::().is_some()), + "should spawn `FailedToSpawnTerminal` pane" + ); + }) + }) + .unwrap(); + } + + fn set_max_tabs(cx: &mut TestAppContext, value: Option) { + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.max_tabs = value.map(|v| NonZero::new(v).unwrap()) + }); + }); + } + pub 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(cx); crate::init(cx); }); diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index 8a9b824286..fa40196645 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -534,10 +534,7 @@ mod tests { let fs = app_cx.update(AppState::test).fs.as_fake().clone(); app_cx.update(|cx| { - terminal::init(cx); theme::init(theme::LoadThemes::JustBase, cx); - Project::init_settings(cx); - language::init(cx); editor::init(cx); }); diff --git a/crates/terminal_view/src/terminal_tab_tooltip.rs b/crates/terminal_view/src/terminal_tab_tooltip.rs deleted file mode 100644 index 6324c0999a..0000000000 --- a/crates/terminal_view/src/terminal_tab_tooltip.rs +++ /dev/null @@ -1,36 +0,0 @@ -use gpui::{IntoElement, Render}; -use ui::{Divider, prelude::*, tooltip_container}; - -pub struct TerminalTooltip { - title: SharedString, - pid: u32, -} - -impl TerminalTooltip { - pub fn new(title: impl Into, pid: u32) -> Self { - Self { - title: title.into(), - pid, - } - } -} - -impl Render for TerminalTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, move |this, _cx| { - this.occlude() - .on_mouse_move(|_, _window, cx| cx.stop_propagation()) - .child( - v_flex() - .gap_1() - .child(Label::new(self.title.clone())) - .child(Divider::horizontal()) - .child( - Label::new(format!("Process ID (PID): {}", self.pid)) - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - }) - } -} diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index cbb3ad92c5..98f7a17a27 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -4,10 +4,9 @@ pub mod terminal_panel; mod terminal_path_like_target; pub mod terminal_scrollbar; mod terminal_slash_command; -pub mod terminal_tab_tooltip; use assistant_slash_command::SlashCommandRegistry; -use editor::{EditorSettings, actions::SelectAll}; +use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager}; use gpui::{ Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render, @@ -32,9 +31,8 @@ use terminal_panel::TerminalPanel; use terminal_path_like_target::{hover_path_like_target, open_path_like_target}; use terminal_scrollbar::TerminalScrollHandle; use terminal_slash_command::TerminalSlashCommand; -use terminal_tab_tooltip::TerminalTooltip; use ui::{ - ContextMenu, Icon, IconName, Label, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, h_flex, + ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*, scrollbars::{self, GlobalSetting, ScrollbarVisibility}, }; @@ -51,7 +49,6 @@ use workspace::{ use serde::Deserialize; use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory}; -use smol::Timer; use zed_actions::assistant::InlineAssist; use std::{ @@ -95,7 +92,6 @@ actions!( pub fn init(cx: &mut App) { assistant_slash_command::init(cx); terminal_panel::init(cx); - terminal::init(cx); register_serializable_item::(cx); @@ -127,12 +123,10 @@ pub struct TerminalView { has_bell: bool, context_menu: Option<(Entity, gpui::Point, Subscription)>, cursor_shape: CursorShape, - blink_state: bool, + blink_manager: Entity, mode: TerminalMode, blinking_terminal_enabled: bool, cwd_serialized: bool, - blinking_paused: bool, - blink_epoch: usize, hover: Option, hover_tooltip_update: Task<()>, workspace_id: Option, @@ -234,12 +228,29 @@ impl TerminalView { terminal_view.focus_out(window, cx); }, ); - let cursor_shape = TerminalSettings::get_global(cx) - .cursor_shape - .unwrap_or_default(); + let cursor_shape = TerminalSettings::get_global(cx).cursor_shape; let scroll_handle = TerminalScrollHandle::new(terminal.read(cx)); + let blink_manager = cx.new(|cx| { + BlinkManager::new( + CURSOR_BLINK_INTERVAL, + |cx| { + !matches!( + TerminalSettings::get_global(cx).blinking, + TerminalBlink::Off + ) + }, + cx, + ) + }); + + let _subscriptions = vec![ + focus_in, + focus_out, + cx.observe(&blink_manager, |_, _, cx| cx.notify()), + cx.observe_global::(Self::settings_changed), + ]; Self { terminal, workspace: workspace_handle, @@ -248,10 +259,8 @@ impl TerminalView { focus_handle, context_menu: None, cursor_shape, - blink_state: true, + blink_manager, blinking_terminal_enabled: false, - blinking_paused: false, - blink_epoch: 0, hover: None, hover_tooltip_update: Task::ready(()), mode: TerminalMode::Standalone, @@ -262,11 +271,7 @@ impl TerminalView { scroll_handle, cwd_serialized: false, ime_state: None, - _subscriptions: vec![ - focus_in, - focus_out, - cx.observe_global::(Self::settings_changed), - ], + _subscriptions, _terminal_subscriptions: terminal_subscriptions, } } @@ -427,7 +432,12 @@ impl TerminalView { let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs; self.show_breadcrumbs = settings.toolbar.breadcrumbs; - let new_cursor_shape = settings.cursor_shape.unwrap_or_default(); + let should_blink = match settings.blinking { + TerminalBlink::Off => false, + TerminalBlink::On => true, + TerminalBlink::TerminalControlled => self.blinking_terminal_enabled, + }; + let new_cursor_shape = settings.cursor_shape; let old_cursor_shape = self.cursor_shape; if old_cursor_shape != new_cursor_shape { self.cursor_shape = new_cursor_shape; @@ -436,6 +446,15 @@ impl TerminalView { }); } + self.blink_manager.update( + cx, + if should_blink { + BlinkManager::enable + } else { + BlinkManager::disable + }, + ); + if breadcrumb_visibility_changed { cx.emit(ItemEvent::UpdateBreadcrumbs); } @@ -522,7 +541,12 @@ impl TerminalView { return; } } - self.terminal.update(cx, |term, _| term.scroll_wheel(event)); + self.terminal.update(cx, |term, cx| { + term.scroll_wheel( + event, + TerminalSettings::get_global(cx).scroll_multiplier.max(0.01), + ) + }); } fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context) { @@ -608,9 +632,8 @@ impl TerminalView { } pub fn should_show_cursor(&self, focused: bool, cx: &mut Context) -> bool { - //Don't blink the cursor when not focused, blinking is disabled, or paused + // Always show cursor when not focused or in special modes if !focused - || self.blinking_paused || self .terminal .read(cx) @@ -621,45 +644,18 @@ impl TerminalView { return true; } + // When focused, check blinking settings and blink manager state match TerminalSettings::get_global(cx).blinking { - //If the user requested to never blink, don't blink it. TerminalBlink::Off => true, - //If the terminal is controlling it, check terminal mode TerminalBlink::TerminalControlled => { - !self.blinking_terminal_enabled || self.blink_state + !self.blinking_terminal_enabled || self.blink_manager.read(cx).visible() } - TerminalBlink::On => self.blink_state, + TerminalBlink::On => self.blink_manager.read(cx).visible(), } } - fn blink_cursors(&mut self, epoch: usize, window: &mut Window, cx: &mut Context) { - if epoch == self.blink_epoch && !self.blinking_paused { - self.blink_state = !self.blink_state; - cx.notify(); - - let epoch = self.next_blink_epoch(); - cx.spawn_in(window, async move |this, cx| { - Timer::after(CURSOR_BLINK_INTERVAL).await; - this.update_in(cx, |this, window, cx| this.blink_cursors(epoch, window, cx)) - .ok(); - }) - .detach(); - } - } - - pub fn pause_cursor_blinking(&mut self, window: &mut Window, cx: &mut Context) { - self.blink_state = true; - cx.notify(); - - let epoch = self.next_blink_epoch(); - cx.spawn_in(window, async move |this, cx| { - Timer::after(CURSOR_BLINK_INTERVAL).await; - this.update_in(cx, |this, window, cx| { - this.resume_cursor_blinking(epoch, window, cx) - }) - .ok(); - }) - .detach(); + pub fn pause_cursor_blinking(&mut self, _window: &mut Window, cx: &mut Context) { + self.blink_manager.update(cx, BlinkManager::pause_blinking); } pub fn terminal(&self) -> &Entity { @@ -683,23 +679,6 @@ impl TerminalView { cx.notify(); } - fn next_blink_epoch(&mut self) -> usize { - self.blink_epoch += 1; - self.blink_epoch - } - - fn resume_cursor_blinking( - &mut self, - epoch: usize, - window: &mut Window, - cx: &mut Context, - ) { - if epoch == self.blink_epoch { - self.blinking_paused = false; - self.blink_cursors(epoch, window, cx); - } - } - ///Attempt to paste the clipboard into the terminal fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { self.terminal.update(cx, |term, _| term.copy(None)); @@ -842,9 +821,7 @@ impl TerminalView { .size(ButtonSize::Compact) .icon_color(Color::Default) .shape(ui::IconButtonShape::Square) - .tooltip(move |window, cx| { - Tooltip::for_action("Rerun task", &RerunTask, window, cx) - }) + .tooltip(move |_window, cx| Tooltip::for_action("Rerun task", &RerunTask, cx)) .on_click(move |_, window, cx| { window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx); }), @@ -893,11 +870,21 @@ fn subscribe_for_terminal_events( } Event::BlinkChanged(blinking) => { + terminal_view.blinking_terminal_enabled = *blinking; + + // If in terminal-controlled mode and focused, update blink manager if matches!( TerminalSettings::get_global(cx).blinking, TerminalBlink::TerminalControlled - ) { - terminal_view.blinking_terminal_enabled = *blinking; + ) && terminal_view.focus_handle.is_focused(window) + { + terminal_view.blink_manager.update(cx, |manager, cx| { + if *blinking { + manager.enable(cx); + } else { + manager.disable(cx); + } + }); } } @@ -1023,12 +1010,23 @@ impl TerminalView { terminal.set_cursor_shape(self.cursor_shape); terminal.focus_in(); }); - self.blink_cursors(self.blink_epoch, window, cx); + + let should_blink = match TerminalSettings::get_global(cx).blinking { + TerminalBlink::Off => false, + TerminalBlink::On => true, + TerminalBlink::TerminalControlled => self.blinking_terminal_enabled, + }; + + if should_blink { + self.blink_manager.update(cx, BlinkManager::enable); + } + window.invalidate_character_coordinates(); cx.notify(); } - fn focus_out(&mut self, _: &mut Window, cx: &mut Context) { + fn focus_out(&mut self, _window: &mut Window, cx: &mut Context) { + self.blink_manager.update(cx, BlinkManager::disable); self.terminal.update(cx, |terminal, _| { terminal.focus_out(); terminal.set_cursor_shape(CursorShape::Hollow); @@ -1118,7 +1116,7 @@ impl Render for TerminalView { ScrollAxes::Vertical, cx.theme().colors().editor_background, ) - .tracked_scroll_handle(self.scroll_handle.clone()), + .tracked_scroll_handle(&self.scroll_handle), window, cx, ) @@ -1140,13 +1138,24 @@ impl Item for TerminalView { type Event = ItemEvent; fn tab_tooltip_content(&self, cx: &App) -> Option { - let terminal = self.terminal().read(cx); - let title = terminal.title(false); - let pid = terminal.pid_getter()?.fallback_pid(); + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + let terminal = self.terminal().read(cx); + let title = terminal.title(false); + let pid = terminal.pid_getter()?.fallback_pid(); - Some(TabTooltipContent::Custom(Box::new(move |_window, cx| { - cx.new(|_| TerminalTooltip::new(title.clone(), pid)).into() - }))) + move |_, _| { + v_flex() + .gap_1() + .child(Label::new(title.clone())) + .child(h_flex().flex_grow().child(Divider::horizontal())) + .child( + Label::new(format!("Process ID (PID): {}", pid)) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } + })))) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { @@ -1213,33 +1222,44 @@ impl Item for TerminalView { None } + fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind { + workspace::item::ItemBufferKind::Singleton + } + + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> { - let terminal = self - .project - .update(cx, |project, cx| { - let cwd = project - .active_project_directory(cx) - .map(|it| it.to_path_buf()); - project.clone_terminal(self.terminal(), cx, cwd) + ) -> Task>> { + let Ok(terminal) = self.project.update(cx, |project, cx| { + let cwd = project + .active_project_directory(cx) + .map(|it| it.to_path_buf()); + project.clone_terminal(self.terminal(), cx, cwd) + }) else { + return Task::ready(None); + }; + cx.spawn_in(window, async move |this, cx| { + let terminal = terminal.await.log_err()?; + this.update_in(cx, |this, window, cx| { + cx.new(|cx| { + TerminalView::new( + terminal, + this.workspace.clone(), + workspace_id, + this.project.clone(), + window, + cx, + ) + }) }) - .ok()? - .log_err()?; - - Some(cx.new(|cx| { - TerminalView::new( - terminal, - self.workspace.clone(), - workspace_id, - self.project.clone(), - window, - cx, - ) - })) + .ok() + }) } fn is_dirty(&self, cx: &gpui::App) -> bool { @@ -1257,7 +1277,11 @@ impl Item for TerminalView { false } - fn as_searchable(&self, handle: &Entity) -> Option> { + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { Some(Box::new(handle.clone())) } @@ -1418,6 +1442,7 @@ impl SearchableItem for TerminalView { fn update_matches( &mut self, matches: &[Self::Match], + _active_match_index: Option, _window: &mut Window, cx: &mut Context, ) { @@ -1544,7 +1569,8 @@ pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Opti .read(cx) .active_project_directory(cx) .as_deref() - .map(Path::to_path_buf), + .map(Path::to_path_buf) + .or_else(|| first_project_directory(workspace, cx)), WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), WorkingDirectory::AlwaysHome => None, WorkingDirectory::Always { directory } => { @@ -1558,10 +1584,13 @@ pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Opti ///Gets the first project's home directory, or the home directory fn first_project_directory(workspace: &Workspace, cx: &App) -> Option { let worktree = workspace.worktrees(cx).next()?.read(cx); - if !worktree.root_entry()?.is_dir() { - return None; + let worktree_path = worktree.abs_path(); + if worktree.root_entry()?.is_dir() { + Some(worktree_path.to_path_buf()) + } else { + // If worktree is a file, return its parent directory + worktree_path.parent().map(|p| p.to_path_buf()) } - Some(worktree.abs_path().to_path_buf()) } #[cfg(test)] @@ -1594,7 +1623,7 @@ mod tests { }); } - // No active entry, but a worktree, worktree is a file -> home_dir() + // No active entry, but a worktree, worktree is a file -> parent directory #[gpui::test] async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) { let (project, workspace) = init_test(cx).await; @@ -1609,9 +1638,9 @@ mod tests { assert!(workspace.worktrees(cx).next().is_some()); let res = default_working_directory(workspace, cx); - assert_eq!(res, None); + assert_eq!(res, Some(Path::new("/").to_path_buf())); let res = first_project_directory(workspace, cx); - assert_eq!(res, None); + assert_eq!(res, Some(Path::new("/").to_path_buf())); }); } @@ -1683,10 +1712,7 @@ mod tests { pub async fn init_test(cx: &mut TestAppContext) -> (Entity, Entity) { let params = cx.update(AppState::test); cx.update(|cx| { - terminal::init(cx); theme::init(theme::LoadThemes::JustBase, cx); - Project::init_settings(cx); - language::init(cx); }); let project = Project::test(params.fs.clone(), [], cx).await; diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index e6c7d81494..ed02381eb8 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -28,7 +28,6 @@ rope.workspace = true smallvec.workspace = true sum_tree.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index a05da1243f..bf660b1302 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -6,16 +6,38 @@ use std::{cmp::Ordering, fmt::Debug, ops::Range}; use sum_tree::{Bias, Dimensions}; /// A timestamped position in a buffer -#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, Default)] +#[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct Anchor { + /// The timestamp of the operation that inserted the text + /// in which this anchor is located. pub timestamp: clock::Lamport, - /// The byte offset in the buffer + /// The byte offset into the text inserted in the operation + /// at `timestamp`. pub offset: usize, - /// Describes which character the anchor is biased towards + /// Whether this anchor stays attached to the character *before* or *after* + /// the offset. pub bias: Bias, pub buffer_id: Option, } +impl Debug for Anchor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.is_min() { + return write!(f, "Anchor::min({:?})", self.buffer_id); + } + if self.is_max() { + return write!(f, "Anchor::max({:?})", self.buffer_id); + } + + f.debug_struct("Anchor") + .field("timestamp", &self.timestamp) + .field("offset", &self.offset) + .field("bias", &self.bias) + .field("buffer_id", &self.buffer_id) + .finish() + } +} + impl Anchor { pub const MIN: Self = Self { timestamp: clock::Lamport::MIN, @@ -31,6 +53,36 @@ impl Anchor { buffer_id: None, }; + pub fn min_for_buffer(buffer_id: BufferId) -> Self { + Self { + timestamp: clock::Lamport::MIN, + offset: usize::MIN, + bias: Bias::Left, + buffer_id: Some(buffer_id), + } + } + + pub fn max_for_buffer(buffer_id: BufferId) -> Self { + Self { + timestamp: clock::Lamport::MAX, + offset: usize::MAX, + bias: Bias::Right, + buffer_id: Some(buffer_id), + } + } + + pub fn min_min_range_for_buffer(buffer_id: BufferId) -> std::ops::Range { + let min = Self::min_for_buffer(buffer_id); + min..min + } + pub fn max_max_range_for_buffer(buffer_id: BufferId) -> std::ops::Range { + let max = Self::max_for_buffer(buffer_id); + max..max + } + pub fn min_max_range_for_buffer(buffer_id: BufferId) -> std::ops::Range { + Self::min_for_buffer(buffer_id)..Self::max_for_buffer(buffer_id) + } + pub fn cmp(&self, other: &Anchor, buffer: &BufferSnapshot) -> Ordering { let fragment_id_comparison = if self.timestamp == other.timestamp { Ordering::Equal @@ -45,19 +97,19 @@ impl Anchor { .then_with(|| self.bias.cmp(&other.bias)) } - pub fn min(&self, other: &Self, buffer: &BufferSnapshot) -> Self { + pub fn min<'a>(&'a self, other: &'a Self, buffer: &BufferSnapshot) -> &'a Self { if self.cmp(other, buffer).is_le() { - *self + self } else { - *other + other } } - pub fn max(&self, other: &Self, buffer: &BufferSnapshot) -> Self { + pub fn max<'a>(&'a self, other: &'a Self, buffer: &BufferSnapshot) -> &'a Self { if self.cmp(other, buffer).is_ge() { - *self + self } else { - *other + other } } @@ -91,7 +143,7 @@ impl Anchor { /// Returns true when the [`Anchor`] is located inside a visible fragment. pub fn is_valid(&self, buffer: &BufferSnapshot) -> bool { - if *self == Anchor::MIN || *self == Anchor::MAX { + if self.is_min() || self.is_max() { true } else if self.buffer_id.is_none_or(|id| id != buffer.remote_id) { false @@ -99,15 +151,28 @@ impl Anchor { let Some(fragment_id) = buffer.try_fragment_id_for_anchor(self) else { return false; }; - let mut fragment_cursor = buffer + let (.., item) = buffer .fragments - .cursor::, usize>>(&None); - fragment_cursor.seek(&Some(fragment_id), Bias::Left); - fragment_cursor - .item() - .is_some_and(|fragment| fragment.visible) + .find::, usize>, _>( + &None, + &Some(fragment_id), + Bias::Left, + ); + item.is_some_and(|fragment| fragment.visible) } } + + pub fn is_min(&self) -> bool { + self.timestamp == clock::Lamport::MIN + && self.offset == usize::MIN + && self.bias == Bias::Left + } + + pub fn is_max(&self) -> bool { + self.timestamp == clock::Lamport::MAX + && self.offset == usize::MAX + && self.bias == Bias::Right + } } pub trait OffsetRangeExt { diff --git a/crates/text/src/operation_queue.rs b/crates/text/src/operation_queue.rs index 6604817edf..f87af381ff 100644 --- a/crates/text/src/operation_queue.rs +++ b/crates/text/src/operation_queue.rs @@ -1,3 +1,4 @@ +use clock::Lamport; use std::{fmt::Debug, ops::Add}; use sum_tree::{ContextLessSummary, Dimension, Edit, Item, KeyedItem, SumTree}; @@ -11,10 +12,10 @@ struct OperationItem(T); #[derive(Clone, Debug)] pub struct OperationQueue(SumTree>); -#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct OperationKey(clock::Lamport); -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct OperationSummary { pub key: OperationKey, pub len: usize, @@ -69,7 +70,10 @@ impl OperationQueue { impl ContextLessSummary for OperationSummary { fn zero() -> Self { - Default::default() + OperationSummary { + key: OperationKey::new(Lamport::MIN), + len: 0, + } } fn add_summary(&mut self, other: &Self) { @@ -93,7 +97,7 @@ impl Add<&Self> for OperationSummary { impl Dimension<'_, OperationSummary> for OperationKey { fn zero(_cx: ()) -> Self { - Default::default() + OperationKey::new(Lamport::MIN) } fn add_summary(&mut self, summary: &OperationSummary, _: ()) { @@ -123,11 +127,13 @@ impl KeyedItem for OperationItem { #[cfg(test)] mod tests { + use clock::ReplicaId; + use super::*; #[test] fn test_len() { - let mut clock = clock::Lamport::new(0); + let mut clock = clock::Lamport::new(ReplicaId::LOCAL); let mut queue = OperationQueue::new(); assert_eq!(queue.len(), 0); diff --git a/crates/text/src/patch.rs b/crates/text/src/patch.rs index b8bb904052..ec495f60fd 100644 --- a/crates/text/src/patch.rs +++ b/crates/text/src/patch.rs @@ -9,15 +9,7 @@ pub struct Patch(Vec>); impl Patch where - T: 'static - + Clone - + Copy - + Ord - + Sub - + Add - + AddAssign - + Default - + PartialEq, + T: 'static + Clone + Copy + Ord + Default, { pub fn new(edits: Vec>) -> Self { #[cfg(debug_assertions)] @@ -41,7 +33,50 @@ where pub fn into_inner(self) -> Vec> { self.0 } + pub fn invert(&mut self) -> &mut Self { + for edit in &mut self.0 { + mem::swap(&mut edit.old, &mut edit.new); + } + self + } + pub fn clear(&mut self) { + self.0.clear(); + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn push(&mut self, edit: Edit) { + if edit.is_empty() { + return; + } + + if let Some(last) = self.0.last_mut() { + if last.old.end >= edit.old.start { + last.old.end = edit.old.end; + last.new.end = edit.new.end; + } else { + self.0.push(edit); + } + } else { + self.0.push(edit); + } + } +} + +impl Patch +where + T: 'static + + Copy + + Ord + + Sub + + Add + + AddAssign + + Default, + TDelta: Ord + Copy, +{ #[must_use] pub fn compose(&self, new_edits_iter: impl IntoIterator>) -> Self { let mut old_edits_iter = self.0.iter().cloned().peekable(); @@ -169,38 +204,6 @@ where composed } - pub fn invert(&mut self) -> &mut Self { - for edit in &mut self.0 { - mem::swap(&mut edit.old, &mut edit.new); - } - self - } - - pub fn clear(&mut self) { - self.0.clear(); - } - - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - pub fn push(&mut self, edit: Edit) { - if edit.is_empty() { - return; - } - - if let Some(last) = self.0.last_mut() { - if last.old.end >= edit.old.start { - last.old.end = edit.old.end; - last.new.end = edit.new.end; - } else { - self.0.push(edit); - } - } else { - self.0.push(edit); - } - } - pub fn old_to_new(&self, old: T) -> T { let ix = match self.0.binary_search_by(|probe| probe.old.start.cmp(&old)) { Ok(ix) => ix, diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index e690792d0c..e355f70c49 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -2,11 +2,15 @@ use crate::{Anchor, BufferSnapshot, TextDimension}; use std::cmp::Ordering; use std::ops::Range; -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Default, Copy, Clone, Debug, PartialEq)] pub enum SelectionGoal { + #[default] None, HorizontalPosition(f64), - HorizontalRange { start: f64, end: f64 }, + HorizontalRange { + start: f64, + end: f64, + }, WrappedHorizontalPosition((u32, f32)), } @@ -19,12 +23,6 @@ pub struct Selection { pub goal: SelectionGoal, } -impl Default for SelectionGoal { - fn default() -> Self { - Self::None - } -} - impl Selection { /// A place where the selection had stopped at. pub fn head(&self) -> T { @@ -132,9 +130,15 @@ impl Selection { } } -impl Selection { +impl Selection { + pub fn len(&self) -> ::Output { + self.end - self.start + } +} + +impl Selection { #[cfg(feature = "test-support")] - pub fn from_offset(offset: usize) -> Self { + pub fn from_offset(offset: T) -> Self { Selection { id: 0, start: offset, @@ -144,7 +148,7 @@ impl Selection { } } - pub fn equals(&self, offset_range: &Range) -> bool { + pub fn equals(&self, offset_range: &Range) -> bool { self.start == offset_range.start && self.end == offset_range.end } } diff --git a/crates/text/src/subscription.rs b/crates/text/src/subscription.rs index 878e8a2cfe..50857a2de4 100644 --- a/crates/text/src/subscription.rs +++ b/crates/text/src/subscription.rs @@ -6,36 +6,55 @@ use std::{ }; #[derive(Default)] -pub struct Topic(Mutex>>>>); +pub struct Topic(Mutex>>>>); -pub struct Subscription(Arc>>); +pub struct Subscription(Arc>>); -impl Topic { - pub fn subscribe(&mut self) -> Subscription { +impl Topic +where + T: 'static + + Copy + + Ord + + std::ops::Sub + + std::ops::Add + + std::ops::AddAssign + + Default, + TDelta: Ord + Copy, +{ + pub fn subscribe(&mut self) -> Subscription { let subscription = Subscription(Default::default()); self.0.get_mut().push(Arc::downgrade(&subscription.0)); subscription } - pub fn publish(&self, edits: impl Clone + IntoIterator>) { + pub fn publish(&self, edits: impl Clone + IntoIterator>) { publish(&mut self.0.lock(), edits); } - pub fn publish_mut(&mut self, edits: impl Clone + IntoIterator>) { + pub fn publish_mut(&mut self, edits: impl Clone + IntoIterator>) { publish(self.0.get_mut(), edits); } } -impl Subscription { - pub fn consume(&self) -> Patch { +impl Subscription { + pub fn consume(&self) -> Patch { mem::take(&mut *self.0.lock()) } } -fn publish( - subscriptions: &mut Vec>>>, - edits: impl Clone + IntoIterator>, -) { +fn publish( + subscriptions: &mut Vec>>>, + edits: impl Clone + IntoIterator>, +) where + T: 'static + + Copy + + Ord + + std::ops::Sub + + std::ops::Add + + std::ops::AddAssign + + Default, + TDelta: Ord + Copy, +{ subscriptions.retain(|subscription| { if let Some(subscription) = subscription.upgrade() { let mut patch = subscription.lock(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4298e704ab..c9e04e407f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -16,7 +16,7 @@ fn init_logger() { #[test] fn test_edit() { - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), "abc"); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "abc"); assert_eq!(buffer.text(), "abc"); buffer.edit([(3..3, "def")]); assert_eq!(buffer.text(), "abcdef"); @@ -40,7 +40,11 @@ fn test_random_edits(mut rng: StdRng) { let mut reference_string = RandomCharIter::new(&mut rng) .take(reference_string_len) .collect::(); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), reference_string.clone()); + let mut buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + reference_string.clone(), + ); LineEnding::normalize(&mut reference_string); buffer.set_group_interval(Duration::from_millis(rng.random_range(0..=200))); @@ -176,7 +180,11 @@ fn test_line_endings() { LineEnding::Windows ); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), "one\r\ntwo\rthree"); + let mut buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + "one\r\ntwo\rthree", + ); assert_eq!(buffer.text(), "one\ntwo\nthree"); assert_eq!(buffer.line_ending(), LineEnding::Windows); buffer.check_invariants(); @@ -190,7 +198,7 @@ fn test_line_endings() { #[test] fn test_line_len() { - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), ""); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), ""); buffer.edit([(0..0, "abcd\nefg\nhij")]); buffer.edit([(12..12, "kl\nmno")]); buffer.edit([(18..18, "\npqrs\n")]); @@ -207,7 +215,7 @@ fn test_line_len() { #[test] fn test_common_prefix_at_position() { let text = "a = str; b = δα"; - let buffer = Buffer::new(0, BufferId::new(1).unwrap(), text); + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), text); let offset1 = offset_after(text, "str"); let offset2 = offset_after(text, "δα"); @@ -256,7 +264,7 @@ fn test_common_prefix_at_position() { #[test] fn test_text_summary_for_range() { let buffer = Buffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), "ab\nefg\nhklm\nnopqrs\ntuvwxyz", ); @@ -348,7 +356,7 @@ fn test_text_summary_for_range() { #[test] fn test_chars_at() { - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), ""); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), ""); buffer.edit([(0..0, "abcd\nefgh\nij")]); buffer.edit([(12..12, "kl\nmno")]); buffer.edit([(18..18, "\npqrs")]); @@ -370,7 +378,7 @@ fn test_chars_at() { assert_eq!(chars.collect::(), "PQrs"); // Regression test: - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), ""); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), ""); buffer.edit([(0..0, "[workspace]\nmembers = [\n \"xray_core\",\n \"xray_server\",\n \"xray_cli\",\n \"xray_wasm\",\n]\n")]); buffer.edit([(60..60, "\n")]); @@ -380,7 +388,7 @@ fn test_chars_at() { #[test] fn test_anchors() { - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), ""); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), ""); buffer.edit([(0..0, "abc")]); let left_anchor = buffer.anchor_before(2); let right_anchor = buffer.anchor_after(2); @@ -498,7 +506,7 @@ fn test_anchors() { #[test] fn test_anchors_at_start_and_end() { - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), ""); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), ""); let before_start_anchor = buffer.anchor_before(0); let after_end_anchor = buffer.anchor_after(0); @@ -521,7 +529,7 @@ fn test_anchors_at_start_and_end() { #[test] fn test_undo_redo() { - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), "1234"); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "1234"); // Set group interval to zero so as to not group edits in the undo stack. buffer.set_group_interval(Duration::from_secs(0)); @@ -558,7 +566,7 @@ fn test_undo_redo() { #[test] fn test_history() { let mut now = Instant::now(); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), "123456"); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "123456"); buffer.set_group_interval(Duration::from_millis(300)); let transaction_1 = buffer.start_transaction_at(now).unwrap(); @@ -625,7 +633,7 @@ fn test_history() { #[test] fn test_finalize_last_transaction() { let now = Instant::now(); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), "123456"); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "123456"); buffer.history.group_interval = Duration::from_millis(1); buffer.start_transaction_at(now); @@ -661,7 +669,7 @@ fn test_finalize_last_transaction() { #[test] fn test_edited_ranges_for_transaction() { let now = Instant::now(); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), "1234567"); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "1234567"); buffer.start_transaction_at(now); buffer.edit([(2..4, "cd")]); @@ -700,9 +708,9 @@ fn test_edited_ranges_for_transaction() { fn test_concurrent_edits() { let text = "abcdef"; - let mut buffer1 = Buffer::new(1, BufferId::new(1).unwrap(), text); - let mut buffer2 = Buffer::new(2, BufferId::new(1).unwrap(), text); - let mut buffer3 = Buffer::new(3, BufferId::new(1).unwrap(), text); + let mut buffer1 = Buffer::new(ReplicaId::new(1), BufferId::new(1).unwrap(), text); + let mut buffer2 = Buffer::new(ReplicaId::new(2), BufferId::new(1).unwrap(), text); + let mut buffer3 = Buffer::new(ReplicaId::new(3), BufferId::new(1).unwrap(), text); let buf1_op = buffer1.edit([(1..2, "12")]); assert_eq!(buffer1.text(), "a12cdef"); @@ -741,11 +749,15 @@ fn test_random_concurrent_edits(mut rng: StdRng) { let mut network = Network::new(rng.clone()); for i in 0..peers { - let mut buffer = Buffer::new(i as ReplicaId, BufferId::new(1).unwrap(), base_text.clone()); + let mut buffer = Buffer::new( + ReplicaId::new(i as u16), + BufferId::new(1).unwrap(), + base_text.clone(), + ); buffer.history.group_interval = Duration::from_millis(rng.random_range(0..=200)); buffers.push(buffer); - replica_ids.push(i as u16); - network.add_peer(i as u16); + replica_ids.push(ReplicaId::new(i as u16)); + network.add_peer(ReplicaId::new(i as u16)); } log::info!("initial text: {:?}", base_text); @@ -759,7 +771,7 @@ fn test_random_concurrent_edits(mut rng: StdRng) { 0..=50 if mutation_count != 0 => { let op = buffer.randomly_edit(&mut rng, 5).1; network.broadcast(buffer.replica_id, vec![op]); - log::info!("buffer {} text: {:?}", buffer.replica_id, buffer.text()); + log::info!("buffer {:?} text: {:?}", buffer.replica_id, buffer.text()); mutation_count -= 1; } 51..=70 if mutation_count != 0 => { @@ -771,7 +783,7 @@ fn test_random_concurrent_edits(mut rng: StdRng) { let ops = network.receive(replica_id); if !ops.is_empty() { log::info!( - "peer {} applying {} ops from the network.", + "peer {:?} applying {} ops from the network.", replica_id, ops.len() ); @@ -792,7 +804,7 @@ fn test_random_concurrent_edits(mut rng: StdRng) { assert_eq!( buffer.text(), first_buffer.text(), - "Replica {} text != Replica 0 text", + "Replica {:?} text != Replica 0 text", buffer.replica_id ); buffer.check_invariants(); diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index d61038d746..866552e4e5 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -12,7 +12,7 @@ mod undo_map; pub use anchor::*; use anyhow::{Context as _, Result}; -use clock::LOCAL_BRANCH_REPLICA_ID; +use clock::Lamport; pub use clock::ReplicaId; use collections::{HashMap, HashSet}; use locator::Locator; @@ -39,6 +39,7 @@ pub use subscription::*; pub use sum_tree::Bias; use sum_tree::{Dimensions, FilterCursor, SumTree, TreeMap, TreeSet}; use undo_map::UndoMap; +use util::debug_panic; #[cfg(any(test, feature = "test-support"))] use util::RandomCharIter; @@ -54,7 +55,7 @@ pub struct Buffer { deferred_ops: OperationQueue, deferred_replicas: HashSet, pub lamport_clock: clock::Lamport, - subscriptions: Topic, + subscriptions: Topic, edit_id_resolvers: HashMap>>, wait_for_version_txs: Vec<(clock::Global, oneshot::Sender<()>)>, } @@ -496,22 +497,26 @@ pub struct Edit { pub old: Range, pub new: Range, } - impl Edit where - D: Sub + PartialEq + Copy, + D: PartialEq, { - pub fn old_len(&self) -> D { + pub fn is_empty(&self) -> bool { + self.old.start == self.old.end && self.new.start == self.new.end + } +} + +impl Edit +where + D: Sub + Copy, +{ + pub fn old_len(&self) -> DDelta { self.old.end - self.old.start } - pub fn new_len(&self) -> D { + pub fn new_len(&self) -> DDelta { self.new.end - self.new.start } - - pub fn is_empty(&self) -> bool { - self.old.start == self.old.end && self.new.start == self.new.end - } } impl Edit<(D1, D2)> { @@ -573,7 +578,7 @@ struct InsertionFragment { fragment_id: Locator, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] struct InsertionFragmentKey { timestamp: clock::Lamport, split_offset: usize, @@ -709,7 +714,7 @@ impl FromIterator for LineIndent { } impl Buffer { - pub fn new(replica_id: u16, remote_id: BufferId, base_text: impl Into) -> Buffer { + pub fn new(replica_id: ReplicaId, remote_id: BufferId, base_text: impl Into) -> Buffer { let mut base_text = base_text.into(); let line_ending = LineEnding::detect(&base_text); LineEnding::normalize(&mut base_text); @@ -717,7 +722,7 @@ impl Buffer { } pub fn new_normalized( - replica_id: u16, + replica_id: ReplicaId, remote_id: BufferId, line_ending: LineEnding, normalized: Rope, @@ -731,10 +736,7 @@ impl Buffer { let visible_text = history.base_text.clone(); if !visible_text.is_empty() { - let insertion_timestamp = clock::Lamport { - replica_id: 0, - value: 1, - }; + let insertion_timestamp = clock::Lamport::new(ReplicaId::LOCAL); lamport_clock.observe(insertion_timestamp); version.observe(insertion_timestamp); let fragment_id = Locator::between(&Locator::min(), &Locator::max()); @@ -788,7 +790,7 @@ impl Buffer { history: History::new(self.base_text().clone()), deferred_ops: OperationQueue::new(), deferred_replicas: HashSet::default(), - lamport_clock: clock::Lamport::new(LOCAL_BRANCH_REPLICA_ID), + lamport_clock: clock::Lamport::new(ReplicaId::LOCAL_BRANCH), subscriptions: Default::default(), edit_id_resolvers: Default::default(), wait_for_version_txs: Default::default(), @@ -1254,7 +1256,7 @@ impl Buffer { for edit_id in edit_ids { let insertion_slice = InsertionSlice { edit_id: *edit_id, - insertion_id: clock::Lamport::default(), + insertion_id: clock::Lamport::MIN, range: 0..0, }; let slices = self @@ -1618,7 +1620,7 @@ impl Buffer { self.edited_ranges_for_edit_ids(&transaction.edit_ids) } - pub fn subscribe(&mut self) -> Subscription { + pub fn subscribe(&mut self) -> Subscription { self.subscriptions.subscribe() } @@ -1651,10 +1653,7 @@ impl Buffer { ) -> impl 'static + Future> + use { let mut futures = Vec::new(); for anchor in anchors { - if !self.version.observed(anchor.timestamp) - && anchor != Anchor::MAX - && anchor != Anchor::MIN - { + if !self.version.observed(anchor.timestamp) && !anchor.is_max() && !anchor.is_min() { let (tx, rx) = oneshot::channel(); self.edit_id_resolvers .entry(anchor.timestamp) @@ -1858,7 +1857,7 @@ impl Buffer { T: rand::Rng, { let mut edits = self.get_random_edits(rng, edit_count); - log::info!("mutating buffer {} with {:?}", self.replica_id, edits); + log::info!("mutating buffer {:?} with {:?}", self.replica_id, edits); let op = self.edit(edits.iter().cloned()); if let Operation::Edit(edit) = &op { @@ -1881,7 +1880,7 @@ impl Buffer { if let Some(entry) = self.history.undo_stack.choose(rng) { let transaction = entry.transaction.clone(); log::info!( - "undoing buffer {} transaction {:?}", + "undoing buffer {:?} transaction {:?}", self.replica_id, transaction ); @@ -2054,6 +2053,14 @@ impl BufferSnapshot { self.visible_text.point_to_offset(point) } + pub fn point_to_offset_utf16(&self, point: Point) -> OffsetUtf16 { + self.visible_text.point_to_offset_utf16(point) + } + + pub fn point_utf16_to_offset_utf16(&self, point: PointUtf16) -> OffsetUtf16 { + self.visible_text.point_utf16_to_offset_utf16(point) + } + pub fn point_utf16_to_offset(&self, point: PointUtf16) -> usize { self.visible_text.point_utf16_to_offset(point) } @@ -2086,6 +2093,10 @@ impl BufferSnapshot { self.visible_text.point_to_point_utf16(point) } + pub fn point_utf16_to_point(&self, point: PointUtf16) -> Point { + self.visible_text.point_utf16_to_point(point) + } + pub fn version(&self) -> &clock::Global { &self.version } @@ -2245,9 +2256,9 @@ impl BufferSnapshot { let mut position = D::zero(()); anchors.map(move |(anchor, payload)| { - if *anchor == Anchor::MIN { + if anchor.is_min() { return (D::zero(()), payload); - } else if *anchor == Anchor::MAX { + } else if anchor.is_max() { return (D::from_text_summary(&self.visible_text.summary()), payload); } @@ -2268,8 +2279,22 @@ impl BufferSnapshot { } else { insertion_cursor.prev(); } - let insertion = insertion_cursor.item().expect("invalid insertion"); - assert_eq!(insertion.timestamp, anchor.timestamp, "invalid insertion"); + let Some(insertion) = insertion_cursor.item() else { + panic!( + "invalid insertion for buffer {}@{:?} with anchor {:?}", + self.remote_id(), + self.version, + anchor + ); + }; + assert_eq!( + insertion.timestamp, + anchor.timestamp, + "invalid insertion for buffer {}@{:?} and anchor {:?}", + self.remote_id(), + self.version, + anchor + ); fragment_cursor.seek_forward(&Some(&insertion.fragment_id), Bias::Left); let fragment = fragment_cursor.item().unwrap(); @@ -2291,12 +2316,18 @@ impl BufferSnapshot { } pub fn offset_for_anchor(&self, anchor: &Anchor) -> usize { - if *anchor == Anchor::MIN { + if anchor.is_min() { 0 - } else if *anchor == Anchor::MAX { + } else if anchor.is_max() { self.visible_text.len() } else { - debug_assert!(anchor.buffer_id == Some(self.remote_id)); + debug_assert_eq!(anchor.buffer_id, Some(self.remote_id)); + debug_assert!( + self.version.observed(anchor.timestamp), + "Anchor timestamp {:?} not observed by buffer {:?}", + anchor.timestamp, + self.version + ); let anchor_key = InsertionFragmentKey { timestamp: anchor.timestamp, split_offset: anchor.offset, @@ -2320,18 +2351,18 @@ impl BufferSnapshot { .item() .filter(|insertion| insertion.timestamp == anchor.timestamp) else { - panic!( - "invalid anchor {:?}. buffer id: {}, version: {:?}", - anchor, self.remote_id, self.version - ); + self.panic_bad_anchor(anchor); }; - let mut fragment_cursor = self + let (start, _, item) = self .fragments - .cursor::, usize>>(&None); - fragment_cursor.seek(&Some(&insertion.fragment_id), Bias::Left); - let fragment = fragment_cursor.item().unwrap(); - let mut fragment_offset = fragment_cursor.start().1; + .find::, usize>, _>( + &None, + &Some(&insertion.fragment_id), + Bias::Left, + ); + let fragment = item.unwrap(); + let mut fragment_offset = start.1; if fragment.visible { fragment_offset += anchor.offset - insertion.split_offset; } @@ -2339,19 +2370,35 @@ impl BufferSnapshot { } } - fn fragment_id_for_anchor(&self, anchor: &Anchor) -> &Locator { - self.try_fragment_id_for_anchor(anchor).unwrap_or_else(|| { + #[cold] + fn panic_bad_anchor(&self, anchor: &Anchor) -> ! { + if anchor.buffer_id.is_some_and(|id| id != self.remote_id) { + panic!( + "invalid anchor - buffer id does not match: anchor {anchor:?}; buffer id: {}, version: {:?}", + self.remote_id, self.version + ); + } else if !self.version.observed(anchor.timestamp) { + panic!( + "invalid anchor - snapshot has not observed lamport: {:?}; version: {:?}", + anchor, self.version + ); + } else { panic!( "invalid anchor {:?}. buffer id: {}, version: {:?}", - anchor, self.remote_id, self.version, - ) - }) + anchor, self.remote_id, self.version + ); + } + } + + fn fragment_id_for_anchor(&self, anchor: &Anchor) -> &Locator { + self.try_fragment_id_for_anchor(anchor) + .unwrap_or_else(|| self.panic_bad_anchor(anchor)) } fn try_fragment_id_for_anchor(&self, anchor: &Anchor) -> Option<&Locator> { - if *anchor == Anchor::MIN { + if anchor.is_min() { Some(Locator::min_ref()) - } else if *anchor == Anchor::MAX { + } else if anchor.is_max() { Some(Locator::max_ref()) } else { let anchor_key = InsertionFragmentKey { @@ -2394,27 +2441,34 @@ impl BufferSnapshot { self.anchor_at_offset(position.to_offset(self), bias) } - fn anchor_at_offset(&self, offset: usize, bias: Bias) -> Anchor { + fn anchor_at_offset(&self, mut offset: usize, bias: Bias) -> Anchor { if bias == Bias::Left && offset == 0 { - Anchor::MIN - } else if bias == Bias::Right && offset == self.len() { - Anchor::MAX + Anchor::min_for_buffer(self.remote_id) + } else if bias == Bias::Right + && ((!cfg!(debug_assertions) && offset >= self.len()) || offset == self.len()) + { + Anchor::max_for_buffer(self.remote_id) } else { - if !self.visible_text.is_char_boundary(offset) { - // find the character - let char_start = self.visible_text.floor_char_boundary(offset); - // `char_start` must be less than len and a char boundary - let ch = self.visible_text.chars_at(char_start).next().unwrap(); - let char_range = char_start..char_start + ch.len_utf8(); - panic!( - "byte index {} is not a char boundary; it is inside {:?} (bytes {:?})", - offset, ch, char_range, - ); + if self + .visible_text + .assert_char_boundary::<{ cfg!(debug_assertions) }>(offset) + { + offset = match bias { + Bias::Left => self.visible_text.floor_char_boundary(offset), + Bias::Right => self.visible_text.ceil_char_boundary(offset), + }; } - let mut fragment_cursor = self.fragments.cursor::(&None); - fragment_cursor.seek(&offset, bias); - let fragment = fragment_cursor.item().unwrap(); - let overshoot = offset - *fragment_cursor.start(); + let (start, _, item) = self.fragments.find::(&None, &offset, bias); + let Some(fragment) = item else { + // We got a bad offset, likely out of bounds + debug_panic!( + "Failed to find fragment at offset {} (len: {})", + offset, + self.len() + ); + return Anchor::max_for_buffer(self.remote_id); + }; + let overshoot = offset - start; Anchor { timestamp: fragment.timestamp, offset: fragment.insertion_offset + overshoot, @@ -2425,8 +2479,8 @@ impl BufferSnapshot { } pub fn can_resolve(&self, anchor: &Anchor) -> bool { - *anchor == Anchor::MIN - || *anchor == Anchor::MAX + anchor.is_min() + || anchor.is_max() || (Some(self.remote_id) == anchor.buffer_id && self.version.observed(anchor.timestamp)) } @@ -2495,15 +2549,17 @@ impl BufferSnapshot { cursor.next(); Some(cursor) }; - let mut cursor = self - .fragments - .cursor::, FragmentTextSummary>>(&None); - let start_fragment_id = self.fragment_id_for_anchor(&range.start); - cursor.seek(&Some(start_fragment_id), Bias::Left); - let mut visible_start = cursor.start().1.visible; - let mut deleted_start = cursor.start().1.deleted; - if let Some(fragment) = cursor.item() { + let (start, _, item) = self + .fragments + .find::, FragmentTextSummary>, _>( + &None, + &Some(start_fragment_id), + Bias::Left, + ); + let mut visible_start = start.1.visible; + let mut deleted_start = start.1.deleted; + if let Some(fragment) = item { let overshoot = range.start.offset - fragment.insertion_offset; if fragment.visible { visible_start += overshoot; @@ -2916,7 +2972,10 @@ impl InsertionFragment { impl sum_tree::ContextLessSummary for InsertionFragmentKey { fn zero() -> Self { - Default::default() + InsertionFragmentKey { + timestamp: Lamport::MIN, + split_offset: 0, + } } fn add_summary(&mut self, summary: &Self) { @@ -3089,43 +3148,48 @@ pub trait ToOffset { } impl ToOffset for Point { + #[inline] fn to_offset(&self, snapshot: &BufferSnapshot) -> usize { snapshot.point_to_offset(*self) } } impl ToOffset for usize { - #[track_caller] fn to_offset(&self, snapshot: &BufferSnapshot) -> usize { - assert!( - *self <= snapshot.len(), - "offset {} is out of range, snapshot length is {}", - self, - snapshot.len() - ); - *self + if snapshot + .as_rope() + .assert_char_boundary::<{ cfg!(debug_assertions) }>(*self) + { + snapshot.as_rope().floor_char_boundary(*self) + } else { + *self + } } } impl ToOffset for Anchor { + #[inline] fn to_offset(&self, snapshot: &BufferSnapshot) -> usize { snapshot.summary_for_anchor(self) } } impl ToOffset for &T { + #[inline] fn to_offset(&self, content: &BufferSnapshot) -> usize { (*self).to_offset(content) } } impl ToOffset for PointUtf16 { + #[inline] fn to_offset(&self, snapshot: &BufferSnapshot) -> usize { snapshot.point_utf16_to_offset(*self) } } impl ToOffset for Unclipped { + #[inline] fn to_offset(&self, snapshot: &BufferSnapshot) -> usize { snapshot.unclipped_point_utf16_to_offset(*self) } @@ -3136,24 +3200,28 @@ pub trait ToPoint { } impl ToPoint for Anchor { + #[inline] fn to_point(&self, snapshot: &BufferSnapshot) -> Point { snapshot.summary_for_anchor(self) } } impl ToPoint for usize { + #[inline] fn to_point(&self, snapshot: &BufferSnapshot) -> Point { snapshot.offset_to_point(*self) } } impl ToPoint for Point { + #[inline] fn to_point(&self, _: &BufferSnapshot) -> Point { *self } } impl ToPoint for Unclipped { + #[inline] fn to_point(&self, snapshot: &BufferSnapshot) -> Point { snapshot.unclipped_point_utf16_to_point(*self) } @@ -3164,24 +3232,28 @@ pub trait ToPointUtf16 { } impl ToPointUtf16 for Anchor { + #[inline] fn to_point_utf16(&self, snapshot: &BufferSnapshot) -> PointUtf16 { snapshot.summary_for_anchor(self) } } impl ToPointUtf16 for usize { + #[inline] fn to_point_utf16(&self, snapshot: &BufferSnapshot) -> PointUtf16 { snapshot.offset_to_point_utf16(*self) } } impl ToPointUtf16 for PointUtf16 { + #[inline] fn to_point_utf16(&self, _: &BufferSnapshot) -> PointUtf16 { *self } } impl ToPointUtf16 for Point { + #[inline] fn to_point_utf16(&self, snapshot: &BufferSnapshot) -> PointUtf16 { snapshot.point_to_point_utf16(*self) } @@ -3192,18 +3264,21 @@ pub trait ToOffsetUtf16 { } impl ToOffsetUtf16 for Anchor { + #[inline] fn to_offset_utf16(&self, snapshot: &BufferSnapshot) -> OffsetUtf16 { snapshot.summary_for_anchor(self) } } impl ToOffsetUtf16 for usize { + #[inline] fn to_offset_utf16(&self, snapshot: &BufferSnapshot) -> OffsetUtf16 { snapshot.offset_to_offset_utf16(*self) } } impl ToOffsetUtf16 for OffsetUtf16 { + #[inline] fn to_offset_utf16(&self, _snapshot: &BufferSnapshot) -> OffsetUtf16 { *self } @@ -3214,24 +3289,28 @@ pub trait FromAnchor { } impl FromAnchor for Anchor { + #[inline] fn from_anchor(anchor: &Anchor, _snapshot: &BufferSnapshot) -> Self { *anchor } } impl FromAnchor for Point { + #[inline] fn from_anchor(anchor: &Anchor, snapshot: &BufferSnapshot) -> Self { snapshot.summary_for_anchor(anchor) } } impl FromAnchor for PointUtf16 { + #[inline] fn from_anchor(anchor: &Anchor, snapshot: &BufferSnapshot) -> Self { snapshot.summary_for_anchor(anchor) } } impl FromAnchor for usize { + #[inline] fn from_anchor(anchor: &Anchor, snapshot: &BufferSnapshot) -> Self { snapshot.summary_for_anchor(anchor) } @@ -3261,6 +3340,13 @@ impl LineEnding { } } + pub fn label(&self) -> &'static str { + match self { + LineEnding::Unix => "LF", + LineEnding::Windows => "CRLF", + } + } + pub fn detect(text: &str) -> Self { let mut max_ix = cmp::min(text.len(), 1000); while !text.is_char_boundary(max_ix) { @@ -3301,6 +3387,25 @@ impl LineEnding { } } +pub fn chunks_with_line_ending(rope: &Rope, line_ending: LineEnding) -> impl Iterator { + rope.chunks().flat_map(move |chunk| { + let mut newline = false; + let end_with_newline = chunk.ends_with('\n').then_some(line_ending.as_str()); + chunk + .lines() + .flat_map(move |line| { + let ending = if newline { + Some(line_ending.as_str()) + } else { + None + }; + newline = true; + ending.into_iter().chain([line]) + }) + .chain(end_with_newline) + }) +} + #[cfg(debug_assertions)] pub mod debug { use super::*; diff --git a/crates/text/src/undo_map.rs b/crates/text/src/undo_map.rs index 60b22a9edb..2c2eba8de6 100644 --- a/crates/text/src/undo_map.rs +++ b/crates/text/src/undo_map.rs @@ -1,4 +1,5 @@ use crate::UndoOperation; +use clock::Lamport; use std::cmp; use sum_tree::{Bias, SumTree}; @@ -24,7 +25,7 @@ impl sum_tree::KeyedItem for UndoMapEntry { } } -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] struct UndoMapKey { edit_id: clock::Lamport, undo_id: clock::Lamport, @@ -32,7 +33,10 @@ struct UndoMapKey { impl sum_tree::ContextLessSummary for UndoMapKey { fn zero() -> Self { - Default::default() + UndoMapKey { + edit_id: Lamport::MIN, + undo_id: Lamport::MIN, + } } fn add_summary(&mut self, summary: &Self) { @@ -69,7 +73,7 @@ impl UndoMap { cursor.seek( &UndoMapKey { edit_id, - undo_id: Default::default(), + undo_id: Lamport::MIN, }, Bias::Left, ); @@ -93,7 +97,7 @@ impl UndoMap { cursor.seek( &UndoMapKey { edit_id, - undo_id: Default::default(), + undo_id: Lamport::MIN, }, Bias::Left, ); diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 306733bf34..ef193c500d 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -36,7 +36,6 @@ strum.workspace = true thiserror.workspace = true util.workspace = true uuid.workspace = true -workspace-hack.workspace = true [dev-dependencies] fs = { workspace = true, features = ["test-support"] } diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 051b7acf10..82be2896c6 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -9,11 +9,17 @@ pub(crate) fn neutral() -> ColorScaleSet { } const ADDED_COLOR: Hsla = Hsla { - h: 142. / 360., - s: 0.68, - l: 0.45, + h: 134. / 360., + s: 0.55, + l: 0.40, a: 1.0, }; +const WORD_ADDED_COLOR: Hsla = Hsla { + h: 134. / 360., + s: 0.55, + l: 0.40, + a: 0.35, +}; const MODIFIED_COLOR: Hsla = Hsla { h: 48. / 360., s: 0.76, @@ -21,11 +27,17 @@ const MODIFIED_COLOR: Hsla = Hsla { a: 1.0, }; const REMOVED_COLOR: Hsla = Hsla { - h: 355. / 360., - s: 0.65, - l: 0.65, + h: 350. / 360., + s: 0.88, + l: 0.25, a: 1.0, }; +const WORD_DELETED_COLOR: Hsla = Hsla { + h: 350. / 360., + s: 0.88, + l: 0.25, + a: 0.80, +}; /// The default colors for the theme. /// @@ -79,13 +91,14 @@ impl ThemeColors { tab_inactive_background: neutral().light().step_2(), tab_active_background: neutral().light().step_1(), search_match_background: neutral().light().step_5(), + search_active_match_background: neutral().light().step_7(), panel_background: neutral().light().step_2(), panel_focused_border: blue().light().step_10(), panel_indent_guide: neutral().light_alpha().step_5(), panel_indent_guide_hover: neutral().light_alpha().step_6(), panel_indent_guide_active: neutral().light_alpha().step_6(), panel_overlay_background: neutral().light().step_2(), - panel_overlay_hover: neutral().light_alpha().step_4(), + panel_overlay_hover: neutral().light().step_4(), pane_focused_border: blue().light().step_5(), pane_group_border: neutral().light().step_6(), scrollbar_thumb_background: neutral().light_alpha().step_3(), @@ -152,8 +165,19 @@ impl ThemeColors { version_control_renamed: MODIFIED_COLOR, version_control_conflict: orange().light().step_12(), version_control_ignored: gray().light().step_12(), + version_control_word_added: WORD_ADDED_COLOR, + version_control_word_deleted: WORD_DELETED_COLOR, version_control_conflict_marker_ours: green().light().step_10().alpha(0.5), version_control_conflict_marker_theirs: blue().light().step_10().alpha(0.5), + vim_normal_background: system.transparent, + vim_insert_background: system.transparent, + vim_replace_background: system.transparent, + vim_visual_background: system.transparent, + vim_visual_line_background: system.transparent, + vim_visual_block_background: system.transparent, + vim_helix_normal_background: system.transparent, + vim_helix_select_background: system.transparent, + vim_mode_text: system.transparent, } } @@ -205,13 +229,14 @@ impl ThemeColors { tab_inactive_background: neutral().dark().step_2(), tab_active_background: neutral().dark().step_1(), search_match_background: neutral().dark().step_5(), + search_active_match_background: neutral().dark().step_3(), panel_background: neutral().dark().step_2(), panel_focused_border: blue().dark().step_8(), panel_indent_guide: neutral().dark_alpha().step_4(), panel_indent_guide_hover: neutral().dark_alpha().step_6(), panel_indent_guide_active: neutral().dark_alpha().step_6(), panel_overlay_background: neutral().dark().step_2(), - panel_overlay_hover: neutral().dark_alpha().step_4(), + panel_overlay_hover: neutral().dark().step_4(), pane_focused_border: blue().dark().step_5(), pane_group_border: neutral().dark().step_6(), scrollbar_thumb_background: neutral().dark_alpha().step_3(), @@ -278,8 +303,19 @@ impl ThemeColors { version_control_renamed: MODIFIED_COLOR, version_control_conflict: orange().dark().step_12(), version_control_ignored: gray().dark().step_12(), + version_control_word_added: WORD_ADDED_COLOR, + version_control_word_deleted: WORD_DELETED_COLOR, version_control_conflict_marker_ours: green().dark().step_10().alpha(0.5), version_control_conflict_marker_theirs: blue().dark().step_10().alpha(0.5), + vim_normal_background: system.transparent, + vim_insert_background: system.transparent, + vim_replace_background: system.transparent, + vim_visual_background: system.transparent, + vim_visual_line_background: system.transparent, + vim_visual_block_background: system.transparent, + vim_helix_normal_background: system.transparent, + vim_helix_select_background: system.transparent, + vim_mode_text: system.transparent, } } } diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 13786aca57..6bfcb1c868 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use gpui::{FontStyle, FontWeight, HighlightStyle, Hsla, WindowBackgroundAppearance, hsla}; use crate::{ - AccentColors, Appearance, PlayerColors, StatusColors, StatusColorsRefinement, SyntaxTheme, - SystemColors, Theme, ThemeColors, ThemeColorsRefinement, ThemeFamily, ThemeStyles, - default_color_scales, + AccentColors, Appearance, DEFAULT_DARK_THEME, PlayerColors, StatusColors, + StatusColorsRefinement, SyntaxTheme, SystemColors, Theme, ThemeColors, ThemeColorsRefinement, + ThemeFamily, ThemeStyles, default_color_scales, }; /// The default theme family for Zed. @@ -71,11 +71,17 @@ pub(crate) fn zed_default_dark() -> Theme { let yellow = hsla(39. / 360., 67. / 100., 69. / 100., 1.0); const ADDED_COLOR: Hsla = Hsla { - h: 142. / 360., - s: 0.68, - l: 0.45, + h: 134. / 360., + s: 0.55, + l: 0.40, a: 1.0, }; + const WORD_ADDED_COLOR: Hsla = Hsla { + h: 134. / 360., + s: 0.55, + l: 0.40, + a: 0.35, + }; const MODIFIED_COLOR: Hsla = Hsla { h: 48. / 360., s: 0.76, @@ -83,16 +89,22 @@ pub(crate) fn zed_default_dark() -> Theme { a: 1.0, }; const REMOVED_COLOR: Hsla = Hsla { - h: 355. / 360., - s: 0.65, - l: 0.65, + h: 350. / 360., + s: 0.88, + l: 0.25, a: 1.0, }; + const WORD_DELETED_COLOR: Hsla = Hsla { + h: 350. / 360., + s: 0.88, + l: 0.25, + a: 0.80, + }; let player = PlayerColors::dark(); Theme { id: "one_dark".to_string(), - name: "One Dark".into(), + name: DEFAULT_DARK_THEME.into(), appearance: Appearance::Dark, styles: ThemeStyles { window_background_appearance: WindowBackgroundAppearance::Opaque, @@ -140,6 +152,7 @@ pub(crate) fn zed_default_dark() -> Theme { tab_inactive_background: bg, tab_active_background: editor, search_match_background: bg, + search_active_match_background: bg, editor_background: editor, editor_gutter_background: editor, @@ -231,8 +244,20 @@ pub(crate) fn zed_default_dark() -> Theme { version_control_renamed: MODIFIED_COLOR, version_control_conflict: crate::orange().light().step_12(), version_control_ignored: crate::gray().light().step_12(), + version_control_word_added: WORD_ADDED_COLOR, + version_control_word_deleted: WORD_DELETED_COLOR, version_control_conflict_marker_ours: crate::green().light().step_12().alpha(0.5), version_control_conflict_marker_theirs: crate::blue().light().step_12().alpha(0.5), + + vim_normal_background: SystemColors::default().transparent, + vim_insert_background: SystemColors::default().transparent, + vim_replace_background: SystemColors::default().transparent, + vim_visual_background: SystemColors::default().transparent, + vim_visual_line_background: SystemColors::default().transparent, + vim_visual_block_background: SystemColors::default().transparent, + vim_helix_normal_background: SystemColors::default().transparent, + vim_helix_select_background: SystemColors::default().transparent, + vim_mode_text: SystemColors::default().transparent, }, status: StatusColors { conflict: yellow, diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index 513dedfe42..818bf1b2f1 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -88,7 +88,9 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ("coffeescript", &["coffee"]), ( "cpp", - &["c++", "cc", "cpp", "cxx", "hh", "hpp", "hxx", "inl", "ixx"], + &[ + "c++", "h++", "cc", "cpp", "cxx", "hh", "hpp", "hxx", "inl", "ixx", + ], ), ("crystal", &["cr", "ecr"]), ("csharp", &["cs"]), @@ -152,7 +154,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ), ("java", &["java"]), ("javascript", &["cjs", "js", "mjs"]), - ("json", &["json"]), + ("json", &["json", "jsonc"]), ("julia", &["jl"]), ("kdl", &["kdl"]), ("kotlin", &["kt"]), @@ -165,6 +167,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ("nim", &["nim"]), ("nix", &["nix"]), ("ocaml", &["ml", "mli"]), + ("odin", &["odin"]), ("php", &["php"]), ( "prettier", @@ -199,9 +202,9 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ( "storage", &[ - "accdb", "csv", "dat", "db", "dbf", "dll", "fmp", "fp7", "frm", "gdb", "ib", "jsonc", - "ldf", "mdb", "mdf", "myd", "myi", "pdb", "RData", "rdata", "sav", "sdf", "sql", - "sqlite", "tsv", + "accdb", "csv", "dat", "db", "dbf", "dll", "fmp", "fp7", "frm", "gdb", "ib", "ldf", + "mdb", "mdf", "myd", "myi", "pdb", "RData", "rdata", "sav", "sdf", "sql", "sqlite", + "tsv", ], ), ( @@ -330,6 +333,7 @@ const FILE_ICONS: &[(&str, &str)] = &[ ("nim", "icons/file_icons/nim.svg"), ("nix", "icons/file_icons/nix.svg"), ("ocaml", "icons/file_icons/ocaml.svg"), + ("odin", "icons/file_icons/odin.svg"), ("phoenix", "icons/file_icons/phoenix.svg"), ("php", "icons/file_icons/php.svg"), ("prettier", "icons/file_icons/prettier.svg"), diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index 2d7e1ff9d8..f52b2cf0e5 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -287,6 +287,15 @@ pub fn theme_colors_refinement( .panel_background .as_ref() .and_then(|color| try_parse_color(color).ok()); + let search_match_background = this + .search_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_active_match_background = this + .search_active_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(search_match_background); ThemeColorsRefinement { border, border_variant: this @@ -442,10 +451,8 @@ pub fn theme_colors_refinement( .tab_active_background .as_ref() .and_then(|color| try_parse_color(color).ok()), - search_match_background: this - .search_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), + search_match_background: search_match_background, + search_active_match_background: search_active_match_background, panel_background, panel_focused_border: this .panel_focused_border @@ -744,6 +751,14 @@ pub fn theme_colors_refinement( .and_then(|color| try_parse_color(color).ok()) // Fall back to `conflict`, for backwards compatibility. .or(status_colors.ignored), + version_control_word_added: this + .version_control_word_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_word_deleted: this + .version_control_word_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), #[allow(deprecated)] version_control_conflict_marker_ours: this .version_control_conflict_marker_ours @@ -756,6 +771,42 @@ pub fn theme_colors_refinement( .as_ref() .or(this.version_control_conflict_theirs_background.as_ref()) .and_then(|color| try_parse_color(color).ok()), + vim_normal_background: this + .vim_normal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_insert_background: this + .vim_insert_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_replace_background: this + .vim_replace_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_background: this + .vim_visual_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_line_background: this + .vim_visual_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_block_background: this + .vim_visual_block_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_normal_background: this + .vim_helix_normal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_select_background: this + .vim_helix_select_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_mode_text: this + .vim_mode_text + .as_ref() + .and_then(|color| try_parse_color(color).ok()), } } diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index 83cd7f9f2e..d60d4882a6 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -1,8 +1,6 @@ -use crate::fallback_themes::zed_default_dark; use crate::{ - Appearance, DEFAULT_ICON_THEME_NAME, IconTheme, IconThemeNotFoundError, SyntaxTheme, Theme, - ThemeNotFoundError, ThemeRegistry, status_colors_refinement, syntax_overrides, - theme_colors_refinement, + Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme, status_colors_refinement, + syntax_overrides, theme_colors_refinement, }; use collections::HashMap; use derive_more::{Deref, DerefMut}; @@ -13,10 +11,9 @@ use gpui::{ use refineable::Refineable; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -pub use settings::{FontFamilyName, IconThemeName, ThemeMode, ThemeName}; -use settings::{Settings, SettingsContent}; +pub use settings::{FontFamilyName, IconThemeName, ThemeAppearanceMode, ThemeName}; +use settings::{RegisterSetting, Settings, SettingsContent}; use std::sync::Arc; -use util::ResultExt as _; const MIN_FONT_SIZE: Pixels = px(6.0); const MAX_FONT_SIZE: Pixels = px(100.0); @@ -97,7 +94,7 @@ impl From for UiDensity { } /// Customizable settings for the UI and theme system. -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, RegisterSetting)] pub struct ThemeSettings { /// The UI font size. Determines the size of text in the UI, /// as well as the size of a [gpui::Rems] unit. @@ -116,7 +113,7 @@ pub struct ThemeSettings { pub buffer_font: Font, /// The agent font size. Determines the size of text in the agent panel. Falls back to the UI font size if unset. agent_ui_font_size: Option, - /// The agent buffer font size. Determines the size of user messages in the agent panel. Falls back to the buffer font size if unset. + /// The agent buffer font size. Determines the size of user messages in the agent panel. agent_buffer_font_size: Option, /// The line height for buffers, and the terminal. /// @@ -125,9 +122,7 @@ pub struct ThemeSettings { /// The terminal font family can be overridden using it's own setting. pub buffer_line_height: BufferLineHeight, /// The current theme selection. - pub theme_selection: Option, - /// The active theme. - pub active_theme: Arc, + pub theme: ThemeSelection, /// Manual overrides for the active theme. /// /// Note: This setting is still experimental. See [this tracking issue](https://github.com/zed-industries/zed/issues/18078) @@ -135,9 +130,7 @@ pub struct ThemeSettings { /// Manual overrides per theme pub theme_overrides: HashMap, /// The current icon theme selection. - pub icon_theme_selection: Option, - /// The active icon theme. - pub active_icon_theme: Arc, + pub icon_theme: IconThemeSelection, /// The density of the UI. /// Note: This setting is still experimental. See [this tracking issue]( pub ui_density: UiDensity, @@ -145,73 +138,14 @@ pub struct ThemeSettings { pub unnecessary_code_fade: f32, } -impl ThemeSettings { - const DEFAULT_LIGHT_THEME: &'static str = "One Light"; - const DEFAULT_DARK_THEME: &'static str = "One Dark"; +pub(crate) const DEFAULT_LIGHT_THEME: &'static str = "One Light"; +pub(crate) const DEFAULT_DARK_THEME: &'static str = "One Dark"; - /// Returns the name of the default theme for the given [`Appearance`]. - pub fn default_theme(appearance: Appearance) -> &'static str { - match appearance { - Appearance::Light => Self::DEFAULT_LIGHT_THEME, - Appearance::Dark => Self::DEFAULT_DARK_THEME, - } - } - - /// Reloads the current theme. - /// - /// Reads the [`ThemeSettings`] to know which theme should be loaded, - /// taking into account the current [`SystemAppearance`]. - pub fn reload_current_theme(cx: &mut App) { - let mut theme_settings = ThemeSettings::get_global(cx).clone(); - let system_appearance = SystemAppearance::global(cx); - - if let Some(theme_selection) = theme_settings.theme_selection.clone() { - let mut theme_name = theme_selection.theme(*system_appearance); - - // If the selected theme doesn't exist, fall back to a default theme - // based on the system appearance. - let theme_registry = ThemeRegistry::global(cx); - if let Err(err @ ThemeNotFoundError(_)) = theme_registry.get(theme_name) { - if theme_registry.extensions_loaded() { - log::error!("{err}"); - } - - theme_name = Self::default_theme(*system_appearance); - }; - - if let Some(_theme) = theme_settings.switch_theme(theme_name, cx) { - ThemeSettings::override_global(theme_settings, cx); - } - } - } - - /// Reloads the current icon theme. - /// - /// Reads the [`ThemeSettings`] to know which icon theme should be loaded, - /// taking into account the current [`SystemAppearance`]. - pub fn reload_current_icon_theme(cx: &mut App) { - let mut theme_settings = ThemeSettings::get_global(cx).clone(); - let system_appearance = SystemAppearance::global(cx); - - if let Some(icon_theme_selection) = theme_settings.icon_theme_selection.clone() { - let mut icon_theme_name = icon_theme_selection.icon_theme(*system_appearance); - - // If the selected icon theme doesn't exist, fall back to the default theme. - let theme_registry = ThemeRegistry::global(cx); - if let Err(err @ IconThemeNotFoundError(_)) = - theme_registry.get_icon_theme(icon_theme_name) - { - if theme_registry.extensions_loaded() { - log::error!("{err}"); - } - - icon_theme_name = DEFAULT_ICON_THEME_NAME; - }; - - if let Some(_theme) = theme_settings.switch_icon_theme(icon_theme_name, cx) { - ThemeSettings::override_global(theme_settings, cx); - } - } +/// Returns the name of the default theme for the given [`Appearance`]. +pub fn default_theme(appearance: Appearance) -> &'static str { + match appearance { + Appearance::Light => DEFAULT_LIGHT_THEME, + Appearance::Dark => DEFAULT_DARK_THEME, } } @@ -237,13 +171,6 @@ impl SystemAppearance { GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into())); } - /// Returns the global [`SystemAppearance`]. - /// - /// Inserts a default [`SystemAppearance`] if one does not yet exist. - pub(crate) fn default_global(cx: &mut App) -> Self { - cx.default_global::().0 - } - /// Returns the global [`SystemAppearance`]. pub fn global(cx: &App) -> Self { cx.global::().0 @@ -281,7 +208,7 @@ pub enum ThemeSelection { Dynamic { /// The mode used to determine which theme to use. #[serde(default)] - mode: ThemeMode, + mode: ThemeAppearanceMode, /// The theme to use for light mode. light: ThemeName, /// The theme to use for dark mode. @@ -302,22 +229,22 @@ impl From for ThemeSelection { impl ThemeSelection { /// Returns the theme name for the selected [ThemeMode]. - pub fn theme(&self, system_appearance: Appearance) -> &str { + pub fn name(&self, system_appearance: Appearance) -> ThemeName { match self { - Self::Static(theme) => &theme.0, + Self::Static(theme) => theme.clone(), Self::Dynamic { mode, light, dark } => match mode { - ThemeMode::Light => &light.0, - ThemeMode::Dark => &dark.0, - ThemeMode::System => match system_appearance { - Appearance::Light => &light.0, - Appearance::Dark => &dark.0, + ThemeAppearanceMode::Light => light.clone(), + ThemeAppearanceMode::Dark => dark.clone(), + ThemeAppearanceMode::System => match system_appearance { + Appearance::Light => light.clone(), + Appearance::Dark => dark.clone(), }, }, } } /// Returns the [ThemeMode] for the [ThemeSelection]. - pub fn mode(&self) -> Option { + pub fn mode(&self) -> Option { match self { ThemeSelection::Static(_) => None, ThemeSelection::Dynamic { mode, .. } => Some(*mode), @@ -333,7 +260,7 @@ pub enum IconThemeSelection { /// A dynamic icon theme selection, which can change based on the [`ThemeMode`]. Dynamic { /// The mode used to determine which theme to use. - mode: ThemeMode, + mode: ThemeAppearanceMode, /// The icon theme to use for light mode. light: IconThemeName, /// The icon theme to use for dark mode. @@ -354,22 +281,22 @@ impl From for IconThemeSelection { impl IconThemeSelection { /// Returns the icon theme name based on the given [`Appearance`]. - pub fn icon_theme(&self, system_appearance: Appearance) -> &str { + pub fn name(&self, system_appearance: Appearance) -> IconThemeName { match self { - Self::Static(theme) => &theme.0, + Self::Static(theme) => theme.clone(), Self::Dynamic { mode, light, dark } => match mode { - ThemeMode::Light => &light.0, - ThemeMode::Dark => &dark.0, - ThemeMode::System => match system_appearance { - Appearance::Light => &light.0, - Appearance::Dark => &dark.0, + ThemeAppearanceMode::Light => light.clone(), + ThemeAppearanceMode::Dark => dark.clone(), + ThemeAppearanceMode::System => match system_appearance { + Appearance::Light => light.clone(), + Appearance::Dark => dark.clone(), }, }, } } /// Returns the [`ThemeMode`] for the [`IconThemeSelection`]. - pub fn mode(&self) -> Option { + pub fn mode(&self) -> Option { match self { IconThemeSelection::Static(_) => None, IconThemeSelection::Dynamic { mode, .. } => Some(*mode), @@ -377,63 +304,80 @@ impl IconThemeSelection { } } -// impl ThemeSettingsContent { /// Sets the theme for the given appearance to the theme with the specified name. +/// +/// The caller should make sure that the [`Appearance`] matches the theme associated with the name. +/// +/// If the current [`ThemeAppearanceMode`] is set to [`System`] and the user's system [`Appearance`] +/// is different than the new theme's [`Appearance`], this function will update the +/// [`ThemeAppearanceMode`] to the new theme's appearance in order to display the new theme. +/// +/// [`System`]: ThemeAppearanceMode::System pub fn set_theme( current: &mut SettingsContent, theme_name: impl Into>, - appearance: Appearance, + theme_appearance: Appearance, + system_appearance: Appearance, ) { - if let Some(selection) = current.theme.theme.as_mut() { - let theme_to_update = match selection { - settings::ThemeSelection::Static(theme) => theme, - settings::ThemeSelection::Dynamic { mode, light, dark } => match mode { - ThemeMode::Light => light, - ThemeMode::Dark => dark, - ThemeMode::System => match appearance { - Appearance::Light => light, - Appearance::Dark => dark, - }, - }, - }; + let theme_name = ThemeName(theme_name.into()); - *theme_to_update = ThemeName(theme_name.into()); - } else { - current.theme.theme = Some(settings::ThemeSelection::Static(ThemeName( - theme_name.into(), - ))); + let Some(selection) = current.theme.theme.as_mut() else { + current.theme.theme = Some(settings::ThemeSelection::Static(theme_name)); + return; + }; + + match selection { + settings::ThemeSelection::Static(theme) => { + *theme = theme_name; + } + settings::ThemeSelection::Dynamic { mode, light, dark } => { + // Update the appropriate theme slot based on appearance. + match theme_appearance { + Appearance::Light => *light = theme_name, + Appearance::Dark => *dark = theme_name, + } + + // Don't update the theme mode if it is set to system and the new theme has the same + // appearance. + let should_update_mode = + !(mode == &ThemeAppearanceMode::System && theme_appearance == system_appearance); + + if should_update_mode { + // Update the mode to the specified appearance (otherwise we might set the theme and + // nothing gets updated because the system specified the other mode appearance). + *mode = ThemeAppearanceMode::from(theme_appearance); + } + } } } /// Sets the icon theme for the given appearance to the icon theme with the specified name. pub fn set_icon_theme( current: &mut SettingsContent, - icon_theme_name: String, + icon_theme_name: IconThemeName, appearance: Appearance, ) { if let Some(selection) = current.theme.icon_theme.as_mut() { let icon_theme_to_update = match selection { settings::IconThemeSelection::Static(theme) => theme, settings::IconThemeSelection::Dynamic { mode, light, dark } => match mode { - ThemeMode::Light => light, - ThemeMode::Dark => dark, - ThemeMode::System => match appearance { + ThemeAppearanceMode::Light => light, + ThemeAppearanceMode::Dark => dark, + ThemeAppearanceMode::System => match appearance { Appearance::Light => light, Appearance::Dark => dark, }, }, }; - *icon_theme_to_update = IconThemeName(icon_theme_name.into()); + *icon_theme_to_update = icon_theme_name; } else { - current.theme.icon_theme = Some(settings::IconThemeSelection::Static(IconThemeName( - icon_theme_name.into(), - ))); + current.theme.icon_theme = Some(settings::IconThemeSelection::Static(icon_theme_name)); } } /// Sets the mode for the theme. -pub fn set_mode(content: &mut SettingsContent, mode: ThemeMode) { +pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { let theme = content.theme.as_mut(); if let Some(selection) = theme.theme.as_mut() { @@ -456,8 +400,8 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeMode) { } else { theme.theme = Some(settings::ThemeSelection::Dynamic { mode, - light: ThemeName(ThemeSettings::DEFAULT_LIGHT_THEME.into()), - dark: ThemeName(ThemeSettings::DEFAULT_DARK_THEME.into()), + light: ThemeName(DEFAULT_LIGHT_THEME.into()), + dark: ThemeName(DEFAULT_DARK_THEME.into()), }); } @@ -549,7 +493,7 @@ impl ThemeSettings { .unwrap_or_else(|| self.ui_font_size(cx)) } - /// Returns the agent panel buffer font size. Falls back to the buffer font size if unset. + /// Returns the agent panel buffer font size. pub fn agent_buffer_font_size(&self, cx: &App) -> Pixels { cx.try_global::() .map(|size| size.0) @@ -596,44 +540,22 @@ impl ThemeSettings { f32::max(self.buffer_line_height.value(), MIN_LINE_HEIGHT) } - /// Switches to the theme with the given name, if it exists. - /// - /// Returns a `Some` containing the new theme if it was successful. - /// Returns `None` otherwise. - pub fn switch_theme(&mut self, theme: &str, cx: &mut App) -> Option> { - let themes = ThemeRegistry::default_global(cx); - - let mut new_theme = None; - - match themes.get(theme) { - Ok(theme) => { - self.active_theme = theme.clone(); - new_theme = Some(theme); - } - Err(err @ ThemeNotFoundError(_)) => { - log::error!("{err}"); - } - } - - self.apply_theme_overrides(); - - new_theme - } - /// Applies the theme overrides, if there are any, to the current theme. - pub fn apply_theme_overrides(&mut self) { + pub fn apply_theme_overrides(&self, mut arc_theme: Arc) -> Arc { // Apply the old overrides setting first, so that the new setting can override those. if let Some(experimental_theme_overrides) = &self.experimental_theme_overrides { - let mut theme = (*self.active_theme).clone(); + let mut theme = (*arc_theme).clone(); ThemeSettings::modify_theme(&mut theme, experimental_theme_overrides); - self.active_theme = Arc::new(theme); + arc_theme = Arc::new(theme); } - if let Some(theme_overrides) = self.theme_overrides.get(self.active_theme.name.as_ref()) { - let mut theme = (*self.active_theme).clone(); + if let Some(theme_overrides) = self.theme_overrides.get(arc_theme.name.as_ref()) { + let mut theme = (*arc_theme).clone(); ThemeSettings::modify_theme(&mut theme, theme_overrides); - self.active_theme = Arc::new(theme); + arc_theme = Arc::new(theme); } + + arc_theme } fn modify_theme(base_theme: &mut Theme, theme_overrides: &settings::ThemeStyleContent) { @@ -654,24 +576,6 @@ impl ThemeSettings { syntax_overrides(&theme_overrides), ); } - - /// Switches to the icon theme with the given name, if it exists. - /// - /// Returns a `Some` containing the new icon theme if it was successful. - /// Returns `None` otherwise. - pub fn switch_icon_theme(&mut self, icon_theme: &str, cx: &mut App) -> Option> { - let themes = ThemeRegistry::default_global(cx); - - let mut new_icon_theme = None; - - if let Some(icon_theme) = themes.get_icon_theme(icon_theme).log_err() { - self.active_icon_theme = icon_theme.clone(); - new_icon_theme = Some(icon_theme); - cx.refresh_windows(); - } - - new_icon_theme - } } /// Observe changes to the adjusted buffer font size. @@ -804,14 +708,11 @@ pub fn font_fallbacks_from_settings( } impl settings::Settings for ThemeSettings { - fn from_settings(content: &settings::SettingsContent, cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let content = &content.theme; - // todo(settings_refactor). This should *not* require cx... - let themes = ThemeRegistry::default_global(cx); - let system_appearance = SystemAppearance::default_global(cx); let theme_selection: ThemeSelection = content.theme.clone().unwrap().into(); let icon_theme_selection: IconThemeSelection = content.icon_theme.clone().unwrap().into(); - let mut this = Self { + Self { ui_font_size: clamp_font_size(content.ui_font_size.unwrap().into()), ui_font: Font { family: content.ui_font_family.as_ref().unwrap().0.clone().into(), @@ -837,31 +738,12 @@ impl settings::Settings for ThemeSettings { buffer_line_height: content.buffer_line_height.unwrap().into(), agent_ui_font_size: content.agent_ui_font_size.map(Into::into), agent_buffer_font_size: content.agent_buffer_font_size.map(Into::into), - active_theme: themes - .get(theme_selection.theme(*system_appearance)) - .or(themes.get(&zed_default_dark().name)) - .unwrap(), - theme_selection: Some(theme_selection), + theme: theme_selection, experimental_theme_overrides: content.experimental_theme_overrides.clone(), theme_overrides: content.theme_overrides.clone(), - active_icon_theme: themes - .get_icon_theme(icon_theme_selection.icon_theme(*system_appearance)) - .or_else(|_| themes.default_icon_theme()) - .unwrap(), - icon_theme_selection: Some(icon_theme_selection), + icon_theme: icon_theme_selection, ui_density: content.ui_density.unwrap_or_default().into(), unnecessary_code_fade: content.unnecessary_code_fade.unwrap().0.clamp(0.0, 0.9), - }; - this.apply_theme_overrides(); - this - } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut SettingsContent) { - vscode.from_f32_setting("editor.fontWeight", &mut current.theme.buffer_font_weight); - vscode.from_f32_setting("editor.fontSize", &mut current.theme.buffer_font_size); - if let Some(font) = vscode.read_string("editor.font") { - current.theme.buffer_font_family = Some(FontFamilyName(font.into())); } - // TODO: possibly map editor.fontLigatures to buffer_font_features? } } diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 198ad97adb..905f2245e0 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -128,6 +128,7 @@ pub struct ThemeColors { pub tab_inactive_background: Hsla, pub tab_active_background: Hsla, pub search_match_background: Hsla, + pub search_active_match_background: Hsla, pub panel_background: Hsla, pub panel_focused_border: Hsla, pub panel_indent_guide: Hsla, @@ -162,6 +163,25 @@ pub struct ThemeColors { /// The border color of the minimap thumb. pub minimap_thumb_border: Hsla, + /// Background color for Vim Normal mode indicator. + pub vim_normal_background: Hsla, + /// Background color for Vim Insert mode indicator. + pub vim_insert_background: Hsla, + /// Background color for Vim Replace mode indicator. + pub vim_replace_background: Hsla, + /// Background color for Vim Visual mode indicator. + pub vim_visual_background: Hsla, + /// Background color for Vim Visual Line mode indicator. + pub vim_visual_line_background: Hsla, + /// Background color for Vim Visual Block mode indicator. + pub vim_visual_block_background: Hsla, + /// Background color for Vim Helix Normal mode indicator. + pub vim_helix_normal_background: Hsla, + /// Background color for Vim Helix Select mode indicator. + pub vim_helix_select_background: Hsla, + /// Text color for Vim mode indicator label. + pub vim_mode_text: Hsla, + // === // Editor // === @@ -281,7 +301,10 @@ pub struct ThemeColors { pub version_control_conflict: Hsla, /// Represents an ignored entry in version control systems. pub version_control_ignored: Hsla, - + /// Represents an added word in a word diff. + pub version_control_word_added: Hsla, + /// Represents a deleted word in a word diff. + pub version_control_word_deleted: Hsla, /// Represents the "ours" region of a merge conflict. pub version_control_conflict_marker_ours: Hsla, /// Represents the "theirs" region of a merge conflict. @@ -330,6 +353,7 @@ pub enum ThemeColorField { TabInactiveBackground, TabActiveBackground, SearchMatchBackground, + SearchActiveMatchBackground, PanelBackground, PanelFocusedBorder, PanelIndentGuide, @@ -445,6 +469,7 @@ impl ThemeColors { ThemeColorField::TabInactiveBackground => self.tab_inactive_background, ThemeColorField::TabActiveBackground => self.tab_active_background, ThemeColorField::SearchMatchBackground => self.search_match_background, + ThemeColorField::SearchActiveMatchBackground => self.search_active_match_background, ThemeColorField::PanelBackground => self.panel_background, ThemeColorField::PanelFocusedBorder => self.panel_focused_border, ThemeColorField::PanelIndentGuide => self.panel_indent_guide, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 5b12e4d33b..c94e0d60bf 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -27,6 +27,8 @@ use ::settings::SettingsStore; use anyhow::Result; use fallback_themes::apply_status_color_defaults; use fs::Fs; +use gpui::BorrowAppContext; +use gpui::Global; use gpui::{ App, AssetSource, HighlightStyle, Hsla, Pixels, Refineable, SharedString, WindowAppearance, WindowBackgroundAppearance, px, @@ -82,6 +84,15 @@ impl From for Appearance { } } +impl From for ThemeAppearanceMode { + fn from(value: Appearance) -> Self { + match value { + Appearance::Light => Self::Light, + Appearance::Dark => Self::Dark, + } + } +} + /// Which themes should be loaded. This is used primarily for testing. pub enum LoadThemes { /// Only load the base theme. @@ -95,6 +106,7 @@ pub enum LoadThemes { /// Initialize the theme system. pub fn init(themes_to_load: LoadThemes, cx: &mut App) { + SystemAppearance::init(cx); let (assets, load_user_themes) = match themes_to_load { LoadThemes::JustBase => (Box::new(()) as Box, false), LoadThemes::All(assets) => (assets, true), @@ -105,43 +117,69 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) { ThemeRegistry::global(cx).load_bundled_themes(); } - ThemeSettings::register(cx); FontFamilyCache::init_global(cx); - let mut prev_buffer_font_size_settings = - ThemeSettings::get_global(cx).buffer_font_size_settings(); - let mut prev_ui_font_size_settings = ThemeSettings::get_global(cx).ui_font_size_settings(); - let mut prev_agent_ui_font_size_settings = - ThemeSettings::get_global(cx).agent_ui_font_size_settings(); - let mut prev_agent_buffer_font_size_settings = - ThemeSettings::get_global(cx).agent_buffer_font_size_settings(); + let theme = GlobalTheme::configured_theme(cx); + let icon_theme = GlobalTheme::configured_icon_theme(cx); + cx.set_global(GlobalTheme { theme, icon_theme }); + + let settings = ThemeSettings::get_global(cx); + + let mut prev_buffer_font_size_settings = settings.buffer_font_size_settings(); + let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); + let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); + let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); + let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); + let mut prev_theme_overrides = ( + settings.experimental_theme_overrides.clone(), + settings.theme_overrides.clone(), + ); cx.observe_global::(move |cx| { - let buffer_font_size_settings = ThemeSettings::get_global(cx).buffer_font_size_settings(); + let settings = ThemeSettings::get_global(cx); + + let buffer_font_size_settings = settings.buffer_font_size_settings(); + let ui_font_size_settings = settings.ui_font_size_settings(); + let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); + let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let theme_name = settings.theme.name(SystemAppearance::global(cx).0); + let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); + let theme_overrides = ( + settings.experimental_theme_overrides.clone(), + settings.theme_overrides.clone(), + ); + if buffer_font_size_settings != prev_buffer_font_size_settings { prev_buffer_font_size_settings = buffer_font_size_settings; reset_buffer_font_size(cx); } - let ui_font_size_settings = ThemeSettings::get_global(cx).ui_font_size_settings(); if ui_font_size_settings != prev_ui_font_size_settings { prev_ui_font_size_settings = ui_font_size_settings; reset_ui_font_size(cx); } - let agent_ui_font_size_settings = - ThemeSettings::get_global(cx).agent_ui_font_size_settings(); if agent_ui_font_size_settings != prev_agent_ui_font_size_settings { prev_agent_ui_font_size_settings = agent_ui_font_size_settings; reset_agent_ui_font_size(cx); } - let agent_buffer_font_size_settings = - ThemeSettings::get_global(cx).agent_buffer_font_size_settings(); if agent_buffer_font_size_settings != prev_agent_buffer_font_size_settings { prev_agent_buffer_font_size_settings = agent_buffer_font_size_settings; reset_agent_buffer_font_size(cx); } + + if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { + prev_theme_name = theme_name; + prev_theme_overrides = theme_overrides; + GlobalTheme::reload_theme(cx); + } + + if icon_theme_name != prev_icon_theme_name { + prev_icon_theme_name = icon_theme_name; + GlobalTheme::reload_icon_theme(cx); + } }) .detach(); } @@ -154,7 +192,7 @@ pub trait ActiveTheme { impl ActiveTheme for App { fn theme(&self) -> &Arc { - &ThemeSettings::get_global(self).active_theme + GlobalTheme::theme(self) } } @@ -378,8 +416,8 @@ impl Theme { /// Asynchronously reads the user theme from the specified path. pub async fn read_user_theme(theme_path: &Path, fs: Arc) -> Result { - let reader = fs.open_sync(theme_path).await?; - let theme_family: ThemeFamilyContent = serde_json_lenient::from_reader(reader)?; + let bytes = fs.load_bytes(theme_path).await?; + let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(&bytes)?; for theme in &theme_family.themes { if theme @@ -403,8 +441,87 @@ pub async fn read_icon_theme( icon_theme_path: &Path, fs: Arc, ) -> Result { - let reader = fs.open_sync(icon_theme_path).await?; - let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_reader(reader)?; + let bytes = fs.load_bytes(icon_theme_path).await?; + let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_slice(&bytes)?; Ok(icon_theme_family) } + +/// The active theme +pub struct GlobalTheme { + theme: Arc, + icon_theme: Arc, +} +impl Global for GlobalTheme {} + +impl GlobalTheme { + fn configured_theme(cx: &mut App) -> Arc { + let themes = ThemeRegistry::default_global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let system_appearance = SystemAppearance::global(cx); + + let theme_name = theme_settings.theme.name(*system_appearance); + + let theme = match themes.get(&theme_name.0) { + Ok(theme) => theme, + Err(err) => { + if themes.extensions_loaded() { + log::error!("{err}"); + } + themes + .get(default_theme(*system_appearance)) + // fallback for tests. + .unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap()) + } + }; + theme_settings.apply_theme_overrides(theme) + } + + /// Reloads the current theme. + /// + /// Reads the [`ThemeSettings`] to know which theme should be loaded, + /// taking into account the current [`SystemAppearance`]. + pub fn reload_theme(cx: &mut App) { + let theme = Self::configured_theme(cx); + cx.update_global::(|this, _| this.theme = theme); + cx.refresh_windows(); + } + + fn configured_icon_theme(cx: &mut App) -> Arc { + let themes = ThemeRegistry::default_global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let system_appearance = SystemAppearance::global(cx); + + let icon_theme_name = theme_settings.icon_theme.name(*system_appearance); + + match themes.get_icon_theme(&icon_theme_name.0) { + Ok(theme) => theme, + Err(err) => { + if themes.extensions_loaded() { + log::error!("{err}"); + } + themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap() + } + } + } + + /// Reloads the current icon theme. + /// + /// Reads the [`ThemeSettings`] to know which icon theme should be loaded, + /// taking into account the current [`SystemAppearance`]. + pub fn reload_icon_theme(cx: &mut App) { + let icon_theme = Self::configured_icon_theme(cx); + cx.update_global::(|this, _| this.icon_theme = icon_theme); + cx.refresh_windows(); + } + + /// the active theme + pub fn theme(cx: &App) -> &Arc { + &cx.global::().theme + } + + /// the active icon theme + pub fn icon_theme(cx: &App) -> &Arc { + &cx.global::().icon_theme + } +} diff --git a/crates/theme_extension/Cargo.toml b/crates/theme_extension/Cargo.toml index 718c35d4e2..d94e15914b 100644 --- a/crates/theme_extension/Cargo.toml +++ b/crates/theme_extension/Cargo.toml @@ -17,4 +17,3 @@ extension.workspace = true fs.workspace = true gpui.workspace = true theme.workspace = true -workspace-hack.workspace = true diff --git a/crates/theme_extension/src/theme_extension.rs b/crates/theme_extension/src/theme_extension.rs index b9c6ed6d4b..10df2349c8 100644 --- a/crates/theme_extension/src/theme_extension.rs +++ b/crates/theme_extension/src/theme_extension.rs @@ -5,7 +5,7 @@ use anyhow::Result; use extension::{ExtensionHostProxy, ExtensionThemeProxy}; use fs::Fs; use gpui::{App, BackgroundExecutor, SharedString, Task}; -use theme::{ThemeRegistry, ThemeSettings}; +use theme::{GlobalTheme, ThemeRegistry}; pub fn init( extension_host_proxy: Arc, @@ -46,7 +46,7 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { } fn reload_current_theme(&self, cx: &mut App) { - ThemeSettings::reload_current_theme(cx) + GlobalTheme::reload_theme(cx) } fn list_icon_theme_names( @@ -83,6 +83,6 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { } fn reload_current_icon_theme(&self, cx: &mut App) { - ThemeSettings::reload_current_icon_theme(cx) + GlobalTheme::reload_icon_theme(cx) } } diff --git a/crates/theme_importer/Cargo.toml b/crates/theme_importer/Cargo.toml index 2fef5a6249..a91ffc4454 100644 --- a/crates/theme_importer/Cargo.toml +++ b/crates/theme_importer/Cargo.toml @@ -23,4 +23,3 @@ simplelog.workspace= true strum = { workspace = true, features = ["derive"] } theme.workspace = true vscode_theme = "0.2.0" -workspace-hack.workspace = true diff --git a/crates/theme_importer/src/main.rs b/crates/theme_importer/src/main.rs index 0ea6bbc4bc..24291fc511 100644 --- a/crates/theme_importer/src/main.rs +++ b/crates/theme_importer/src/main.rs @@ -2,7 +2,7 @@ mod color; mod vscode; use std::fs::File; -use std::io::Write; +use std::io::{Read, Write}; use std::path::PathBuf; use anyhow::{Context as _, Result}; @@ -89,15 +89,16 @@ fn main() -> Result<()> { let theme_file_path = args.theme_path; - let theme_file = match File::open(&theme_file_path) { - Ok(file) => file, + let mut buffer = Vec::new(); + match File::open(&theme_file_path).and_then(|mut file| file.read_to_end(&mut buffer)) { + Ok(_) => {} Err(err) => { log::info!("Failed to open file at path: {:?}", theme_file_path); return Err(err)?; } }; - let vscode_theme: VsCodeTheme = serde_json_lenient::from_reader(theme_file) + let vscode_theme: VsCodeTheme = serde_json_lenient::from_slice(&buffer) .context(format!("failed to parse theme {theme_file_path:?}"))?; let theme_metadata = ThemeMetadata { diff --git a/crates/theme_selector/Cargo.toml b/crates/theme_selector/Cargo.toml index 8ec3e5b63f..1a563e81f2 100644 --- a/crates/theme_selector/Cargo.toml +++ b/crates/theme_selector/Cargo.toml @@ -26,6 +26,5 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 5cd04aa895..2ea3436d43 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -7,7 +7,10 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use settings::{Settings as _, SettingsStore, update_settings_file}; use std::sync::Arc; -use theme::{Appearance, IconTheme, ThemeMeta, ThemeRegistry, ThemeSettings}; +use theme::{ + Appearance, IconThemeName, IconThemeSelection, SystemAppearance, ThemeMeta, ThemeRegistry, + ThemeSettings, +}; use ui::{ListItem, ListItemSpacing, prelude::*, v_flex}; use util::ResultExt; use workspace::{ModalView, ui::HighlightedLabel}; @@ -51,9 +54,9 @@ pub(crate) struct IconThemeSelectorDelegate { fs: Arc, themes: Vec, matches: Vec, - original_theme: Arc, + original_theme: IconThemeName, selection_completed: bool, - selected_theme: Option>, + selected_theme: Option, selected_index: usize, selector: WeakEntity, } @@ -66,7 +69,9 @@ impl IconThemeSelectorDelegate { cx: &mut Context, ) -> Self { let theme_settings = ThemeSettings::get_global(cx); - let original_theme = theme_settings.active_icon_theme.clone(); + let original_theme = theme_settings + .icon_theme + .name(SystemAppearance::global(cx).0); let registry = ThemeRegistry::global(cx); let mut themes = registry @@ -107,29 +112,18 @@ impl IconThemeSelectorDelegate { selector, }; - this.select_if_matching(&original_theme.name); + this.select_if_matching(&original_theme.0); this } fn show_selected_theme( &mut self, cx: &mut Context>, - ) -> Option> { - if let Some(mat) = self.matches.get(self.selected_index) { - let registry = ThemeRegistry::global(cx); - match registry.get_icon_theme(&mat.string) { - Ok(theme) => { - Self::set_icon_theme(theme.clone(), cx); - Some(theme) - } - Err(err) => { - log::error!("error loading icon theme {}: {err}", mat.string); - None - } - } - } else { - None - } + ) -> Option { + let mat = self.matches.get(self.selected_index)?; + let name = IconThemeName(mat.string.clone().into()); + Self::set_icon_theme(name.clone(), cx); + Some(name) } fn select_if_matching(&mut self, theme_name: &str) { @@ -140,12 +134,11 @@ impl IconThemeSelectorDelegate { .unwrap_or(self.selected_index); } - fn set_icon_theme(theme: Arc, cx: &mut App) { - SettingsStore::update_global(cx, |store, cx| { + fn set_icon_theme(name: IconThemeName, cx: &mut App) { + SettingsStore::update_global(cx, |store, _| { let mut theme_settings = store.get::(None).clone(); - theme_settings.active_icon_theme = theme; + theme_settings.icon_theme = IconThemeSelection::Static(name); store.override_global(theme_settings); - cx.refresh_windows(); }); } } @@ -170,7 +163,9 @@ impl PickerDelegate for IconThemeSelectorDelegate { self.selection_completed = true; let theme_settings = ThemeSettings::get_global(cx); - let theme_name = theme_settings.active_icon_theme.name.clone(); + let theme_name = theme_settings + .icon_theme + .name(SystemAppearance::global(cx).0); telemetry::event!( "Settings Changed", @@ -181,7 +176,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { let appearance = Appearance::from(window.appearance()); update_settings_file(self.fs.clone(), cx, move |settings, _| { - theme::set_icon_theme(settings, theme_name.to_string(), appearance); + theme::set_icon_theme(settings, theme_name, appearance); }); self.selector @@ -268,7 +263,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { .matches .iter() .enumerate() - .find(|(_, mtch)| mtch.string == selected.name) + .find(|(_, mtch)| mtch.string.as_str() == selected.0.as_ref()) .map(|(ix, _)| ix) .unwrap_or_default(); } else { diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index de41f3155f..74b242dd0b 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -7,9 +7,12 @@ use gpui::{ Window, actions, }; use picker::{Picker, PickerDelegate}; -use settings::{SettingsStore, update_settings_file}; +use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::Arc; -use theme::{Appearance, Theme, ThemeMeta, ThemeRegistry, ThemeSettings}; +use theme::{ + Appearance, SystemAppearance, Theme, ThemeAppearanceMode, ThemeMeta, ThemeName, ThemeRegistry, + ThemeSelection, ThemeSettings, +}; use ui::{ListItem, ListItemSpacing, prelude::*, v_flex}; use util::ResultExt; use workspace::{ModalView, Workspace, ui::HighlightedLabel, with_active_or_new_workspace}; @@ -114,7 +117,14 @@ struct ThemeSelectorDelegate { fs: Arc, themes: Vec, matches: Vec, - original_theme: Arc, + /// The theme that was selected before the `ThemeSelector` menu was opened. + /// + /// We use this to return back to theme that was set if the user dismisses the menu. + original_theme_settings: ThemeSettings, + /// The current system appearance. + original_system_appearance: Appearance, + /// The currently selected new theme. + new_theme: Arc, selection_completed: bool, selected_theme: Option>, selected_index: usize, @@ -129,6 +139,8 @@ impl ThemeSelectorDelegate { cx: &mut Context, ) -> Self { let original_theme = cx.theme().clone(); + let original_theme_settings = ThemeSettings::get_global(cx).clone(); + let original_system_appearance = SystemAppearance::global(cx).0; let registry = ThemeRegistry::global(cx); let mut themes = registry @@ -143,13 +155,15 @@ impl ThemeSelectorDelegate { }) .collect::>(); + // Sort by dark vs light, then by name. themes.sort_unstable_by(|a, b| { a.appearance .is_light() .cmp(&b.appearance.is_light()) .then(a.name.cmp(&b.name)) }); - let matches = themes + + let matches: Vec = themes .iter() .map(|meta| StringMatch { candidate_id: 0, @@ -158,19 +172,25 @@ impl ThemeSelectorDelegate { string: meta.name.to_string(), }) .collect(); - let mut this = Self { + + // The current theme is likely in this list, so default to first showing that. + let selected_index = matches + .iter() + .position(|mat| mat.string == original_theme.name) + .unwrap_or(0); + + Self { fs, themes, matches, - original_theme: original_theme.clone(), - selected_index: 0, + original_theme_settings, + original_system_appearance, + new_theme: original_theme, // Start with the original theme. + selected_index, selection_completed: false, selected_theme: None, selector, - }; - - this.select_if_matching(&original_theme.name); - this + } } fn show_selected_theme( @@ -179,9 +199,10 @@ impl ThemeSelectorDelegate { ) -> Option> { if let Some(mat) = self.matches.get(self.selected_index) { let registry = ThemeRegistry::global(cx); + match registry.get(&mat.string) { Ok(theme) => { - Self::set_theme(theme.clone(), cx); + self.set_theme(theme.clone(), cx); Some(theme) } Err(error) => { @@ -194,22 +215,122 @@ impl ThemeSelectorDelegate { } } - fn select_if_matching(&mut self, theme_name: &str) { - self.selected_index = self - .matches - .iter() - .position(|mat| mat.string == theme_name) - .unwrap_or(self.selected_index); - } - - fn set_theme(theme: Arc, cx: &mut App) { - SettingsStore::update_global(cx, |store, cx| { - let mut theme_settings = store.get::(None).clone(); - theme_settings.active_theme = theme; - theme_settings.apply_theme_overrides(); - store.override_global(theme_settings); - cx.refresh_windows(); + fn set_theme(&mut self, new_theme: Arc, cx: &mut App) { + // Update the global (in-memory) theme settings. + SettingsStore::update_global(cx, |store, _| { + override_global_theme( + store, + &new_theme, + &self.original_theme_settings.theme, + self.original_system_appearance, + ) }); + + self.new_theme = new_theme; + } +} + +/// Overrides the global (in-memory) theme settings. +/// +/// Note that this does **not** update the user's `settings.json` file (see the +/// [`ThemeSelectorDelegate::confirm`] method and [`theme::set_theme`] function). +fn override_global_theme( + store: &mut SettingsStore, + new_theme: &Theme, + original_theme: &ThemeSelection, + system_appearance: Appearance, +) { + let theme_name = ThemeName(new_theme.name.clone().into()); + let new_appearance = new_theme.appearance(); + let new_theme_is_light = new_appearance.is_light(); + + let mut curr_theme_settings = store.get::(None).clone(); + + match (original_theme, &curr_theme_settings.theme) { + // Override the currently selected static theme. + (ThemeSelection::Static(_), ThemeSelection::Static(_)) => { + curr_theme_settings.theme = ThemeSelection::Static(theme_name); + } + + // If the current theme selection is dynamic, then only override the global setting for the + // specific mode (light or dark). + ( + ThemeSelection::Dynamic { + mode: original_mode, + light: original_light, + dark: original_dark, + }, + ThemeSelection::Dynamic { .. }, + ) => { + let new_mode = update_mode_if_new_appearance_is_different_from_system( + original_mode, + system_appearance, + new_appearance, + ); + + let updated_theme = retain_original_opposing_theme( + new_theme_is_light, + new_mode, + theme_name, + original_light, + original_dark, + ); + + curr_theme_settings.theme = updated_theme; + } + + // The theme selection mode changed while selecting new themes (someone edited the settings + // file on disk while we had the dialogue open), so don't do anything. + _ => return, + }; + + store.override_global(curr_theme_settings); +} + +/// Helper function for determining the new [`ThemeAppearanceMode`] for the new theme. +/// +/// If the the original theme mode was [`System`] and the new theme's appearance matches the system +/// appearance, we don't need to change the mode setting. +/// +/// Otherwise, we need to change the mode in order to see the new theme. +/// +/// [`System`]: ThemeAppearanceMode::System +fn update_mode_if_new_appearance_is_different_from_system( + original_mode: &ThemeAppearanceMode, + system_appearance: Appearance, + new_appearance: Appearance, +) -> ThemeAppearanceMode { + if original_mode == &ThemeAppearanceMode::System && system_appearance == new_appearance { + ThemeAppearanceMode::System + } else { + ThemeAppearanceMode::from(new_appearance) + } +} + +/// Helper function for updating / displaying the [`ThemeSelection`] while using the theme selector. +/// +/// We want to retain the alternate theme selection of the original settings (before the menu was +/// opened), not the currently selected theme (which likely has changed multiple times while the +/// menu has been open). +fn retain_original_opposing_theme( + new_theme_is_light: bool, + new_mode: ThemeAppearanceMode, + theme_name: ThemeName, + original_light: &ThemeName, + original_dark: &ThemeName, +) -> ThemeSelection { + if new_theme_is_light { + ThemeSelection::Dynamic { + mode: new_mode, + light: theme_name, + dark: original_dark.clone(), + } + } else { + ThemeSelection::Dynamic { + mode: new_mode, + light: original_light.clone(), + dark: theme_name, + } } } @@ -226,20 +347,20 @@ impl PickerDelegate for ThemeSelectorDelegate { fn confirm( &mut self, - _: bool, - window: &mut Window, + _secondary: bool, + _window: &mut Window, cx: &mut Context>, ) { self.selection_completed = true; - let theme_name = cx.theme().name.clone(); + let theme_name: Arc = self.new_theme.name.as_str().into(); + let theme_appearance = self.new_theme.appearance; + let system_appearance = SystemAppearance::global(cx).0; telemetry::event!("Settings Changed", setting = "theme", value = theme_name); - let appearance = Appearance::from(window.appearance()); - update_settings_file(self.fs.clone(), cx, move |settings, _| { - theme::set_theme(settings, theme_name.to_string(), appearance); + theme::set_theme(settings, theme_name, theme_appearance, system_appearance); }); self.selector @@ -251,7 +372,9 @@ impl PickerDelegate for ThemeSelectorDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { if !self.selection_completed { - Self::set_theme(self.original_theme.clone(), cx); + SettingsStore::update_global(cx, |store, _| { + store.override_global(self.original_theme_settings.clone()); + }); self.selection_completed = true; } diff --git a/crates/time_format/Cargo.toml b/crates/time_format/Cargo.toml index 5175a26a78..b598d19887 100644 --- a/crates/time_format/Cargo.toml +++ b/crates/time_format/Cargo.toml @@ -15,7 +15,6 @@ doctest = false [dependencies] sys-locale.workspace = true time.workspace = true -workspace-hack.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation.workspace = true diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 127fad3d8b..6d5d0ce170 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -30,6 +30,7 @@ test-support = [ anyhow.workspace = true auto_update.workspace = true call.workspace = true +channel.workspace = true chrono.workspace = true client.workspace = true cloud_llm_client.workspace = true @@ -50,7 +51,6 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 4a8cac2435..817b73c45e 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -110,16 +110,24 @@ impl ApplicationMenu { .into_iter() .fold(menu, |menu, item| match item { OwnedMenuItem::Separator => menu.separator(), - OwnedMenuItem::Action { name, action, .. } => menu.action(name, action), + OwnedMenuItem::Action { + name, + action, + checked, + .. + } => menu.action_checked(name, action, checked), OwnedMenuItem::Submenu(submenu) => { submenu .items .into_iter() .fold(menu, |menu, item| match item { OwnedMenuItem::Separator => menu.separator(), - OwnedMenuItem::Action { name, action, .. } => { - menu.action(name, action) - } + OwnedMenuItem::Action { + name, + action, + checked, + .. + } => menu.action_checked(name, action, checked), OwnedMenuItem::Submenu(_) => menu, OwnedMenuItem::SystemMenu(_) => { // A system menu doesn't make sense in this context, so ignore it @@ -143,10 +151,10 @@ impl ApplicationMenu { // Application menu must have same ids as first menu item in standard menu div() - .id(SharedString::from(format!("{}-menu-item", menu_name))) + .id(format!("{}-menu-item", menu_name)) .occlude() .child( - PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name))) + PopoverMenu::new(format!("{}-menu-popover", menu_name)) .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) @@ -176,10 +184,10 @@ impl ApplicationMenu { .collect(); div() - .id(SharedString::from(format!("{}-menu-item", menu_name))) + .id(format!("{}-menu-item", menu_name)) .occlude() .child( - PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name))) + PopoverMenu::new(format!("{}-menu-popover", menu_name)) .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index b5a51976a0..8a2d23dd26 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -2,35 +2,27 @@ use std::rc::Rc; use std::sync::Arc; use call::{ActiveCall, ParticipantLocation, Room}; +use channel::ChannelStore; use client::{User, proto::PeerId}; use gpui::{ AnyElement, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, WeakEntity, canvas, point, }; -use gpui::{App, Task, Window, actions}; +use gpui::{App, Task, Window}; +use project::WorktreeSettings; use rpc::proto::{self}; +use settings::{Settings as _, SettingsLocation}; use theme::ActiveTheme; use ui::{ Avatar, AvatarAudioStatusIndicator, ContextMenu, ContextMenuItem, Divider, DividerColor, Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*, }; +use util::rel_path::RelPath; use workspace::notifications::DetachAndPromptErr; use crate::TitleBar; -actions!( - collab, - [ - /// Toggles screen sharing on or off. - ToggleScreenSharing, - /// Toggles microphone mute. - ToggleMute, - /// Toggles deafen mode (mute both microphone and speakers). - ToggleDeafen - ] -); - -fn toggle_screen_sharing( +pub fn toggle_screen_sharing( screen: anyhow::Result>>, window: &mut Window, cx: &mut App, @@ -86,7 +78,7 @@ fn toggle_screen_sharing( toggle_screen_sharing.detach_and_prompt_err("Sharing Screen Failed", window, cx, |e, _, _| Some(format!("{:?}\n\nPlease check that you have given Zed permissions to record your screen in Settings.", e))); } -fn toggle_mute(_: &ToggleMute, cx: &mut App) { +pub fn toggle_mute(cx: &mut App) { let call = ActiveCall::global(cx).read(cx); if let Some(room) = call.room().cloned() { room.update(cx, |room, cx| { @@ -106,7 +98,7 @@ fn toggle_mute(_: &ToggleMute, cx: &mut App) { } } -fn toggle_deafen(_: &ToggleDeafen, cx: &mut App) { +pub fn toggle_deafen(cx: &mut App) { if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { room.update(cx, |room, cx| room.toggle_deafen(cx)); } @@ -178,7 +170,9 @@ impl TitleBar { this.children(current_user_face_pile.map(|face_pile| { v_flex() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) .child(face_pile) .child(render_color_ribbon(player_colors.local().cursor)) })) @@ -213,9 +207,14 @@ impl TitleBar { .child(facepile) .child(render_color_ribbon(player_color.cursor)) .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) .on_click({ let peer_id = collaborator.peer_id; cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.workspace .update(cx, |workspace, cx| { if is_following { @@ -347,6 +346,11 @@ impl TitleBar { let can_share_projects = room.can_share_projects(); let screen_sharing_supported = cx.is_screen_capture_supported(); + let channel_store = ChannelStore::global(cx); + let channel = room + .channel_id() + .and_then(|channel_id| channel_store.read(cx).channel_for_id(channel_id).cloned()); + let mut children = Vec::new(); children.push( @@ -368,6 +372,20 @@ impl TitleBar { ); if is_local && can_share_projects && !is_connecting_to_project { + let is_sharing_disabled = channel.is_some_and(|channel| match channel.visibility { + proto::ChannelVisibility::Public => project.visible_worktrees(cx).any(|worktree| { + let worktree_id = worktree.read(cx).id(); + + let settings_location = Some(SettingsLocation { + worktree_id, + path: RelPath::empty(), + }); + + WorktreeSettings::get(settings_location, cx).prevent_sharing_in_public_channels + }), + proto::ChannelVisibility::Members => false, + }); + children.push( Button::new( "toggle_sharing", @@ -382,6 +400,11 @@ impl TitleBar { .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .toggle_state(is_shared) .label_size(LabelSize::Small) + .when(is_sharing_disabled, |parent| { + parent.disabled(true).tooltip(Tooltip::text( + "This project may not be shared in a public channel.", + )) + }) .on_click(cx.listener(move |this, _, window, cx| { if is_shared { this.unshare_project(window, cx); @@ -403,14 +426,13 @@ impl TitleBar { IconName::Mic }, ) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if is_muted { if is_deafened { Tooltip::with_meta( "Unmute Microphone", None, "Audio will be unmuted", - window, cx, ) } else { @@ -424,9 +446,7 @@ impl TitleBar { .icon_size(IconSize::Small) .toggle_state(is_muted) .selected_style(ButtonStyle::Tinted(TintColor::Error)) - .on_click(move |_, _window, cx| { - toggle_mute(&Default::default(), cx); - }) + .on_click(move |_, _window, cx| toggle_mute(cx)) .into_any_element(), ); } @@ -444,12 +464,12 @@ impl TitleBar { .selected_style(ButtonStyle::Tinted(TintColor::Error)) .icon_size(IconSize::Small) .toggle_state(is_deafened) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if is_deafened { let label = "Unmute Audio"; if !muted_by_user { - Tooltip::with_meta(label, None, "Microphone will be unmuted", window, cx) + Tooltip::with_meta(label, None, "Microphone will be unmuted", cx) } else { Tooltip::simple(label, cx) } @@ -457,13 +477,13 @@ impl TitleBar { let label = "Mute Audio"; if !muted_by_user { - Tooltip::with_meta(label, None, "Microphone will be muted", window, cx) + Tooltip::with_meta(label, None, "Microphone will be muted", cx) } else { Tooltip::simple(label, cx) } } }) - .on_click(move |_, _, cx| toggle_deafen(&Default::default(), cx)) + .on_click(move |_, _, cx| toggle_deafen(cx)) .into_any_element(), ); diff --git a/crates/title_bar/src/onboarding_banner.rs b/crates/title_bar/src/onboarding_banner.rs index 6adc576949..750ef0a6cd 100644 --- a/crates/title_bar/src/onboarding_banner.rs +++ b/crates/title_bar/src/onboarding_banner.rs @@ -154,12 +154,11 @@ impl Render for OnboardingBanner { telemetry::event!("Banner Dismissed", source = this.source); this.dismiss(cx) })) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Close Announcement Banner", None, "It won't show again for this feature", - window, cx, ) }), diff --git a/crates/title_bar/src/platform_title_bar.rs b/crates/title_bar/src/platform_title_bar.rs index c816f0930c..6ce7d089bb 100644 --- a/crates/title_bar/src/platform_title_bar.rs +++ b/crates/title_bar/src/platform_title_bar.rs @@ -77,6 +77,47 @@ impl Render for PlatformTitleBar { .window_control_area(WindowControlArea::Drag) .w_full() .h(height) + .map(|this| { + this.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| { + this.should_move = false; + })) + .on_mouse_up( + gpui::MouseButton::Left, + cx.listener(move |this, _ev, _window, _cx| { + this.should_move = false; + }), + ) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _ev, _window, _cx| { + this.should_move = true; + }), + ) + .on_mouse_move(cx.listener(move |this, _ev, window, _| { + if this.should_move { + this.should_move = false; + window.start_window_move(); + } + })) + }) + .map(|this| { + // Note: On Windows the title bar behavior is handled by the platform implementation. + this.id(self.id.clone()) + .when(self.platform_style == PlatformStyle::Mac, |this| { + this.on_click(|event, window, _| { + if event.click_count() == 2 { + window.titlebar_double_click(); + } + }) + }) + .when(self.platform_style == PlatformStyle::Linux, |this| { + this.on_click(|event, window, _| { + if event.click_count() == 2 { + window.zoom_window(); + } + }) + }) + }) .map(|this| { if window.is_fullscreen() { this.pl_2() @@ -97,6 +138,7 @@ impl Render for PlatformTitleBar { }) // this border is to avoid a transparent gap in the rounded corners .mt(px(-1.)) + .mb(px(-1.)) .border(px(1.)) .border_color(titlebar_color), }) @@ -111,21 +153,6 @@ impl Render for PlatformTitleBar { .justify_between() .overflow_x_hidden() .w_full() - // Note: On Windows the title bar behavior is handled by the platform implementation. - .when(self.platform_style == PlatformStyle::Mac, |this| { - this.on_click(|event, window, _| { - if event.click_count() == 2 { - window.titlebar_double_click(); - } - }) - }) - .when(self.platform_style == PlatformStyle::Linux, |this| { - this.on_click(|event, window, _| { - if event.click_count() == 2 { - window.zoom_window(); - } - }) - }) .children(children), ) .when(!window.is_fullscreen(), |title_bar| { @@ -141,27 +168,6 @@ impl Render for PlatformTitleBar { window.show_window_menu(ev.position) }) }) - .on_mouse_move(cx.listener(move |this, _ev, window, _| { - if this.should_move { - this.should_move = false; - window.start_window_move(); - } - })) - .on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| { - this.should_move = false; - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(move |this, _ev, _window, _cx| { - this.should_move = false; - }), - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _ev, _window, _cx| { - this.should_move = true; - }), - ) } else { title_bar } diff --git a/crates/title_bar/src/system_window_tabs.rs b/crates/title_bar/src/system_window_tabs.rs index ba898da716..a9bf46cc4f 100644 --- a/crates/title_bar/src/system_window_tabs.rs +++ b/crates/title_bar/src/system_window_tabs.rs @@ -227,6 +227,15 @@ impl SystemWindowTabs { window.activate_window(); }); }) + .on_mouse_up(MouseButton::Middle, move |_, window, cx| { + if item.handle.window_id() == window.window_handle().window_id() { + window.dispatch_action(Box::new(CloseWindow), cx); + } else { + let _ = item.handle.update(cx, |_, window, cx| { + window.dispatch_action(Box::new(CloseWindow), cx); + }); + } + }) .child(label) .map(|this| match show_close_button { ShowCloseButton::Hidden => this, diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 85890f017f..5bd47d0269 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -1,5 +1,5 @@ mod application_menu; -mod collab; +pub mod collab; mod onboarding_banner; pub mod platform_title_bar; mod platforms; @@ -30,10 +30,7 @@ use gpui::{ Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; -use project::{ - Project, WorktreeSettings, - git_store::{GitStoreEvent, RepositoryEvent}, -}; +use project::{Project, WorktreeSettings, git_store::GitStoreEvent}; use remote::RemoteConnectionOptions; use settings::{Settings, SettingsLocation}; use std::sync::Arc; @@ -69,7 +66,6 @@ actions!( ); pub fn init(cx: &mut App) { - TitleBarSettings::register(cx); SystemWindowTabs::init(cx); cx.observe_new(|workspace: &mut Workspace, window, cx| { @@ -171,7 +167,7 @@ impl Render for TitleBar { .child(self.render_project_name(cx)) }) .when(title_bar_settings.show_branch_name, |title_bar| { - title_bar.children(self.render_project_branch(cx)) + title_bar.children(self.render_project_repo(cx)) }) }) }) @@ -189,18 +185,28 @@ impl Render for TitleBar { let status = &*status.borrow(); let user = self.user_store.read(cx).current_user(); + let signed_in = user.is_some(); + children.push( h_flex() + .map(|this| { + if signed_in { + this.pr_1p5() + } else { + this.pr_1() + } + }) .gap_1() - .pr_1() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .children(self.render_call_controls(window, cx)) .children(self.render_connection_status(status, cx)) .when( user.is_none() && TitleBarSettings::get_global(cx).show_sign_in, - |el| el.child(self.render_sign_in_button(cx)), + |this| this.child(self.render_sign_in_button(cx)), ) - .child(self.render_app_menu_button(cx)) + .when(TitleBarSettings::get_global(cx).show_user_menu, |this| { + this.child(self.render_user_menu_button(cx)) + }) .into_any_element(), ); @@ -279,9 +285,7 @@ impl TitleBar { subscriptions.push( cx.subscribe(&git_store, move |_, _, event, cx| match event { GitStoreEvent::ActiveRepositoryChanged(_) - | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, _) - | GitStoreEvent::RepositoryAdded(_) - | GitStoreEvent::RepositoryRemoved(_) => { + | GitStoreEvent::RepositoryUpdated(_, _, true) => { cx.notify(); } _ => {} @@ -317,16 +321,43 @@ impl TitleBar { } } + fn project_name(&self, cx: &Context) -> Option { + self.project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + let settings_location = SettingsLocation { + worktree_id: worktree.id(), + path: RelPath::empty(), + }; + + let settings = WorktreeSettings::get(Some(settings_location), cx); + let name = match &settings.project_name { + Some(name) => name.as_str(), + None => worktree.root_name_str(), + }; + SharedString::new(name) + }) + .next() + } + fn render_remote_project_connection(&self, cx: &mut Context) -> Option { let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.display_name().into(); - let (nickname, icon) = match options { - RemoteConnectionOptions::Ssh(options) => { - (options.nickname.map(|nick| nick.into()), IconName::Server) + let (nickname, tooltip_title, icon) = match options { + RemoteConnectionOptions::Ssh(options) => ( + options.nickname.map(|nick| nick.into()), + "Remote Project", + IconName::Server, + ), + RemoteConnectionOptions::Wsl(_) => (None, "Remote Project", IconName::Linux), + RemoteConnectionOptions::Docker(_dev_container_connection) => { + (None, "Dev Container", IconName::Box) } - RemoteConnectionOptions::Wsl(_) => (None, IconName::Linux), }; + let nickname = nickname.unwrap_or_else(|| host.clone()); let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? { @@ -371,15 +402,14 @@ impl TitleBar { ) .child(Label::new(nickname).size(LabelSize::Small).truncate()), ) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( - "Remote Project", + tooltip_title, Some(&OpenRemote { from_existing_connection: false, create_new_window: false, }), meta.clone(), - window, cx, ) }) @@ -444,27 +474,10 @@ impl TitleBar { } pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { - let name = self - .project - .read(cx) - .visible_worktrees(cx) - .map(|worktree| { - let worktree = worktree.read(cx); - let settings_location = SettingsLocation { - worktree_id: worktree.id(), - path: RelPath::empty(), - }; - - let settings = WorktreeSettings::get(Some(settings_location), cx); - match &settings.project_name { - Some(name) => name.as_str(), - None => worktree.root_name_str(), - } - }) - .next(); + let name = self.project_name(cx); let is_project_selected = name.is_some(); let name = if let Some(name) = name { - util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH) + util::truncate_and_trailoff(&name, MAX_PROJECT_NAME_LENGTH) } else { "Open recent project".to_string() }; @@ -473,13 +486,12 @@ impl TitleBar { .when(!is_project_selected, |b| b.color(Color::Muted)) .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::for_action( "Recent Projects", &zed_actions::OpenRecent { create_new_window: false, }, - window, cx, ) }) @@ -494,9 +506,10 @@ impl TitleBar { })) } - pub fn render_project_branch(&self, cx: &mut Context) -> Option { + pub fn render_project_repo(&self, cx: &mut Context) -> Option { let settings = TitleBarSettings::get_global(cx); let repository = self.project.read(cx).active_repository(cx)?; + let repository_count = self.project.read(cx).repositories(cx).len(); let workspace = self.workspace.upgrade()?; let repo = repository.read(cx); let branch_name = repo @@ -513,18 +526,30 @@ impl TitleBar { .collect::() }) })?; + let project_name = self.project_name(cx); + let repo_name = repo + .work_directory_abs_path + .file_name() + .and_then(|name| name.to_str()) + .map(SharedString::new); + let show_repo_name = + repository_count > 1 && repo.branch.is_some() && repo_name != project_name; + let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) { + format!("{repo_name}/{branch_name}") + } else { + branch_name + }; Some( Button::new("project_branch_trigger", branch_name) .color(Color::Muted) .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( "Recent Branches", Some(&zed_actions::git::Branch), "Local branches only", - window, cx, ) }) @@ -662,51 +687,57 @@ impl TitleBar { }) } - pub fn render_app_menu_button(&mut self, cx: &mut Context) -> impl Element { + pub fn render_user_menu_button(&mut self, cx: &mut Context) -> impl Element { let user_store = self.user_store.read(cx); - if let Some(user) = user_store.current_user() { - let has_subscription_period = user_store.subscription_period().is_some(); - let plan = user_store.plan().filter(|_| { - // Since the user might be on the legacy free plan we filter based on whether we have a subscription period. - has_subscription_period - }); + let user = user_store.current_user(); - let user_avatar = user.avatar_uri.clone(); - let free_chip_bg = cx - .theme() - .colors() - .editor_background - .opacity(0.5) - .blend(cx.theme().colors().text_accent.opacity(0.05)); + let user_avatar = user.as_ref().map(|u| u.avatar_uri.clone()); + let user_login = user.as_ref().map(|u| u.github_login.clone()); - let pro_chip_bg = cx - .theme() - .colors() - .editor_background - .opacity(0.5) - .blend(cx.theme().colors().text_accent.opacity(0.2)); + let is_signed_in = user.is_some(); - PopoverMenu::new("user-menu") - .anchor(Corner::TopRight) - .menu(move |window, cx| { - ContextMenu::build(window, cx, |menu, _, _cx| { - let user_login = user.github_login.clone(); + let has_subscription_period = user_store.subscription_period().is_some(); + let plan = user_store.plan().filter(|_| { + // Since the user might be on the legacy free plan we filter based on whether we have a subscription period. + has_subscription_period + }); - let (plan_name, label_color, bg_color) = match plan { - None | Some(Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree)) => { - ("Free", Color::Default, free_chip_bg) - } - Some(Plan::V1(PlanV1::ZedProTrial) | Plan::V2(PlanV2::ZedProTrial)) => { - ("Pro Trial", Color::Accent, pro_chip_bg) - } - Some(Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro)) => { - ("Pro", Color::Accent, pro_chip_bg) - } - }; + let free_chip_bg = cx + .theme() + .colors() + .editor_background + .opacity(0.5) + .blend(cx.theme().colors().text_accent.opacity(0.05)); - menu.custom_entry( + let pro_chip_bg = cx + .theme() + .colors() + .editor_background + .opacity(0.5) + .blend(cx.theme().colors().text_accent.opacity(0.2)); + + PopoverMenu::new("user-menu") + .anchor(Corner::TopRight) + .menu(move |window, cx| { + ContextMenu::build(window, cx, |menu, _, _cx| { + let user_login = user_login.clone(); + + let (plan_name, label_color, bg_color) = match plan { + None | Some(Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree)) => { + ("Free", Color::Default, free_chip_bg) + } + Some(Plan::V1(PlanV1::ZedProTrial) | Plan::V2(PlanV2::ZedProTrial)) => { + ("Pro Trial", Color::Accent, pro_chip_bg) + } + Some(Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro)) => { + ("Pro", Color::Accent, pro_chip_bg) + } + }; + + menu.when(is_signed_in, |this| { + this.custom_entry( move |_window, _cx| { - let user_login = user_login.clone(); + let user_login = user_login.clone().unwrap_or_default(); h_flex() .w_full() @@ -724,75 +755,43 @@ impl TitleBar { }, ) .separator() - .action("Settings", zed_actions::OpenSettingsEditor.boxed_clone()) - .action("Keymap Editor", Box::new(zed_actions::OpenKeymapEditor)) - .action( - "Themes…", - zed_actions::theme_selector::Toggle::default().boxed_clone(), - ) - .action( - "Icon Themes…", - zed_actions::icon_theme_selector::Toggle::default().boxed_clone(), - ) - .action( - "Extensions", - zed_actions::Extensions::default().boxed_clone(), - ) - .separator() - .action("Sign Out", client::SignOut.boxed_clone()) }) - .into() - }) - .trigger_with_tooltip( - ButtonLike::new("user-menu") - .child( - h_flex() - .gap_0p5() - .children( - TitleBarSettings::get_global(cx) - .show_user_picture - .then(|| Avatar::new(user_avatar)), - ) - .child( - Icon::new(IconName::ChevronDown) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - .style(ButtonStyle::Subtle), - Tooltip::text("Toggle User Menu"), - ) - .anchor(gpui::Corner::TopRight) - } else { - PopoverMenu::new("user-menu") - .anchor(Corner::TopRight) - .menu(|window, cx| { - ContextMenu::build(window, cx, |menu, _, _| { - menu.action("Settings", zed_actions::OpenSettings.boxed_clone()) - .action( - "Settings Profiles", - zed_actions::settings_profile_selector::Toggle.boxed_clone(), - ) - .action("Key Bindings", Box::new(zed_actions::OpenKeymapEditor)) - .action( - "Themes…", - zed_actions::theme_selector::Toggle::default().boxed_clone(), - ) - .action( - "Icon Themes…", - zed_actions::icon_theme_selector::Toggle::default().boxed_clone(), - ) - .action( - "Extensions", - zed_actions::Extensions::default().boxed_clone(), - ) + .action("Settings", zed_actions::OpenSettings.boxed_clone()) + .action("Keymap", Box::new(zed_actions::OpenKeymap)) + .action( + "Themes…", + zed_actions::theme_selector::Toggle::default().boxed_clone(), + ) + .action( + "Icon Themes…", + zed_actions::icon_theme_selector::Toggle::default().boxed_clone(), + ) + .action( + "Extensions", + zed_actions::Extensions::default().boxed_clone(), + ) + .when(is_signed_in, |this| { + this.separator() + .action("Sign Out", client::SignOut.boxed_clone()) }) - .into() }) - .trigger_with_tooltip( - IconButton::new("user-menu", IconName::ChevronDown).icon_size(IconSize::Small), - Tooltip::text("Toggle User Menu"), - ) - } + .into() + }) + .map(|this| { + if is_signed_in && TitleBarSettings::get_global(cx).show_user_picture { + this.trigger_with_tooltip( + ButtonLike::new("user-menu") + .children(user_avatar.clone().map(|avatar| Avatar::new(avatar))), + Tooltip::text("Toggle User Menu"), + ) + } else { + this.trigger_with_tooltip( + IconButton::new("user-menu", IconName::ChevronDown) + .icon_size(IconSize::Small), + Tooltip::text("Toggle User Menu"), + ) + } + }) + .anchor(gpui::Corner::TopRight) } } diff --git a/crates/title_bar/src/title_bar_settings.rs b/crates/title_bar/src/title_bar_settings.rs index 712346abfb..155b7b7bc7 100644 --- a/crates/title_bar/src/title_bar_settings.rs +++ b/crates/title_bar/src/title_bar_settings.rs @@ -1,7 +1,6 @@ -use settings::{Settings, SettingsContent}; -use ui::App; +use settings::{RegisterSetting, Settings, SettingsContent}; -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, RegisterSetting)] pub struct TitleBarSettings { pub show_branch_icon: bool, pub show_onboarding_banner: bool, @@ -9,11 +8,12 @@ pub struct TitleBarSettings { pub show_branch_name: bool, pub show_project_items: bool, pub show_sign_in: bool, + pub show_user_menu: bool, pub show_menus: bool, } impl Settings for TitleBarSettings { - fn from_settings(s: &SettingsContent, _: &mut App) -> Self { + fn from_settings(s: &SettingsContent) -> Self { let content = s.title_bar.clone().unwrap(); TitleBarSettings { show_branch_icon: content.show_branch_icon.unwrap(), @@ -22,6 +22,7 @@ impl Settings for TitleBarSettings { show_branch_name: content.show_branch_name.unwrap(), show_project_items: content.show_project_items.unwrap(), show_sign_in: content.show_sign_in.unwrap(), + show_user_menu: content.show_user_menu.unwrap(), show_menus: content.show_menus.unwrap(), } } diff --git a/crates/toolchain_selector/Cargo.toml b/crates/toolchain_selector/Cargo.toml index a17f825640..94a655b727 100644 --- a/crates/toolchain_selector/Cargo.toml +++ b/crates/toolchain_selector/Cargo.toml @@ -20,7 +20,6 @@ project.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [lints] workspace = true diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index 1c6cbe5235..03c152e3fd 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -2,12 +2,12 @@ use std::sync::Arc; use editor::Editor; use gpui::{ - AsyncWindowContext, Context, Entity, IntoElement, ParentElement, Render, Subscription, Task, - WeakEntity, Window, div, + AsyncWindowContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, + Task, WeakEntity, Window, div, }; use language::{Buffer, BufferEvent, LanguageName, Toolchain, ToolchainScope}; use project::{Project, ProjectPath, Toolchains, WorktreeId, toolchain_store::ToolchainStoreEvent}; -use ui::{Button, ButtonCommon, Clickable, FluentBuilder, LabelSize, SharedString, Tooltip}; +use ui::{Button, ButtonCommon, Clickable, LabelSize, SharedString, Tooltip}; use util::{maybe, rel_path::RelPath}; use workspace::{StatusItemView, Workspace, item::ItemHandle}; @@ -124,7 +124,7 @@ impl ActiveToolchain { &buffer, window, |this, _, event: &BufferEvent, window, cx| { - if matches!(event, BufferEvent::LanguageChanged) { + if matches!(event, BufferEvent::LanguageChanged(_)) { this._update_toolchain_task = Self::spawn_tracker_task(window, cx); } }, @@ -230,21 +230,22 @@ impl ActiveToolchain { impl Render for ActiveToolchain { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div().when_some(self.active_toolchain.as_ref(), |el, active_toolchain| { - let term = self.term.clone(); - el.child( - Button::new("change-toolchain", active_toolchain.name.clone()) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - if let Some(workspace) = this.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - ToolchainSelector::toggle(workspace, window, cx) - }); - } - })) - .tooltip(Tooltip::text(format!("Select {}", &term))), - ) - }) + let Some(active_toolchain) = self.active_toolchain.as_ref() else { + return div().hidden(); + }; + + div().child( + Button::new("change-toolchain", active_toolchain.name.clone()) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + if let Some(workspace) = this.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + ToolchainSelector::toggle(workspace, window, cx) + }); + } + })) + .tooltip(Tooltip::text(format!("Select {}", &self.term))), + ) } } diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index 3ebf7670d3..b58b2f8d69 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -128,66 +128,61 @@ impl AddToolchainState { ) -> (OpenPathDelegate, oneshot::Receiver>>) { let (tx, rx) = oneshot::channel(); let weak = cx.weak_entity(); - let lister = OpenPathDelegate::new( - tx, - DirectoryLister::Project(project), - false, - PathStyle::local(), - ) - .show_hidden() - .with_footer(Arc::new(move |_, cx| { - let error = weak - .read_with(cx, |this, _| { - if let AddState::Path { error, .. } = &this.state { - error.clone() - } else { - None - } - }) - .ok() - .flatten(); - let is_loading = weak - .read_with(cx, |this, _| { - matches!( - this.state, - AddState::Path { - input_state: PathInputState::Resolving(_), - .. + let lister = OpenPathDelegate::new(tx, DirectoryLister::Project(project), false, cx) + .show_hidden() + .with_footer(Arc::new(move |_, cx| { + let error = weak + .read_with(cx, |this, _| { + if let AddState::Path { error, .. } = &this.state { + error.clone() + } else { + None } - ) - }) - .unwrap_or_default(); - Some( - v_flex() - .child(Divider::horizontal()) - .child( - h_flex() - .p_1() - .justify_between() - .gap_2() - .child(Label::new("Select Toolchain Path").color(Color::Muted).map( - |this| { - if is_loading { - this.with_animation( - "select-toolchain-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - this.into_any_element() - } - }, - )) - .when_some(error, |this, error| { - this.child(Label::new(error).color(Color::Error)) - }), - ) - .into_any(), - ) - })); + }) + .ok() + .flatten(); + let is_loading = weak + .read_with(cx, |this, _| { + matches!( + this.state, + AddState::Path { + input_state: PathInputState::Resolving(_), + .. + } + ) + }) + .unwrap_or_default(); + Some( + v_flex() + .child(Divider::horizontal()) + .child( + h_flex() + .p_1() + .justify_between() + .gap_2() + .child(Label::new("Select Toolchain Path").color(Color::Muted).map( + |this| { + if is_loading { + this.with_animation( + "select-toolchain-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any() + } else { + this.into_any_element() + } + }, + )) + .when_some(error, |this, error| { + this.child(Label::new(error).color(Color::Error)) + }), + ) + .into_any(), + ) + })); (lister, rx) } @@ -489,7 +484,6 @@ impl Render for AddToolchainState { .key_binding(KeyBinding::for_action_in( &menu::Confirm, &handle, - window, cx, )) .on_click(cx.listener(|this, _, window, cx| { @@ -588,19 +582,20 @@ impl ToolchainSelector { .worktree_for_id(worktree_id, cx)? .read(cx) .abs_path(); - let workspace_id = workspace.database_id()?; let weak = workspace.weak_handle(); cx.spawn_in(window, async move |workspace, cx| { - let active_toolchain = workspace::WORKSPACE_DB - .toolchain( - workspace_id, - worktree_id, - relative_path.clone(), - language_name.clone(), - ) - .await - .ok() - .flatten(); + let active_toolchain = project + .read_with(cx, |this, cx| { + this.active_toolchain( + ProjectPath { + worktree_id, + path: relative_path.clone(), + }, + language_name.clone(), + cx, + ) + })? + .await; workspace .update_in(cx, |this, window, cx| { this.toggle_modal(window, cx, move |window, cx| { @@ -618,6 +613,7 @@ impl ToolchainSelector { }); }) .ok(); + anyhow::Ok(()) }) .detach(); @@ -867,12 +863,16 @@ impl ToolchainSelectorDelegate { add_toolchain_text: Arc::from("Add Toolchain"), } } - fn relativize_path(path: SharedString, worktree_root: &Path) -> SharedString { + fn relativize_path( + path: SharedString, + worktree_root: &Path, + path_style: PathStyle, + ) -> SharedString { Path::new(&path.as_ref()) .strip_prefix(&worktree_root) .ok() - .map(|suffix| Path::new(".").join(suffix)) - .and_then(|path| path.to_str().map(String::from).map(SharedString::from)) + .and_then(|suffix| suffix.to_str()) + .map(|suffix| format!(".{}{suffix}", path_style.primary_separator()).into()) .unwrap_or(path) } } @@ -954,14 +954,18 @@ impl PickerDelegate for ToolchainSelectorDelegate { let background = cx.background_executor().clone(); let candidates = self.candidates.clone(); let worktree_root_path = self.worktree_abs_path_root.clone(); + let path_style = self.project.read(cx).path_style(cx); cx.spawn_in(window, async move |this, cx| { let matches = if query.is_empty() { candidates .into_iter() .enumerate() .map(|(index, (candidate, _))| { - let path = - Self::relativize_path(candidate.path.clone(), &worktree_root_path); + let path = Self::relativize_path( + candidate.path.clone(), + &worktree_root_path, + path_style, + ); let string = format!("{}{}", candidate.name, path); StringMatch { candidate_id: index, @@ -976,8 +980,11 @@ impl PickerDelegate for ToolchainSelectorDelegate { .into_iter() .enumerate() .map(|(candidate_id, (toolchain, _))| { - let path = - Self::relativize_path(toolchain.path.clone(), &worktree_root_path); + let path = Self::relativize_path( + toolchain.path.clone(), + &worktree_root_path, + path_style, + ); let string = format!("{}{}", toolchain.name, path); StringMatchCandidate::new(candidate_id, &string) }) @@ -1017,7 +1024,12 @@ impl PickerDelegate for ToolchainSelectorDelegate { let (toolchain, scope) = &self.candidates.get(mat.candidate_id)?; let label = toolchain.name.clone(); - let path = Self::relativize_path(toolchain.path.clone(), &self.worktree_abs_path_root); + let path_style = self.project.read(cx).path_style(cx); + let path = Self::relativize_path( + toolchain.path.clone(), + &self.worktree_abs_path_root, + path_style, + ); let (name_highlights, mut path_highlights) = mat .positions .iter() @@ -1100,7 +1112,6 @@ impl PickerDelegate for ToolchainSelectorDelegate { .key_binding(KeyBinding::for_action_in( &AddToolchain, &self.focus_handle, - _window, cx, )) .on_click(|_, window, cx| { @@ -1112,7 +1123,6 @@ impl PickerDelegate for ToolchainSelectorDelegate { .key_binding(KeyBinding::for_action_in( &menu::Confirm, &self.focus_handle, - _window, cx, )) .on_click(|_, window, cx| { diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 985a2bcdc7..5eb58bf1da 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -30,7 +30,6 @@ strum.workspace = true theme.workspace = true ui_macros.workspace = true util.workspace = true -workspace-hack.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index fae444c0ef..c9cb943277 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -1,5 +1,5 @@ +mod ai; mod avatar; -mod badge; mod banner; mod button; mod callout; @@ -7,6 +7,7 @@ mod chip; mod content_group; mod context_menu; mod data_table; +mod diff_stat; mod disclosure; mod divider; mod dropdown_menu; @@ -16,6 +17,7 @@ mod icon; mod image; mod indent_guides; mod indicator; +mod inline_code; mod keybinding; mod keybinding_hint; mod label; @@ -35,6 +37,7 @@ mod stack; mod sticky_items; mod tab; mod tab_bar; +mod thread_item; mod toggle; mod tooltip; mod tree_view_item; @@ -42,8 +45,8 @@ mod tree_view_item; #[cfg(feature = "stories")] mod stories; +pub use ai::*; pub use avatar::*; -pub use badge::*; pub use banner::*; pub use button::*; pub use callout::*; @@ -51,6 +54,7 @@ pub use chip::*; pub use content_group::*; pub use context_menu::*; pub use data_table::*; +pub use diff_stat::*; pub use disclosure::*; pub use divider::*; pub use dropdown_menu::*; @@ -60,6 +64,7 @@ pub use icon::*; pub use image::*; pub use indent_guides::*; pub use indicator::*; +pub use inline_code::*; pub use keybinding::*; pub use keybinding_hint::*; pub use label::*; @@ -79,6 +84,7 @@ pub use stack::*; pub use sticky_items::*; pub use tab::*; pub use tab_bar::*; +pub use thread_item::*; pub use toggle::*; pub use tooltip::*; pub use tree_view_item::*; diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs new file mode 100644 index 0000000000..e36361b7b0 --- /dev/null +++ b/crates/ui/src/components/ai.rs @@ -0,0 +1,3 @@ +mod configured_api_card; + +pub use configured_api_card::*; diff --git a/crates/ui/src/components/ai/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs new file mode 100644 index 0000000000..37f9ac7602 --- /dev/null +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -0,0 +1,97 @@ +use crate::{Tooltip, prelude::*}; +use gpui::{ClickEvent, IntoElement, ParentElement, SharedString}; + +#[derive(IntoElement)] +pub struct ConfiguredApiCard { + label: SharedString, + button_label: Option, + button_tab_index: Option, + tooltip_label: Option, + disabled: bool, + on_click: Option>, +} + +impl ConfiguredApiCard { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + button_label: None, + button_tab_index: None, + tooltip_label: None, + disabled: false, + on_click: None, + } + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } + + pub fn button_label(mut self, button_label: impl Into) -> Self { + self.button_label = Some(button_label.into()); + self + } + + pub fn tooltip_label(mut self, tooltip_label: impl Into) -> Self { + self.tooltip_label = Some(tooltip_label.into()); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn button_tab_index(mut self, tab_index: isize) -> Self { + self.button_tab_index = Some(tab_index); + self + } +} + +impl RenderOnce for ConfiguredApiCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let button_label = self.button_label.unwrap_or("Reset Key".into()); + let button_id = SharedString::new(format!("id-{}", button_label)); + + h_flex() + .min_w_0() + .mt_0p5() + .p_1() + .justify_between() + .rounded_md() + .flex_wrap() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + .child( + h_flex() + .min_w_0() + .gap_1() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(Label::new(self.label)), + ) + .child( + Button::new(button_id, button_label) + .when_some(self.button_tab_index, |elem, tab_index| { + elem.tab_index(tab_index) + }) + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .disabled(self.disabled) + .when_some(self.tooltip_label, |this, label| { + this.tooltip(Tooltip::text(label)) + }) + .when_some( + self.on_click.filter(|_| !self.disabled), + |this, on_click| this.on_click(on_click), + ), + ) + } +} diff --git a/crates/languages/src/tsx/highlights-jsx.scm b/crates/ui/src/components/ai/copilot_configuration_callout.rs similarity index 100% rename from crates/languages/src/tsx/highlights-jsx.scm rename to crates/ui/src/components/ai/copilot_configuration_callout.rs diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index 19f7c4660b..7b2ba8ce5c 100644 --- a/crates/ui/src/components/avatar.rs +++ b/crates/ui/src/components/avatar.rs @@ -91,7 +91,18 @@ impl RenderOnce for Avatar { self.image .size(image_size) .rounded_full() - .bg(cx.theme().colors().ghost_element_background), + .bg(cx.theme().colors().element_disabled) + .with_fallback(|| { + h_flex() + .size_full() + .justify_center() + .child( + Icon::new(IconName::Person) + .color(Color::Muted) + .size(IconSize::Small), + ) + .into_any_element() + }), ) .children(self.indicator.map(|indicator| div().child(indicator))) } diff --git a/crates/ui/src/components/badge.rs b/crates/ui/src/components/badge.rs deleted file mode 100644 index 9db6fd616f..0000000000 --- a/crates/ui/src/components/badge.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::rc::Rc; - -use crate::Divider; -use crate::DividerColor; -use crate::Tooltip; -use crate::component_prelude::*; -use crate::prelude::*; -use gpui::AnyView; -use gpui::{AnyElement, IntoElement, SharedString, Window}; - -#[derive(IntoElement, RegisterComponent)] -pub struct Badge { - label: SharedString, - icon: IconName, - tooltip: Option AnyView>>, -} - -impl Badge { - pub fn new(label: impl Into) -> Self { - Self { - label: label.into(), - icon: IconName::Check, - tooltip: None, - } - } - - pub fn icon(mut self, icon: IconName) -> Self { - self.icon = icon; - self - } - - pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { - self.tooltip = Some(Rc::new(tooltip)); - self - } -} - -impl RenderOnce for Badge { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let tooltip = self.tooltip; - - h_flex() - .id(self.label.clone()) - .h_full() - .gap_1() - .pl_1() - .pr_2() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.6)) - .bg(cx.theme().colors().element_background) - .rounded_sm() - .overflow_hidden() - .child( - Icon::new(self.icon) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Divider::vertical().color(DividerColor::Border)) - .child(Label::new(self.label.clone()).size(LabelSize::Small).ml_1()) - .when_some(tooltip, |this, tooltip| { - this.hoverable_tooltip(move |window, cx| tooltip(window, cx)) - }) - } -} - -impl Component for Badge { - fn scope() -> ComponentScope { - ComponentScope::DataDisplay - } - - fn description() -> Option<&'static str> { - Some( - "A compact, labeled component with optional icon for displaying status, categories, or metadata.", - ) - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .child(single_example( - "Basic Badge", - Badge::new("Default").into_any_element(), - )) - .child(single_example( - "With Tooltip", - Badge::new("Tooltip") - .tooltip(Tooltip::text("This is a tooltip.")) - .into_any_element(), - )) - .into_any_element(), - ) - } -} diff --git a/crates/ui/src/components/button.rs b/crates/ui/src/components/button.rs index 23e7702f62..d56a9c09d3 100644 --- a/crates/ui/src/components/button.rs +++ b/crates/ui/src/components/button.rs @@ -1,12 +1,14 @@ mod button; mod button_icon; mod button_like; +mod button_link; mod icon_button; mod split_button; mod toggle_button; pub use button::*; pub use button_like::*; +pub use button_link::*; pub use icon_button::*; pub use split_button::*; pub use toggle_button::*; diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 1c67e95652..83b50b6341 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -285,6 +285,10 @@ impl Disableable for Button { /// This results in a button that is disabled and does not respond to click events. fn disabled(mut self, disabled: bool) -> Self { self.base = self.base.disabled(disabled); + self.key_binding = self + .key_binding + .take() + .map(|binding| binding.disabled(disabled)); self } } @@ -474,7 +478,6 @@ impl RenderOnce for Button { } } -// View this component preview using `workspace: open component-preview` impl Component for Button { fn scope() -> ComponentScope { ComponentScope::Input diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 223a5e4949..4ce7aeed0d 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -135,6 +135,9 @@ pub enum ButtonStyle { /// a fully transparent button. Outlined, + /// A more de-emphasized version of the outlined button. + OutlinedGhost, + /// The default button style, used for most buttons. Has a transparent background, /// but has a background color to indicate states like hover and active. #[default] @@ -146,11 +149,38 @@ pub enum ButtonStyle { Transparent, } +/// Rounding for a button that may have straight edges. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -pub(crate) enum ButtonLikeRounding { - All, - Left, - Right, +pub(crate) struct ButtonLikeRounding { + /// Top-left corner rounding + pub top_left: bool, + /// Top-right corner rounding + pub top_right: bool, + /// Bottom-right corner rounding + pub bottom_right: bool, + /// Bottom-left corner rounding + pub bottom_left: bool, +} + +impl ButtonLikeRounding { + pub const ALL: Self = Self { + top_left: true, + top_right: true, + bottom_right: true, + bottom_left: true, + }; + pub const LEFT: Self = Self { + top_left: true, + top_right: false, + bottom_right: false, + bottom_left: true, + }; + pub const RIGHT: Self = Self { + top_left: false, + top_right: true, + bottom_right: true, + bottom_left: false, + }; } #[derive(Debug, Clone)] @@ -195,6 +225,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedGhost => ButtonLikeStyles { + background: transparent_black(), + border_color: cx.theme().colors().border_variant, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_background, border_color: transparent_black(), @@ -240,6 +276,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedGhost => ButtonLikeStyles { + background: cx.theme().colors().ghost_element_hover, + border_color: cx.theme().colors().border, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_hover, border_color: transparent_black(), @@ -278,6 +320,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedGhost => ButtonLikeStyles { + background: transparent_black(), + border_color: cx.theme().colors().border_variant, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: transparent_black(), @@ -311,6 +359,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedGhost => ButtonLikeStyles { + background: transparent_black(), + border_color: cx.theme().colors().border, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: cx.theme().colors().border_focused, @@ -347,6 +401,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedGhost => ButtonLikeStyles { + background: transparent_black(), + border_color: cx.theme().colors().border_disabled, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: transparent_black(), @@ -422,7 +482,7 @@ impl ButtonLike { width: None, height: None, size: ButtonSize::Default, - rounding: Some(ButtonLikeRounding::All), + rounding: Some(ButtonLikeRounding::ALL), tooltip: None, hoverable_tooltip: None, children: SmallVec::new(), @@ -436,15 +496,15 @@ impl ButtonLike { } pub fn new_rounded_left(id: impl Into) -> Self { - Self::new(id).rounding(ButtonLikeRounding::Left) + Self::new(id).rounding(ButtonLikeRounding::LEFT) } pub fn new_rounded_right(id: impl Into) -> Self { - Self::new(id).rounding(ButtonLikeRounding::Right) + Self::new(id).rounding(ButtonLikeRounding::RIGHT) } pub fn new_rounded_all(id: impl Into) -> Self { - Self::new(id).rounding(ButtonLikeRounding::All) + Self::new(id).rounding(ButtonLikeRounding::ALL) } pub fn opacity(mut self, opacity: f32) -> Self { @@ -580,6 +640,11 @@ impl RenderOnce for ButtonLike { .filter(|_| self.selected) .unwrap_or(self.style); + let is_outlined = matches!( + self.style, + ButtonStyle::Outlined | ButtonStyle::OutlinedGhost + ); + self.base .h_flex() .id(self.id.clone()) @@ -594,17 +659,16 @@ impl RenderOnce for ButtonLike { .when_some(self.width, |this, width| { this.w(width).justify_center().text_center() }) - .when(matches!(self.style, ButtonStyle::Outlined), |this| { - this.border_1() - }) - .when_some(self.rounding, |this, rounding| match rounding { - ButtonLikeRounding::All => this.rounded_sm(), - ButtonLikeRounding::Left => this.rounded_l_sm(), - ButtonLikeRounding::Right => this.rounded_r_sm(), + .when(is_outlined, |this| this.border_1()) + .when_some(self.rounding, |this, rounding| { + this.when(rounding.top_left, |this| this.rounded_tl_sm()) + .when(rounding.top_right, |this| this.rounded_tr_sm()) + .when(rounding.bottom_right, |this| this.rounded_br_sm()) + .when(rounding.bottom_left, |this| this.rounded_bl_sm()) }) .gap(DynamicSpacing::Base04.rems(cx)) .map(|this| match self.size { - ButtonSize::Large | ButtonSize::Medium => this.px(DynamicSpacing::Base06.rems(cx)), + ButtonSize::Large | ButtonSize::Medium => this.px(DynamicSpacing::Base08.rems(cx)), ButtonSize::Default | ButtonSize::Compact => { this.px(DynamicSpacing::Base04.rems(cx)) } @@ -623,9 +687,18 @@ impl RenderOnce for ButtonLike { let hovered_style = style.hovered(self.layer, cx); let focus_color = |refinement: StyleRefinement| refinement.bg(hovered_style.background); + this.cursor(self.cursor_style) .hover(focus_color) - .focus(focus_color) + .map(|this| { + if is_outlined { + this.focus_visible(|s| { + s.border_color(cx.theme().colors().border_focused) + }) + } else { + this.focus_visible(focus_color) + } + }) .active(|active| active.bg(style.active(cx).background)) }) .when_some( diff --git a/crates/ui/src/components/button/button_link.rs b/crates/ui/src/components/button/button_link.rs new file mode 100644 index 0000000000..caffe2772b --- /dev/null +++ b/crates/ui/src/components/button/button_link.rs @@ -0,0 +1,102 @@ +use gpui::{IntoElement, Window, prelude::*}; + +use crate::{ButtonLike, prelude::*}; + +/// A button that takes an underline to look like a regular web link. +/// It also contains an arrow icon to communicate the link takes you out of Zed. +/// +/// # Usage Example +/// +/// ``` +/// use ui::ButtonLink; +/// +/// let button_link = ButtonLink::new("Click me", "https://example.com"); +/// ``` +#[derive(IntoElement, RegisterComponent)] +pub struct ButtonLink { + label: SharedString, + label_size: LabelSize, + label_color: Color, + link: String, + no_icon: bool, +} + +impl ButtonLink { + pub fn new(label: impl Into, link: impl Into) -> Self { + Self { + link: link.into(), + label: label.into(), + label_size: LabelSize::Default, + label_color: Color::Default, + no_icon: false, + } + } + + pub fn no_icon(mut self, no_icon: bool) -> Self { + self.no_icon = no_icon; + self + } + + pub fn label_size(mut self, label_size: LabelSize) -> Self { + self.label_size = label_size; + self + } + + pub fn label_color(mut self, label_color: Color) -> Self { + self.label_color = label_color; + self + } +} + +impl RenderOnce for ButtonLink { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = format!("{}-{}", self.label, self.link); + + ButtonLike::new(id) + .size(ButtonSize::None) + .child( + h_flex() + .gap_0p5() + .child( + Label::new(self.label) + .size(self.label_size) + .color(self.label_color) + .underline(), + ) + .when(!self.no_icon, |this| { + this.child( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + }), + ) + .on_click(move |_, _, cx| cx.open_url(&self.link)) + .into_any_element() + } +} + +impl Component for ButtonLink { + fn scope() -> ComponentScope { + ComponentScope::Navigation + } + + fn description() -> Option<&'static str> { + Some("A button that opens a URL.") + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .child( + example_group(vec![single_example( + "Simple", + ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(), + )]) + .vertical(), + ) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index 14b9fd153c..48f06ff378 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -4,7 +4,7 @@ use gpui::{ }; use theme::ActiveTheme; -use crate::{ElevationIndex, h_flex}; +use crate::{ElevationIndex, IconButton, h_flex}; use super::ButtonLike; @@ -15,6 +15,23 @@ pub enum SplitButtonStyle { Transparent, } +pub enum SplitButtonKind { + ButtonLike(ButtonLike), + IconButton(IconButton), +} + +impl From for SplitButtonKind { + fn from(icon_button: IconButton) -> Self { + Self::IconButton(icon_button) + } +} + +impl From for SplitButtonKind { + fn from(button_like: ButtonLike) -> Self { + Self::ButtonLike(button_like) + } +} + /// /// A button with two parts: a primary action on the left and a secondary action on the right. /// /// The left side is a [`ButtonLike`] with the main action, while the right side can contain @@ -23,15 +40,15 @@ pub enum SplitButtonStyle { /// The two sections are visually separated by a divider, but presented as a unified control. #[derive(IntoElement)] pub struct SplitButton { - pub left: ButtonLike, - pub right: AnyElement, + left: SplitButtonKind, + right: AnyElement, style: SplitButtonStyle, } impl SplitButton { - pub fn new(left: ButtonLike, right: AnyElement) -> Self { + pub fn new(left: impl Into, right: AnyElement) -> Self { Self { - left, + left: left.into(), right, style: SplitButtonStyle::Filled, } @@ -56,7 +73,10 @@ impl RenderOnce for SplitButton { this.border_1() .border_color(cx.theme().colors().border.opacity(0.8)) }) - .child(div().flex_grow().child(self.left)) + .child(div().flex_grow().child(match self.left { + SplitButtonKind::ButtonLike(button) => button.into_any_element(), + SplitButtonKind::IconButton(icon) => icon.into_any_element(), + })) .child( div() .h_full() diff --git a/crates/ui/src/components/button/toggle_button.rs b/crates/ui/src/components/button/toggle_button.rs index 36f1972cf9..5cecfef062 100644 --- a/crates/ui/src/components/button/toggle_button.rs +++ b/crates/ui/src/components/button/toggle_button.rs @@ -2,305 +2,45 @@ use std::rc::Rc; use gpui::{AnyView, ClickEvent, relative}; -use crate::{ButtonLike, ButtonLikeRounding, ElevationIndex, TintColor, Tooltip, prelude::*}; +use crate::{ButtonLike, ButtonLikeRounding, TintColor, Tooltip, prelude::*}; /// The position of a [`ToggleButton`] within a group of buttons. #[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum ToggleButtonPosition { - /// The toggle button is first in the group. - First, - - /// The toggle button is in the middle of the group (i.e., it is not the first or last toggle button). - Middle, - - /// The toggle button is last in the group. - Last, +pub struct ToggleButtonPosition { + /// The toggle button is one of the leftmost of the group. + leftmost: bool, + /// The toggle button is one of the rightmost of the group. + rightmost: bool, + /// The toggle button is one of the topmost of the group. + topmost: bool, + /// The toggle button is one of the bottommost of the group. + bottommost: bool, } -#[derive(IntoElement, RegisterComponent)] -pub struct ToggleButton { - base: ButtonLike, - position_in_group: Option, - label: SharedString, - label_color: Option, -} +impl ToggleButtonPosition { + pub const HORIZONTAL_FIRST: Self = Self { + leftmost: true, + ..Self::HORIZONTAL_MIDDLE + }; + pub const HORIZONTAL_MIDDLE: Self = Self { + leftmost: false, + rightmost: false, + topmost: true, + bottommost: true, + }; + pub const HORIZONTAL_LAST: Self = Self { + rightmost: true, + ..Self::HORIZONTAL_MIDDLE + }; -impl ToggleButton { - pub fn new(id: impl Into, label: impl Into) -> Self { - Self { - base: ButtonLike::new(id), - position_in_group: None, - label: label.into(), - label_color: None, + pub(crate) fn to_rounding(self) -> ButtonLikeRounding { + ButtonLikeRounding { + top_left: self.topmost && self.leftmost, + top_right: self.topmost && self.rightmost, + bottom_right: self.bottommost && self.rightmost, + bottom_left: self.bottommost && self.leftmost, } } - - pub fn color(mut self, label_color: impl Into>) -> Self { - self.label_color = label_color.into(); - self - } - - pub fn position_in_group(mut self, position: ToggleButtonPosition) -> Self { - self.position_in_group = Some(position); - self - } - - pub fn first(self) -> Self { - self.position_in_group(ToggleButtonPosition::First) - } - - pub fn middle(self) -> Self { - self.position_in_group(ToggleButtonPosition::Middle) - } - - pub fn last(self) -> Self { - self.position_in_group(ToggleButtonPosition::Last) - } -} - -impl Toggleable for ToggleButton { - fn toggle_state(mut self, selected: bool) -> Self { - self.base = self.base.toggle_state(selected); - self - } -} - -impl SelectableButton for ToggleButton { - fn selected_style(mut self, style: ButtonStyle) -> Self { - self.base.selected_style = Some(style); - self - } -} - -impl FixedWidth for ToggleButton { - fn width(mut self, width: impl Into) -> Self { - self.base.width = Some(width.into()); - self - } - - fn full_width(mut self) -> Self { - self.base.width = Some(relative(1.)); - self - } -} - -impl Disableable for ToggleButton { - fn disabled(mut self, disabled: bool) -> Self { - self.base = self.base.disabled(disabled); - self - } -} - -impl Clickable for ToggleButton { - fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { - self.base = self.base.on_click(handler); - self - } - - fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self { - self.base = self.base.cursor_style(cursor_style); - self - } -} - -impl ButtonCommon for ToggleButton { - fn id(&self) -> &ElementId { - self.base.id() - } - - fn style(mut self, style: ButtonStyle) -> Self { - self.base = self.base.style(style); - self - } - - fn size(mut self, size: ButtonSize) -> Self { - self.base = self.base.size(size); - self - } - - fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { - self.base = self.base.tooltip(tooltip); - self - } - - fn tab_index(mut self, tab_index: impl Into) -> Self { - self.base = self.base.tab_index(tab_index); - self - } - - fn layer(mut self, elevation: ElevationIndex) -> Self { - self.base = self.base.layer(elevation); - self - } - - fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self { - self.base = self.base.track_focus(focus_handle); - self - } -} - -impl RenderOnce for ToggleButton { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let is_disabled = self.base.disabled; - let is_selected = self.base.selected; - - let label_color = if is_disabled { - Color::Disabled - } else if is_selected { - Color::Selected - } else { - self.label_color.unwrap_or_default() - }; - - self.base - .when_some(self.position_in_group, |this, position| match position { - ToggleButtonPosition::First => this.rounding(ButtonLikeRounding::Left), - ToggleButtonPosition::Middle => this.rounding(None), - ToggleButtonPosition::Last => this.rounding(ButtonLikeRounding::Right), - }) - .child( - Label::new(self.label) - .color(label_color) - .line_height_style(LineHeightStyle::UiLabel), - ) - } -} - -impl Component for ToggleButton { - fn scope() -> ComponentScope { - ComponentScope::Input - } - - fn sort_name() -> &'static str { - "ButtonC" - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Button Styles", - vec![ - single_example( - "Off", - ToggleButton::new("off", "Off") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "On", - ToggleButton::new("on", "On") - .layer(ElevationIndex::Background) - .toggle_state(true) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Off – Disabled", - ToggleButton::new("disabled_off", "Disabled Off") - .layer(ElevationIndex::Background) - .disabled(true) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "On – Disabled", - ToggleButton::new("disabled_on", "Disabled On") - .layer(ElevationIndex::Background) - .disabled(true) - .toggle_state(true) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Button Group", - vec![ - single_example( - "Three Buttons", - h_flex() - .child( - ToggleButton::new("three_btn_first", "First") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .first() - .into_any_element(), - ) - .child( - ToggleButton::new("three_btn_middle", "Middle") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .middle() - .toggle_state(true) - .into_any_element(), - ) - .child( - ToggleButton::new("three_btn_last", "Last") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .last() - .into_any_element(), - ) - .into_any_element(), - ), - single_example( - "Two Buttons", - h_flex() - .child( - ToggleButton::new("two_btn_first", "First") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .first() - .into_any_element(), - ) - .child( - ToggleButton::new("two_btn_last", "Last") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .last() - .into_any_element(), - ) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Alternate Sizes", - vec![ - single_example( - "None", - ToggleButton::new("none", "None") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .size(ButtonSize::None) - .into_any_element(), - ), - single_example( - "Compact", - ToggleButton::new("compact", "Compact") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .size(ButtonSize::Compact) - .into_any_element(), - ), - single_example( - "Large", - ToggleButton::new("large", "Large") - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) - } } pub struct ButtonConfiguration { @@ -423,6 +163,8 @@ pub enum ToggleButtonGroupStyle { pub enum ToggleButtonGroupSize { Default, Medium, + Large, + Custom(Rems), } #[derive(IntoElement)] @@ -434,7 +176,9 @@ where rows: [[T; COLS]; ROWS], style: ToggleButtonGroupStyle, size: ToggleButtonGroupSize, + label_size: LabelSize, group_width: Option, + auto_width: bool, selected_index: usize, tab_index: Option, } @@ -446,7 +190,9 @@ impl ToggleButtonGroup { rows: [buttons], style: ToggleButtonGroupStyle::Transparent, size: ToggleButtonGroupSize::Default, + label_size: LabelSize::Small, group_width: None, + auto_width: false, selected_index: 0, tab_index: None, } @@ -464,7 +210,9 @@ impl ToggleButtonGroup { rows: [first_row, second_row], style: ToggleButtonGroupStyle::Transparent, size: ToggleButtonGroupSize::Default, + label_size: LabelSize::Small, group_width: None, + auto_width: false, selected_index: 0, tab_index: None, } @@ -487,6 +235,18 @@ impl ToggleButtonGroup Self { + self.auto_width = true; + self + } + + pub fn label_size(mut self, label_size: LabelSize) -> Self { + self.label_size = label_size; + self + } + /// Sets the tab index for the toggle button group. /// The tab index is set to the initial value provided, then the /// value is incremented by the number of buttons in the group. @@ -519,6 +279,11 @@ impl RenderOnce for ToggleButtonGroup { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let custom_height = match self.size { + ToggleButtonGroupSize::Custom(height) => Some(height), + _ => None, + }; + let entries = self.rows.into_iter().enumerate().map(|(row_index, row)| { let group_name = self.group_name.clone(); @@ -534,8 +299,16 @@ impl RenderOnce let entry_index = row_index * COLS + col_index; ButtonLike::new((group_name.clone(), entry_index)) - .full_width() - .rounding(None) + .when(!self.auto_width, |this| this.full_width()) + .rounding(Some( + ToggleButtonPosition { + leftmost: col_index == 0, + rightmost: col_index == COLS - 1, + topmost: row_index == 0, + bottommost: row_index == ROWS - 1, + } + .to_rounding(), + )) .when_some(self.tab_index, |this, tab_index| { this.tab_index(tab_index + entry_index as isize) }) @@ -549,13 +322,17 @@ impl RenderOnce .when(self.size == ToggleButtonGroupSize::Medium, |button| { button.size(ButtonSize::Medium) }) + .when(self.size == ToggleButtonGroupSize::Large, |button| { + button.size(ButtonSize::Large) + }) + .when_some(custom_height, |button, height| button.height(height.into())) .child( h_flex() .w_full() + .px_2() .gap_1p5() - .px_3() - .py_1() .justify_center() + .flex_none() .when_some(icon, |this, icon| { this.py_2() .child(Icon::new(icon).size(IconSize::XSmall).map(|this| { @@ -566,7 +343,7 @@ impl RenderOnce } })) }) - .child(Label::new(label).size(LabelSize::Small).when( + .child(Label::new(label).size(self.label_size).when( entry_index == self.selected_index || selected, |this| this.color(Color::Accent), )), @@ -588,6 +365,8 @@ impl RenderOnce .map(|this| { if let Some(width) = self.group_width { this.w(width) + } else if self.auto_width { + this } else { this.w_full() } @@ -614,7 +393,7 @@ impl RenderOnce .when(is_outlined_or_filled && !last_item, |this| { this.border_r_1().border_color(border_color) }) - .w(Self::button_width()) + .when(!self.auto_width, |this| this.w(Self::button_width())) .overflow_hidden() .child(item) })) diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index b5d1d7f255..4eb849d7f6 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -30,6 +30,7 @@ pub struct Callout { icon: Option, title: Option, description: Option, + description_slot: Option, actions_slot: Option, dismiss_action: Option, line_height: Option, @@ -44,6 +45,7 @@ impl Callout { icon: None, title: None, description: None, + description_slot: None, actions_slot: None, dismiss_action: None, line_height: None, @@ -76,6 +78,13 @@ impl Callout { self } + /// Allows for any element—like markdown elements—to fill the description slot of the callout. + /// This method wins over `description` if both happen to be set. + pub fn description_slot(mut self, description: impl IntoElement) -> Self { + self.description_slot = Some(description.into_any_element()); + self + } + /// Sets the primary call-to-action button. pub fn actions_slot(mut self, action: impl IntoElement) -> Self { self.actions_slot = Some(action.into_any_element()); @@ -179,15 +188,27 @@ impl RenderOnce for Callout { ) }), ) - .when_some(self.description, |this, description| { - this.child( - div() - .w_full() - .flex_1() - .text_ui_sm(cx) - .text_color(cx.theme().colors().text_muted) - .child(description), - ) + .map(|this| { + if let Some(description_slot) = self.description_slot { + this.child( + div() + .w_full() + .flex_1() + .text_ui_sm(cx) + .child(description_slot), + ) + } else if let Some(description) = self.description { + this.child( + div() + .w_full() + .flex_1() + .text_ui_sm(cx) + .text_color(cx.theme().colors().text_muted) + .child(description), + ) + } else { + this + } }), ) } diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 7b61789b3c..a4bae64740 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -47,6 +47,8 @@ pub struct ContextMenuEntry { toggle: Option<(IconPosition, bool)>, label: SharedString, icon: Option, + custom_icon_path: Option, + custom_icon_svg: Option, icon_position: IconPosition, icon_size: IconSize, icon_color: Option, @@ -66,6 +68,8 @@ impl ContextMenuEntry { toggle: None, label: label.into(), icon: None, + custom_icon_path: None, + custom_icon_svg: None, icon_position: IconPosition::Start, icon_size: IconSize::Small, icon_color: None, @@ -90,6 +94,20 @@ impl ContextMenuEntry { self } + pub fn custom_icon_path(mut self, path: impl Into) -> Self { + self.custom_icon_path = Some(path.into()); + self.custom_icon_svg = None; // Clear other icon sources if custom path is set + self.icon = None; + self + } + + pub fn custom_icon_svg(mut self, svg: impl Into) -> Self { + self.custom_icon_svg = Some(svg.into()); + self.custom_icon_path = None; // Clear other icon sources if custom path is set + self.icon = None; + self + } + pub fn icon_position(mut self, position: IconPosition) -> Self { self.icon_position = position; self @@ -206,39 +224,46 @@ impl EventEmitter for ContextMenu {} impl FluentBuilder for ContextMenu {} impl ContextMenu { + pub fn new( + window: &mut Window, + cx: &mut Context, + f: impl FnOnce(Self, &mut Window, &mut Context) -> Self, + ) -> Self { + let focus_handle = cx.focus_handle(); + let _on_blur_subscription = cx.on_blur( + &focus_handle, + window, + |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx), + ); + window.refresh(); + + f( + Self { + builder: None, + items: Default::default(), + focus_handle, + action_context: None, + selected_index: None, + delayed: false, + clicked: false, + key_context: "menu".into(), + _on_blur_subscription, + keep_open_on_confirm: false, + documentation_aside: None, + fixed_width: None, + end_slot_action: None, + }, + window, + cx, + ) + } + pub fn build( window: &mut Window, cx: &mut App, f: impl FnOnce(Self, &mut Window, &mut Context) -> Self, ) -> Entity { - cx.new(|cx| { - let focus_handle = cx.focus_handle(); - let _on_blur_subscription = cx.on_blur( - &focus_handle, - window, - |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx), - ); - window.refresh(); - f( - Self { - builder: None, - items: Default::default(), - focus_handle, - action_context: None, - selected_index: None, - delayed: false, - clicked: false, - key_context: "menu".into(), - _on_blur_subscription, - keep_open_on_confirm: false, - documentation_aside: None, - fixed_width: None, - end_slot_action: None, - }, - window, - cx, - ) - }) + cx.new(|cx| Self::new(window, cx, f)) } /// Builds a [`ContextMenu`] that will stay open when making changes instead of closing after each confirmation. @@ -380,6 +405,8 @@ impl ContextMenu { label: label.into(), handler: Rc::new(move |_, window, cx| handler(window, cx)), icon: None, + custom_icon_path: None, + custom_icon_svg: None, icon_position: IconPosition::End, icon_size: IconSize::Small, icon_color: None, @@ -408,6 +435,8 @@ impl ContextMenu { label: label.into(), handler: Rc::new(move |_, window, cx| handler(window, cx)), icon: None, + custom_icon_path: None, + custom_icon_svg: None, icon_position: IconPosition::End, icon_size: IconSize::Small, icon_color: None, @@ -436,6 +465,8 @@ impl ContextMenu { label: label.into(), handler: Rc::new(move |_, window, cx| handler(window, cx)), icon: None, + custom_icon_path: None, + custom_icon_svg: None, icon_position: IconPosition::End, icon_size: IconSize::Small, icon_color: None, @@ -463,6 +494,8 @@ impl ContextMenu { label: label.into(), handler: Rc::new(move |_, window, cx| handler(window, cx)), icon: None, + custom_icon_path: None, + custom_icon_svg: None, icon_position: position, icon_size: IconSize::Small, icon_color: None, @@ -509,9 +542,22 @@ impl ContextMenu { self } - pub fn action(mut self, label: impl Into, action: Box) -> Self { + pub fn action(self, label: impl Into, action: Box) -> Self { + self.action_checked(label, action, false) + } + + pub fn action_checked( + mut self, + label: impl Into, + action: Box, + checked: bool, + ) -> Self { self.items.push(ContextMenuItem::Entry(ContextMenuEntry { - toggle: None, + toggle: if checked { + Some((IconPosition::Start, true)) + } else { + None + }, label: label.into(), action: Some(action.boxed_clone()), handler: Rc::new(move |context, window, cx| { @@ -521,6 +567,8 @@ impl ContextMenu { window.dispatch_action(action.boxed_clone(), cx); }), icon: None, + custom_icon_path: None, + custom_icon_svg: None, icon_position: IconPosition::End, icon_size: IconSize::Small, icon_color: None, @@ -551,6 +599,8 @@ impl ContextMenu { window.dispatch_action(action.boxed_clone(), cx); }), icon: None, + custom_icon_path: None, + custom_icon_svg: None, icon_size: IconSize::Small, icon_position: IconPosition::End, icon_color: None, @@ -571,6 +621,8 @@ impl ContextMenu { action: Some(action.boxed_clone()), handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)), icon: Some(IconName::ArrowUpRight), + custom_icon_path: None, + custom_icon_svg: None, icon_size: IconSize::XSmall, icon_position: IconPosition::End, icon_color: None, @@ -834,9 +886,9 @@ impl ContextMenu { .disabled(true) .child(Label::new(label.clone())) .into_any_element(), - ContextMenuItem::Entry(entry) => self - .render_menu_entry(ix, entry, window, cx) - .into_any_element(), + ContextMenuItem::Entry(entry) => { + self.render_menu_entry(ix, entry, cx).into_any_element() + } ContextMenuItem::CustomEntry { entry_render, handler, @@ -883,7 +935,6 @@ impl ContextMenu { &self, ix: usize, entry: &ContextMenuEntry, - window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let ContextMenuEntry { @@ -891,6 +942,8 @@ impl ContextMenu { label, handler, icon, + custom_icon_path, + custom_icon_svg, icon_position, icon_size, icon_color, @@ -921,7 +974,51 @@ impl ContextMenu { Color::Default }; - let label_element = if let Some(icon_name) = icon { + let label_element = if let Some(custom_path) = custom_icon_path { + h_flex() + .gap_1p5() + .when( + *icon_position == IconPosition::Start && toggle.is_none(), + |flex| { + flex.child( + Icon::from_path(custom_path.clone()) + .size(*icon_size) + .color(icon_color), + ) + }, + ) + .child(Label::new(label.clone()).color(label_color).truncate()) + .when(*icon_position == IconPosition::End, |flex| { + flex.child( + Icon::from_path(custom_path.clone()) + .size(*icon_size) + .color(icon_color), + ) + }) + .into_any_element() + } else if let Some(custom_icon_svg) = custom_icon_svg { + h_flex() + .gap_1p5() + .when( + *icon_position == IconPosition::Start && toggle.is_none(), + |flex| { + flex.child( + Icon::from_external_svg(custom_icon_svg.clone()) + .size(*icon_size) + .color(icon_color), + ) + }, + ) + .child(Label::new(label.clone()).color(label_color).truncate()) + .when(*icon_position == IconPosition::End, |flex| { + flex.child( + Icon::from_external_svg(custom_icon_svg.clone()) + .size(*icon_size) + .color(icon_color), + ) + }) + .into_any_element() + } else if let Some(icon_name) = icon { h_flex() .gap_1p5() .when( @@ -980,18 +1077,18 @@ impl ContextMenu { .justify_between() .child(label_element) .debug_selector(|| format!("MENU_ITEM-{}", label)) - .children(action.as_ref().and_then(|action| { - self.action_context + .children(action.as_ref().map(|action| { + let binding = self + .action_context .as_ref() - .and_then(|focus| { - KeyBinding::for_action_in(&**action, focus, window, cx) - }) - .or_else(|| KeyBinding::for_action(&**action, window, cx)) - .map(|binding| { - div().ml_4().child(binding.disabled(*disabled)).when( - *disabled && documentation_aside.is_some(), - |parent| parent.invisible(), - ) + .map(|focus| KeyBinding::for_action_in(&**action, focus, cx)) + .unwrap_or_else(|| KeyBinding::for_action(&**action, cx)); + + div() + .ml_4() + .child(binding.disabled(*disabled)) + .when(*disabled && documentation_aside.is_some(), |parent| { + parent.invisible() }) })) .when(*disabled && documentation_aside.is_some(), |parent| { @@ -1016,7 +1113,7 @@ impl ContextMenu { let action_context = self.action_context.clone(); let title = title.clone(); let action = action.boxed_clone(); - move |window, cx| { + move |_window, cx| { action_context .as_ref() .map(|focus| { @@ -1024,17 +1121,11 @@ impl ContextMenu { title.clone(), &*action, focus, - window, cx, ) }) .unwrap_or_else(|| { - Tooltip::for_action( - title.clone(), - &*action, - window, - cx, - ) + Tooltip::for_action(title.clone(), &*action, cx) }) } }) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 4a1f4939cc..9cd2a5cb7a 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -485,6 +485,7 @@ pub struct Table { interaction_state: Option>, col_widths: Option>, map_row: Option), &mut Window, &mut App) -> AnyElement>>, + use_ui_font: bool, empty_table_callback: Option AnyElement>>, } @@ -498,6 +499,7 @@ impl Table { rows: TableContents::Vec(Vec::new()), interaction_state: None, map_row: None, + use_ui_font: true, empty_table_callback: None, col_widths: None, } @@ -590,6 +592,11 @@ impl Table { self } + pub fn no_ui_font(mut self) -> Self { + self.use_ui_font = false; + self + } + pub fn map_row( mut self, callback: impl Fn((usize, Stateful
), &mut Window, &mut App) -> AnyElement + 'static, @@ -618,8 +625,8 @@ fn base_cell_style(width: Option) -> Div { .overflow_hidden() } -fn base_cell_style_text(width: Option, cx: &App) -> Div { - base_cell_style(width).text_ui(cx) +fn base_cell_style_text(width: Option, use_ui_font: bool, cx: &App) -> Div { + base_cell_style(width).when(use_ui_font, |el| el.text_ui(cx)) } pub fn render_table_row( @@ -641,11 +648,10 @@ pub fn render_table_row( .map_or([None; COLS], |widths| widths.map(Some)); let mut row = h_flex() - .h_full() .id(("table_row", row_index)) - .w_full() - .justify_between() + .size_full() .when_some(bg, |row, bg| row.bg(bg)) + .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.6))) .when(!is_striped, |row| { row.border_b_1() .border_color(transparent_black()) @@ -657,7 +663,12 @@ pub fn render_table_row( .map(IntoElement::into_any_element) .into_iter() .zip(column_widths) - .map(|(cell, width)| base_cell_style_text(width, cx).px_1().py_0p5().child(cell)), + .map(|(cell, width)| { + base_cell_style_text(width, table_context.use_ui_font, cx) + .px_1() + .py_0p5() + .child(cell) + }), ); let row = if let Some(map_row) = table_context.map_row { @@ -701,7 +712,7 @@ pub fn render_table_header( .border_color(cx.theme().colors().border) .children(headers.into_iter().enumerate().zip(column_widths).map( |((header_idx, h), width)| { - base_cell_style_text(width, cx) + base_cell_style_text(width, table_context.use_ui_font, cx) .child(h) .id(ElementId::NamedInteger( shared_element_id.clone(), @@ -740,6 +751,7 @@ pub struct TableRenderContext { pub total_row_count: usize, pub column_widths: Option<[Length; COLS]>, pub map_row: Option), &mut Window, &mut App) -> AnyElement>>, + pub use_ui_font: bool, } impl TableRenderContext { @@ -749,6 +761,7 @@ impl TableRenderContext { total_row_count: table.rows.len(), column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)), map_row: table.map_row.clone(), + use_ui_font: table.use_ui_font, } } } @@ -873,7 +886,7 @@ impl RenderOnce for Table { interaction_state.as_ref(), |this, state| { this.track_scroll( - state.read_with(cx, |s, _| s.scroll_handle.clone()), + &state.read_with(cx, |s, _| s.scroll_handle.clone()), ) }, ), @@ -907,7 +920,7 @@ impl RenderOnce for Table { .unwrap_or_else(|| Scrollbars::new(super::ScrollAxes::Both)); content .custom_scrollbars( - scrollbars.tracked_scroll_handle(state.read(cx).scroll_handle.clone()), + scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle), window, cx, ) diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs new file mode 100644 index 0000000000..2606963555 --- /dev/null +++ b/crates/ui/src/components/diff_stat.rs @@ -0,0 +1,85 @@ +use crate::prelude::*; + +#[derive(IntoElement, RegisterComponent)] +pub struct DiffStat { + id: ElementId, + added: usize, + removed: usize, +} + +impl DiffStat { + pub fn new(id: impl Into, added: usize, removed: usize) -> Self { + Self { + id: id.into(), + added, + removed, + } + } +} + +impl RenderOnce for DiffStat { + fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement { + h_flex() + .id(self.id) + .gap_1() + .child( + h_flex() + .gap_0p5() + .child( + Icon::new(IconName::Plus) + .size(IconSize::XSmall) + .color(Color::Success), + ) + .child( + Label::new(self.added.to_string()) + .color(Color::Success) + .size(LabelSize::Small), + ), + ) + .child( + h_flex() + .gap_0p5() + .child( + Icon::new(IconName::Dash) + .size(IconSize::XSmall) + .color(Color::Error), + ) + .child( + Label::new(self.removed.to_string()) + .color(Color::Error) + .size(LabelSize::Small), + ), + ) + } +} + +impl Component for DiffStat { + fn scope() -> ComponentScope { + ComponentScope::VersionControl + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || { + h_flex() + .py_4() + .w_72() + .justify_center() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + }; + + let diff_stat_example = vec![single_example( + "Default", + container() + .child(DiffStat::new("id", 1, 2)) + .into_any_element(), + )]; + + Some( + example_group(diff_stat_example) + .vertical() + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs index 98eb45fd1d..cc7ad19875 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -1,4 +1,4 @@ -use gpui::{Hsla, IntoElement}; +use gpui::{Hsla, IntoElement, PathBuilder, canvas, point}; use crate::prelude::*; @@ -59,15 +59,6 @@ pub struct Divider { inset: bool, } -impl RenderOnce for Divider { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - match self.style { - DividerStyle::Solid => self.render_solid(cx).into_any_element(), - DividerStyle::Dashed => self.render_dashed(cx).into_any_element(), - } - } -} - impl Divider { pub fn horizontal() -> Self { Self { @@ -115,49 +106,62 @@ impl Divider { self } - pub fn render_solid(self, cx: &mut App) -> impl IntoElement { - div() - .map(|this| match self.direction { - DividerDirection::Horizontal => { - this.h_px().w_full().when(self.inset, |this| this.mx_1p5()) - } - DividerDirection::Vertical => { - this.w_px().h_full().when(self.inset, |this| this.my_1p5()) - } - }) - .bg(self.color.hsla(cx)) + pub fn render_solid(self, base: Div, cx: &mut App) -> impl IntoElement { + base.bg(self.color.hsla(cx)) } - // TODO: Use canvas or a shader here - // This obviously is a short term approach - pub fn render_dashed(self, cx: &mut App) -> impl IntoElement { - let segment_count = 128; - let segment_count_f = segment_count as f32; - let segment_min_w = 6.; - let base = match self.direction { - DividerDirection::Horizontal => h_flex(), - DividerDirection::Vertical => v_flex(), - }; - let (w, h) = match self.direction { - DividerDirection::Horizontal => (px(segment_min_w), px(1.)), - DividerDirection::Vertical => (px(1.), px(segment_min_w)), - }; - let color = self.color.hsla(cx); - let total_min_w = segment_min_w * segment_count_f * 2.; // * 2 because of the gap - - base.min_w(px(total_min_w)) - .map(|this| { - if self.direction == DividerDirection::Horizontal { - this.w_full().h_px() - } else { - this.w_px().h_full() - } - }) - .gap(px(segment_min_w)) - .overflow_hidden() - .children( - (0..segment_count).map(|_| div().flex_grow().flex_shrink_0().w(w).h(h).bg(color)), + pub fn render_dashed(self, base: Div) -> impl IntoElement { + base.relative().child( + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + let mut builder = PathBuilder::stroke(px(1.)).dash_array(&[px(4.), px(2.)]); + let (start, end) = match self.direction { + DividerDirection::Horizontal => { + let x = bounds.origin.x; + let y = bounds.origin.y + px(0.5); + (point(x, y), point(x + bounds.size.width, y)) + } + DividerDirection::Vertical => { + let x = bounds.origin.x + px(0.5); + let y = bounds.origin.y; + (point(x, y), point(x, y + bounds.size.height)) + } + }; + builder.move_to(start); + builder.line_to(end); + if let Ok(line) = builder.build() { + window.paint_path(line, self.color.hsla(cx)); + } + }, ) + .absolute() + .size_full(), + ) + } +} + +impl RenderOnce for Divider { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let base = match self.direction { + DividerDirection::Horizontal => div() + .min_w_0() + .flex_none() + .h_px() + .w_full() + .when(self.inset, |this| this.mx_1p5()), + DividerDirection::Vertical => div() + .min_w_0() + .flex_none() + .w_px() + .h_full() + .when(self.inset, |this| this.my_1p5()), + }; + + match self.style { + DividerStyle::Solid => self.render_solid(base, cx).into_any_element(), + DividerStyle::Dashed => self.render_dashed(base).into_any_element(), + } } } @@ -232,6 +236,7 @@ impl Component for Divider { vec![single_example( "Between Content", v_flex() + .w_full() .gap_4() .px_4() .child(Label::new("Section One")) diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index 8a1abc3127..5b5de7a257 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -1,6 +1,6 @@ -use gpui::{Corner, Entity, Pixels, Point}; +use gpui::{AnyView, Corner, Entity, Pixels, Point}; -use crate::{ContextMenu, PopoverMenu, prelude::*}; +use crate::{ButtonLike, ContextMenu, PopoverMenu, prelude::*}; use super::PopoverMenuHandle; @@ -9,6 +9,7 @@ pub enum DropdownStyle { #[default] Solid, Outlined, + Subtle, Ghost, } @@ -22,6 +23,8 @@ pub struct DropdownMenu { id: ElementId, label: LabelKind, trigger_size: ButtonSize, + trigger_tooltip: Option AnyView + 'static>>, + trigger_icon: Option, style: DropdownStyle, menu: Entity, full_width: bool, @@ -30,6 +33,7 @@ pub struct DropdownMenu { attach: Option, offset: Option>, tab_index: Option, + chevron: bool, } impl DropdownMenu { @@ -42,6 +46,8 @@ impl DropdownMenu { id: id.into(), label: LabelKind::Text(label.into()), trigger_size: ButtonSize::Default, + trigger_tooltip: None, + trigger_icon: Some(IconName::ChevronUpDown), style: DropdownStyle::default(), menu, full_width: false, @@ -50,6 +56,7 @@ impl DropdownMenu { attach: None, offset: None, tab_index: None, + chevron: true, } } @@ -62,6 +69,8 @@ impl DropdownMenu { id: id.into(), label: LabelKind::Element(label), trigger_size: ButtonSize::Default, + trigger_tooltip: None, + trigger_icon: Some(IconName::ChevronUpDown), style: DropdownStyle::default(), menu, full_width: false, @@ -70,16 +79,30 @@ impl DropdownMenu { attach: None, offset: None, tab_index: None, + chevron: true, } } + pub fn style(mut self, style: DropdownStyle) -> Self { + self.style = style; + self + } + pub fn trigger_size(mut self, size: ButtonSize) -> Self { self.trigger_size = size; self } - pub fn style(mut self, style: DropdownStyle) -> Self { - self.style = style; + pub fn trigger_tooltip( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.trigger_tooltip = Some(Box::new(tooltip)); + self + } + + pub fn trigger_icon(mut self, icon: IconName) -> Self { + self.trigger_icon = Some(icon); self } @@ -109,6 +132,11 @@ impl DropdownMenu { self.tab_index = Some(arg); self } + + pub fn no_chevron(mut self) -> Self { + self.chevron = false; + self + } } impl Disableable for DropdownMenu { @@ -122,6 +150,7 @@ impl RenderOnce for DropdownMenu { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let button_style = match self.style { DropdownStyle::Solid => ButtonStyle::Filled, + DropdownStyle::Subtle => ButtonStyle::Subtle, DropdownStyle::Outlined => ButtonStyle::Outlined, DropdownStyle::Ghost => ButtonStyle::Transparent, }; @@ -129,32 +158,62 @@ impl RenderOnce for DropdownMenu { let full_width = self.full_width; let trigger_size = self.trigger_size; - let button = match self.label { - LabelKind::Text(text) => Button::new(self.id.clone(), text) - .style(button_style) - .icon(IconName::ChevronUpDown) - .icon_position(IconPosition::End) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .when(full_width, |this| this.full_width()) - .size(trigger_size) - .disabled(self.disabled), - LabelKind::Element(_element) => Button::new(self.id.clone(), "") - .style(button_style) - .icon(IconName::ChevronUpDown) - .icon_position(IconPosition::End) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .when(full_width, |this| this.full_width()) - .size(trigger_size) - .disabled(self.disabled), - } - .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)); + let (text_button, element_button) = match self.label { + LabelKind::Text(text) => ( + Some( + Button::new(self.id.clone(), text) + .style(button_style) + .when(self.chevron, |this| { + this.icon(self.trigger_icon) + .icon_position(IconPosition::End) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + }) + .when(full_width, |this| this.full_width()) + .size(trigger_size) + .disabled(self.disabled) + .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)), + ), + None, + ), + LabelKind::Element(element) => ( + None, + Some( + ButtonLike::new(self.id.clone()) + .child(element) + .style(button_style) + .when(self.chevron, |this| { + this.child( + Icon::new(IconName::ChevronUpDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + }) + .when(full_width, |this| this.full_width()) + .size(trigger_size) + .disabled(self.disabled) + .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)), + ), + ), + }; - PopoverMenu::new((self.id.clone(), "popover")) + let mut popover = PopoverMenu::new((self.id.clone(), "popover")) .full_width(self.full_width) - .menu(move |_window, _cx| Some(self.menu.clone())) - .trigger(button) + .menu(move |_window, _cx| Some(self.menu.clone())); + + popover = match (text_button, element_button, self.trigger_tooltip) { + (Some(text_button), None, Some(tooltip)) => { + popover.trigger_with_tooltip(text_button, tooltip) + } + (Some(text_button), None, None) => popover.trigger(text_button), + (None, Some(element_button), Some(tooltip)) => { + popover.trigger_with_tooltip(element_button, tooltip) + } + (None, Some(element_button), None) => popover.trigger(element_button), + _ => popover, + }; + + popover .attach(match self.attach { Some(attach) => attach, None => Corner::BottomRight, diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 8f7ef41108..1c8e36ec18 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -115,24 +115,24 @@ impl From for Icon { /// The source of an icon. enum IconSource { /// An SVG embedded in the Zed binary. - Svg(SharedString), + Embedded(SharedString), /// An image file located at the specified path. /// - /// Currently our SVG renderer is missing support for the following features: - /// 1. Loading SVGs from external files. - /// 2. Rendering polychrome SVGs. + /// Currently our SVG renderer is missing support for rendering polychrome SVGs. /// /// In order to support icon themes, we render the icons as images instead. - Image(Arc), + External(Arc), + /// An SVG not embedded in the Zed binary. + ExternalSvg(SharedString), } impl IconSource { fn from_path(path: impl Into) -> Self { let path = path.into(); if path.starts_with("icons/") { - Self::Svg(path) + Self::Embedded(path) } else { - Self::Image(Arc::from(PathBuf::from(path.as_ref()))) + Self::External(Arc::from(PathBuf::from(path.as_ref()))) } } } @@ -148,7 +148,7 @@ pub struct Icon { impl Icon { pub fn new(icon: IconName) -> Self { Self { - source: IconSource::Svg(icon.path().into()), + source: IconSource::Embedded(icon.path().into()), color: Color::default(), size: IconSize::default().rems(), transformation: Transformation::default(), @@ -164,6 +164,15 @@ impl Icon { } } + pub fn from_external_svg(svg: SharedString) -> Self { + Self { + source: IconSource::ExternalSvg(svg), + color: Color::default(), + size: IconSize::default().rems(), + transformation: Transformation::default(), + } + } + pub fn color(mut self, color: Color) -> Self { self.color = color; self @@ -193,14 +202,21 @@ impl Transformable for Icon { impl RenderOnce for Icon { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { match self.source { - IconSource::Svg(path) => svg() + IconSource::Embedded(path) => svg() .with_transformation(self.transformation) .size(self.size) .flex_none() .path(path) .text_color(self.color.color(cx)) .into_any_element(), - IconSource::Image(path) => img(path) + IconSource::ExternalSvg(path) => svg() + .external_path(path) + .with_transformation(self.transformation) + .size(self.size) + .flex_none() + .text_color(self.color.color(cx)) + .into_any_element(), + IconSource::External(path) => img(path) .size(self.size) .flex_none() .text_color(self.color.color(cx)) @@ -279,40 +295,74 @@ impl Component for Icon { ) } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> Option { Some( v_flex() .gap_6() .children(vec![ example_group_with_title( "Sizes", - vec![ - single_example("Default", Icon::new(IconName::Star).into_any_element()), - single_example( - "Small", - Icon::new(IconName::Star) - .size(IconSize::Small) - .into_any_element(), - ), - single_example( - "Large", - Icon::new(IconName::Star) - .size(IconSize::XLarge) - .into_any_element(), - ), - ], + vec![single_example( + "XSmall, Small, Default, Large", + h_flex() + .gap_1() + .child( + Icon::new(IconName::Star) + .size(IconSize::XSmall) + .into_any_element(), + ) + .child( + Icon::new(IconName::Star) + .size(IconSize::Small) + .into_any_element(), + ) + .child(Icon::new(IconName::Star).into_any_element()) + .child( + Icon::new(IconName::Star) + .size(IconSize::XLarge) + .into_any_element(), + ) + .into_any_element(), + )], ), example_group_with_title( "Colors", - vec![ - single_example("Default", Icon::new(IconName::Bell).into_any_element()), - single_example( - "Custom Color", - Icon::new(IconName::Bell) - .color(Color::Error) - .into_any_element(), - ), - ], + vec![single_example( + "Default & Custom", + h_flex() + .gap_1() + .child(Icon::new(IconName::Star).into_any_element()) + .child( + Icon::new(IconName::Star) + .color(Color::Error) + .into_any_element(), + ) + .into_any_element(), + )], + ), + example_group_with_title( + "All Icons", + vec![single_example( + "All Icons", + h_flex() + .image_cache(gpui::retain_all("all icons")) + .flex_wrap() + .gap_2() + .children(::iter().map( + |icon_name| { + h_flex() + .p_1() + .gap_1() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().element_disabled) + .rounded_sm() + .child(Icon::new(icon_name).into_any_element()) + .child(SharedString::new_static(icon_name.into())) + }, + )) + .into_any_element(), + )], ), ]) .into_any_element(), diff --git a/crates/ui/src/components/image.rs b/crates/ui/src/components/image.rs index 8a14cffd3b..3e8cbd8fff 100644 --- a/crates/ui/src/components/image.rs +++ b/crates/ui/src/components/image.rs @@ -115,6 +115,8 @@ impl Component for Vector { } fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let size = rems_from_px(60.); + Some( v_flex() .gap_6() @@ -124,11 +126,18 @@ impl Component for Vector { vec![ single_example( "Default", - Vector::square(VectorName::ZedLogo, rems(8.)).into_any_element(), + Vector::square(VectorName::ZedLogo, size).into_any_element(), ), single_example( "Custom Size", - Vector::new(VectorName::ZedLogo, rems(12.), rems(6.)) + h_flex() + .h(rems_from_px(120.)) + .justify_center() + .child(Vector::new( + VectorName::ZedLogo, + rems_from_px(120.), + rems_from_px(200.), + )) .into_any_element(), ), ], @@ -138,13 +147,13 @@ impl Component for Vector { vec![ single_example( "Accent Color", - Vector::square(VectorName::ZedLogo, rems(8.)) + Vector::square(VectorName::ZedLogo, size) .color(Color::Accent) .into_any_element(), ), single_example( "Error Color", - Vector::square(VectorName::ZedLogo, rems(8.)) + Vector::square(VectorName::ZedLogo, size) .color(Color::Error) .into_any_element(), ), @@ -152,17 +161,11 @@ impl Component for Vector { ), example_group_with_title( "Different Vectors", - vec![ - single_example( - "Zed Logo", - Vector::square(VectorName::ZedLogo, rems(8.)).into_any_element(), - ), - single_example( - "Zed X Copilot", - Vector::square(VectorName::ZedXCopilot, rems(8.)) - .into_any_element(), - ), - ], + vec![single_example( + "Zed X Copilot", + Vector::square(VectorName::ZedXCopilot, rems_from_px(100.)) + .into_any_element(), + )], ), ]) .into_any_element(), diff --git a/crates/ui/src/components/inline_code.rs b/crates/ui/src/components/inline_code.rs new file mode 100644 index 0000000000..43507127fe --- /dev/null +++ b/crates/ui/src/components/inline_code.rs @@ -0,0 +1,64 @@ +use crate::prelude::*; +use gpui::{AnyElement, IntoElement, ParentElement, Styled}; + +/// InlineCode mimics the way inline code is rendered when wrapped in backticks in Markdown. +/// +/// # Usage Example +/// +/// ``` +/// use ui::InlineCode; +/// +/// let InlineCode = InlineCode::new("
hey
"); +/// ``` +#[derive(IntoElement, RegisterComponent)] +pub struct InlineCode { + label: SharedString, + label_size: LabelSize, +} + +impl InlineCode { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + label_size: LabelSize::Default, + } + } + + /// Sets the size of the label. + pub fn label_size(mut self, size: LabelSize) -> Self { + self.label_size = size; + self + } +} + +impl RenderOnce for InlineCode { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .min_w_0() + .px_0p5() + .overflow_hidden() + .bg(cx.theme().colors().text.opacity(0.05)) + .child(Label::new(self.label).size(self.label_size).buffer_font(cx)) + } +} + +impl Component for InlineCode { + fn scope() -> ComponentScope { + ComponentScope::DataDisplay + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .child( + example_group(vec![single_example( + "Simple", + InlineCode::new("zed.dev").into_any_element(), + )]) + .vertical(), + ) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index f8ac85528e..e22669995d 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use crate::PlatformStyle; use crate::{Icon, IconName, IconSize, h_flex, prelude::*}; use gpui::{ @@ -5,23 +7,49 @@ use gpui::{ Modifiers, Window, relative, }; use itertools::Itertools; +use settings::KeybindSource; -#[derive(Debug, IntoElement, Clone, RegisterComponent)] +#[derive(Debug)] +enum Source { + Action { + action: Box, + focus_handle: Option, + }, + Keystrokes { + /// A keybinding consists of a set of keystrokes, + /// where each keystroke is a key and a set of modifier keys. + /// More than one keystroke produces a chord. + /// + /// This should always contain at least one keystroke. + keystrokes: Rc<[KeybindingKeystroke]>, + }, +} + +impl Clone for Source { + fn clone(&self) -> Self { + match self { + Source::Action { + action, + focus_handle, + } => Source::Action { + action: action.boxed_clone(), + focus_handle: focus_handle.clone(), + }, + Source::Keystrokes { keystrokes } => Source::Keystrokes { + keystrokes: keystrokes.clone(), + }, + } + } +} + +#[derive(Clone, Debug, IntoElement, RegisterComponent)] pub struct KeyBinding { - /// A keybinding consists of a set of keystrokes, - /// where each keystroke is a key and a set of modifier keys. - /// More than one keystroke produces a chord. - /// - /// This should always contain at least one keystroke. - pub keystrokes: Vec, - + source: Source, + size: Option, /// The [`PlatformStyle`] to use when displaying this keybinding. platform_style: PlatformStyle, - size: Option, - /// Determines whether the keybinding is meant for vim mode. vim_mode: bool, - /// Indicates whether the keybinding is currently disabled. disabled: bool, } @@ -32,23 +60,25 @@ impl Global for VimStyle {} impl KeyBinding { /// Returns the highest precedence keybinding for an action. This is the last binding added to /// the keymap. User bindings are added after built-in bindings so that they take precedence. - pub fn for_action(action: &dyn Action, window: &mut Window, cx: &App) -> Option { - if let Some(focused) = window.focused(cx) { - return Self::for_action_in(action, &focused, window, cx); - } - let key_binding = window.highest_precedence_binding_for_action(action)?; - Some(Self::new_from_gpui(key_binding, cx)) + pub fn for_action(action: &dyn Action, cx: &App) -> Self { + Self::new(action, None, cx) } /// Like `for_action`, but lets you specify the context from which keybindings are matched. - pub fn for_action_in( - action: &dyn Action, - focus: &FocusHandle, - window: &Window, - cx: &App, - ) -> Option { - let key_binding = window.highest_precedence_binding_for_action_in(action, focus)?; - Some(Self::new_from_gpui(key_binding, cx)) + pub fn for_action_in(action: &dyn Action, focus: &FocusHandle, cx: &App) -> Self { + Self::new(action, Some(focus.clone()), cx) + } + pub fn has_binding(&self, window: &Window) -> bool { + match &self.source { + Source::Action { + action, + focus_handle: Some(focus), + } => window + .highest_precedence_binding_for_action_in(action.as_ref(), focus) + .or_else(|| window.highest_precedence_binding_for_action(action.as_ref())) + .is_some(), + _ => false, + } } pub fn set_vim_mode(cx: &mut App, enabled: bool) { @@ -59,18 +89,27 @@ impl KeyBinding { cx.try_global::().is_some_and(|g| g.0) } - pub fn new(keystrokes: Vec, cx: &App) -> Self { + pub fn new(action: &dyn Action, focus_handle: Option, cx: &App) -> Self { Self { - keystrokes, - platform_style: PlatformStyle::platform(), + source: Source::Action { + action: action.boxed_clone(), + focus_handle, + }, size: None, vim_mode: KeyBinding::is_vim_mode(cx), + platform_style: PlatformStyle::platform(), disabled: false, } } - pub fn new_from_gpui(key_binding: gpui::KeyBinding, cx: &App) -> Self { - Self::new(key_binding.keystrokes().to_vec(), cx) + pub fn from_keystrokes(keystrokes: Rc<[KeybindingKeystroke]>, source: KeybindSource) -> Self { + Self { + source: Source::Keystrokes { keystrokes }, + size: None, + vim_mode: source == KeybindSource::Vim, + platform_style: PlatformStyle::platform(), + disabled: false, + } } /// Sets the [`PlatformStyle`] for this [`KeyBinding`]. @@ -91,11 +130,6 @@ impl KeyBinding { self.disabled = disabled; self } - - pub fn vim_mode(mut self, enabled: bool) -> Self { - self.vim_mode = enabled; - self - } } fn render_key( @@ -115,36 +149,54 @@ fn render_key( } impl RenderOnce for KeyBinding { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let color = self.disabled.then_some(Color::Disabled); + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let render_keybinding = |keystrokes: &[KeybindingKeystroke]| { + let color = self.disabled.then_some(Color::Disabled); - h_flex() - .debug_selector(|| { - format!( - "KEY_BINDING-{}", - self.keystrokes - .iter() - .map(|k| k.key().to_string()) - .collect::>() - .join(" ") - ) - }) - .gap(DynamicSpacing::Base04.rems(cx)) - .flex_none() - .children(self.keystrokes.iter().map(|keystroke| { - h_flex() - .flex_none() - .py_0p5() - .rounded_xs() - .text_color(cx.theme().colors().text_muted) - .children(render_keybinding_keystroke( - keystroke, - color, - self.size, - self.platform_style, - self.vim_mode, - )) - })) + h_flex() + .debug_selector(|| { + format!( + "KEY_BINDING-{}", + keystrokes + .iter() + .map(|k| k.key().to_string()) + .collect::>() + .join(" ") + ) + }) + .gap(DynamicSpacing::Base04.rems(cx)) + .flex_none() + .children(keystrokes.iter().map(|keystroke| { + h_flex() + .flex_none() + .py_0p5() + .rounded_xs() + .text_color(cx.theme().colors().text_muted) + .children(render_keybinding_keystroke( + keystroke, + color, + self.size, + PlatformStyle::platform(), + self.vim_mode, + )) + })) + .into_any_element() + }; + + match self.source { + Source::Action { + action, + focus_handle, + } => focus_handle + .or_else(|| window.focused(cx)) + .and_then(|focus| { + window.highest_precedence_binding_for_action_in(action.as_ref(), &focus) + }) + .or_else(|| window.highest_precedence_binding_for_action(action.as_ref())) + .map(|binding| render_keybinding(binding.keystrokes())), + Source::Keystrokes { keystrokes } => Some(render_keybinding(keystrokes.as_ref())), + } + .unwrap_or_else(|| gpui::Empty.into_any_element()) } } @@ -517,79 +569,79 @@ impl Component for KeyBinding { ) } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - KeyBinding::new_from_gpui( - gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None), - cx, - ) - .into_any_element(), - ), - single_example( - "Mac Style", - KeyBinding::new_from_gpui( - gpui::KeyBinding::new("cmd-s", gpui::NoAction, None), - cx, - ) - .platform_style(PlatformStyle::Mac) - .into_any_element(), - ), - single_example( - "Windows Style", - KeyBinding::new_from_gpui( - gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None), - cx, - ) - .platform_style(PlatformStyle::Windows) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Vim Mode", - vec![single_example( - "Vim Mode Enabled", - KeyBinding::new_from_gpui( - gpui::KeyBinding::new("dd", gpui::NoAction, None), - cx, - ) - .vim_mode(true) - .into_any_element(), - )], - ), - example_group_with_title( - "Complex Bindings", - vec![ - single_example( - "Multiple Keys", - KeyBinding::new_from_gpui( - gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None), - cx, - ) - .into_any_element(), - ), - single_example( - "With Shift", - KeyBinding::new_from_gpui( - gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None), - cx, - ) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) - } + // fn preview(_window: &mut Window, cx: &mut App) -> Option { + // Some( + // v_flex() + // .gap_6() + // .children(vec![ + // example_group_with_title( + // "Basic Usage", + // vec![ + // single_example( + // "Default", + // KeyBinding::new_from_gpui( + // gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None), + // cx, + // ) + // .into_any_element(), + // ), + // single_example( + // "Mac Style", + // KeyBinding::new_from_gpui( + // gpui::KeyBinding::new("cmd-s", gpui::NoAction, None), + // cx, + // ) + // .platform_style(PlatformStyle::Mac) + // .into_any_element(), + // ), + // single_example( + // "Windows Style", + // KeyBinding::new_from_gpui( + // gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None), + // cx, + // ) + // .platform_style(PlatformStyle::Windows) + // .into_any_element(), + // ), + // ], + // ), + // example_group_with_title( + // "Vim Mode", + // vec![single_example( + // "Vim Mode Enabled", + // KeyBinding::new_from_gpui( + // gpui::KeyBinding::new("dd", gpui::NoAction, None), + // cx, + // ) + // .vim_mode(true) + // .into_any_element(), + // )], + // ), + // example_group_with_title( + // "Complex Bindings", + // vec![ + // single_example( + // "Multiple Keys", + // KeyBinding::new_from_gpui( + // gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None), + // cx, + // ) + // .into_any_element(), + // ), + // single_example( + // "With Shift", + // KeyBinding::new_from_gpui( + // gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None), + // cx, + // ) + // .into_any_element(), + // ), + // ], + // ), + // ]) + // .into_any_element(), + // ) + // } } #[cfg(test)] diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index 0e2d306df7..7c19953ca4 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -1,5 +1,5 @@ use crate::KeyBinding; -use crate::{h_flex, prelude::*}; +use crate::prelude::*; use gpui::{AnyElement, App, BoxShadow, FontStyle, Hsla, IntoElement, Window, point}; use theme::Appearance; @@ -14,10 +14,11 @@ use theme::Appearance; /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; +/// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( -/// KeyBinding::new(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-s").unwrap())], cx), +/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-s").unwrap())].into(), KeybindSource::Base), /// Hsla::black() /// ) /// .prefix("Save:") @@ -45,10 +46,11 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; + /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( - /// KeyBinding::new(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())], cx), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())].into(), KeybindSource::Base), /// Hsla::black() /// ); /// # } @@ -74,11 +76,12 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; + /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::with_prefix( /// "Copy:", - /// KeyBinding::new(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())], cx), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())].into(), KeybindSource::Base), /// Hsla::black() /// ); /// # } @@ -108,10 +111,11 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; + /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::with_suffix( - /// KeyBinding::new(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-v").unwrap())], cx), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-v").unwrap())].into(), KeybindSource::Base), /// "Paste", /// Hsla::black() /// ); @@ -141,10 +145,11 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; + /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( - /// KeyBinding::new(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-x").unwrap())], cx), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-x").unwrap())].into(), KeybindSource::Base), /// Hsla::black() /// ) /// .prefix("Cut:"); @@ -165,10 +170,11 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; + /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( - /// KeyBinding::new(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-f").unwrap())], cx), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-f").unwrap())].into(), KeybindSource::Base), /// Hsla::black() /// ) /// .suffix("Find"); @@ -189,10 +195,11 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; + /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( - /// KeyBinding::new(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-z").unwrap())], cx), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-z").unwrap())].into(), KeybindSource::Base), /// Hsla::black() /// ) /// .size(Pixels::from(16.0)); @@ -206,37 +213,36 @@ impl KeybindingHint { impl RenderOnce for KeybindingHint { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let colors = cx.theme().colors().clone(); + let colors = cx.theme().colors(); let is_light = cx.theme().appearance() == Appearance::Light; let border_color = self.background_color .blend(colors.text.alpha(if is_light { 0.08 } else { 0.16 })); - let bg_color = - self.background_color - .blend(colors.text.alpha(if is_light { 0.06 } else { 0.12 })); + + let bg_color = self + .background_color + .blend(colors.text_accent.alpha(if is_light { 0.05 } else { 0.1 })); + let shadow_color = colors.text.alpha(if is_light { 0.04 } else { 0.08 }); let size = self .size .unwrap_or(TextSize::Small.rems(cx).to_pixels(window.rem_size())); + let kb_size = size - px(2.0); let mut base = h_flex(); - base.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + base.text_style().font_style = Some(FontStyle::Italic); - base.items_center() - .gap_0p5() + base.gap_1() .font_buffer(cx) .text_size(size) .text_color(colors.text_disabled) .children(self.prefix) .child( h_flex() - .items_center() .rounded_sm() .px_0p5() .mr_0p5() @@ -264,10 +270,8 @@ impl Component for KeybindingHint { Some("Displays a keyboard shortcut hint with optional prefix and suffix text") } - fn preview(window: &mut Window, cx: &mut App) -> Option { - let enter_fallback = gpui::KeyBinding::new("enter", menu::Confirm, None); - let enter = KeyBinding::for_action(&menu::Confirm, window, cx) - .unwrap_or(KeyBinding::new_from_gpui(enter_fallback, cx)); + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let enter = KeyBinding::for_action(&menu::Confirm, cx); let bg_color = cx.theme().colors().surface_background; diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index a3cbc33553..840bba7b17 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -15,9 +15,16 @@ impl HighlightedLabel { /// Constructs a label with the given characters highlighted. /// Characters are identified by UTF-8 byte position. pub fn new(label: impl Into, highlight_indices: Vec) -> Self { + let label = label.into(); + for &run in &highlight_indices { + assert!( + label.is_char_boundary(run), + "highlight index {run} is not a valid UTF-8 boundary" + ); + } Self { base: LabelLike::new(), - label: label.into(), + label, highlight_indices, } } diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 1fa6b14c83..31fb7bfd88 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -223,11 +223,9 @@ impl RenderOnce for LabelLike { }) .when(self.italic, |this| this.italic()) .when(self.underline, |mut this| { - this.text_style() - .get_or_insert_with(Default::default) - .underline = Some(UnderlineStyle { + this.text_style().underline = Some(UnderlineStyle { thickness: px(1.), - color: None, + color: Some(cx.theme().colors().text_muted.opacity(0.4)), wavy: false, }); this diff --git a/crates/ui/src/components/label/loading_label.rs b/crates/ui/src/components/label/loading_label.rs index 2a1e705979..0b6b027e47 100644 --- a/crates/ui/src/components/label/loading_label.rs +++ b/crates/ui/src/components/label/loading_label.rs @@ -1,5 +1,5 @@ use crate::prelude::*; -use gpui::{Animation, AnimationExt, FontWeight, pulsating_between}; +use gpui::{Animation, AnimationExt, FontWeight}; use std::time::Duration; #[derive(IntoElement)] @@ -84,38 +84,29 @@ impl RenderOnce for LoadingLabel { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let text = self.text.clone(); - self.base - .color(Color::Muted) - .with_animations( - "loading_label", - vec![ - Animation::new(Duration::from_secs(1)), - Animation::new(Duration::from_secs(1)).repeat(), - ], - move |mut label, animation_ix, delta| { - match animation_ix { - 0 => { - let chars_to_show = (delta * text.len() as f32).ceil() as usize; - let text = SharedString::from(text[0..chars_to_show].to_string()); - label.set_text(text); - } - 1 => match delta { - d if d < 0.25 => label.set_text(text.clone()), - d if d < 0.5 => label.set_text(format!("{}.", text)), - d if d < 0.75 => label.set_text(format!("{}..", text)), - _ => label.set_text(format!("{}...", text)), - }, - _ => {} + self.base.color(Color::Muted).with_animations( + "loading_label", + vec![ + Animation::new(Duration::from_secs(1)), + Animation::new(Duration::from_secs(1)).repeat(), + ], + move |mut label, animation_ix, delta| { + match animation_ix { + 0 => { + let chars_to_show = (delta * text.len() as f32).ceil() as usize; + let text = SharedString::from(text[0..chars_to_show].to_string()); + label.set_text(text); } - label - }, - ) - .with_animation( - "pulsating-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.)), - |label, delta| label.map_element(|label| label.alpha(delta)), - ) + 1 => match delta { + d if d < 0.25 => label.set_text(text.clone()), + d if d < 0.5 => label.set_text(format!("{}.", text)), + d if d < 0.75 => label.set_text(format!("{}..", text)), + _ => label.set_text(format!("{}...", text)), + }, + _ => {} + } + label + }, + ) } } diff --git a/crates/ui/src/components/label/spinner_label.rs b/crates/ui/src/components/label/spinner_label.rs index b7b65fbcc9..33eeeae125 100644 --- a/crates/ui/src/components/label/spinner_label.rs +++ b/crates/ui/src/components/label/spinner_label.rs @@ -8,6 +8,7 @@ pub enum SpinnerVariant { #[default] Dots, DotsVariant, + Sand, } /// A spinner indication, based on the label component, that loops through @@ -41,6 +42,11 @@ impl SpinnerVariant { match self { SpinnerVariant::Dots => vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], SpinnerVariant::DotsVariant => vec!["⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶"], + SpinnerVariant::Sand => vec![ + "⠁", "⠂", "⠄", "⡀", "⡈", "⡐", "⡠", "⣀", "⣁", "⣂", "⣄", "⣌", "⣔", "⣤", "⣥", "⣦", + "⣮", "⣶", "⣷", "⣿", "⡿", "⠿", "⢟", "⠟", "⡛", "⠛", "⠫", "⢋", "⠋", "⠍", "⡉", "⠉", + "⠑", "⠡", "⢁", + ], } } @@ -48,6 +54,7 @@ impl SpinnerVariant { match self { SpinnerVariant::Dots => Duration::from_millis(1000), SpinnerVariant::DotsVariant => Duration::from_millis(1000), + SpinnerVariant::Sand => Duration::from_millis(2000), } } @@ -55,6 +62,7 @@ impl SpinnerVariant { match self { SpinnerVariant::Dots => "spinner_label_dots", SpinnerVariant::DotsVariant => "spinner_label_dots_variant", + SpinnerVariant::Sand => "spinner_label_dots_variant_2", } } } @@ -69,7 +77,7 @@ impl SpinnerLabel { let duration = variant.duration(); SpinnerLabel { - base: Label::new(frames[0]), + base: Label::new(frames[0]).color(Color::Muted), variant, frames, duration, @@ -83,6 +91,10 @@ impl SpinnerLabel { pub fn dots_variant() -> Self { Self::with_variant(SpinnerVariant::DotsVariant) } + + pub fn sand() -> Self { + Self::with_variant(SpinnerVariant::Sand) + } } impl LabelCommon for SpinnerLabel { @@ -152,7 +164,7 @@ impl RenderOnce for SpinnerLabel { let frames = self.frames.clone(); let duration = self.duration; - self.base.color(Color::Muted).with_animation( + self.base.with_animation( self.variant.animation_id(), Animation::new(duration).repeat(), move |mut label, delta| { @@ -185,6 +197,7 @@ impl Component for SpinnerLabel { "Dots Variant", SpinnerLabel::dots_variant().into_any_element(), ), + single_example("Sand Variant", SpinnerLabel::sand().into_any_element()), ]; Some(example_group(examples).vertical().into_any_element()) diff --git a/crates/ui/src/components/list/list.rs b/crates/ui/src/components/list/list.rs index b6950f06a4..ccae5bed23 100644 --- a/crates/ui/src/components/list/list.rs +++ b/crates/ui/src/components/list/list.rs @@ -1,14 +1,15 @@ +use component::{Component, ComponentScope, example_group_with_title, single_example}; use gpui::AnyElement; use smallvec::SmallVec; -use crate::{Label, ListHeader, prelude::*, v_flex}; +use crate::{Label, ListHeader, ListItem, prelude::*}; pub enum EmptyMessage { Text(SharedString), Element(AnyElement), } -#[derive(IntoElement)] +#[derive(IntoElement, RegisterComponent)] pub struct List { /// Message to display when the list is empty /// Defaults to "No items" @@ -92,3 +93,50 @@ impl RenderOnce for List { }) } } + +impl Component for List { + fn scope() -> ComponentScope { + ComponentScope::Layout + } + + fn description() -> Option<&'static str> { + Some( + "A container component for displaying a collection of list items with optional header and empty state.", + ) + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Basic Lists", + vec![ + single_example( + "Simple List", + List::new() + .child(ListItem::new("item1").child(Label::new("Item 1"))) + .child(ListItem::new("item2").child(Label::new("Item 2"))) + .child(ListItem::new("item3").child(Label::new("Item 3"))) + .into_any_element(), + ), + single_example( + "With Header", + List::new() + .header(ListHeader::new("Section Header")) + .child(ListItem::new("item1").child(Label::new("Item 1"))) + .child(ListItem::new("item2").child(Label::new("Item 2"))) + .into_any_element(), + ), + single_example( + "Empty List", + List::new() + .empty_message("No items to display") + .into_any_element(), + ), + ], + )]) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/list/list_bullet_item.rs b/crates/ui/src/components/list/list_bullet_item.rs index 9ac2095b57..934f0853db 100644 --- a/crates/ui/src/components/list/list_bullet_item.rs +++ b/crates/ui/src/components/list/list_bullet_item.rs @@ -1,17 +1,33 @@ -use crate::{ListItem, prelude::*}; +use crate::{ButtonLink, ListItem, prelude::*}; +use component::{Component, ComponentScope, example_group, single_example}; use gpui::{IntoElement, ParentElement, SharedString}; -#[derive(IntoElement)] +#[derive(IntoElement, RegisterComponent)] pub struct ListBulletItem { label: SharedString, + label_color: Option, + children: Vec, } impl ListBulletItem { pub fn new(label: impl Into) -> Self { Self { label: label.into(), + label_color: None, + children: Vec::new(), } } + + pub fn label_color(mut self, color: Color) -> Self { + self.label_color = Some(color); + self + } +} + +impl ParentElement for ListBulletItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } } impl RenderOnce for ListBulletItem { @@ -33,8 +49,67 @@ impl RenderOnce for ListBulletItem { .color(Color::Hidden), ), ) - .child(div().w_full().min_w_0().child(Label::new(self.label))), + .map(|this| { + if !self.children.is_empty() { + this.child(h_flex().gap_0p5().flex_wrap().children(self.children)) + } else { + this.child( + div().w_full().min_w_0().child( + Label::new(self.label) + .color(self.label_color.unwrap_or(Color::Default)), + ), + ) + } + }), ) .into_any_element() } } + +impl Component for ListBulletItem { + fn scope() -> ComponentScope { + ComponentScope::DataDisplay + } + + fn description() -> Option<&'static str> { + Some("A list item with a dash indicator for unordered lists.") + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let basic_examples = vec![ + single_example( + "Simple", + ListBulletItem::new("First bullet item").into_any_element(), + ), + single_example( + "Multiple Lines", + v_flex() + .child(ListBulletItem::new("First item")) + .child(ListBulletItem::new("Second item")) + .child(ListBulletItem::new("Third item")) + .into_any_element(), + ), + single_example( + "Long Text", + ListBulletItem::new( + "A longer bullet item that demonstrates text wrapping behavior", + ) + .into_any_element(), + ), + single_example( + "With Link", + ListBulletItem::new("") + .child(Label::new("Create a Zed account by")) + .child(ButtonLink::new("visiting the website", "https://zed.dev")) + .into_any_element(), + ), + ]; + + Some( + v_flex() + .gap_6() + .child(example_group(basic_examples).vertical()) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index d59af07fa5..8726dca50d 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -1,11 +1,12 @@ use std::sync::Arc; -use crate::{Disclosure, Label, h_flex, prelude::*}; +use crate::{Disclosure, prelude::*}; +use component::{Component, ComponentScope, example_group_with_title, single_example}; use gpui::{AnyElement, ClickEvent}; use settings::Settings; use theme::ThemeSettings; -#[derive(IntoElement)] +#[derive(IntoElement, RegisterComponent)] pub struct ListHeader { /// The label of the header. label: SharedString, @@ -138,3 +139,80 @@ impl RenderOnce for ListHeader { ) } } + +impl Component for ListHeader { + fn scope() -> ComponentScope { + ComponentScope::DataDisplay + } + + fn description() -> Option<&'static str> { + Some( + "A header component for lists with support for icons, actions, and collapsible sections.", + ) + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Headers", + vec![ + single_example( + "Simple", + ListHeader::new("Section Header").into_any_element(), + ), + single_example( + "With Icon", + ListHeader::new("Files") + .start_slot(Icon::new(IconName::File)) + .into_any_element(), + ), + single_example( + "With End Slot", + ListHeader::new("Recent") + .end_slot(Label::new("5").color(Color::Muted)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Collapsible Headers", + vec![ + single_example( + "Expanded", + ListHeader::new("Expanded Section") + .toggle(Some(true)) + .into_any_element(), + ), + single_example( + "Collapsed", + ListHeader::new("Collapsed Section") + .toggle(Some(false)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Selected", + ListHeader::new("Selected Header") + .toggle_state(true) + .into_any_element(), + ), + single_example( + "Inset", + ListHeader::new("Inset Header") + .inset(true) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index a58291438a..d581fad945 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use component::{Component, ComponentScope, example_group_with_title, single_example}; use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px}; use smallvec::SmallVec; @@ -13,7 +14,7 @@ pub enum ListItemSpacing { Sparse, } -#[derive(IntoElement)] +#[derive(IntoElement, RegisterComponent)] pub struct ListItem { id: ElementId, group_name: Option, @@ -355,3 +356,115 @@ impl RenderOnce for ListItem { ) } } + +impl Component for ListItem { + fn scope() -> ComponentScope { + ComponentScope::DataDisplay + } + + fn description() -> Option<&'static str> { + Some( + "A flexible list item component with support for icons, actions, disclosure toggles, and hierarchical display.", + ) + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic List Items", + vec![ + single_example( + "Simple", + ListItem::new("simple") + .child(Label::new("Simple list item")) + .into_any_element(), + ), + single_example( + "With Icon", + ListItem::new("with_icon") + .start_slot(Icon::new(IconName::File)) + .child(Label::new("List item with icon")) + .into_any_element(), + ), + single_example( + "Selected", + ListItem::new("selected") + .toggle_state(true) + .start_slot(Icon::new(IconName::Check)) + .child(Label::new("Selected item")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "List Item Spacing", + vec![ + single_example( + "Dense", + ListItem::new("dense") + .spacing(ListItemSpacing::Dense) + .child(Label::new("Dense spacing")) + .into_any_element(), + ), + single_example( + "Extra Dense", + ListItem::new("extra_dense") + .spacing(ListItemSpacing::ExtraDense) + .child(Label::new("Extra dense spacing")) + .into_any_element(), + ), + single_example( + "Sparse", + ListItem::new("sparse") + .spacing(ListItemSpacing::Sparse) + .child(Label::new("Sparse spacing")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Slots", + vec![ + single_example( + "End Slot", + ListItem::new("end_slot") + .child(Label::new("Item with end slot")) + .end_slot(Icon::new(IconName::ChevronRight)) + .into_any_element(), + ), + single_example( + "With Toggle", + ListItem::new("with_toggle") + .toggle(Some(true)) + .child(Label::new("Expandable item")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Disabled", + ListItem::new("disabled") + .disabled(true) + .child(Label::new("Disabled item")) + .into_any_element(), + ), + single_example( + "Non-selectable", + ListItem::new("non_selectable") + .selectable(false) + .child(Label::new("Non-selectable item")) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/list/list_sub_header.rs b/crates/ui/src/components/list/list_sub_header.rs index e6f5abfe0a..b4a82fb2ed 100644 --- a/crates/ui/src/components/list/list_sub_header.rs +++ b/crates/ui/src/components/list/list_sub_header.rs @@ -1,7 +1,7 @@ use crate::prelude::*; -use crate::{Icon, IconName, IconSize, Label, h_flex}; +use component::{Component, ComponentScope, example_group_with_title, single_example}; -#[derive(IntoElement)] +#[derive(IntoElement, RegisterComponent)] pub struct ListSubHeader { label: SharedString, start_slot: Option, @@ -85,3 +85,65 @@ impl RenderOnce for ListSubHeader { ) } } + +impl Component for ListSubHeader { + fn scope() -> ComponentScope { + ComponentScope::DataDisplay + } + + fn description() -> Option<&'static str> { + Some( + "A sub-header component for organizing list content into subsections with optional icons and end slots.", + ) + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Sub-headers", + vec![ + single_example( + "Simple", + ListSubHeader::new("Subsection").into_any_element(), + ), + single_example( + "With Icon", + ListSubHeader::new("Documents") + .left_icon(Some(IconName::File)) + .into_any_element(), + ), + single_example( + "With End Slot", + ListSubHeader::new("Recent") + .end_slot( + Label::new("3").color(Color::Muted).into_any_element(), + ) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Selected", + ListSubHeader::new("Selected") + .toggle_state(true) + .into_any_element(), + ), + single_example( + "Inset", + ListSubHeader::new("Inset Sub-header") + .inset(true) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/modal.rs b/crates/ui/src/components/modal.rs index a70f5e1ea5..85565f5488 100644 --- a/crates/ui/src/components/modal.rs +++ b/crates/ui/src/components/modal.rs @@ -77,6 +77,7 @@ impl RenderOnce for Modal { .w_full() .flex_1() .gap(DynamicSpacing::Base08.rems(cx)) + .when(self.footer.is_some(), |this| this.pb_4()) .when_some( self.container_scroll_handler, |this, container_scroll_handle| { @@ -276,7 +277,6 @@ impl RenderOnce for ModalFooter { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { h_flex() .w_full() - .mt_4() .p(DynamicSpacing::Base08.rems(cx)) .flex_none() .justify_between() diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index 439b53f038..b1a52bec8f 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -270,11 +270,11 @@ fn show_menu( window: &mut Window, cx: &mut App, ) { + let previous_focus_handle = window.focused(cx); let Some(new_menu) = (builder)(window, cx) else { return; }; let menu2 = menu.clone(); - let previous_focus_handle = window.focused(cx); window .subscribe(&new_menu, cx, move |modal, _: &DismissEvent, window, cx| { diff --git a/crates/ui/src/components/right_click_menu.rs b/crates/ui/src/components/right_click_menu.rs index 761189671b..dff4230737 100644 --- a/crates/ui/src/components/right_click_menu.rs +++ b/crates/ui/src/components/right_click_menu.rs @@ -223,7 +223,6 @@ impl Element for RightClickMenu { if let Some(mut menu) = request_layout.menu_element.take() { menu.paint(window, cx); - return; } let Some(builder) = this.menu_builder.take() else { diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index d85ef8f506..391d480fb3 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -101,13 +101,21 @@ where T: ScrollableHandle, { let element_id = config.id.take().unwrap_or_else(|| caller_location.into()); + let track_color = config.track_color; - window.use_keyed_state(element_id, cx, |window, cx| { + let state = window.use_keyed_state(element_id, cx, |window, cx| { let parent_id = cx.entity_id(); ScrollbarStateWrapper( cx.new(|cx| ScrollbarState::new_from_config(config, parent_id, window, cx)), ) - }) + }); + + state.update(cx, |state, cx| { + state + .0 + .update(cx, |state, _cx| state.update_track_color(track_color)) + }); + state } pub trait WithScrollbar: Sized { @@ -142,9 +150,9 @@ pub trait WithScrollbar: Sized { // } #[track_caller] - fn vertical_scrollbar_for( + fn vertical_scrollbar_for( self, - scroll_handle: ScrollHandle, + scroll_handle: &ScrollHandle, window: &mut Window, cx: &mut App, ) -> Self::Output { @@ -334,7 +342,7 @@ enum ReservedSpace { #[default] None, Thumb, - Track(Hsla), + Track, } impl ReservedSpace { @@ -343,14 +351,7 @@ impl ReservedSpace { } fn needs_scroll_track(&self) -> bool { - matches!(self, ReservedSpace::Track(_)) - } - - fn track_color(&self) -> Option { - match self { - ReservedSpace::Track(color) => Some(*color), - _ => None, - } + *self == ReservedSpace::Track } } @@ -385,6 +386,7 @@ pub struct Scrollbars { tracked_entity: Option>, scrollable_handle: Handle, visibility: Point, + track_color: Option, scrollbar_width: ScrollbarWidth, } @@ -406,6 +408,7 @@ impl Scrollbars { scrollable_handle: Handle::Untracked(ScrollHandle::new), tracked_entity: None, visibility: show_along.apply_to(Default::default(), ReservedSpace::Thumb), + track_color: None, scrollbar_width: ScrollbarWidth::Normal, } } @@ -438,7 +441,7 @@ impl Scrollbars { pub fn tracked_scroll_handle( self, - tracked_scroll_handle: TrackedHandle, + tracked_scroll_handle: &TrackedHandle, ) -> Scrollbars { let Self { id, @@ -446,15 +449,17 @@ impl Scrollbars { scrollbar_width, visibility, get_visibility, + track_color, .. } = self; Scrollbars { - scrollable_handle: Handle::Tracked(tracked_scroll_handle), + scrollable_handle: Handle::Tracked(tracked_scroll_handle.clone()), id, tracked_entity: tracked_entity_id, visibility, scrollbar_width, + track_color, get_visibility, } } @@ -465,7 +470,8 @@ impl Scrollbars { } pub fn with_track_along(mut self, along: ScrollAxes, background_color: Hsla) -> Self { - self.visibility = along.apply_to(self.visibility, ReservedSpace::Track(background_color)); + self.visibility = along.apply_to(self.visibility, ReservedSpace::Track); + self.track_color = Some(background_color); self } @@ -593,6 +599,7 @@ struct ScrollbarState { show_behavior: ShowBehavior, get_visibility: fn(&App) -> ShowScrollbar, visibility: Point, + track_color: Option, show_state: VisibilityState, mouse_in_parent: bool, last_prepaint_state: Option, @@ -622,6 +629,7 @@ impl ScrollbarState { scroll_handle, width: config.scrollbar_width, visibility: config.visibility, + track_color: config.track_color, show_behavior, get_visibility: config.get_visibility, show_state: VisibilityState::from_behavior(show_behavior), @@ -794,6 +802,10 @@ impl ScrollbarState { } } + fn update_track_color(&mut self, track_color: Option) { + self.track_color = track_color; + } + fn parent_hovered(&self, window: &Window) -> bool { self.last_prepaint_state .as_ref() @@ -956,7 +968,7 @@ impl ScrollableHandle for ScrollHandle { } } -pub trait ScrollableHandle: 'static + Any + Sized { +pub trait ScrollableHandle: 'static + Any + Sized + Clone { fn max_offset(&self) -> Size; fn set_offset(&self, point: Point); fn offset(&self) -> Point; @@ -1102,10 +1114,11 @@ impl Element for ScrollbarElement { .disabled() .not() .then(|| ScrollbarPrepaintState { - parent_bounds_hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal), thumbs: { - let thumb_ranges = self.state.read(cx).thumb_ranges().collect::>(); - let width = self.state.read(cx).width.to_pixels(); + let state = self.state.read(cx); + let thumb_ranges = state.thumb_ranges().collect::>(); + let width = state.width.to_pixels(); + let track_color = state.track_color; let additional_padding = if thumb_ranges.len() == 2 { width @@ -1158,26 +1171,29 @@ impl Element for ScrollbarElement { .apply_along(axis, |_| thumb_end - thumb_offset), ); + let needs_scroll_track = reserved_space.needs_scroll_track(); + ScrollbarLayout { thumb_bounds, track_bounds: padded_bounds, axis, cursor_hitbox: window.insert_hitbox( - if reserved_space.needs_scroll_track() { + if needs_scroll_track { padded_bounds } else { thumb_bounds }, HitboxBehavior::BlockMouseExceptScroll, ), - track_background: reserved_space - .track_color() + track_background: track_color + .filter(|_| needs_scroll_track) .map(|color| (padded_bounds.dilate(SCROLLBAR_PADDING), color)), reserved_space, } }) .collect() }, + parent_bounds_hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal), }); if prepaint_state .as_ref() @@ -1279,10 +1295,15 @@ impl Element for ScrollbarElement { } if let Some((track_bounds, color)) = track_background { + let mut color = *color; + if let Some(fade) = autohide_fade { + color.fade_out(fade); + } + window.paint_quad(quad( *track_bounds, Corners::default(), - *color, + color, Edges::default(), Hsla::transparent_black(), BorderStyle::default(), diff --git a/crates/ui/src/components/stories.rs b/crates/ui/src/components/stories.rs index 05e8cd18d5..bcfcfd04c3 100644 --- a/crates/ui/src/components/stories.rs +++ b/crates/ui/src/components/stories.rs @@ -1,19 +1,3 @@ mod context_menu; -mod icon_button; -mod keybinding; -mod list; -mod list_header; -mod list_item; -mod tab; -mod tab_bar; -mod toggle_button; pub use context_menu::*; -pub use icon_button::*; -pub use keybinding::*; -pub use list::*; -pub use list_header::*; -pub use list_item::*; -pub use tab::*; -pub use tab_bar::*; -pub use toggle_button::*; diff --git a/crates/ui/src/components/stories/avatar.rs b/crates/ui/src/components/stories/avatar.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/crates/ui/src/components/stories/button.rs b/crates/ui/src/components/stories/button.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/crates/ui/src/components/stories/disclosure.rs b/crates/ui/src/components/stories/disclosure.rs deleted file mode 100644 index 5a395388f4..0000000000 --- a/crates/ui/src/components/stories/disclosure.rs +++ /dev/null @@ -1,18 +0,0 @@ -use gpui::Render; -use story::Story; - -use crate::Disclosure; -use crate::prelude::*; - -pub struct DisclosureStory; - -impl Render for DisclosureStory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - Story::container(cx) - .child(Story::title_for::(cx)) - .child(Story::label("Toggled")) - .child(Disclosure::new("toggled", true)) - .child(Story::label("Not Toggled")) - .child(Disclosure::new("not_toggled", false)) - } -} diff --git a/crates/ui/src/components/stories/icon_button.rs b/crates/ui/src/components/stories/icon_button.rs deleted file mode 100644 index 166297eabc..0000000000 --- a/crates/ui/src/components/stories/icon_button.rs +++ /dev/null @@ -1,148 +0,0 @@ -use gpui::Render; -use story::{Story, StoryItem, StorySection}; - -use crate::{IconButton, IconName}; -use crate::{IconButtonShape, Tooltip, prelude::*}; - -pub struct IconButtonStory; - -impl Render for IconButtonStory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let default_button = StoryItem::new( - "Default", - IconButton::new("default_icon_button", IconName::Hash), - ) - .description("Displays an icon button.") - .usage( - r#" - IconButton::new("default_icon_button", Icon::Hash) - "#, - ); - - let selected_button = StoryItem::new( - "Selected", - IconButton::new("selected_icon_button", IconName::Hash).toggle_state(true), - ) - .description("Displays an icon button that is selected.") - .usage( - r#" - IconButton::new("selected_icon_button", Icon::Hash).selected(true) - "#, - ); - - let selected_with_selected_icon = StoryItem::new( - "Selected with `selected_icon`", - IconButton::new("selected_with_selected_icon_button", IconName::AudioOn) - .toggle_state(true) - .selected_icon(IconName::AudioOff), - ) - .description( - "Displays an icon button that is selected and shows a different icon when selected.", - ) - .usage( - r#" - IconButton::new("selected_with_selected_icon_button", Icon::AudioOn) - .selected(true) - .selected_icon(Icon::AudioOff) - "#, - ); - - let disabled_button = StoryItem::new( - "Disabled", - IconButton::new("disabled_icon_button", IconName::Hash).disabled(true), - ) - .description("Displays an icon button that is disabled.") - .usage( - r#" - IconButton::new("disabled_icon_button", Icon::Hash).disabled(true) - "#, - ); - - let with_on_click_button = StoryItem::new( - "With `on_click`", - IconButton::new("with_on_click_button", IconName::Ai).on_click( - |_event, _window, _cx| { - println!("Clicked!"); - }, - ), - ) - .description("Displays an icon button which triggers an event on click.") - .usage( - r#" - IconButton::new("with_on_click_button", Icon::Ai).on_click(|_event, _cx| { - println!("Clicked!"); - }) - "#, - ); - - let with_tooltip_button = StoryItem::new( - "With `tooltip`", - IconButton::new("with_tooltip_button", IconName::Chat) - .tooltip(Tooltip::text("Open messages")), - ) - .description("Displays an icon button that has a tooltip when hovered.") - .usage( - r#" - IconButton::new("with_tooltip_button", Icon::MessageBubbles) - .tooltip(Tooltip::text_f("Open messages")) - "#, - ); - - let selected_with_tooltip_button = StoryItem::new( - "Selected with `tooltip`", - IconButton::new("selected_with_tooltip_button", IconName::CaseSensitive) - .toggle_state(true) - .tooltip(Tooltip::text("Toggle inlay hints")), - ) - .description("Displays a selected icon button with tooltip.") - .usage( - r#" - IconButton::new("selected_with_tooltip_button", Icon::InlayHint) - .selected(true) - .tooltip(Tooltip::text_f("Toggle inlay hints")) - "#, - ); - - let buttons = vec![ - default_button, - selected_button, - selected_with_selected_icon, - disabled_button, - with_on_click_button, - with_tooltip_button, - selected_with_tooltip_button, - ]; - - Story::container(cx) - .child(Story::title_for::(cx)) - .child(StorySection::new().children(buttons)) - .child( - StorySection::new().child(StoryItem::new( - "Square", - h_flex() - .gap_2() - .child( - IconButton::new("square-medium", IconName::Close) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Medium), - ) - .child( - IconButton::new("square-small", IconName::Close) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small), - ) - .child( - IconButton::new("square-xsmall", IconName::Close) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall), - ) - .child( - IconButton::new("square-indicator", IconName::Close) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Indicator), - ), - )), - ) - .into_element() - } -} diff --git a/crates/ui/src/components/stories/keybinding.rs b/crates/ui/src/components/stories/keybinding.rs deleted file mode 100644 index 594f70b6ab..0000000000 --- a/crates/ui/src/components/stories/keybinding.rs +++ /dev/null @@ -1,99 +0,0 @@ -use gpui::NoAction; -use gpui::Render; -use itertools::Itertools; -use story::Story; - -use crate::{KeyBinding, prelude::*}; - -pub struct KeybindingStory; - -pub fn binding(key: &str) -> gpui::KeyBinding { - gpui::KeyBinding::new(key, NoAction {}, None) -} - -impl Render for KeybindingStory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let all_modifier_permutations = ["ctrl", "alt", "cmd", "shift"].into_iter().permutations(2); - - Story::container(cx) - .child(Story::title_for::(cx)) - .child(Story::label("Single Key", cx)) - .child(KeyBinding::new_from_gpui(binding("Z"), cx)) - .child(Story::label("Single Key with Modifier", cx)) - .child( - div() - .flex() - .gap_3() - .child(KeyBinding::new_from_gpui(binding("ctrl-c"), cx)) - .child(KeyBinding::new_from_gpui(binding("alt-c"), cx)) - .child(KeyBinding::new_from_gpui(binding("cmd-c"), cx)) - .child(KeyBinding::new_from_gpui(binding("shift-c"), cx)), - ) - .child(Story::label("Single Key with Modifier (Permuted)", cx)) - .child( - div().flex().flex_col().children( - all_modifier_permutations - .chunks(4) - .into_iter() - .map(|chunk| { - div() - .flex() - .gap_4() - .py_3() - .children(chunk.map(|permutation| { - KeyBinding::new_from_gpui( - binding(&(permutation.join("-") + "-x")), - cx, - ) - })) - }), - ), - ) - .child(Story::label("Single Key with All Modifiers", cx)) - .child(KeyBinding::new_from_gpui( - binding("ctrl-alt-cmd-shift-z"), - cx, - )) - .child(Story::label("Chord", cx)) - .child(KeyBinding::new_from_gpui(binding("a z"), cx)) - .child(Story::label("Chord with Modifier", cx)) - .child(KeyBinding::new_from_gpui(binding("ctrl-a shift-z"), cx)) - .child(KeyBinding::new_from_gpui(binding("fn-s"), cx)) - .child(Story::label("Single Key with All Modifiers (Linux)", cx)) - .child( - KeyBinding::new_from_gpui(binding("ctrl-alt-cmd-shift-z"), cx) - .platform_style(PlatformStyle::Linux), - ) - .child(Story::label("Chord (Linux)", cx)) - .child( - KeyBinding::new_from_gpui(binding("a z"), cx).platform_style(PlatformStyle::Linux), - ) - .child(Story::label("Chord with Modifier (Linux)", cx)) - .child( - KeyBinding::new_from_gpui(binding("ctrl-a shift-z"), cx) - .platform_style(PlatformStyle::Linux), - ) - .child( - KeyBinding::new_from_gpui(binding("fn-s"), cx).platform_style(PlatformStyle::Linux), - ) - .child(Story::label("Single Key with All Modifiers (Windows)", cx)) - .child( - KeyBinding::new_from_gpui(binding("ctrl-alt-cmd-shift-z"), cx) - .platform_style(PlatformStyle::Windows), - ) - .child(Story::label("Chord (Windows)", cx)) - .child( - KeyBinding::new_from_gpui(binding("a z"), cx) - .platform_style(PlatformStyle::Windows), - ) - .child(Story::label("Chord with Modifier (Windows)", cx)) - .child( - KeyBinding::new_from_gpui(binding("ctrl-a shift-z"), cx) - .platform_style(PlatformStyle::Windows), - ) - .child( - KeyBinding::new_from_gpui(binding("fn-s"), cx) - .platform_style(PlatformStyle::Windows), - ) - } -} diff --git a/crates/ui/src/components/stories/list.rs b/crates/ui/src/components/stories/list.rs deleted file mode 100644 index 6a0e672d31..0000000000 --- a/crates/ui/src/components/stories/list.rs +++ /dev/null @@ -1,36 +0,0 @@ -use gpui::Render; -use story::Story; - -use crate::{List, ListItem}; -use crate::{ListHeader, ListSeparator, ListSubHeader, prelude::*}; - -pub struct ListStory; - -impl Render for ListStory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - Story::container(cx) - .child(Story::title_for::(cx)) - .child(Story::label("Default", cx)) - .child( - List::new() - .child(ListItem::new("apple").child("Apple")) - .child(ListItem::new("banana").child("Banana")) - .child(ListItem::new("cherry").child("Cherry")), - ) - .child(Story::label("With sections", cx)) - .child( - List::new() - .header(ListHeader::new("Produce")) - .child(ListSubHeader::new("Fruits")) - .child(ListItem::new("apple").child("Apple")) - .child(ListItem::new("banana").child("Banana")) - .child(ListItem::new("cherry").child("Cherry")) - .child(ListSeparator) - .child(ListSubHeader::new("Root Vegetables")) - .child(ListItem::new("carrot").child("Carrot")) - .child(ListItem::new("potato").child("Potato")) - .child(ListSubHeader::new("Leafy Vegetables")) - .child(ListItem::new("kale").child("Kale")), - ) - } -} diff --git a/crates/ui/src/components/stories/list_header.rs b/crates/ui/src/components/stories/list_header.rs deleted file mode 100644 index f7fa068d5a..0000000000 --- a/crates/ui/src/components/stories/list_header.rs +++ /dev/null @@ -1,31 +0,0 @@ -use gpui::Render; -use story::Story; - -use crate::{IconButton, prelude::*}; -use crate::{IconName, ListHeader}; - -pub struct ListHeaderStory; - -impl Render for ListHeaderStory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - Story::container(cx) - .child(Story::title_for::(cx)) - .child(Story::label("Default", cx)) - .child(ListHeader::new("Section 1")) - .child(Story::label("With left icon", cx)) - .child(ListHeader::new("Section 2").start_slot(Icon::new(IconName::Bell))) - .child(Story::label("With left icon and meta", cx)) - .child( - ListHeader::new("Section 3") - .start_slot(Icon::new(IconName::BellOff)) - .end_slot(IconButton::new("action_1", IconName::BoltFilled)), - ) - .child(Story::label("With multiple meta", cx)) - .child( - ListHeader::new("Section 4") - .end_slot(IconButton::new("action_1", IconName::BoltFilled)) - .end_slot(IconButton::new("action_2", IconName::Warning)) - .end_slot(IconButton::new("action_3", IconName::Plus)), - ) - } -} diff --git a/crates/ui/src/components/stories/list_item.rs b/crates/ui/src/components/stories/list_item.rs deleted file mode 100644 index ee8f5e6c72..0000000000 --- a/crates/ui/src/components/stories/list_item.rs +++ /dev/null @@ -1,131 +0,0 @@ -use gpui::Render; -use story::Story; - -use crate::{Avatar, prelude::*}; -use crate::{IconName, ListItem}; - -const OVERFLOWING_TEXT: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean mauris ligula, luctus vel dignissim eu, vestibulum sed libero. Sed at convallis velit."; - -pub struct ListItemStory; - -impl Render for ListItemStory { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - Story::container(cx) - .bg(cx.theme().colors().background) - .child(Story::title_for::(cx)) - .child(Story::label("Default", cx)) - .child(ListItem::new("hello_world").child("Hello, world!")) - .child(Story::label("Inset", cx)) - .child( - ListItem::new("inset_list_item") - .inset(true) - .start_slot( - Icon::new(IconName::Bell) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child("Hello, world!") - .end_slot( - Icon::new(IconName::Bell) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - .child(Story::label("With start slot icon", cx)) - .child( - ListItem::new("with start slot_icon") - .child("Hello, world!") - .start_slot( - Icon::new(IconName::Bell) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - .child(Story::label("With start slot avatar", cx)) - .child( - ListItem::new("with_start slot avatar") - .child("Hello, world!") - .start_slot(Avatar::new( - "https://avatars.githubusercontent.com/u/1714999?v=4", - )), - ) - .child(Story::label("With end slot", cx)) - .child( - ListItem::new("with_left_avatar") - .child("Hello, world!") - .end_slot(Avatar::new( - "https://avatars.githubusercontent.com/u/1714999?v=4", - )), - ) - .child(Story::label("With end hover slot", cx)) - .child( - ListItem::new("with_end_hover_slot") - .child("Hello, world!") - .end_slot( - h_flex() - .gap_2() - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/1789?v=4", - )) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/1789?v=4", - )) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/1789?v=4", - )) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/1789?v=4", - )) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/1789?v=4", - )), - ) - .end_hover_slot(Avatar::new( - "https://avatars.githubusercontent.com/u/1714999?v=4", - )), - ) - .child(Story::label("With `on_click`", cx)) - .child(ListItem::new("with_on_click").child("Click me").on_click( - |_event, _window, _cx| { - println!("Clicked!"); - }, - )) - .child(Story::label("With `on_secondary_mouse_down`", cx)) - .child( - ListItem::new("with_on_secondary_mouse_down") - .child("Right click me") - .on_secondary_mouse_down(|_event, _window, _cx| { - println!("Right mouse down!"); - }), - ) - .child(Story::label( - "With overflowing content in the `end_slot`", - cx, - )) - .child( - ListItem::new("with_overflowing_content_in_end_slot") - .child("An excerpt") - .end_slot(Label::new(OVERFLOWING_TEXT).color(Color::Muted)), - ) - .child(Story::label( - "`inset` with overflowing content in the `end_slot`", - cx, - )) - .child( - ListItem::new("inset_with_overflowing_content_in_end_slot") - .inset(true) - .child("An excerpt") - .end_slot(Label::new(OVERFLOWING_TEXT).color(Color::Muted)), - ) - .child(Story::label( - "`inset` with overflowing content in `children` and `end_slot`", - cx, - )) - .child( - ListItem::new("inset_with_overflowing_content_in_children_and_end_slot") - .inset(true) - .child(Label::new(OVERFLOWING_TEXT)) - .end_slot(Label::new(OVERFLOWING_TEXT).color(Color::Muted)), - ) - } -} diff --git a/crates/ui/src/components/stories/tab.rs b/crates/ui/src/components/stories/tab.rs deleted file mode 100644 index e6c80c54e9..0000000000 --- a/crates/ui/src/components/stories/tab.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::cmp::Ordering; - -use gpui::Render; -use story::Story; - -use crate::{IconButtonShape, TabPosition, prelude::*}; -use crate::{Indicator, Tab}; - -pub struct TabStory; - -impl Render for TabStory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - Story::container(cx) - .child(Story::title_for::(cx)) - .child(Story::label("Default", cx)) - .child(h_flex().child(Tab::new("tab_1").child("Tab 1"))) - .child(Story::label("With indicator", cx)) - .child( - h_flex().child( - Tab::new("tab_1") - .start_slot(Indicator::dot().color(Color::Warning)) - .child("Tab 1"), - ), - ) - .child(Story::label("With close button", cx)) - .child( - h_flex().child( - Tab::new("tab_1") - .end_slot( - IconButton::new("close_button", IconName::Close) - .visible_on_hover("") - .shape(IconButtonShape::Square) - .icon_color(Color::Muted) - .size(ButtonSize::None) - .icon_size(IconSize::XSmall), - ) - .child("Tab 1"), - ), - ) - .child(Story::label("List of tabs", cx)) - .child( - h_flex() - .child(Tab::new("tab_1").child("Tab 1")) - .child(Tab::new("tab_2").child("Tab 2")), - ) - .child(Story::label("List of tabs with first tab selected", cx)) - .child( - h_flex() - .child( - Tab::new("tab_1") - .toggle_state(true) - .position(TabPosition::First) - .child("Tab 1"), - ) - .child( - Tab::new("tab_2") - .position(TabPosition::Middle(Ordering::Greater)) - .child("Tab 2"), - ) - .child( - Tab::new("tab_3") - .position(TabPosition::Middle(Ordering::Greater)) - .child("Tab 3"), - ) - .child(Tab::new("tab_4").position(TabPosition::Last).child("Tab 4")), - ) - .child(Story::label("List of tabs with last tab selected", cx)) - .child( - h_flex() - .child( - Tab::new("tab_1") - .position(TabPosition::First) - .child("Tab 1"), - ) - .child( - Tab::new("tab_2") - .position(TabPosition::Middle(Ordering::Less)) - .child("Tab 2"), - ) - .child( - Tab::new("tab_3") - .position(TabPosition::Middle(Ordering::Less)) - .child("Tab 3"), - ) - .child( - Tab::new("tab_4") - .position(TabPosition::Last) - .toggle_state(true) - .child("Tab 4"), - ), - ) - .child(Story::label("List of tabs with second tab selected", cx)) - .child( - h_flex() - .child( - Tab::new("tab_1") - .position(TabPosition::First) - .child("Tab 1"), - ) - .child( - Tab::new("tab_2") - .position(TabPosition::Middle(Ordering::Equal)) - .toggle_state(true) - .child("Tab 2"), - ) - .child( - Tab::new("tab_3") - .position(TabPosition::Middle(Ordering::Greater)) - .child("Tab 3"), - ) - .child(Tab::new("tab_4").position(TabPosition::Last).child("Tab 4")), - ) - } -} diff --git a/crates/ui/src/components/stories/tab_bar.rs b/crates/ui/src/components/stories/tab_bar.rs deleted file mode 100644 index fbb6c8c248..0000000000 --- a/crates/ui/src/components/stories/tab_bar.rs +++ /dev/null @@ -1,59 +0,0 @@ -use gpui::Render; -use story::Story; - -use crate::{Tab, TabBar, TabPosition, prelude::*}; - -pub struct TabBarStory; - -impl Render for TabBarStory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let tab_count = 20; - let selected_tab_index = 3; - - let tabs = (0..tab_count) - .map(|index| { - Tab::new(index) - .toggle_state(index == selected_tab_index) - .position(if index == 0 { - TabPosition::First - } else if index == tab_count - 1 { - TabPosition::Last - } else { - TabPosition::Middle(index.cmp(&selected_tab_index)) - }) - .child(Label::new(format!("Tab {}", index + 1)).color( - if index == selected_tab_index { - Color::Default - } else { - Color::Muted - }, - )) - }) - .collect::>(); - - Story::container(cx) - .child(Story::title_for::(cx)) - .child(Story::label("Default", cx)) - .child( - h_flex().child( - TabBar::new("tab_bar_1") - .start_child( - IconButton::new("navigate_backward", IconName::ArrowLeft) - .icon_size(IconSize::Small), - ) - .start_child( - IconButton::new("navigate_forward", IconName::ArrowRight) - .icon_size(IconSize::Small), - ) - .end_child( - IconButton::new("new", IconName::Plus).icon_size(IconSize::Small), - ) - .end_child( - IconButton::new("split_pane", IconName::Split) - .icon_size(IconSize::Small), - ) - .children(tabs), - ), - ) - } -} diff --git a/crates/ui/src/components/stories/toggle_button.rs b/crates/ui/src/components/stories/toggle_button.rs deleted file mode 100644 index 903c7059a8..0000000000 --- a/crates/ui/src/components/stories/toggle_button.rs +++ /dev/null @@ -1,93 +0,0 @@ -use gpui::Render; -use story::{Story, StoryItem, StorySection}; - -use crate::{ToggleButton, prelude::*}; - -pub struct ToggleButtonStory; - -impl Render for ToggleButtonStory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - Story::container(cx) - .child(Story::title_for::(cx)) - .child( - StorySection::new().child( - StoryItem::new( - "Default", - ToggleButton::new("default_toggle_button", "Hello"), - ) - .description("Displays a toggle button.") - .usage(""), - ), - ) - .child( - StorySection::new().child( - StoryItem::new( - "Toggle button group", - h_flex() - .child( - ToggleButton::new(1, "Apple") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .first(), - ) - .child( - ToggleButton::new(2, "Banana") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .middle(), - ) - .child( - ToggleButton::new(3, "Cherry") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .middle(), - ) - .child( - ToggleButton::new(4, "Dragonfruit") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .last(), - ), - ) - .description("Displays a group of toggle buttons.") - .usage(""), - ), - ) - .child( - StorySection::new().child( - StoryItem::new( - "Toggle button group with selection", - h_flex() - .child( - ToggleButton::new(1, "Apple") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .first(), - ) - .child( - ToggleButton::new(2, "Banana") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .toggle_state(true) - .middle(), - ) - .child( - ToggleButton::new(3, "Cherry") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .middle(), - ) - .child( - ToggleButton::new(4, "Dragonfruit") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .last(), - ), - ) - .description("Displays a group of toggle buttons.") - .usage(""), - ), - ) - .into_element() - } -} diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 3c467c06ce..86598b8c6f 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -10,6 +10,7 @@ pub struct TabBar { start_children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>, end_children: SmallVec<[AnyElement; 2]>, + pre_end_children: SmallVec<[AnyElement; 2]>, scroll_handle: Option, } @@ -20,12 +21,13 @@ impl TabBar { start_children: SmallVec::new(), children: SmallVec::new(), end_children: SmallVec::new(), + pre_end_children: SmallVec::new(), scroll_handle: None, } } - pub fn track_scroll(mut self, scroll_handle: ScrollHandle) -> Self { - self.scroll_handle = Some(scroll_handle); + pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self { + self.scroll_handle = Some(scroll_handle.clone()); self } @@ -70,6 +72,15 @@ impl TabBar { self } + pub fn pre_end_child(mut self, end_child: impl IntoElement) -> Self + where + Self: Sized, + { + self.pre_end_children + .push(end_child.into_element().into_any()); + self + } + pub fn end_children(mut self, end_children: impl IntoIterator) -> Self where Self: Sized, @@ -137,18 +148,32 @@ impl RenderOnce for TabBar { .children(self.children), ), ) - .when(!self.end_children.is_empty(), |this| { - this.child( - h_flex() - .flex_none() - .gap(DynamicSpacing::Base04.rems(cx)) - .px(DynamicSpacing::Base06.rems(cx)) - .border_b_1() - .border_l_1() - .border_color(cx.theme().colors().border) - .children(self.end_children), - ) - }) + .when( + !self.end_children.is_empty() || !self.pre_end_children.is_empty(), + |this| { + this.child( + h_flex() + .flex_none() + .gap(DynamicSpacing::Base04.rems(cx)) + .px(DynamicSpacing::Base06.rems(cx)) + .children(self.pre_end_children) + .border_color(cx.theme().colors().border) + .border_b_1() + .when(!self.end_children.is_empty(), |div| { + div.child( + h_flex() + .h_full() + .flex_none() + .pl(DynamicSpacing::Base04.rems(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + .children(self.end_children), + ) + }), + ) + }, + ) } } diff --git a/crates/ui/src/components/thread_item.rs b/crates/ui/src/components/thread_item.rs new file mode 100644 index 0000000000..a4f6a8a533 --- /dev/null +++ b/crates/ui/src/components/thread_item.rs @@ -0,0 +1,260 @@ +use crate::{ + Chip, DecoratedIcon, DiffStat, IconDecoration, IconDecorationKind, SpinnerLabel, prelude::*, +}; +use gpui::{ClickEvent, SharedString}; + +#[derive(IntoElement, RegisterComponent)] +pub struct ThreadItem { + id: ElementId, + icon: IconName, + title: SharedString, + timestamp: SharedString, + running: bool, + generation_done: bool, + selected: bool, + added: Option, + removed: Option, + worktree: Option, + on_click: Option>, +} + +impl ThreadItem { + pub fn new(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + icon: IconName::ZedAgent, + title: title.into(), + timestamp: "".into(), + running: false, + generation_done: false, + selected: false, + added: None, + removed: None, + worktree: None, + on_click: None, + } + } + + pub fn timestamp(mut self, timestamp: impl Into) -> Self { + self.timestamp = timestamp.into(); + self + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = icon; + self + } + + pub fn running(mut self, running: bool) -> Self { + self.running = running; + self + } + + pub fn generation_done(mut self, generation_done: bool) -> Self { + self.generation_done = generation_done; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn added(mut self, added: usize) -> Self { + self.added = Some(added); + self + } + + pub fn removed(mut self, removed: usize) -> Self { + self.removed = Some(removed); + self + } + + pub fn worktree(mut self, worktree: impl Into) -> Self { + self.worktree = Some(worktree.into()); + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for ThreadItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let icon_container = || h_flex().size_4().justify_center(); + let agent_icon = Icon::new(self.icon) + .color(Color::Muted) + .size(IconSize::Small); + + let icon = if self.generation_done { + DecoratedIcon::new( + agent_icon, + Some( + IconDecoration::new( + IconDecorationKind::Dot, + cx.theme().colors().surface_background, + cx, + ) + .color(cx.theme().colors().text_accent) + .position(gpui::Point { + x: px(-2.), + y: px(-2.), + }), + ), + ) + .into_any_element() + } else { + agent_icon.into_any_element() + }; + + let has_no_changes = self.added.is_none() && self.removed.is_none(); + + v_flex() + .id(self.id.clone()) + .cursor_pointer() + .p_2() + .when(self.selected, |this| { + this.bg(cx.theme().colors().element_active) + }) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child( + h_flex() + .w_full() + .gap_1p5() + .child(icon) + .child(Label::new(self.title).truncate()) + .when(self.running, |this| { + this.child(icon_container().child(SpinnerLabel::new().color(Color::Accent))) + }), + ) + .child( + h_flex() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .when_some(self.worktree, |this, name| { + this.child(Chip::new(name).label_size(LabelSize::XSmall)) + }) + .child( + Label::new(self.timestamp) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5), + ) + .when(has_no_changes, |this| { + this.child( + Label::new("No Changes") + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .when(self.added.is_some() || self.removed.is_some(), |this| { + this.child(DiffStat::new( + self.id, + self.added.unwrap_or(0), + self.removed.unwrap_or(0), + )) + }), + ) + .when_some(self.on_click, |this, on_click| this.on_click(on_click)) + } +} + +impl Component for ThreadItem { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || { + v_flex() + .w_72() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + }; + + let thread_item_examples = vec![ + single_example( + "Default", + container() + .child( + ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings") + .icon(IconName::AiOpenAi) + .timestamp("1:33 AM"), + ) + .into_any_element(), + ), + single_example( + "Generation Done", + container() + .child( + ThreadItem::new("ti-2", "Refine thread view scrolling behavior") + .timestamp("12:12 AM") + .generation_done(true), + ) + .into_any_element(), + ), + single_example( + "Running Agent", + container() + .child( + ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock") + .icon(IconName::AiClaude) + .timestamp("7:30 PM") + .running(true), + ) + .into_any_element(), + ), + single_example( + "In Worktree", + container() + .child( + ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock") + .icon(IconName::AiClaude) + .timestamp("7:37 PM") + .worktree("link-agent-panel"), + ) + .into_any_element(), + ), + single_example( + "With Changes", + container() + .child( + ThreadItem::new("ti-5", "Managing user and project settings interactions") + .icon(IconName::AiClaude) + .timestamp("7:37 PM") + .added(10) + .removed(3), + ) + .into_any_element(), + ), + single_example( + "Selected Item", + container() + .child( + ThreadItem::new("ti-6", "Refine textarea interaction behavior") + .icon(IconName::AiGemini) + .timestamp("3:00 PM") + .selected(true), + ) + .into_any_element(), + ), + ]; + + Some( + example_group(thread_item_examples) + .vertical() + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index ae74f76b9c..86ff1d8eff 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -1,7 +1,8 @@ use gpui::{ - AnyElement, AnyView, ClickEvent, ElementId, Hsla, IntoElement, Styled, Window, div, hsla, - prelude::*, + AnyElement, AnyView, ClickEvent, ElementId, Hsla, IntoElement, KeybindingKeystroke, Keystroke, + Styled, Window, div, hsla, prelude::*, }; +use settings::KeybindSource; use std::{rc::Rc, sync::Arc}; use crate::utils::is_light; @@ -43,13 +44,16 @@ pub enum ToggleStyle { pub struct Checkbox { id: ElementId, toggle_state: ToggleState, + style: ToggleStyle, disabled: bool, placeholder: bool, - on_click: Option>, filled: bool, - style: ToggleStyle, - tooltip: Option AnyView>>, + visualization: bool, label: Option, + label_size: LabelSize, + label_color: Color, + tooltip: Option AnyView>>, + on_click: Option>, } impl Checkbox { @@ -58,13 +62,16 @@ impl Checkbox { Self { id: id.into(), toggle_state: checked, - disabled: false, - on_click: None, - filled: false, style: ToggleStyle::default(), - tooltip: None, - label: None, + disabled: false, placeholder: false, + filled: false, + visualization: false, + label: None, + label_size: LabelSize::Default, + label_color: Color::Muted, + tooltip: None, + on_click: None, } } @@ -105,6 +112,13 @@ impl Checkbox { self } + /// Makes the checkbox look enabled but without pointer cursor and hover styles. + /// Primarily used for uninteractive markdown previews. + pub fn visualization_only(mut self, visualization: bool) -> Self { + self.visualization = visualization; + self + } + /// Sets the style of the checkbox using the specified [`ToggleStyle`]. pub fn style(mut self, style: ToggleStyle) -> Self { self.style = style; @@ -128,6 +142,16 @@ impl Checkbox { self.label = Some(label.into()); self } + + pub fn label_size(mut self, size: LabelSize) -> Self { + self.label_size = size; + self + } + + pub fn label_color(mut self, color: Color) -> Self { + self.label_color = color; + self + } } impl Checkbox { @@ -155,7 +179,6 @@ impl Checkbox { } } - /// container size pub fn container_size() -> Pixels { px(20.0) } @@ -169,6 +192,7 @@ impl RenderOnce for Checkbox { } else { Color::Selected }; + let icon = match self.toggle_state { ToggleState::Selected => { if self.placeholder { @@ -194,11 +218,10 @@ impl RenderOnce for Checkbox { let size = Self::container_size(); let checkbox = h_flex() - .id(self.id.clone()) - .justify_center() - .items_center() - .size(size) .group(group_id.clone()) + .id(self.id.clone()) + .size(size) + .justify_center() .child( div() .flex() @@ -215,7 +238,7 @@ impl RenderOnce for Checkbox { .when(self.disabled, |this| { this.bg(cx.theme().colors().element_disabled.opacity(0.6)) }) - .when(!self.disabled, |this| { + .when(!self.disabled && !self.visualization, |this| { this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color)) }) .when(self.placeholder, |this| { @@ -232,8 +255,27 @@ impl RenderOnce for Checkbox { h_flex() .id(self.id) + .map(|this| { + if self.disabled { + this.cursor_not_allowed() + } else if self.visualization { + this.cursor_default() + } else { + this.cursor_pointer() + } + }) .gap(DynamicSpacing::Base06.rems(cx)) .child(checkbox) + .when_some(self.label, |this, label| { + this.child( + Label::new(label) + .color(self.label_color) + .size(self.label_size), + ) + }) + .when_some(self.tooltip, |this, tooltip| { + this.tooltip(move |window, cx| tooltip(window, cx)) + }) .when_some( self.on_click.filter(|_| !self.disabled), |this, on_click| { @@ -242,111 +284,6 @@ impl RenderOnce for Checkbox { }) }, ) - // TODO: Allow label size to be different from default. - // TODO: Allow label color to be different from muted. - .when_some(self.label, |this, label| { - this.child(Label::new(label).color(Color::Muted)) - }) - .when_some(self.tooltip, |this, tooltip| { - this.tooltip(move |window, cx| tooltip(window, cx)) - }) - } -} - -/// A [`Checkbox`] that has a [`Label`]. -#[derive(IntoElement, RegisterComponent)] -pub struct CheckboxWithLabel { - id: ElementId, - label: Label, - checked: ToggleState, - on_click: Arc, - filled: bool, - style: ToggleStyle, - checkbox_position: IconPosition, -} - -// TODO: Remove `CheckboxWithLabel` now that `label` is a method of `Checkbox`. -impl CheckboxWithLabel { - /// Creates a checkbox with an attached label. - pub fn new( - id: impl Into, - label: Label, - checked: ToggleState, - on_click: impl Fn(&ToggleState, &mut Window, &mut App) + 'static, - ) -> Self { - Self { - id: id.into(), - label, - checked, - on_click: Arc::new(on_click), - filled: false, - style: ToggleStyle::default(), - checkbox_position: IconPosition::Start, - } - } - - /// Sets the style of the checkbox using the specified [`ToggleStyle`]. - pub fn style(mut self, style: ToggleStyle) -> Self { - self.style = style; - self - } - - /// Match the style of the checkbox to the current elevation using [`ToggleStyle::ElevationBased`]. - pub fn elevation(mut self, elevation: ElevationIndex) -> Self { - self.style = ToggleStyle::ElevationBased(elevation); - self - } - - /// Sets the `fill` setting of the checkbox, indicating whether it should be filled. - pub fn fill(mut self) -> Self { - self.filled = true; - self - } - - pub fn checkbox_position(mut self, position: IconPosition) -> Self { - self.checkbox_position = position; - self - } -} - -impl RenderOnce for CheckboxWithLabel { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - h_flex() - .gap(DynamicSpacing::Base08.rems(cx)) - .when(self.checkbox_position == IconPosition::Start, |this| { - this.child( - Checkbox::new(self.id.clone(), self.checked) - .style(self.style.clone()) - .when(self.filled, Checkbox::fill) - .on_click({ - let on_click = self.on_click.clone(); - move |checked, window, cx| { - (on_click)(checked, window, cx); - } - }), - ) - }) - .child( - div() - .id(SharedString::from(format!("{}-label", self.id))) - .on_click({ - let on_click = self.on_click.clone(); - move |_event, window, cx| { - (on_click)(&self.checked.inverse(), window, cx); - } - }) - .child(self.label), - ) - .when(self.checkbox_position == IconPosition::End, |this| { - this.child( - Checkbox::new(self.id.clone(), self.checked) - .style(self.style) - .when(self.filled, Checkbox::fill) - .on_click(move |checked, window, cx| { - (self.on_click)(checked, window, cx); - }), - ) - }) } } @@ -354,11 +291,7 @@ impl RenderOnce for CheckboxWithLabel { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum SwitchColor { #[default] - Default, Accent, - Error, - Warning, - Success, Custom(Hsla), } @@ -372,27 +305,10 @@ impl SwitchColor { } match self { - SwitchColor::Default => { - let colors = cx.theme().colors(); - let base_color = colors.text; - let bg_color = colors.element_background.blend(base_color.opacity(0.08)); - (bg_color, colors.border_variant) - } SwitchColor::Accent => { let status = cx.theme().status(); - (status.info.opacity(0.4), status.info.opacity(0.2)) - } - SwitchColor::Error => { - let status = cx.theme().status(); - (status.error.opacity(0.4), status.error.opacity(0.2)) - } - SwitchColor::Warning => { - let status = cx.theme().status(); - (status.warning.opacity(0.4), status.warning.opacity(0.2)) - } - SwitchColor::Success => { - let status = cx.theme().status(); - (status.success.opacity(0.4), status.success.opacity(0.2)) + let colors = cx.theme().colors(); + (status.info.opacity(0.4), colors.text_accent.opacity(0.2)) } SwitchColor::Custom(color) => (*color, color.opacity(0.6)), } @@ -402,16 +318,20 @@ impl SwitchColor { impl From for Color { fn from(color: SwitchColor) -> Self { match color { - SwitchColor::Default => Color::Default, SwitchColor::Accent => Color::Accent, - SwitchColor::Error => Color::Error, - SwitchColor::Warning => Color::Warning, - SwitchColor::Success => Color::Success, SwitchColor::Custom(_) => Color::Default, } } } +/// Defines the color for a switch component. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] +pub enum SwitchLabelPosition { + Start, + #[default] + End, +} + /// # Switch /// /// Switches are used to represent opposite states, such as enabled or disabled. @@ -422,6 +342,9 @@ pub struct Switch { disabled: bool, on_click: Option>, label: Option, + label_position: Option, + label_size: LabelSize, + full_width: bool, key_binding: Option, color: SwitchColor, tab_index: Option, @@ -436,6 +359,9 @@ impl Switch { disabled: false, on_click: None, label: None, + label_position: None, + label_size: LabelSize::Small, + full_width: false, key_binding: None, color: SwitchColor::default(), tab_index: None, @@ -469,6 +395,24 @@ impl Switch { self } + pub fn label_position( + mut self, + label_position: impl Into>, + ) -> Self { + self.label_position = label_position.into(); + self + } + + pub fn label_size(mut self, size: LabelSize) -> Self { + self.label_size = size; + self + } + + pub fn full_width(mut self, full_width: bool) -> Self { + self.full_width = full_width; + self + } + /// Display the keybinding that triggers the switch action. pub fn key_binding(mut self, key_binding: impl Into>) -> Self { self.key_binding = key_binding.into(); @@ -503,6 +447,7 @@ impl RenderOnce for Switch { }; let group_id = format!("switch_group_{:?}", self.id); + let label = self.label; let switch = div() .id((self.id.clone(), "switch")) @@ -514,7 +459,7 @@ impl RenderOnce for Switch { self.tab_index.filter(|_| !self.disabled), |this, tab_index| { this.tab_index(tab_index) - .focus(|mut style| { + .focus_visible(|mut style| { style.border_color = Some(cx.theme().colors().border_focused); style }) @@ -555,9 +500,27 @@ impl RenderOnce for Switch { h_flex() .id(self.id) - .gap(DynamicSpacing::Base06.rems(cx)) .cursor_pointer() + .gap(DynamicSpacing::Base06.rems(cx)) + .when(self.full_width, |this| this.w_full().justify_between()) + .when( + self.label_position == Some(SwitchLabelPosition::Start), + |this| { + this.when_some(label.clone(), |this, label| { + this.child(Label::new(label).size(self.label_size)) + }) + }, + ) .child(switch) + .when( + self.label_position == Some(SwitchLabelPosition::End), + |this| { + this.when_some(label, |this, label| { + this.child(Label::new(label).size(self.label_size)) + }) + }, + ) + .children(self.key_binding) .when_some( self.on_click.filter(|_| !self.disabled), |this, on_click| { @@ -566,10 +529,6 @@ impl RenderOnce for Switch { }) }, ) - .when_some(self.label, |this, label| { - this.child(Label::new(label).size(LabelSize::Small)) - }) - .children(self.key_binding) } } @@ -585,7 +544,7 @@ impl RenderOnce for Switch { /// /// let switch_field = SwitchField::new( /// "feature-toggle", -/// "Enable feature", +/// Some("Enable feature"), /// Some("This feature adds new functionality to the app.".into()), /// ToggleState::Unselected, /// |state, window, cx| { @@ -596,7 +555,7 @@ impl RenderOnce for Switch { #[derive(IntoElement, RegisterComponent)] pub struct SwitchField { id: ElementId, - label: SharedString, + label: Option, description: Option, toggle_state: ToggleState, on_click: Arc, @@ -609,14 +568,14 @@ pub struct SwitchField { impl SwitchField { pub fn new( id: impl Into, - label: impl Into, + label: Option>, description: Option, toggle_state: impl Into, on_click: impl Fn(&ToggleState, &mut Window, &mut App) + 'static, ) -> Self { Self { id: id.into(), - label: label.into(), + label: label.map(Into::into), description, toggle_state: toggle_state.into(), on_click: Arc::new(on_click), @@ -657,11 +616,11 @@ impl SwitchField { impl RenderOnce for SwitchField { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let tooltip = self.tooltip.map(|tooltip_fn| { - h_flex() - .gap_0p5() - .child(Label::new(self.label.clone())) - .child( + let tooltip = self + .tooltip + .zip(self.label.clone()) + .map(|(tooltip_fn, label)| { + h_flex().gap_0p5().child(Label::new(label)).child( IconButton::new("tooltip_button", IconName::Info) .icon_size(IconSize::XSmall) .icon_color(Color::Muted) @@ -673,7 +632,7 @@ impl RenderOnce for SwitchField { }) .on_click(|_, _, _| {}), // Intentional empty on click handler so that clicking on the info tooltip icon doesn't trigger the switch toggle ) - }); + }); h_flex() .id((self.id.clone(), "container")) @@ -694,11 +653,17 @@ impl RenderOnce for SwitchField { (Some(description), None) => v_flex() .gap_0p5() .max_w_5_6() - .child(Label::new(self.label.clone())) + .when_some(self.label, |this, label| this.child(Label::new(label))) .child(Label::new(description.clone()).color(Color::Muted)) .into_any_element(), (None, Some(tooltip)) => tooltip.into_any_element(), - (None, None) => Label::new(self.label.clone()).into_any_element(), + (None, None) => { + if let Some(label) = self.label.clone() { + Label::new(label).into_any_element() + } else { + gpui::Empty.into_any_element() + } + } }) .child( Switch::new((self.id.clone(), "switch"), self.toggle_state) @@ -748,7 +713,7 @@ impl Component for SwitchField { "Unselected", SwitchField::new( "switch_field_unselected", - "Enable notifications", + Some("Enable notifications"), Some("Receive notifications when new messages arrive.".into()), ToggleState::Unselected, |_, _, _| {}, @@ -759,7 +724,7 @@ impl Component for SwitchField { "Selected", SwitchField::new( "switch_field_selected", - "Enable notifications", + Some("Enable notifications"), Some("Receive notifications when new messages arrive.".into()), ToggleState::Selected, |_, _, _| {}, @@ -775,7 +740,7 @@ impl Component for SwitchField { "Default", SwitchField::new( "switch_field_default", - "Default color", + Some("Default color"), Some("This uses the default switch color.".into()), ToggleState::Selected, |_, _, _| {}, @@ -786,7 +751,7 @@ impl Component for SwitchField { "Accent", SwitchField::new( "switch_field_accent", - "Accent color", + Some("Accent color"), Some("This uses the accent color scheme.".into()), ToggleState::Selected, |_, _, _| {}, @@ -802,7 +767,7 @@ impl Component for SwitchField { "Disabled", SwitchField::new( "switch_field_disabled", - "Disabled field", + Some("Disabled field"), Some("This field is disabled and cannot be toggled.".into()), ToggleState::Selected, |_, _, _| {}, @@ -817,7 +782,7 @@ impl Component for SwitchField { "No Description", SwitchField::new( "switch_field_disabled", - "Disabled field", + Some("Disabled field"), None, ToggleState::Selected, |_, _, _| {}, @@ -832,7 +797,7 @@ impl Component for SwitchField { "Tooltip with Description", SwitchField::new( "switch_field_tooltip_with_desc", - "Nice Feature", + Some("Nice Feature"), Some("Enable advanced configuration options.".into()), ToggleState::Unselected, |_, _, _| {}, @@ -844,7 +809,7 @@ impl Component for SwitchField { "Tooltip without Description", SwitchField::new( "switch_field_tooltip_no_desc", - "Nice Feature", + Some("Nice Feature"), None, ToggleState::Selected, |_, _, _| {}, @@ -959,6 +924,15 @@ impl Component for Checkbox { .into_any_element(), )], ), + example_group_with_title( + "Extra", + vec![single_example( + "Visualization-Only", + Checkbox::new("viz_only", ToggleState::Selected) + .visualization_only(true) + .into_any_element(), + )], + ), ]) .into_any_element(), ) @@ -1000,37 +974,8 @@ impl Component for Switch { "Colors", vec![ single_example( - "Default", - Switch::new("switch_default_style", ToggleState::Selected) - .color(SwitchColor::Default) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Accent", + "Accent (Default)", Switch::new("switch_accent_style", ToggleState::Selected) - .color(SwitchColor::Accent) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Error", - Switch::new("switch_error_style", ToggleState::Selected) - .color(SwitchColor::Error) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Warning", - Switch::new("switch_warning_style", ToggleState::Selected) - .color(SwitchColor::Warning) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Success", - Switch::new("switch_success_style", ToggleState::Selected) - .color(SwitchColor::Success) .on_click(|_, _, _cx| {}) .into_any_element(), ), @@ -1064,75 +1009,55 @@ impl Component for Switch { "With Label", vec![ single_example( - "Label", - Switch::new("switch_with_label", ToggleState::Selected) + "Start Label", + Switch::new("switch_with_label_start", ToggleState::Selected) .label("Always save on quit") + .label_position(SwitchLabelPosition::Start) + .into_any_element(), + ), + single_example( + "End Label", + Switch::new("switch_with_label_end", ToggleState::Selected) + .label("Always save on quit") + .label_position(SwitchLabelPosition::End) + .into_any_element(), + ), + single_example( + "Default Size Label", + Switch::new( + "switch_with_label_default_size", + ToggleState::Selected, + ) + .label("Always save on quit") + .label_size(LabelSize::Default) + .into_any_element(), + ), + single_example( + "Small Size Label", + Switch::new("switch_with_label_small_size", ToggleState::Selected) + .label("Always save on quit") + .label_size(LabelSize::Small) .into_any_element(), ), - // TODO: Where did theme_preview_keybinding go? - // single_example( - // "Keybinding", - // Switch::new("switch_with_keybinding", ToggleState::Selected) - // .key_binding(theme_preview_keybinding("cmd-shift-e")) - // .into_any_element(), - // ), ], ), + example_group_with_title( + "With Keybinding", + vec![single_example( + "Keybinding", + Switch::new("switch_with_keybinding", ToggleState::Selected) + .key_binding(Some(KeyBinding::from_keystrokes( + vec![KeybindingKeystroke::from_keystroke( + Keystroke::parse("cmd-s").unwrap(), + )] + .into(), + KeybindSource::Base, + ))) + .into_any_element(), + )], + ), ]) .into_any_element(), ) } } - -impl Component for CheckboxWithLabel { - fn scope() -> ComponentScope { - ComponentScope::Input - } - - fn description() -> Option<&'static str> { - Some("A checkbox component with an attached label") - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "States", - vec![ - single_example( - "Unselected", - CheckboxWithLabel::new( - "checkbox_with_label_unselected", - Label::new("Always save on quit"), - ToggleState::Unselected, - |_, _, _| {}, - ) - .into_any_element(), - ), - single_example( - "Indeterminate", - CheckboxWithLabel::new( - "checkbox_with_label_indeterminate", - Label::new("Always save on quit"), - ToggleState::Indeterminate, - |_, _, _| {}, - ) - .into_any_element(), - ), - single_example( - "Selected", - CheckboxWithLabel::new( - "checkbox_with_label_selected", - Label::new("Always save on quit"), - ToggleState::Selected, - |_, _, _| {}, - ) - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) - } -} diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 4bfb7d2fc3..8b4ff3f731 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -64,11 +64,11 @@ impl Tooltip { ) -> impl Fn(&mut Window, &mut App) -> AnyView + use { let title = title.into(); let action = action.boxed_clone(); - move |window, cx| { + move |_, cx| { cx.new(|cx| Self { title: Title::Str(title.clone()), meta: None, - key_binding: KeyBinding::for_action(action.as_ref(), window, cx), + key_binding: Some(KeyBinding::for_action(action.as_ref(), cx)), }) .into() } @@ -82,11 +82,15 @@ impl Tooltip { let title = title.into(); let action = action.boxed_clone(); let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_, cx| { cx.new(|cx| Self { title: Title::Str(title.clone()), meta: None, - key_binding: KeyBinding::for_action_in(action.as_ref(), &focus_handle, window, cx), + key_binding: Some(KeyBinding::for_action_in( + action.as_ref(), + &focus_handle, + cx, + )), }) .into() } @@ -95,13 +99,12 @@ impl Tooltip { pub fn for_action( title: impl Into, action: &dyn Action, - window: &mut Window, cx: &mut App, ) -> AnyView { cx.new(|cx| Self { title: Title::Str(title.into()), meta: None, - key_binding: KeyBinding::for_action(action, window, cx), + key_binding: Some(KeyBinding::for_action(action, cx)), }) .into() } @@ -110,13 +113,12 @@ impl Tooltip { title: impl Into, action: &dyn Action, focus_handle: &FocusHandle, - window: &mut Window, cx: &mut App, ) -> AnyView { cx.new(|cx| Self { title: title.into().into(), meta: None, - key_binding: KeyBinding::for_action_in(action, focus_handle, window, cx), + key_binding: Some(KeyBinding::for_action_in(action, focus_handle, cx)), }) .into() } @@ -125,13 +127,12 @@ impl Tooltip { title: impl Into, action: Option<&dyn Action>, meta: impl Into, - window: &mut Window, cx: &mut App, ) -> AnyView { cx.new(|cx| Self { title: title.into().into(), meta: Some(meta.into()), - key_binding: action.and_then(|action| KeyBinding::for_action(action, window, cx)), + key_binding: action.map(|action| KeyBinding::for_action(action, cx)), }) .into() } @@ -141,14 +142,12 @@ impl Tooltip { action: Option<&dyn Action>, meta: impl Into, focus_handle: &FocusHandle, - window: &mut Window, cx: &mut App, ) -> AnyView { cx.new(|cx| Self { title: title.into().into(), meta: Some(meta.into()), - key_binding: action - .and_then(|action| KeyBinding::for_action_in(action, focus_handle, window, cx)), + key_binding: action.map(|action| KeyBinding::for_action_in(action, focus_handle, cx)), }) .into() } diff --git a/crates/ui/src/components/tree_view_item.rs b/crates/ui/src/components/tree_view_item.rs index 53539736e7..c96800223d 100644 --- a/crates/ui/src/components/tree_view_item.rs +++ b/crates/ui/src/components/tree_view_item.rs @@ -21,6 +21,7 @@ pub struct TreeViewItem { on_toggle: Option>, on_secondary_mouse_down: Option>, tab_index: Option, + focus_handle: Option, } impl TreeViewItem { @@ -41,6 +42,7 @@ impl TreeViewItem { on_toggle: None, on_secondary_mouse_down: None, tab_index: None, + focus_handle: None, } } @@ -107,6 +109,11 @@ impl TreeViewItem { self.focused = focused; self } + + pub fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self { + self.focus_handle = Some(focus_handle.clone()); + self + } } impl Disableable for TreeViewItem { @@ -126,11 +133,12 @@ impl Toggleable for TreeViewItem { impl RenderOnce for TreeViewItem { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let selected_bg = cx.theme().colors().element_active.opacity(0.5); - let selected_border = cx.theme().colors().border.opacity(0.6); - let focused_border = cx.theme().colors().border_focused; - let transparent_border = cx.theme().colors().border_transparent; - let item_size = rems_from_px(28.); + let transparent_border = cx.theme().colors().border.opacity(0.); + let selected_border = cx.theme().colors().border.opacity(0.4); + let focused_border = cx.theme().colors().border_focused; + + let item_size = rems_from_px(28.); let indentation_line = h_flex().size(item_size).flex_none().justify_center().child( div() .w_px() @@ -145,26 +153,23 @@ impl RenderOnce for TreeViewItem { .child( h_flex() .id("inner_tree_view_item") - .group("tree_view_item") .cursor_pointer() .size_full() - .relative() - .when_some(self.tab_index, |this, index| this.tab_index(index)) + .h(item_size) + .rounded_sm() + .border_1() + .border_color(transparent_border) + .focus_visible(|s| s.border_color(focused_border)) + .when(self.selected, |this| { + this.border_color(selected_border).bg(selected_bg) + }) + .hover(|s| s.bg(cx.theme().colors().element_hover)) .map(|this| { let label = self.label; + if self.root_item { - this.h(item_size) - .px_1() - .mb_1() + this.px_1() .gap_2p5() - .rounded_sm() - .border_1() - .focus(|s| s.border_color(focused_border)) - .border_color(transparent_border) - .when(self.selected, |this| { - this.border_color(selected_border).bg(selected_bg) - }) - .hover(|s| s.bg(cx.theme().colors().element_hover)) .child( Disclosure::new("toggle", self.expanded) .when_some( @@ -187,15 +192,6 @@ impl RenderOnce for TreeViewItem { .w_full() .flex_grow() .px_1() - .rounded_sm() - .border_1() - .focusable() - .in_focus(|s| s.border_color(focused_border)) - .border_color(transparent_border) - .when(self.selected, |this| { - this.border_color(selected_border).bg(selected_bg) - }) - .hover(|s| s.bg(cx.theme().colors().element_hover)) .child( Label::new(label) .when(!self.selected, |this| this.color(Color::Muted)), @@ -203,23 +199,12 @@ impl RenderOnce for TreeViewItem { ) } }) + .when_some(self.focus_handle, |this, handle| this.track_focus(&handle)) + .when_some(self.tab_index, |this, index| this.tab_index(index)) .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover)) .when_some( self.on_click.filter(|_| !self.disabled), - |this, on_click| { - if self.root_item - && let Some(on_toggle) = self.on_toggle.clone() - { - this.on_click(move |event, window, cx| { - if !event.is_keyboard() { - on_click(event, window, cx); - } - on_toggle(event, window, cx); - }) - } else { - this.on_click(on_click) - } - }, + |this, on_click| this.on_click(on_click), ) .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| { this.on_mouse_down(MouseButton::Right, move |event, window, cx| { diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 0d7d5af9e7..2bb0b35720 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -144,6 +144,19 @@ impl TextSize { Self::Editor => rems_from_px(theme_settings.buffer_font_size(cx)), } } + + pub fn pixels(self, cx: &App) -> Pixels { + let theme_settings = ThemeSettings::get_global(cx); + + match self { + Self::Large => px(16.), + Self::Default => px(14.), + Self::Small => px(12.), + Self::XSmall => px(10.), + Self::Ui => theme_settings.ui_font_size(cx), + Self::Editor => theme_settings.buffer_font_size(cx), + } + } } /// The size of a [`Headline`] element diff --git a/crates/ui/src/traits/styled_ext.rs b/crates/ui/src/traits/styled_ext.rs index cf452a2826..849e56a024 100644 --- a/crates/ui/src/traits/styled_ext.rs +++ b/crates/ui/src/traits/styled_ext.rs @@ -18,7 +18,11 @@ fn elevated_borderless(this: E, cx: &mut App, index: ElevationIndex) } /// Extends [`gpui::Styled`] with Zed-specific styling methods. -#[cfg_attr(debug_assertions, gpui_macros::derive_inspector_reflection)] +// gate on rust-analyzer so rust-analyzer never needs to expand this macro, it takes up to 10 seconds to expand due to inefficiencies in rust-analyzers proc-macro srv +#[cfg_attr( + all(debug_assertions, not(rust_analyzer)), + gpui_macros::derive_inspector_reflection +)] pub trait StyledExt: Styled + Sized { /// Horizontally stacks elements. /// diff --git a/crates/ui_input/Cargo.toml b/crates/ui_input/Cargo.toml index 0f107e42c3..4e7b08241d 100644 --- a/crates/ui_input/Cargo.toml +++ b/crates/ui_input/Cargo.toml @@ -14,14 +14,11 @@ path = "src/ui_input.rs" [dependencies] component.workspace = true editor.workspace = true -fuzzy.workspace = true gpui.workspace = true menu.workspace = true -picker.workspace = true settings.workspace = true theme.workspace = true ui.workspace = true -workspace-hack.workspace = true [features] default = [] diff --git a/crates/ui_input/src/input_field.rs b/crates/ui_input/src/input_field.rs new file mode 100644 index 0000000000..2bae8c172d --- /dev/null +++ b/crates/ui_input/src/input_field.rs @@ -0,0 +1,259 @@ +use component::{example_group, single_example}; +use editor::{Editor, EditorElement, EditorStyle}; +use gpui::{App, Entity, FocusHandle, Focusable, FontStyle, Hsla, Length, TextStyle}; +use settings::Settings; +use std::sync::Arc; +use theme::ThemeSettings; +use ui::prelude::*; + +pub struct InputFieldStyle { + text_color: Hsla, + background_color: Hsla, + border_color: Hsla, +} + +/// An Input Field component that can be used to create text fields like search inputs, form fields, etc. +/// +/// It wraps a single line [`Editor`] and allows for common field properties like labels, placeholders, icons, etc. +#[derive(RegisterComponent)] +pub struct InputField { + /// An optional label for the text field. + /// + /// Its position is determined by the [`FieldLabelLayout`]. + label: Option, + /// The size of the label text. + label_size: LabelSize, + /// The placeholder text for the text field. + placeholder: SharedString, + /// Exposes the underlying [`Entity`] to allow for customizing the editor beyond the provided API. + /// + /// This likely will only be public in the short term, ideally the API will be expanded to cover necessary use cases. + pub editor: Entity, + /// An optional icon that is displayed at the start of the text field. + /// + /// For example, a magnifying glass icon in a search field. + start_icon: Option, + /// Whether the text field is disabled. + disabled: bool, + /// The minimum width of for the input + min_width: Length, + /// The tab index for keyboard navigation order. + tab_index: Option, + /// Whether this field is a tab stop (can be focused via Tab key). + tab_stop: bool, +} + +impl Focusable for InputField { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl InputField { + pub fn new(window: &mut Window, cx: &mut App, placeholder: impl Into) -> Self { + let placeholder_text = placeholder.into(); + + let editor = cx.new(|cx| { + let mut input = Editor::single_line(window, cx); + input.set_placeholder_text(&placeholder_text, window, cx); + input + }); + + Self { + label: None, + label_size: LabelSize::Small, + placeholder: placeholder_text, + editor, + start_icon: None, + disabled: false, + min_width: px(192.).into(), + tab_index: None, + tab_stop: true, + } + } + + pub fn start_icon(mut self, icon: IconName) -> Self { + self.start_icon = Some(icon); + self + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn label_size(mut self, size: LabelSize) -> Self { + self.label_size = size; + self + } + + pub fn label_min_width(mut self, width: impl Into) -> Self { + self.min_width = width.into(); + self + } + + pub fn tab_index(mut self, index: isize) -> Self { + self.tab_index = Some(index); + self + } + + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { + self.disabled = disabled; + self.editor + .update(cx, |editor, _| editor.set_read_only(disabled)) + } + + pub fn is_empty(&self, cx: &App) -> bool { + self.editor().read(cx).text(cx).trim().is_empty() + } + + pub fn editor(&self) -> &Entity { + &self.editor + } + + pub fn text(&self, cx: &App) -> String { + self.editor().read(cx).text(cx) + } + + pub fn clear(&self, window: &mut Window, cx: &mut App) { + self.editor() + .update(cx, |editor, cx| editor.clear(window, cx)) + } + + pub fn set_text(&self, text: impl Into>, window: &mut Window, cx: &mut App) { + self.editor() + .update(cx, |editor, cx| editor.set_text(text, window, cx)) + } +} + +impl Render for InputField { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let editor = self.editor.clone(); + let settings = ThemeSettings::get_global(cx); + let theme_color = cx.theme().colors(); + + let mut style = InputFieldStyle { + text_color: theme_color.text, + background_color: theme_color.editor_background, + border_color: theme_color.border_variant, + }; + + if self.disabled { + style.text_color = theme_color.text_disabled; + style.background_color = theme_color.editor_background; + style.border_color = theme_color.border_disabled; + } + + // if self.error_message.is_some() { + // style.text_color = cx.theme().status().error; + // style.border_color = cx.theme().status().error_border + // } + + let text_style = TextStyle { + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_size: rems(0.875).into(), + font_weight: settings.buffer_font.weight, + font_style: FontStyle::Normal, + line_height: relative(1.2), + color: style.text_color, + ..Default::default() + }; + + let editor_style = EditorStyle { + background: theme_color.ghost_element_background, + local_player: cx.theme().players().local(), + syntax: cx.theme().syntax().clone(), + text: text_style, + ..Default::default() + }; + + let focus_handle = self.editor.focus_handle(cx); + + let configured_handle = if let Some(tab_index) = self.tab_index { + focus_handle.tab_index(tab_index).tab_stop(self.tab_stop) + } else if !self.tab_stop { + focus_handle.tab_stop(false) + } else { + focus_handle + }; + + v_flex() + .id(self.placeholder.clone()) + .w_full() + .gap_1() + .when_some(self.label.clone(), |this, label| { + this.child( + Label::new(label) + .size(self.label_size) + .color(if self.disabled { + Color::Disabled + } else { + Color::Default + }), + ) + }) + .child( + h_flex() + .track_focus(&configured_handle) + .min_w(self.min_width) + .min_h_8() + .w_full() + .px_2() + .py_1p5() + .flex_grow() + .text_color(style.text_color) + .rounded_md() + .bg(style.background_color) + .border_1() + .border_color(style.border_color) + .when( + editor.focus_handle(cx).contains_focused(window, cx), + |this| this.border_color(theme_color.border_focused), + ) + .when_some(self.start_icon, |this, icon| { + this.gap_1() + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + }) + .child(EditorElement::new(&self.editor, editor_style)), + ) + } +} + +impl Component for InputField { + fn scope() -> ComponentScope { + ComponentScope::Input + } + + fn preview(window: &mut Window, cx: &mut App) -> Option { + let input_small = + cx.new(|cx| InputField::new(window, cx, "placeholder").label("Small Label")); + + let input_regular = cx.new(|cx| { + InputField::new(window, cx, "placeholder") + .label("Regular Label") + .label_size(LabelSize::Default) + }); + + Some( + v_flex() + .gap_6() + .children(vec![example_group(vec![ + single_example( + "Small Label (Default)", + div().child(input_small).into_any_element(), + ), + single_example( + "Regular Label", + div().child(input_regular).into_any_element(), + ), + ])]) + .into_any_element(), + ) + } +} diff --git a/crates/ui_input/src/numeric_stepper.rs b/crates/ui_input/src/number_field.rs similarity index 54% rename from crates/ui_input/src/numeric_stepper.rs rename to crates/ui_input/src/number_field.rs index 436f67d654..ee5c57b43b 100644 --- a/crates/ui_input/src/numeric_stepper.rs +++ b/crates/ui_input/src/number_field.rs @@ -8,26 +8,17 @@ use std::{ use editor::{Editor, EditorStyle}; use gpui::{ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers}; -use settings::{CodeFade, MinimumContrast}; -use ui::{IconButtonShape, prelude::*}; +use settings::{CenteredPaddingSettings, CodeFade, DelayMs, InactiveOpacity, MinimumContrast}; +use ui::prelude::*; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -pub enum NumericStepperStyle { - Outlined, - #[default] - Ghost, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -pub enum NumericStepperMode { +pub enum NumberFieldMode { #[default] Read, Edit, } -pub trait NumericStepperType: - Display + Copy + Clone + Sized + PartialOrd + FromStr + 'static -{ +pub trait NumberFieldType: Display + Copy + Clone + Sized + PartialOrd + FromStr + 'static { fn default_format(value: &Self) -> String { format!("{}", value) } @@ -40,81 +31,92 @@ pub trait NumericStepperType: fn saturating_sub(self, rhs: Self) -> Self; } -impl NumericStepperType for gpui::FontWeight { - fn default_step() -> Self { - FontWeight(10.0) - } - fn large_step() -> Self { - FontWeight(50.0) - } - fn small_step() -> Self { - FontWeight(5.0) - } - fn min_value() -> Self { - gpui::FontWeight::THIN - } - fn max_value() -> Self { - gpui::FontWeight::BLACK - } - fn saturating_add(self, rhs: Self) -> Self { - FontWeight((self.0 + rhs.0).min(Self::max_value().0)) - } - fn saturating_sub(self, rhs: Self) -> Self { - FontWeight((self.0 - rhs.0).max(Self::min_value().0)) - } +macro_rules! impl_newtype_numeric_stepper_float { + ($type:ident, $default:expr, $large:expr, $small:expr, $min:expr, $max:expr) => { + impl NumberFieldType for $type { + fn default_step() -> Self { + $default.into() + } + + fn large_step() -> Self { + $large.into() + } + + fn small_step() -> Self { + $small.into() + } + + fn min_value() -> Self { + $min.into() + } + + fn max_value() -> Self { + $max.into() + } + + fn saturating_add(self, rhs: Self) -> Self { + $type((self.0 + rhs.0).min(Self::max_value().0)) + } + + fn saturating_sub(self, rhs: Self) -> Self { + $type((self.0 - rhs.0).max(Self::min_value().0)) + } + } + }; } -impl NumericStepperType for settings::CodeFade { - fn default_step() -> Self { - CodeFade(0.10) - } - fn large_step() -> Self { - CodeFade(0.20) - } - fn small_step() -> Self { - CodeFade(0.05) - } - fn min_value() -> Self { - CodeFade(0.0) - } - fn max_value() -> Self { - CodeFade(0.9) - } - fn saturating_add(self, rhs: Self) -> Self { - CodeFade((self.0 + rhs.0).min(Self::max_value().0)) - } - fn saturating_sub(self, rhs: Self) -> Self { - CodeFade((self.0 - rhs.0).max(Self::min_value().0)) - } +macro_rules! impl_newtype_numeric_stepper_int { + ($type:ident, $default:expr, $large:expr, $small:expr, $min:expr, $max:expr) => { + impl NumberFieldType for $type { + fn default_step() -> Self { + $default.into() + } + + fn large_step() -> Self { + $large.into() + } + + fn small_step() -> Self { + $small.into() + } + + fn min_value() -> Self { + $min.into() + } + + fn max_value() -> Self { + $max.into() + } + + fn saturating_add(self, rhs: Self) -> Self { + $type(self.0.saturating_add(rhs.0).min(Self::max_value().0)) + } + + fn saturating_sub(self, rhs: Self) -> Self { + $type(self.0.saturating_sub(rhs.0).max(Self::min_value().0)) + } + } + }; } -impl NumericStepperType for settings::MinimumContrast { - fn default_step() -> Self { - MinimumContrast(1.0) - } - fn large_step() -> Self { - MinimumContrast(10.0) - } - fn small_step() -> Self { - MinimumContrast(0.5) - } - fn min_value() -> Self { - MinimumContrast(0.0) - } - fn max_value() -> Self { - MinimumContrast(106.0) - } - fn saturating_add(self, rhs: Self) -> Self { - MinimumContrast((self.0 + rhs.0).min(Self::max_value().0)) - } - fn saturating_sub(self, rhs: Self) -> Self { - MinimumContrast((self.0 - rhs.0).max(Self::min_value().0)) - } -} +#[rustfmt::skip] +impl_newtype_numeric_stepper_float!(FontWeight, 50., 100., 10., FontWeight::THIN, FontWeight::BLACK); +impl_newtype_numeric_stepper_float!(CodeFade, 0.1, 0.2, 0.05, 0.0, 0.9); +impl_newtype_numeric_stepper_float!(InactiveOpacity, 0.1, 0.2, 0.05, 0.0, 1.0); +impl_newtype_numeric_stepper_float!(MinimumContrast, 1., 10., 0.5, 0.0, 106.0); +impl_newtype_numeric_stepper_int!(DelayMs, 100, 500, 10, 0, 2000); +impl_newtype_numeric_stepper_float!( + CenteredPaddingSettings, + 0.05, + 0.2, + 0.1, + CenteredPaddingSettings::MIN_PADDING, + CenteredPaddingSettings::MAX_PADDING +); macro_rules! impl_numeric_stepper_int { ($type:ident) => { - impl NumericStepperType for $type { + impl NumberFieldType for $type { fn default_step() -> Self { 1 } @@ -148,7 +150,7 @@ macro_rules! impl_numeric_stepper_int { macro_rules! impl_numeric_stepper_nonzero_int { ($nonzero:ty, $inner:ty) => { - impl NumericStepperType for $nonzero { + impl NumberFieldType for $nonzero { fn default_step() -> Self { <$nonzero>::new(1).unwrap() } @@ -184,7 +186,7 @@ macro_rules! impl_numeric_stepper_nonzero_int { macro_rules! impl_numeric_stepper_float { ($type:ident) => { - impl NumericStepperType for $type { + impl NumberFieldType for $type { fn default_format(value: &Self) -> String { format!("{:.2}", value) } @@ -234,12 +236,11 @@ impl_numeric_stepper_nonzero_int!(NonZeroU64, u64); impl_numeric_stepper_nonzero_int!(NonZero, usize); #[derive(RegisterComponent)] -pub struct NumericStepper { +pub struct NumberField { id: ElementId, value: T, - style: NumericStepperStyle, focus_handle: FocusHandle, - mode: Entity, + mode: Entity, format: Box String>, large_step: T, small_step: T, @@ -251,12 +252,12 @@ pub struct NumericStepper { tab_index: Option, } -impl NumericStepper { +impl NumberField { pub fn new(id: impl Into, value: T, window: &mut Window, cx: &mut App) -> Self { let id = id.into(); let (mode, focus_handle) = window.with_id(id.clone(), |window| { - let mode = window.use_state(cx, |_, _| NumericStepperMode::default()); + let mode = window.use_state(cx, |_, _| NumberFieldMode::default()); let focus_handle = window.use_state(cx, |_, cx| cx.focus_handle()); (mode, focus_handle) }); @@ -266,7 +267,6 @@ impl NumericStepper { mode, value, focus_handle: focus_handle.read(cx).clone(), - style: NumericStepperStyle::default(), format: Box::new(T::default_format), large_step: T::large_step(), step: T::default_step(), @@ -309,11 +309,6 @@ impl NumericStepper { self } - pub fn style(mut self, style: NumericStepperStyle) -> Self { - self.style = style; - self - } - pub fn on_reset( mut self, on_reset: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -333,7 +328,7 @@ impl NumericStepper { } } -impl IntoElement for NumericStepper { +impl IntoElement for NumberField { type Element = gpui::Component; fn into_element(self) -> Self::Element { @@ -341,12 +336,8 @@ impl IntoElement for NumericStepper { } } -impl RenderOnce for NumericStepper { +impl RenderOnce for NumberField { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let shape = IconButtonShape::Square; - let icon_size = IconSize::Small; - - let is_outlined = matches!(self.style, NumericStepperStyle::Outlined); let mut tab_index = self.tab_index; let get_step = { @@ -364,6 +355,27 @@ impl RenderOnce for NumericStepper { } }; + let bg_color = cx.theme().colors().surface_background; + let hover_bg_color = cx.theme().colors().element_hover; + + let border_color = cx.theme().colors().border_variant; + let focus_border_color = cx.theme().colors().border_focused; + + let base_button = |icon: IconName| { + h_flex() + .cursor_pointer() + .p_1p5() + .size_full() + .justify_center() + .overflow_hidden() + .border_1() + .border_color(border_color) + .bg(bg_color) + .hover(|s| s.bg(hover_bg_color)) + .focus_visible(|s| s.border_color(focus_border_color).bg(hover_bg_color)) + .child(Icon::new(icon).size(IconSize::Small)) + }; + h_flex() .id(self.id.clone()) .track_focus(&self.focus_handle) @@ -371,8 +383,7 @@ impl RenderOnce for NumericStepper { .when_some(self.on_reset, |this, on_reset| { this.child( IconButton::new("reset", IconName::RotateCcw) - .shape(shape) - .icon_size(icon_size) + .icon_size(IconSize::Small) .when_some(tab_index.as_mut(), |this, tab_index| { *tab_index += 1; this.tab_index(*tab_index - 1) @@ -382,18 +393,6 @@ impl RenderOnce for NumericStepper { }) .child( h_flex() - .gap_1() - .rounded_sm() - .map(|this| { - if is_outlined { - this.overflow_hidden() - .bg(cx.theme().colors().surface_background) - .border_1() - .border_color(cx.theme().colors().border_variant) - } else { - this.px_1().bg(cx.theme().colors().editor_background) - } - }) .map(|decrement| { let decrement_handler = { let value = self.value; @@ -404,76 +403,49 @@ impl RenderOnce for NumericStepper { let new_value = value.saturating_sub(step); let new_value = if new_value < min { min } else { new_value }; on_change(&new_value, window, cx); - window.focus_prev(); } }; - if is_outlined { - decrement.child( - h_flex() - .id("decrement_button") - .p_1p5() - .size_full() - .justify_center() - .hover(|s| { - s.bg(cx.theme().colors().element_hover) - .cursor(gpui::CursorStyle::PointingHand) - }) - .border_r_1() - .border_color(cx.theme().colors().border_variant) - .child(Icon::new(IconName::Dash).size(IconSize::Small)) - .when_some(tab_index.as_mut(), |this, tab_index| { - *tab_index += 1; - this.tab_index(*tab_index - 1).focus(|style| { - style.bg(cx.theme().colors().element_hover) + decrement.child( + base_button(IconName::Dash) + .id("decrement_button") + .rounded_tl_sm() + .rounded_bl_sm() + .tab_index( + tab_index + .as_mut() + .map(|tab_index| { + *tab_index += 1; + *tab_index - 1 }) - }) - .on_click(decrement_handler), - ) - } else { - decrement.child( - IconButton::new("decrement", IconName::Dash) - .shape(shape) - .icon_size(icon_size) - .when_some(tab_index.as_mut(), |this, tab_index| { - *tab_index += 1; - this.tab_index(*tab_index - 1) - }) - .on_click(decrement_handler), - ) - } + .unwrap_or(0), + ) + .on_click(decrement_handler), + ) }) .child( h_flex() .min_w_16() - .w_full() - .border_1() - .border_color(cx.theme().colors().border_transparent) - .in_focus(|this| this.border_color(cx.theme().colors().border_focused)) + .size_full() + .border_y_1() + .border_color(border_color) + .bg(bg_color) + .in_focus(|this| this.border_color(focus_border_color)) .child(match *self.mode.read(cx) { - NumericStepperMode::Read => h_flex() - .id("numeric_stepper_label") + NumberFieldMode::Read => h_flex() .px_1() .flex_1() .justify_center() .child(Label::new((self.format)(&self.value))) - .when_some(tab_index.as_mut(), |this, tab_index| { - *tab_index += 1; - this.tab_index(*tab_index - 1).focus(|style| { - style.bg(cx.theme().colors().element_hover) - }) - }) - .on_click({ - let _mode = self.mode.clone(); - move |click, _, _cx| { - if click.click_count() == 2 || click.is_keyboard() { - // Edit mode is disabled until we implement center text alignment for editor - // mode.write(cx, NumericStepperMode::Edit); - } - } - }) .into_any_element(), - NumericStepperMode::Edit => h_flex() + // Edit mode is disabled until we implement center text alignment for editor + // mode.write(cx, NumberFieldMode::Edit); + // + // When we get to making Edit mode work, we shouldn't even focus the decrement/increment buttons. + // Focus should go instead straight to the editor, avoiding any double-step focus. + // In this world, the buttons become a mouse-only interaction, given users should be able + // to do everything they'd do with the buttons straight in the editor anyway. + NumberFieldMode::Edit => h_flex() .flex_1() .child(window.use_state(cx, { |window, cx| { @@ -508,7 +480,7 @@ impl RenderOnce for NumericStepper { } on_change(&new_value, window, cx); }; - mode.write(cx, NumericStepperMode::Read); + mode.write(cx, NumberFieldMode::Read); } }) .detach(); @@ -539,52 +511,34 @@ impl RenderOnce for NumericStepper { } }; - if is_outlined { - increment.child( - h_flex() - .id("increment_button") - .p_1p5() - .size_full() - .justify_center() - .hover(|s| { - s.bg(cx.theme().colors().element_hover) - .cursor(gpui::CursorStyle::PointingHand) - }) - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .child(Icon::new(IconName::Plus).size(IconSize::Small)) - .when_some(tab_index.as_mut(), |this, tab_index| { - *tab_index += 1; - this.tab_index(*tab_index - 1).focus(|style| { - style.bg(cx.theme().colors().element_hover) + increment.child( + base_button(IconName::Plus) + .id("increment_button") + .rounded_tr_sm() + .rounded_br_sm() + .tab_index( + tab_index + .as_mut() + .map(|tab_index| { + *tab_index += 1; + *tab_index - 1 }) - }) - .on_click(increment_handler), - ) - } else { - increment.child( - IconButton::new("increment", IconName::Plus) - .shape(shape) - .icon_size(icon_size) - .when_some(tab_index.as_mut(), |this, tab_index| { - *tab_index += 1; - this.tab_index(*tab_index - 1) - }) - .on_click(increment_handler), - ) - } + .unwrap_or(0), + ) + .on_click(increment_handler), + ) }), ) } } -impl Component for NumericStepper { +impl Component for NumberField { fn scope() -> ComponentScope { ComponentScope::Input } fn name() -> &'static str { - "Numeric Stepper" + "Number Field" } fn sort_name() -> &'static str { @@ -592,50 +546,30 @@ impl Component for NumericStepper { } fn description() -> Option<&'static str> { - Some("A button used to increment or decrement a numeric value.") + Some("A numeric input element with increment and decrement buttons.") } fn preview(window: &mut Window, cx: &mut App) -> Option { - let first_stepper = window.use_state(cx, |_, _| 100usize); - let second_stepper = window.use_state(cx, |_, _| 100.0); + let stepper_example = window.use_state(cx, |_, _| 100.0); + Some( v_flex() .gap_6() - .children(vec![example_group_with_title( - "Styles", - vec![ - single_example( - "Default", - NumericStepper::new( - "numeric-stepper-component-preview", - *first_stepper.read(cx), - window, - cx, - ) - .on_change({ - let first_stepper = first_stepper.clone(); - move |value, _, cx| first_stepper.write(cx, *value) - }) - .into_any_element(), - ), - single_example( - "Outlined", - NumericStepper::new( - "numeric-stepper-with-border-component-preview", - *second_stepper.read(cx), - window, - cx, - ) - .on_change({ - let second_stepper = second_stepper.clone(); - move |value, _, cx| second_stepper.write(cx, *value) - }) - .min(1.0) - .max(100.0) - .style(NumericStepperStyle::Outlined) - .into_any_element(), - ), - ], + .children(vec![single_example( + "Default Numeric Stepper", + NumberField::new( + "numeric-stepper-component-preview", + *stepper_example.read(cx), + window, + cx, + ) + .on_change({ + let stepper_example = stepper_example.clone(); + move |value, _, cx| stepper_example.write(cx, *value) + }) + .min(1.0) + .max(100.0) + .into_any_element(), )]) .into_any_element(), ) diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index 0e3baec69b..ddc0e659a2 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/crates/ui_input/src/ui_input.rs @@ -1,233 +1,9 @@ -//! # UI – Text Field -//! -//! This crate provides a text field component that can be used to create text fields like search inputs, form fields, etc. +//! This crate provides UI components that can be used for form-like scenarios, such as a input and number field. //! //! It can't be located in the `ui` crate because it depends on `editor`. //! -mod font_picker; -mod numeric_stepper; +mod input_field; +mod number_field; -use component::{example_group, single_example}; -use editor::{Editor, EditorElement, EditorStyle}; -pub use font_picker::*; -use gpui::{App, Entity, FocusHandle, Focusable, FontStyle, Hsla, Length, TextStyle}; -pub use numeric_stepper::*; -use settings::Settings; -use std::sync::Arc; -use theme::ThemeSettings; -use ui::prelude::*; - -pub struct SingleLineInputStyle { - text_color: Hsla, - background_color: Hsla, - border_color: Hsla, -} - -/// A Text Field that can be used to create text fields like search inputs, form fields, etc. -/// -/// It wraps a single line [`Editor`] and allows for common field properties like labels, placeholders, icons, etc. -#[derive(RegisterComponent)] -pub struct SingleLineInput { - /// An optional label for the text field. - /// - /// Its position is determined by the [`FieldLabelLayout`]. - label: Option, - /// The size of the label text. - label_size: LabelSize, - /// The placeholder text for the text field. - placeholder: SharedString, - /// Exposes the underlying [`Entity`] to allow for customizing the editor beyond the provided API. - /// - /// This likely will only be public in the short term, ideally the API will be expanded to cover necessary use cases. - pub editor: Entity, - /// An optional icon that is displayed at the start of the text field. - /// - /// For example, a magnifying glass icon in a search field. - start_icon: Option, - /// Whether the text field is disabled. - disabled: bool, - /// The minimum width of for the input - min_width: Length, -} - -impl Focusable for SingleLineInput { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.editor.focus_handle(cx) - } -} - -impl SingleLineInput { - pub fn new(window: &mut Window, cx: &mut App, placeholder: impl Into) -> Self { - let placeholder_text = placeholder.into(); - - let editor = cx.new(|cx| { - let mut input = Editor::single_line(window, cx); - input.set_placeholder_text(&placeholder_text, window, cx); - input - }); - - Self { - label: None, - label_size: LabelSize::Small, - placeholder: placeholder_text, - editor, - start_icon: None, - disabled: false, - min_width: px(192.).into(), - } - } - - pub fn start_icon(mut self, icon: IconName) -> Self { - self.start_icon = Some(icon); - self - } - - pub fn label(mut self, label: impl Into) -> Self { - self.label = Some(label.into()); - self - } - - pub fn label_size(mut self, size: LabelSize) -> Self { - self.label_size = size; - self - } - - pub fn label_min_width(mut self, width: impl Into) -> Self { - self.min_width = width.into(); - self - } - - pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { - self.disabled = disabled; - self.editor - .update(cx, |editor, _| editor.set_read_only(disabled)) - } - - pub fn is_empty(&self, cx: &App) -> bool { - self.editor().read(cx).text(cx).trim().is_empty() - } - - pub fn editor(&self) -> &Entity { - &self.editor - } - - pub fn text(&self, cx: &App) -> String { - self.editor().read(cx).text(cx) - } - - pub fn set_text(&self, text: impl Into>, window: &mut Window, cx: &mut App) { - self.editor() - .update(cx, |editor, cx| editor.set_text(text, window, cx)) - } -} - -impl Render for SingleLineInput { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let theme_color = cx.theme().colors(); - - let mut style = SingleLineInputStyle { - text_color: theme_color.text, - background_color: theme_color.editor_background, - border_color: theme_color.border_variant, - }; - - if self.disabled { - style.text_color = theme_color.text_disabled; - style.background_color = theme_color.editor_background; - style.border_color = theme_color.border_disabled; - } - - // if self.error_message.is_some() { - // style.text_color = cx.theme().status().error; - // style.border_color = cx.theme().status().error_border - // } - - let text_style = TextStyle { - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_size: rems(0.875).into(), - font_weight: settings.buffer_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.2), - color: style.text_color, - ..Default::default() - }; - - let editor_style = EditorStyle { - background: theme_color.ghost_element_background, - local_player: cx.theme().players().local(), - syntax: cx.theme().syntax().clone(), - text: text_style, - ..Default::default() - }; - - v_flex() - .id(self.placeholder.clone()) - .w_full() - .gap_1() - .when_some(self.label.clone(), |this, label| { - this.child( - Label::new(label) - .size(self.label_size) - .color(if self.disabled { - Color::Disabled - } else { - Color::Default - }), - ) - }) - .child( - h_flex() - .min_w(self.min_width) - .min_h_8() - .w_full() - .px_2() - .py_1p5() - .flex_grow() - .text_color(style.text_color) - .rounded_md() - .bg(style.background_color) - .border_1() - .border_color(style.border_color) - .when_some(self.start_icon, |this, icon| { - this.gap_1() - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) - }) - .child(EditorElement::new(&self.editor, editor_style)), - ) - } -} - -impl Component for SingleLineInput { - fn scope() -> ComponentScope { - ComponentScope::Input - } - - fn preview(window: &mut Window, cx: &mut App) -> Option { - let input_small = - cx.new(|cx| SingleLineInput::new(window, cx, "placeholder").label("Small Label")); - - let input_regular = cx.new(|cx| { - SingleLineInput::new(window, cx, "placeholder") - .label("Regular Label") - .label_size(LabelSize::Default) - }); - - Some( - v_flex() - .gap_6() - .children(vec![example_group(vec![ - single_example( - "Small Label (Default)", - div().child(input_small).into_any_element(), - ), - single_example( - "Regular Label", - div().child(input_regular).into_any_element(), - ), - ])]) - .into_any_element(), - ) - } -} +pub use input_field::*; +pub use number_field::*; diff --git a/crates/ui_macros/Cargo.toml b/crates/ui_macros/Cargo.toml index 830b9dca8d..74bd2186a7 100644 --- a/crates/ui_macros/Cargo.toml +++ b/crates/ui_macros/Cargo.toml @@ -15,7 +15,6 @@ proc-macro = true [dependencies] quote.workspace = true syn.workspace = true -workspace-hack.workspace = true [dev-dependencies] component.workspace = true diff --git a/crates/ui_prompt/Cargo.toml b/crates/ui_prompt/Cargo.toml index eefc71d257..55a9828843 100644 --- a/crates/ui_prompt/Cargo.toml +++ b/crates/ui_prompt/Cargo.toml @@ -22,4 +22,3 @@ settings.workspace = true theme.workspace = true ui.workspace = true workspace.workspace = true -workspace-hack.workspace = true diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index da500edd1b..f58bc32bb4 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "zed-util" +name = "util" version = "0.1.0" edition.workspace = true -publish = true +publish = false license = "Apache-2.0" description = "A collection of utility structs and functions used by Zed and GPUI" @@ -45,13 +45,15 @@ unicase.workspace = true util_macros = { workspace = true, optional = true } walkdir.workspace = true which.workspace = true -workspace-hack.workspace = true [target.'cfg(unix)'.dependencies] command-fds = "0.3.1" libc.workspace = true nix = { workspace = true, features = ["user"] } +[target.'cfg(target_os = "macos")'.dependencies] +mach2.workspace = true + [target.'cfg(windows)'.dependencies] tendril = "0.4.3" diff --git a/crates/util/src/archive.rs b/crates/util/src/archive.rs index 9b58b16bed..5a5dc77772 100644 --- a/crates/util/src/archive.rs +++ b/crates/util/src/archive.rs @@ -169,6 +169,7 @@ mod tests { writer.close().await?; out.flush().await?; + out.sync_all().await?; Ok(()) } diff --git a/crates/util/src/command.rs b/crates/util/src/command.rs index 85e2234991..40f1ec323f 100644 --- a/crates/util/src/command.rs +++ b/crates/util/src/command.rs @@ -26,7 +26,77 @@ pub fn new_smol_command(program: impl AsRef) -> smol::process::Command { command } -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "macos")] +pub fn new_smol_command(program: impl AsRef) -> smol::process::Command { + use std::os::unix::process::CommandExt; + + // Create a std::process::Command first so we can use pre_exec + let mut std_cmd = std::process::Command::new(program); + + // WORKAROUND: Reset exception ports before exec to prevent inheritance of + // crash handler exception ports. Due to a timing issue, child processes can + // inherit the parent's exception ports before they're fully stabilized, + // which can block child process spawning. + // See: https://github.com/zed-industries/zed/issues/36754 + unsafe { + std_cmd.pre_exec(|| { + // Reset all exception ports to system defaults for this task. + // This prevents the child from inheriting the parent's crash handler + // exception ports. + reset_exception_ports(); + Ok(()) + }); + } + + // Convert to async_process::Command via From trait + smol::process::Command::from(std_cmd) +} + +#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] pub fn new_smol_command(program: impl AsRef) -> smol::process::Command { smol::process::Command::new(program) } + +#[cfg(target_os = "macos")] +pub fn reset_exception_ports() { + use mach2::exception_types::{ + EXC_MASK_ALL, EXCEPTION_DEFAULT, exception_behavior_t, exception_mask_t, + }; + use mach2::kern_return::{KERN_SUCCESS, kern_return_t}; + use mach2::mach_types::task_t; + use mach2::port::{MACH_PORT_NULL, mach_port_t}; + use mach2::thread_status::{THREAD_STATE_NONE, thread_state_flavor_t}; + use mach2::traps::mach_task_self; + + // FFI binding for task_set_exception_ports (not exposed by mach2 crate) + unsafe extern "C" { + fn task_set_exception_ports( + task: task_t, + exception_mask: exception_mask_t, + new_port: mach_port_t, + behavior: exception_behavior_t, + new_flavor: thread_state_flavor_t, + ) -> kern_return_t; + } + + unsafe { + let task = mach_task_self(); + // Reset all exception ports to MACH_PORT_NULL (system default) + // This prevents the child process from inheriting the parent's crash handler + let kr = task_set_exception_ports( + task, + EXC_MASK_ALL, + MACH_PORT_NULL, + EXCEPTION_DEFAULT as exception_behavior_t, + THREAD_STATE_NONE, + ); + + if kr != KERN_SUCCESS { + // Log but don't fail - the process can still work without this workaround + eprintln!( + "Warning: failed to reset exception ports in child process (kern_return: {})", + kr + ); + } + } +} diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 8fc62ae117..a54f91c7a0 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -1,9 +1,11 @@ use anyhow::Context; -use globset::{Glob, GlobSet, GlobSetBuilder}; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; use itertools::Itertools; use regex::Regex; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::cmp::Ordering; +use std::error::Error; use std::fmt::{Display, Formatter}; use std::mem; use std::path::StripPrefixError; @@ -14,7 +16,8 @@ use std::{ sync::LazyLock, }; -use crate::rel_path::RelPath; +use crate::rel_path::RelPathBuf; +use crate::{rel_path::RelPath, shell::ShellKind}; static HOME_DIR: OnceLock = OnceLock::new(); @@ -83,9 +86,7 @@ pub trait PathExt { fn multiple_extensions(&self) -> Option; /// Try to make a shell-safe representation of the path. - /// - /// For Unix, the path is escaped to be safe for POSIX shells - fn try_shell_safe(&self) -> anyhow::Result; + fn try_shell_safe(&self, shell_kind: ShellKind) -> anyhow::Result; } impl> PathExt for T { @@ -163,25 +164,42 @@ impl> PathExt for T { Some(parts.into_iter().join(".")) } - fn try_shell_safe(&self) -> anyhow::Result { - #[cfg(target_os = "windows")] - { - Ok(self.as_ref().to_string_lossy().to_string()) - } + fn try_shell_safe(&self, shell_kind: ShellKind) -> anyhow::Result { + let path_str = self + .as_ref() + .to_str() + .with_context(|| "Path contains invalid UTF-8")?; + shell_kind + .try_quote(path_str) + .as_deref() + .map(ToOwned::to_owned) + .context("Failed to quote path") + } +} - #[cfg(not(target_os = "windows"))] - { - let path_str = self - .as_ref() - .to_str() - .with_context(|| "Path contains invalid UTF-8")?; +pub fn path_ends_with(base: &Path, suffix: &Path) -> bool { + strip_path_suffix(base, suffix).is_some() +} - // As of writing, this can only be fail if the path contains a null byte, which shouldn't be possible - // but shlex has annotated the error as #[non_exhaustive] so we can't make it a compile error if other - // errors are introduced in the future :( - Ok(shlex::try_quote(path_str)?.into_owned()) +pub fn strip_path_suffix<'a>(base: &'a Path, suffix: &Path) -> Option<&'a Path> { + if let Some(remainder) = base + .as_os_str() + .as_encoded_bytes() + .strip_suffix(suffix.as_os_str().as_encoded_bytes()) + { + if remainder + .last() + .is_none_or(|last_byte| std::path::is_separator(*last_byte as char)) + { + let os_str = unsafe { + OsStr::from_encoded_bytes_unchecked( + &remainder[0..remainder.len().saturating_sub(1)], + ) + }; + return Some(Path::new(os_str)); } } + None } /// In memory, this is identical to `Path`. On non-Windows conversions to this type are no-ops. On @@ -209,9 +227,16 @@ impl SanitizedPath { #[cfg(not(target_os = "windows"))] return unsafe { mem::transmute::, Arc>(path) }; - // TODO: could avoid allocating here if dunce::simplified results in the same path #[cfg(target_os = "windows")] - return Self::new(&path).into(); + { + let simplified = dunce::simplified(path.as_ref()); + if simplified == path.as_ref() { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::, Arc>(path) } + } else { + Self::unchecked_new(simplified).into() + } + } } pub fn new_arc + ?Sized>(path: &T) -> Arc { @@ -315,17 +340,35 @@ impl PathStyle { } #[inline] - pub fn separator(&self) -> &'static str { + pub fn primary_separator(&self) -> &'static str { match self { PathStyle::Posix => "/", PathStyle::Windows => "\\", } } + pub fn separators(&self) -> &'static [&'static str] { + match self { + PathStyle::Posix => &["/"], + PathStyle::Windows => &["\\", "/"], + } + } + + pub fn separators_ch(&self) -> &'static [char] { + match self { + PathStyle::Posix => &['/'], + PathStyle::Windows => &['\\', '/'], + } + } + pub fn is_windows(&self) -> bool { *self == PathStyle::Windows } + pub fn is_posix(&self) -> bool { + *self == PathStyle::Posix + } + pub fn join(self, left: impl AsRef, right: impl AsRef) -> Option { let right = right.as_ref().to_str()?; if is_absolute(right, self) { @@ -337,25 +380,54 @@ impl PathStyle { } else { Some(format!( "{left}{}{right}", - if left.ends_with(self.separator()) { + if left.ends_with(self.primary_separator()) { "" } else { - self.separator() + self.primary_separator() } )) } } pub fn split(self, path_like: &str) -> (Option<&str>, &str) { - let Some(pos) = path_like.rfind(self.separator()) else { + let Some(pos) = path_like.rfind(self.primary_separator()) else { return (None, path_like); }; - let filename_start = pos + self.separator().len(); + let filename_start = pos + self.primary_separator().len(); ( Some(&path_like[..filename_start]), &path_like[filename_start..], ) } + + pub fn strip_prefix<'a>( + &self, + child: &'a Path, + parent: &'a Path, + ) -> Option> { + let parent = parent.to_str()?; + if parent.is_empty() { + return RelPath::new(child, *self).ok(); + } + let parent = self + .separators() + .iter() + .find_map(|sep| parent.strip_suffix(sep)) + .unwrap_or(parent); + let child = child.to_str()?; + let stripped = child.strip_prefix(parent)?; + if let Some(relative) = self + .separators() + .iter() + .find_map(|sep| stripped.strip_prefix(sep)) + { + RelPath::new(relative.as_ref(), *self).ok() + } else if stripped.is_empty() { + Some(Cow::Borrowed(RelPath::empty())) + } else { + None + } + } } #[derive(Debug, Clone)] @@ -401,6 +473,82 @@ pub fn is_absolute(path_like: &str, path_style: PathStyle) -> bool { .is_some_and(|path| path.starts_with('/') || path.starts_with('\\'))) } +#[derive(Debug, PartialEq)] +#[non_exhaustive] +pub struct NormalizeError; + +impl Error for NormalizeError {} + +impl std::fmt::Display for NormalizeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("parent reference `..` points outside of base directory") + } +} + +/// Copied from stdlib where it's unstable. +/// +/// Normalize a path, including `..` without traversing the filesystem. +/// +/// Returns an error if normalization would leave leading `..` components. +/// +///
+/// +/// This function always resolves `..` to the "lexical" parent. +/// That is "a/b/../c" will always resolve to `a/c` which can change the meaning of the path. +/// In particular, `a/c` and `a/b/../c` are distinct on many systems because `b` may be a symbolic link, so its parent isn't `a`. +/// +///
+/// +/// [`path::absolute`](absolute) is an alternative that preserves `..`. +/// Or [`Path::canonicalize`] can be used to resolve any `..` by querying the filesystem. +pub fn normalize_lexically(path: &Path) -> Result { + use std::path::Component; + + let mut lexical = PathBuf::new(); + let mut iter = path.components().peekable(); + + // Find the root, if any, and add it to the lexical path. + // Here we treat the Windows path "C:\" as a single "root" even though + // `components` splits it into two: (Prefix, RootDir). + let root = match iter.peek() { + Some(Component::ParentDir) => return Err(NormalizeError), + Some(p @ Component::RootDir) | Some(p @ Component::CurDir) => { + lexical.push(p); + iter.next(); + lexical.as_os_str().len() + } + Some(Component::Prefix(prefix)) => { + lexical.push(prefix.as_os_str()); + iter.next(); + if let Some(p @ Component::RootDir) = iter.peek() { + lexical.push(p); + iter.next(); + } + lexical.as_os_str().len() + } + None => return Ok(PathBuf::new()), + Some(Component::Normal(_)) => 0, + }; + + for component in iter { + match component { + Component::RootDir => unreachable!(), + Component::Prefix(_) => return Err(NormalizeError), + Component::CurDir => continue, + Component::ParentDir => { + // It's an error if ParentDir causes us to go above the "root". + if lexical.as_os_str().len() == root { + return Err(NormalizeError); + } else { + lexical.pop(); + } + } + Component::Normal(path) => lexical.push(path), + } + } + Ok(lexical) +} + /// A delimiter to use in `path_query:row_number:column_number` strings parsing. pub const FILE_ROW_COLUMN_DELIMITER: char = ':'; @@ -456,7 +604,7 @@ impl PathWithPosition { /// # Examples /// /// ``` - /// # use zed_util::paths::PathWithPosition; + /// # use util::paths::PathWithPosition; /// # use std::path::PathBuf; /// assert_eq!(PathWithPosition::parse_str("test_file"), PathWithPosition { /// path: PathBuf::from("test_file"), @@ -487,7 +635,7 @@ impl PathWithPosition { /// /// # Expected parsing results when encounter ill-formatted inputs. /// ``` - /// # use zed_util::paths::PathWithPosition; + /// # use util::paths::PathWithPosition; /// # use std::path::PathBuf; /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a"), PathWithPosition { /// path: PathBuf::from("test_file.rs:a"), @@ -533,7 +681,14 @@ impl PathWithPosition { pub fn parse_str(s: &str) -> Self { let trimmed = s.trim(); let path = Path::new(trimmed); - let maybe_file_name_with_row_col = path.file_name().unwrap_or_default().to_string_lossy(); + let Some(maybe_file_name_with_row_col) = path.file_name().unwrap_or_default().to_str() + else { + return Self { + path: Path::new(s).to_path_buf(), + row: None, + column: None, + }; + }; if maybe_file_name_with_row_col.is_empty() { return Self { path: Path::new(s).to_path_buf(), @@ -548,15 +703,15 @@ impl PathWithPosition { static SUFFIX_RE: LazyLock = LazyLock::new(|| Regex::new(ROW_COL_CAPTURE_REGEX).unwrap()); match SUFFIX_RE - .captures(&maybe_file_name_with_row_col) + .captures(maybe_file_name_with_row_col) .map(|caps| caps.extract()) { Some((_, [file_name, maybe_row, maybe_column])) => { let row = maybe_row.parse::().ok(); let column = maybe_column.parse::().ok(); - let suffix_length = maybe_file_name_with_row_col.len() - file_name.len(); - let path_without_suffix = &trimmed[..trimmed.len() - suffix_length]; + let (_, suffix) = trimmed.split_once(file_name).unwrap(); + let path_without_suffix = &trimmed[..trimmed.len() - suffix.len()]; Self { path: Path::new(path_without_suffix).to_path_buf(), @@ -633,17 +788,11 @@ impl PathWithPosition { #[derive(Clone, Debug)] pub struct PathMatcher { - sources: Vec, + sources: Vec<(String, RelPathBuf, /*trailing separator*/ bool)>, glob: GlobSet, path_style: PathStyle, } -// impl std::fmt::Display for PathMatcher { -// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -// self.sources.fmt(f) -// } -// } - impl PartialEq for PathMatcher { fn eq(&self, other: &Self) -> bool { self.sources.eq(&other.sources) @@ -659,9 +808,25 @@ impl PathMatcher { ) -> Result { let globs = globs .into_iter() - .map(|as_str| Glob::new(as_str.as_ref())) + .map(|as_str| { + GlobBuilder::new(as_str.as_ref()) + .backslash_escape(path_style.is_posix()) + .build() + }) .collect::, _>>()?; - let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); + let sources = globs + .iter() + .filter_map(|glob| { + let glob = glob.glob(); + Some(( + glob.to_string(), + RelPath::new(&glob.as_ref(), path_style) + .ok() + .map(std::borrow::Cow::into_owned)?, + glob.ends_with(path_style.separators_ch()), + )) + }) + .collect(); let mut glob_builder = GlobSetBuilder::new(); for single_glob in globs { glob_builder.add(single_glob); @@ -674,27 +839,24 @@ impl PathMatcher { }) } - pub fn sources(&self) -> &[String] { - &self.sources + pub fn sources(&self) -> impl Iterator + Clone { + self.sources.iter().map(|(source, ..)| source.as_str()) } - pub fn is_match>(&self, other: P) -> bool { - let other_path = other.as_ref(); - self.sources.iter().any(|source| { - let as_bytes = other_path.as_os_str().as_encoded_bytes(); - as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes()) - }) || self.glob.is_match(other_path) - || self.check_with_end_separator(other_path) - } - - fn check_with_end_separator(&self, path: &Path) -> bool { - let path_str = path.to_string_lossy(); - let separator = self.path_style.separator(); - if path_str.ends_with(separator) { - false - } else { - self.glob.is_match(path_str.to_string() + separator) + pub fn is_match>(&self, other: P) -> bool { + if self.sources.iter().any(|(_, source, _)| { + other.as_ref().starts_with(source) || other.as_ref().ends_with(source) + }) { + return true; } + let other_path = other.as_ref().display(self.path_style); + + if self.glob.is_match(&*other_path) { + return true; + } + + self.glob + .is_match(other_path.into_owned() + self.path_style.primary_separator()) } } @@ -708,22 +870,6 @@ impl Default for PathMatcher { } } -/// Custom character comparison that prioritizes lowercase for same letters -fn compare_chars(a: char, b: char) -> Ordering { - // First compare case-insensitive - match a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()) { - Ordering::Equal => { - // If same letter, prioritize lowercase (lowercase < uppercase) - match (a.is_ascii_lowercase(), b.is_ascii_lowercase()) { - (true, false) => Ordering::Less, // lowercase comes first - (false, true) => Ordering::Greater, // uppercase comes after - _ => Ordering::Equal, // both same case or both non-ascii - } - } - other => other, - } -} - /// Compares two sequences of consecutive digits for natural sorting. /// /// This function is a core component of natural sorting that handles numeric comparison @@ -824,21 +970,25 @@ where /// * Numbers are compared by numeric value, not character by character /// * Leading zeros affect ordering when numeric values are equal /// * Can handle numbers larger than u128::MAX (falls back to string comparison) +/// * When strings are equal case-insensitively, lowercase is prioritized (lowercase < uppercase) /// /// # Algorithm /// /// The function works by: -/// 1. Processing strings character by character +/// 1. Processing strings character by character in a case-insensitive manner /// 2. When encountering digits, treating consecutive digits as a single number /// 3. Comparing numbers by their numeric value rather than lexicographically -/// 4. For non-numeric characters, using case-sensitive comparison with lowercase priority -fn natural_sort(a: &str, b: &str) -> Ordering { +/// 4. For non-numeric characters, using case-insensitive comparison +/// 5. If everything is equal case-insensitively, using case-sensitive comparison as final tie-breaker +pub fn natural_sort(a: &str, b: &str) -> Ordering { let mut a_iter = a.chars().peekable(); let mut b_iter = b.chars().peekable(); loop { match (a_iter.peek(), b_iter.peek()) { - (None, None) => return Ordering::Equal, + (None, None) => { + return b.cmp(a); + } (None, _) => return Ordering::Less, (_, None) => return Ordering::Greater, (Some(&a_char), Some(&b_char)) => { @@ -848,7 +998,10 @@ fn natural_sort(a: &str, b: &str) -> Ordering { ordering => return ordering, } } else { - match compare_chars(a_char, b_char) { + match a_char + .to_ascii_lowercase() + .cmp(&b_char.to_ascii_lowercase()) + { Ordering::Equal => { a_iter.next(); b_iter.next(); @@ -860,36 +1013,48 @@ fn natural_sort(a: &str, b: &str) -> Ordering { } } } + +/// Case-insensitive natural sort without applying the final lowercase/uppercase tie-breaker. +/// This is useful when comparing individual path components where we want to keep walking +/// deeper components before deciding on casing. +fn natural_sort_no_tiebreak(a: &str, b: &str) -> Ordering { + if a.eq_ignore_ascii_case(b) { + Ordering::Equal + } else { + natural_sort(a, b) + } +} + +fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) { + if filename.is_empty() { + return (None, None); + } + + match filename.rsplit_once('.') { + // Case 1: No dot was found. The entire name is the stem. + None => (Some(filename), None), + + // Case 2: A dot was found. + Some((before, after)) => { + // This is the crucial check for dotfiles like ".bashrc". + // If `before` is empty, the dot was the first character. + // In that case, we revert to the "whole name is the stem" logic. + if before.is_empty() { + (Some(filename), None) + } else { + // Otherwise, we have a standard stem and extension. + (Some(before), Some(after)) + } + } + } +} + pub fn compare_rel_paths( (path_a, a_is_file): (&RelPath, bool), (path_b, b_is_file): (&RelPath, bool), ) -> Ordering { let mut components_a = path_a.components(); let mut components_b = path_b.components(); - - fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) { - if filename.is_empty() { - return (None, None); - } - - match filename.rsplit_once('.') { - // Case 1: No dot was found. The entire name is the stem. - None => (Some(filename), None), - - // Case 2: A dot was found. - Some((before, after)) => { - // This is the crucial check for dotfiles like ".bashrc". - // If `before` is empty, the dot was the first character. - // In that case, we revert to the "whole name is the stem" logic. - if before.is_empty() { - (Some(filename), None) - } else { - // Otherwise, we have a standard stem and extension. - (Some(before), Some(after)) - } - } - } - } loop { match (components_a.next(), components_b.next()) { (Some(component_a), Some(component_b)) => { @@ -936,6 +1101,156 @@ pub fn compare_rel_paths( } } +/// Compare two relative paths with mixed files and directories using +/// case-insensitive natural sorting. For example, "Apple", "aardvark.txt", +/// and "Zebra" would be sorted as: aardvark.txt, Apple, Zebra +/// (case-insensitive alphabetical). +pub fn compare_rel_paths_mixed( + (path_a, a_is_file): (&RelPath, bool), + (path_b, b_is_file): (&RelPath, bool), +) -> Ordering { + let original_paths_equal = std::ptr::eq(path_a, path_b) || path_a == path_b; + let mut components_a = path_a.components(); + let mut components_b = path_b.components(); + + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_leaf_file = a_is_file && components_a.rest().is_empty(); + let b_leaf_file = b_is_file && components_b.rest().is_empty(); + + let (a_stem, a_ext) = a_leaf_file + .then(|| stem_and_extension(component_a)) + .unwrap_or_default(); + let (b_stem, b_ext) = b_leaf_file + .then(|| stem_and_extension(component_b)) + .unwrap_or_default(); + let a_key = if a_leaf_file { + a_stem + } else { + Some(component_a) + }; + let b_key = if b_leaf_file { + b_stem + } else { + Some(component_b) + }; + + let ordering = match (a_key, b_key) { + (Some(a), Some(b)) => natural_sort_no_tiebreak(a, b) + .then_with(|| match (a_leaf_file, b_leaf_file) { + (true, false) if a == b => Ordering::Greater, + (false, true) if a == b => Ordering::Less, + _ => Ordering::Equal, + }) + .then_with(|| { + if a_leaf_file && b_leaf_file { + let a_ext_str = a_ext.unwrap_or_default().to_lowercase(); + let b_ext_str = b_ext.unwrap_or_default().to_lowercase(); + b_ext_str.cmp(&a_ext_str) + } else { + Ordering::Equal + } + }), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + }; + + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => { + // Deterministic tie-break: use natural sort to prefer lowercase when paths + // are otherwise equal but still differ in casing. + if !original_paths_equal { + return natural_sort(path_a.as_unix_str(), path_b.as_unix_str()); + } + return Ordering::Equal; + } + } + } +} + +/// Compare two relative paths with files before directories using +/// case-insensitive natural sorting. At each directory level, all files +/// are sorted before all directories, with case-insensitive alphabetical +/// ordering within each group. +pub fn compare_rel_paths_files_first( + (path_a, a_is_file): (&RelPath, bool), + (path_b, b_is_file): (&RelPath, bool), +) -> Ordering { + let original_paths_equal = std::ptr::eq(path_a, path_b) || path_a == path_b; + let mut components_a = path_a.components(); + let mut components_b = path_b.components(); + + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_leaf_file = a_is_file && components_a.rest().is_empty(); + let b_leaf_file = b_is_file && components_b.rest().is_empty(); + + let (a_stem, a_ext) = a_leaf_file + .then(|| stem_and_extension(component_a)) + .unwrap_or_default(); + let (b_stem, b_ext) = b_leaf_file + .then(|| stem_and_extension(component_b)) + .unwrap_or_default(); + let a_key = if a_leaf_file { + a_stem + } else { + Some(component_a) + }; + let b_key = if b_leaf_file { + b_stem + } else { + Some(component_b) + }; + + let ordering = match (a_key, b_key) { + (Some(a), Some(b)) => { + if a_leaf_file && !b_leaf_file { + Ordering::Less + } else if !a_leaf_file && b_leaf_file { + Ordering::Greater + } else { + natural_sort_no_tiebreak(a, b).then_with(|| { + if a_leaf_file && b_leaf_file { + let a_ext_str = a_ext.unwrap_or_default().to_lowercase(); + let b_ext_str = b_ext.unwrap_or_default().to_lowercase(); + a_ext_str.cmp(&b_ext_str) + } else { + Ordering::Equal + } + }) + } + } + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + }; + + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => { + // Deterministic tie-break: use natural sort to prefer lowercase when paths + // are otherwise equal but still differ in casing. + if !original_paths_equal { + return natural_sort(path_a.as_unix_str(), path_b.as_unix_str()); + } + return Ordering::Equal; + } + } + } +} + pub fn compare_paths( (path_a, a_is_file): (&Path, bool), (path_b, b_is_file): (&Path, bool), @@ -995,8 +1310,72 @@ pub fn compare_paths( } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WslPath { + pub distro: String, + + // the reason this is an OsString and not any of the path types is that it needs to + // represent a unix path (with '/' separators) on windows. `from_path` does this by + // manually constructing it from the path components of a given windows path. + pub path: std::ffi::OsString, +} + +impl WslPath { + pub fn from_path>(path: P) -> Option { + if cfg!(not(target_os = "windows")) { + return None; + } + use std::{ + ffi::OsString, + path::{Component, Prefix}, + }; + + let mut components = path.as_ref().components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return None; + }; + let (server, distro) = match prefix.kind() { + Prefix::UNC(server, distro) => (server, distro), + Prefix::VerbatimUNC(server, distro) => (server, distro), + _ => return None, + }; + let Some(Component::RootDir) = components.next() else { + return None; + }; + + let server_str = server.to_string_lossy(); + if server_str == "wsl.localhost" || server_str == "wsl$" { + let mut result = OsString::from(""); + for c in components { + use Component::*; + match c { + Prefix(p) => unreachable!("got {p:?}, but already stripped prefix"), + RootDir => unreachable!("got root dir, but already stripped root"), + CurDir => continue, + ParentDir => result.push("/.."), + Normal(s) => { + result.push("/"); + result.push(s); + } + } + } + if result.is_empty() { + result.push("/"); + } + Some(WslPath { + distro: distro.to_string_lossy().to_string(), + path: result, + }) + } else { + None + } + } +} + #[cfg(test)] mod tests { + use crate::rel_path::rel_path; + use super::*; use util_macros::perf; @@ -1092,6 +1471,312 @@ mod tests { ); } + #[perf] + fn compare_paths_mixed_case_numeric_ordering() { + let mut entries = [ + (Path::new(".config"), false), + (Path::new("Dir1"), false), + (Path::new("dir01"), false), + (Path::new("dir2"), false), + (Path::new("Dir02"), false), + (Path::new("dir10"), false), + (Path::new("Dir10"), false), + ]; + + entries.sort_by(|&a, &b| compare_paths(a, b)); + + let ordered: Vec<&str> = entries + .iter() + .map(|(path, _)| path.to_str().unwrap()) + .collect(); + + assert_eq!( + ordered, + vec![ + ".config", "Dir1", "dir01", "dir2", "Dir02", "dir10", "Dir10" + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_case_insensitive() { + // Test that mixed mode is case-insensitive + let mut paths = vec![ + (RelPath::unix("zebra.txt").unwrap(), true), + (RelPath::unix("Apple").unwrap(), false), + (RelPath::unix("banana.rs").unwrap(), true), + (RelPath::unix("Carrot").unwrap(), false), + (RelPath::unix("aardvark.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + // Case-insensitive: aardvark < Apple < banana < Carrot < zebra + assert_eq!( + paths, + vec![ + (RelPath::unix("aardvark.txt").unwrap(), true), + (RelPath::unix("Apple").unwrap(), false), + (RelPath::unix("banana.rs").unwrap(), true), + (RelPath::unix("Carrot").unwrap(), false), + (RelPath::unix("zebra.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_basic() { + // Test that files come before directories + let mut paths = vec![ + (RelPath::unix("zebra.txt").unwrap(), true), + (RelPath::unix("Apple").unwrap(), false), + (RelPath::unix("banana.rs").unwrap(), true), + (RelPath::unix("Carrot").unwrap(), false), + (RelPath::unix("aardvark.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + // Files first (case-insensitive), then directories (case-insensitive) + assert_eq!( + paths, + vec![ + (RelPath::unix("aardvark.txt").unwrap(), true), + (RelPath::unix("banana.rs").unwrap(), true), + (RelPath::unix("zebra.txt").unwrap(), true), + (RelPath::unix("Apple").unwrap(), false), + (RelPath::unix("Carrot").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_case_insensitive() { + // Test case-insensitive sorting within files and directories + let mut paths = vec![ + (RelPath::unix("Zebra.txt").unwrap(), true), + (RelPath::unix("apple").unwrap(), false), + (RelPath::unix("Banana.rs").unwrap(), true), + (RelPath::unix("carrot").unwrap(), false), + (RelPath::unix("Aardvark.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("Aardvark.txt").unwrap(), true), + (RelPath::unix("Banana.rs").unwrap(), true), + (RelPath::unix("Zebra.txt").unwrap(), true), + (RelPath::unix("apple").unwrap(), false), + (RelPath::unix("carrot").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_numeric() { + // Test natural number sorting with files first + let mut paths = vec![ + (RelPath::unix("file10.txt").unwrap(), true), + (RelPath::unix("dir2").unwrap(), false), + (RelPath::unix("file2.txt").unwrap(), true), + (RelPath::unix("dir10").unwrap(), false), + (RelPath::unix("file1.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("file1.txt").unwrap(), true), + (RelPath::unix("file2.txt").unwrap(), true), + (RelPath::unix("file10.txt").unwrap(), true), + (RelPath::unix("dir2").unwrap(), false), + (RelPath::unix("dir10").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_case() { + // Test case-insensitive sorting with varied capitalization + let mut paths = vec![ + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix("readme.txt").unwrap(), true), + (RelPath::unix("ReadMe.rs").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + // All "readme" variants should group together, sorted by extension + assert_eq!( + paths, + vec![ + (RelPath::unix("readme.txt").unwrap(), true), + (RelPath::unix("ReadMe.rs").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_files_and_dirs() { + // Verify directories and files are still mixed + let mut paths = vec![ + (RelPath::unix("file2.txt").unwrap(), true), + (RelPath::unix("Dir1").unwrap(), false), + (RelPath::unix("file1.txt").unwrap(), true), + (RelPath::unix("dir2").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + // Case-insensitive: dir1, dir2, file1, file2 (all mixed) + assert_eq!( + paths, + vec![ + (RelPath::unix("Dir1").unwrap(), false), + (RelPath::unix("dir2").unwrap(), false), + (RelPath::unix("file1.txt").unwrap(), true), + (RelPath::unix("file2.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_with_nested_paths() { + // Test that nested paths still work correctly + let mut paths = vec![ + (RelPath::unix("src/main.rs").unwrap(), true), + (RelPath::unix("Cargo.toml").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + (RelPath::unix("target").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("Cargo.toml").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + (RelPath::unix("src/main.rs").unwrap(), true), + (RelPath::unix("target").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_with_nested() { + // Files come before directories, even with nested paths + let mut paths = vec![ + (RelPath::unix("src/lib.rs").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + (RelPath::unix("tests").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + (RelPath::unix("src/lib.rs").unwrap(), true), + (RelPath::unix("tests").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_dotfiles() { + // Test that dotfiles are handled correctly in mixed mode + let mut paths = vec![ + (RelPath::unix(".gitignore").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix(".github").unwrap(), false), + (RelPath::unix("src").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix(".github").unwrap(), false), + (RelPath::unix(".gitignore").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_dotfiles() { + // Test that dotfiles come first when they're files + let mut paths = vec![ + (RelPath::unix(".gitignore").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix(".github").unwrap(), false), + (RelPath::unix("src").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix(".gitignore").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix(".github").unwrap(), false), + (RelPath::unix("src").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_same_stem_different_extension() { + // Files with same stem but different extensions should sort by extension + let mut paths = vec![ + (RelPath::unix("file.rs").unwrap(), true), + (RelPath::unix("file.md").unwrap(), true), + (RelPath::unix("file.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("file.txt").unwrap(), true), + (RelPath::unix("file.rs").unwrap(), true), + (RelPath::unix("file.md").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_same_stem() { + // Same stem files should still sort by extension with files_first + let mut paths = vec![ + (RelPath::unix("main.rs").unwrap(), true), + (RelPath::unix("main.c").unwrap(), true), + (RelPath::unix("main").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("main.c").unwrap(), true), + (RelPath::unix("main.rs").unwrap(), true), + (RelPath::unix("main").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_deep_nesting() { + // Test sorting with deeply nested paths + let mut paths = vec![ + (RelPath::unix("a/b/c.txt").unwrap(), true), + (RelPath::unix("A/B.txt").unwrap(), true), + (RelPath::unix("a.txt").unwrap(), true), + (RelPath::unix("A.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("A/B.txt").unwrap(), true), + (RelPath::unix("a/b/c.txt").unwrap(), true), + (RelPath::unix("a.txt").unwrap(), true), + (RelPath::unix("A.txt").unwrap(), true), + ] + ); + } + #[perf] fn path_with_position_parse_posix_path() { // Test POSIX filename edge cases @@ -1409,27 +2094,41 @@ mod tests { } #[perf] - fn edge_of_glob() { - let path = Path::new("/work/node_modules"); - let path_matcher = - PathMatcher::new(&["**/node_modules/**".to_owned()], PathStyle::Posix).unwrap(); - assert!( - path_matcher.is_match(path), - "Path matcher should match {path:?}" - ); - } + // fn edge_of_glob() { + // let path = Path::new("/work/node_modules"); + // let path_matcher = + // PathMatcher::new(&["**/node_modules/**".to_owned()], PathStyle::Posix).unwrap(); + // assert!( + // path_matcher.is_match(path), + // "Path matcher should match {path:?}" + // ); + // } - #[perf] - fn project_search() { - let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules"); - let path_matcher = - PathMatcher::new(&["**/node_modules/**".to_owned()], PathStyle::Posix).unwrap(); - assert!( - path_matcher.is_match(path), - "Path matcher should match {path:?}" - ); - } + // #[perf] + // fn file_in_dirs() { + // let path = Path::new("/work/.env"); + // let path_matcher = PathMatcher::new(&["**/.env".to_owned()], PathStyle::Posix).unwrap(); + // assert!( + // path_matcher.is_match(path), + // "Path matcher should match {path:?}" + // ); + // let path = Path::new("/work/package.json"); + // assert!( + // !path_matcher.is_match(path), + // "Path matcher should not match {path:?}" + // ); + // } + // #[perf] + // fn project_search() { + // let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules"); + // let path_matcher = + // PathMatcher::new(&["**/node_modules/**".to_owned()], PathStyle::Posix).unwrap(); + // assert!( + // path_matcher.is_match(path), + // "Path matcher should match {path:?}" + // ); + // } #[perf] #[cfg(target_os = "windows")] fn test_sanitized_path() { @@ -1748,10 +2447,25 @@ mod tests { ), Ordering::Less ); + } - // Mixed case with numbers - assert_eq!(natural_sort("File1", "file2"), Ordering::Greater); + #[perf] + fn test_natural_sort_case_sensitive() { + // Numerically smaller values come first. + assert_eq!(natural_sort("File1", "file2"), Ordering::Less); assert_eq!(natural_sort("file1", "File2"), Ordering::Less); + + // Numerically equal values: the case-insensitive comparison decides first. + // Case-sensitive comparison only occurs when both are equal case-insensitively. + assert_eq!(natural_sort("Dir1", "dir01"), Ordering::Less); + assert_eq!(natural_sort("dir2", "Dir02"), Ordering::Less); + assert_eq!(natural_sort("dir2", "dir02"), Ordering::Less); + + // Numerically equal and case-insensitively equal: + // the lexicographically smaller (case-sensitive) one wins. + assert_eq!(natural_sort("dir1", "Dir1"), Ordering::Less); + assert_eq!(natural_sort("dir02", "Dir02"), Ordering::Less); + assert_eq!(natural_sort("dir10", "Dir10"), Ordering::Less); } #[perf] @@ -1798,4 +2512,159 @@ mod tests { let path = Path::new("/a/b/c/long.app.tar.gz"); assert_eq!(path.multiple_extensions(), Some("app.tar.gz".to_string())); } + + #[test] + fn test_strip_path_suffix() { + let base = Path::new("/a/b/c/file_name"); + let suffix = Path::new("file_name"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b/c"))); + + let base = Path::new("/a/b/c/file_name.tsx"); + let suffix = Path::new("file_name.tsx"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b/c"))); + + let base = Path::new("/a/b/c/file_name.stories.tsx"); + let suffix = Path::new("c/file_name.stories.tsx"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b"))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("b/c/long.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a"))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("/a/b/c/long.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new(""))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("/a/b/c/no_match.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), None); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), None); + } + + #[test] + fn test_strip_prefix() { + let expected = [ + ( + PathStyle::Posix, + "/a/b/c", + "/a/b", + Some(rel_path("c").into_arc()), + ), + ( + PathStyle::Posix, + "/a/b/c", + "/a/b/", + Some(rel_path("c").into_arc()), + ), + ( + PathStyle::Posix, + "/a/b/c", + "/", + Some(rel_path("a/b/c").into_arc()), + ), + (PathStyle::Posix, "/a/b/c", "", None), + (PathStyle::Posix, "/a/b//c", "/a/b/", None), + (PathStyle::Posix, "/a/bc", "/a/b", None), + ( + PathStyle::Posix, + "/a/b/c", + "/a/b/c", + Some(rel_path("").into_arc()), + ), + ( + PathStyle::Windows, + "C:\\a\\b\\c", + "C:\\a\\b", + Some(rel_path("c").into_arc()), + ), + ( + PathStyle::Windows, + "C:\\a\\b\\c", + "C:\\a\\b\\", + Some(rel_path("c").into_arc()), + ), + ( + PathStyle::Windows, + "C:\\a\\b\\c", + "C:\\", + Some(rel_path("a/b/c").into_arc()), + ), + (PathStyle::Windows, "C:\\a\\b\\c", "", None), + (PathStyle::Windows, "C:\\a\\b\\\\c", "C:\\a\\b\\", None), + (PathStyle::Windows, "C:\\a\\bc", "C:\\a\\b", None), + ( + PathStyle::Windows, + "C:\\a\\b/c", + "C:\\a\\b", + Some(rel_path("c").into_arc()), + ), + ( + PathStyle::Windows, + "C:\\a\\b/c", + "C:\\a\\b\\", + Some(rel_path("c").into_arc()), + ), + ( + PathStyle::Windows, + "C:\\a\\b/c", + "C:\\a\\b/", + Some(rel_path("c").into_arc()), + ), + ]; + let actual = expected.clone().map(|(style, child, parent, _)| { + ( + style, + child, + parent, + style + .strip_prefix(child.as_ref(), parent.as_ref()) + .map(|rel_path| rel_path.into_arc()), + ) + }); + pretty_assertions::assert_eq!(actual, expected); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_wsl_path() { + use super::WslPath; + let path = "/a/b/c"; + assert_eq!(WslPath::from_path(&path), None); + + let path = r"\\wsl.localhost"; + assert_eq!(WslPath::from_path(&path), None); + + let path = r"\\wsl.localhost\Distro"; + assert_eq!( + WslPath::from_path(&path), + Some(WslPath { + distro: "Distro".to_owned(), + path: "/".into(), + }) + ); + + let path = r"\\wsl.localhost\Distro\blue"; + assert_eq!( + WslPath::from_path(&path), + Some(WslPath { + distro: "Distro".to_owned(), + path: "/blue".into() + }) + ); + + let path = r"\\wsl$\archlinux\tomato\.\paprika\..\aubergine.txt"; + assert_eq!( + WslPath::from_path(&path), + Some(WslPath { + distro: "archlinux".to_owned(), + path: "/tomato/paprika/../aubergine.txt".into() + }) + ); + + let path = r"\\windows.localhost\Distro\foo"; + assert_eq!(WslPath::from_path(&path), None); + } } diff --git a/crates/util/src/rel_path.rs b/crates/util/src/rel_path.rs index b360297f20..5e20aacad5 100644 --- a/crates/util/src/rel_path.rs +++ b/crates/util/src/rel_path.rs @@ -27,7 +27,7 @@ pub struct RelPath(str); /// relative and normalized. /// /// This type is to [`RelPath`] as [`std::path::PathBuf`] is to [`std::path::Path`] -#[derive(Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct RelPathBuf(String); impl RelPath { @@ -161,7 +161,7 @@ impl RelPath { false } - pub fn strip_prefix<'a>(&'a self, other: &Self) -> Result<&'a Self> { + pub fn strip_prefix<'a>(&'a self, other: &Self) -> Result<&'a Self, StripPrefixError> { if other.is_empty() { return Ok(self); } @@ -172,7 +172,7 @@ impl RelPath { return Ok(Self::empty()); } } - Err(anyhow!("failed to strip prefix: {other:?} from {self:?}")) + Err(StripPrefixError) } pub fn len(&self) -> usize { @@ -228,7 +228,8 @@ impl RelPath { pub fn display(&self, style: PathStyle) -> Cow<'_, str> { match style { PathStyle::Posix => Cow::Borrowed(&self.0), - PathStyle::Windows => Cow::Owned(self.0.replace('/', "\\")), + PathStyle::Windows if self.0.contains('/') => Cow::Owned(self.0.replace('/', "\\")), + PathStyle::Windows => Cow::Borrowed(&self.0), } } @@ -250,6 +251,9 @@ impl RelPath { } } +#[derive(Debug)] +pub struct StripPrefixError; + impl ToOwned for RelPath { type Owned = RelPathBuf; @@ -341,6 +345,12 @@ impl AsRef for RelPathBuf { } } +impl AsRef for RelPath { + fn as_ref(&self) -> &RelPath { + self + } +} + impl Deref for RelPathBuf { type Target = RelPath; @@ -374,6 +384,7 @@ impl PartialEq for RelPath { } } +#[derive(Default)] pub struct RelPathComponents<'a>(&'a str); pub struct RelPathAncestors<'a>(Option<&'a str>); diff --git a/crates/util/src/schemars.rs b/crates/util/src/schemars.rs index 9314eda4ac..8124ca8cfe 100644 --- a/crates/util/src/schemars.rs +++ b/crates/util/src/schemars.rs @@ -53,3 +53,20 @@ impl schemars::transform::Transform for DefaultDenyUnknownFields { transform_subschemas(self, schema); } } + +/// Defaults `allowTrailingCommas` to `true`, for use with `json-language-server`. +/// This can be applied to any schema that will be treated as `jsonc`. +/// +/// Note that this is non-recursive and only applied to the root schema. +#[derive(Clone)] +pub struct AllowTrailingCommas; + +impl schemars::transform::Transform for AllowTrailingCommas { + fn transform(&mut self, schema: &mut schemars::Schema) { + if let Some(object) = schema.as_object_mut() + && !object.contains_key("allowTrailingCommas") + { + object.insert("allowTrailingCommas".to_string(), true.into()); + } + } +} diff --git a/crates/util/src/shell.rs b/crates/util/src/shell.rs index 5c1837d822..d51cb39aed 100644 --- a/crates/util/src/shell.rs +++ b/crates/util/src/shell.rs @@ -1,6 +1,54 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use std::{borrow::Cow, fmt, path::Path, sync::LazyLock}; -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +/// Shell configuration to open the terminal with. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)] +#[serde(rename_all = "snake_case")] +pub enum Shell { + /// Use the system's default terminal configuration in /etc/passwd + #[default] + System, + /// Use a specific program with no arguments. + Program(String), + /// Use a specific program with arguments. + WithArguments { + /// The program to run. + program: String, + /// The arguments to pass to the program. + args: Vec, + /// An optional string to override the title of the terminal tab + title_override: Option, + }, +} + +impl Shell { + pub fn program(&self) -> String { + match self { + Shell::Program(program) => program.clone(), + Shell::WithArguments { program, .. } => program.clone(), + Shell::System => get_system_shell(), + } + } + + pub fn program_and_args(&self) -> (String, &[String]) { + match self { + Shell::Program(program) => (program.clone(), &[]), + Shell::WithArguments { program, args, .. } => (program.clone(), args), + Shell::System => (get_system_shell(), &[]), + } + } + + pub fn shell_kind(&self, is_windows: bool) -> ShellKind { + match self { + Shell::Program(program) => ShellKind::new(program, is_windows), + Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows), + Shell::System => ShellKind::system(), + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ShellKind { #[default] Posix, @@ -8,9 +56,14 @@ pub enum ShellKind { Tcsh, Rc, Fish, + /// Pre-installed "legacy" powershell for windows PowerShell, + /// PowerShell 7.x + Pwsh, Nushell, Cmd, + Xonsh, + Elvish, } pub fn get_system_shell() -> String { @@ -29,28 +82,42 @@ pub fn get_default_system_shell() -> String { } } -/// Get the default system shell, preferring git-bash on Windows. +/// Get the default system shell, preferring bash on Windows. pub fn get_default_system_shell_preferring_bash() -> String { if cfg!(windows) { - get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell()) + get_windows_bash().unwrap_or_else(|| get_windows_system_shell()) } else { "/bin/sh".to_string() } } -pub fn get_windows_git_bash() -> Option { - static GIT_BASH: LazyLock> = LazyLock::new(|| { +pub fn get_windows_bash() -> Option { + use std::path::PathBuf; + + fn find_bash_in_scoop() -> Option { + let bash_exe = + PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\bash.exe"); + bash_exe.exists().then_some(bash_exe) + } + + fn find_bash_in_git() -> Option { // /path/to/git/cmd/git.exe/../../bin/bash.exe let git = which::which("git").ok()?; let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe"); - if git_bash.is_file() { - Some(git_bash.to_string_lossy().to_string()) - } else { - None + git_bash.exists().then_some(git_bash) + } + + static BASH: LazyLock> = LazyLock::new(|| { + let bash = find_bash_in_scoop() + .or_else(|| find_bash_in_git()) + .map(|p| p.to_string_lossy().into_owned()); + if let Some(ref path) = bash { + log::info!("Found bash at {}", path); } + bash }); - (*GIT_BASH).clone() + (*BASH).clone() } pub fn get_windows_system_shell() -> String { @@ -140,15 +207,27 @@ pub fn get_windows_system_shell() -> String { } static SYSTEM_SHELL: LazyLock = LazyLock::new(|| { - find_pwsh_in_programfiles(false, false) - .or_else(|| find_pwsh_in_programfiles(true, false)) - .or_else(|| find_pwsh_in_msix(false)) - .or_else(|| find_pwsh_in_programfiles(false, true)) - .or_else(|| find_pwsh_in_msix(true)) - .or_else(|| find_pwsh_in_programfiles(true, true)) - .or_else(find_pwsh_in_scoop) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or("powershell.exe".to_string()) + let locations = [ + || find_pwsh_in_programfiles(false, false), + || find_pwsh_in_programfiles(true, false), + || find_pwsh_in_msix(false), + || find_pwsh_in_programfiles(false, true), + || find_pwsh_in_msix(true), + || find_pwsh_in_programfiles(true, true), + || find_pwsh_in_scoop(), + || which::which_global("pwsh.exe").ok(), + || which::which_global("powershell.exe").ok(), + ]; + + locations + .into_iter() + .find_map(|f| f()) + .map(|p| p.to_string_lossy().trim().to_owned()) + .inspect(|shell| log::info!("Found powershell in: {}", shell)) + .unwrap_or_else(|| { + log::warn!("Powershell not found, falling back to `cmd`"); + "cmd.exe".to_string() + }) }); (*SYSTEM_SHELL).clone() @@ -162,57 +241,50 @@ impl fmt::Display for ShellKind { ShellKind::Tcsh => write!(f, "tcsh"), ShellKind::Fish => write!(f, "fish"), ShellKind::PowerShell => write!(f, "powershell"), + ShellKind::Pwsh => write!(f, "pwsh"), ShellKind::Nushell => write!(f, "nu"), ShellKind::Cmd => write!(f, "cmd"), ShellKind::Rc => write!(f, "rc"), + ShellKind::Xonsh => write!(f, "xonsh"), + ShellKind::Elvish => write!(f, "elvish"), } } } impl ShellKind { pub fn system() -> Self { - Self::new(&get_system_shell()) + Self::new(&get_system_shell(), cfg!(windows)) } - pub fn new(program: impl AsRef) -> Self { + pub fn new(program: impl AsRef, is_windows: bool) -> Self { let program = program.as_ref(); - let Some(program) = program.file_stem().and_then(|s| s.to_str()) else { - return if cfg!(windows) { - ShellKind::PowerShell - } else { - ShellKind::Posix - }; - }; - if program == "powershell" || program == "pwsh" { - ShellKind::PowerShell - } else if program == "cmd" { - ShellKind::Cmd - } else if program == "nu" { - ShellKind::Nushell - } else if program == "fish" { - ShellKind::Fish - } else if program == "csh" { - ShellKind::Csh - } else if program == "tcsh" { - ShellKind::Tcsh - } else if program == "rc" { - ShellKind::Rc - } else if program == "sh" || program == "bash" { - ShellKind::Posix - } else { - if cfg!(windows) { - ShellKind::PowerShell - } else { - // Some other shell detected, the user might install and use a - // unix-like shell. - ShellKind::Posix - } + let program = program + .file_stem() + .unwrap_or_else(|| program.as_os_str()) + .to_string_lossy(); + + match &*program { + "powershell" => ShellKind::PowerShell, + "pwsh" => ShellKind::Pwsh, + "cmd" => ShellKind::Cmd, + "nu" => ShellKind::Nushell, + "fish" => ShellKind::Fish, + "csh" => ShellKind::Csh, + "tcsh" => ShellKind::Tcsh, + "rc" => ShellKind::Rc, + "xonsh" => ShellKind::Xonsh, + "elvish" => ShellKind::Elvish, + "sh" | "bash" | "zsh" => ShellKind::Posix, + _ if is_windows => ShellKind::PowerShell, + // Some other shell detected, the user might install and use a + // unix-like shell. + _ => ShellKind::Posix, } } pub fn to_shell_variable(self, input: &str) -> String { match self { - Self::PowerShell => Self::to_powershell_variable(input), + Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input), Self::Cmd => Self::to_cmd_variable(input), Self::Posix => input.to_owned(), Self::Fish => input.to_owned(), @@ -220,6 +292,8 @@ impl ShellKind { Self::Tcsh => input.to_owned(), Self::Rc => input.to_owned(), Self::Nushell => Self::to_nushell_variable(input), + Self::Xonsh => input.to_owned(), + Self::Elvish => input.to_owned(), } } @@ -338,14 +412,20 @@ impl ShellKind { pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec { match self { - ShellKind::PowerShell => vec!["-C".to_owned(), combined_command], - ShellKind::Cmd => vec!["/C".to_owned(), combined_command], + ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command], + ShellKind::Cmd => vec![ + "/S".to_owned(), + "/C".to_owned(), + format!("\"{combined_command}\""), + ], ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh | ShellKind::Tcsh - | ShellKind::Rc => interactive + | ShellKind::Rc + | ShellKind::Xonsh + | ShellKind::Elvish => interactive .then(|| "-i".to_owned()) .into_iter() .chain(["-c".to_owned(), combined_command]) @@ -353,21 +433,548 @@ impl ShellKind { } } - pub fn command_prefix(&self) -> Option { + pub const fn command_prefix(&self) -> Option { match self { - ShellKind::PowerShell => Some('&'), + ShellKind::PowerShell | ShellKind::Pwsh => Some('&'), ShellKind::Nushell => Some('^'), - _ => None, + ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::Cmd + | ShellKind::Xonsh + | ShellKind::Elvish => None, + } + } + + pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> { + match self.command_prefix() { + Some(prefix) if !command.starts_with(prefix) => { + Cow::Owned(format!("{prefix}{command}")) + } + _ => Cow::Borrowed(command), + } + } + + pub const fn sequential_commands_separator(&self) -> char { + match self { + ShellKind::Cmd => '&', + ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::PowerShell + | ShellKind::Pwsh + | ShellKind::Nushell + | ShellKind::Xonsh + | ShellKind::Elvish => ';', + } + } + + pub const fn sequential_and_commands_separator(&self) -> &'static str { + match self { + ShellKind::Cmd + | ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::Pwsh + | ShellKind::PowerShell + | ShellKind::Xonsh => "&&", + ShellKind::Nushell | ShellKind::Elvish => ";", } } pub fn try_quote<'a>(&self, arg: &'a str) -> Option> { - shlex::try_quote(arg).ok().map(|arg| match self { - // If we are running in PowerShell, we want to take extra care when escaping strings. - // In particular, we want to escape strings with a backtick (`) rather than a backslash (\). - // TODO double escaping backslashes is not necessary in PowerShell and probably CMD - ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")), - _ => arg, + match self { + ShellKind::PowerShell => Some(Self::quote_powershell(arg)), + ShellKind::Pwsh => Some(Self::quote_pwsh(arg)), + ShellKind::Cmd => Some(Self::quote_cmd(arg)), + ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::Nushell + | ShellKind::Xonsh + | ShellKind::Elvish => shlex::try_quote(arg).ok(), + } + } + + fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> { + if arg.is_empty() { + return Cow::Borrowed("\"\""); + } + + let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"'); + if !needs_quoting { + return Cow::Borrowed(arg); + } + + let mut result = String::with_capacity(arg.len() + 2); + + if enclose { + result.push('"'); + } + + let chars: Vec = arg.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '\\' { + let mut num_backslashes = 0; + while i < chars.len() && chars[i] == '\\' { + num_backslashes += 1; + i += 1; + } + + if i < chars.len() && chars[i] == '"' { + // Backslashes followed by quote: double the backslashes and escape the quote + for _ in 0..(num_backslashes * 2 + 1) { + result.push('\\'); + } + result.push('"'); + i += 1; + } else if i >= chars.len() { + // Trailing backslashes: double them (they precede the closing quote) + for _ in 0..(num_backslashes * 2) { + result.push('\\'); + } + } else { + // Backslashes not followed by quote: output as-is + for _ in 0..num_backslashes { + result.push('\\'); + } + } + } else if chars[i] == '"' { + // Quote not preceded by backslash: escape it + result.push('\\'); + result.push('"'); + i += 1; + } else { + result.push(chars[i]); + i += 1; + } + } + + if enclose { + result.push('"'); + } + Cow::Owned(result) + } + + fn needs_quoting_powershell(s: &str) -> bool { + s.is_empty() + || s.chars().any(|c| { + c.is_whitespace() + || matches!( + c, + '"' | '`' + | '$' + | '&' + | '|' + | '<' + | '>' + | ';' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | ',' + | '\'' + | '@' + ) + }) + } + + fn need_quotes_powershell(arg: &str) -> bool { + let mut quote_count = 0; + for c in arg.chars() { + if c == '"' { + quote_count += 1; + } else if c.is_whitespace() && (quote_count % 2 == 0) { + return true; + } + } + false + } + + fn escape_powershell_quotes(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + result.push('\''); + for c in s.chars() { + if c == '\'' { + result.push('\''); + } + result.push(c); + } + result.push('\''); + result + } + + pub fn quote_powershell(arg: &str) -> Cow<'_, str> { + let ps_will_quote = Self::need_quotes_powershell(arg); + let crt_quoted = Self::quote_windows(arg, !ps_will_quote); + + if !Self::needs_quoting_powershell(arg) { + return crt_quoted; + } + + Cow::Owned(Self::escape_powershell_quotes(&crt_quoted)) + } + + pub fn quote_pwsh(arg: &str) -> Cow<'_, str> { + if arg.is_empty() { + return Cow::Borrowed("''"); + } + + if !Self::needs_quoting_powershell(arg) { + return Cow::Borrowed(arg); + } + + Cow::Owned(Self::escape_powershell_quotes(arg)) + } + + pub fn quote_cmd(arg: &str) -> Cow<'_, str> { + let crt_quoted = Self::quote_windows(arg, true); + + let needs_cmd_escaping = crt_quoted.contains('"') + || crt_quoted.contains('%') + || crt_quoted + .chars() + .any(|c| matches!(c, '^' | '<' | '>' | '&' | '|' | '(' | ')')); + + if !needs_cmd_escaping { + return crt_quoted; + } + + let mut result = String::with_capacity(crt_quoted.len() * 2); + for c in crt_quoted.chars() { + match c { + '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => { + result.push('^'); + result.push(c); + } + '%' => { + result.push_str("%%cd:~,%"); + } + _ => result.push(c), + } + } + Cow::Owned(result) + } + + /// Quotes the given argument if necessary, taking into account the command prefix. + /// + /// In other words, this will consider quoting arg without its command prefix to not break the command. + /// You should use this over `try_quote` when you want to quote a shell command. + pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option> { + if let Some(char) = self.command_prefix() { + if let Some(arg) = arg.strip_prefix(char) { + // we have a command that is prefixed + for quote in ['\'', '"'] { + if let Some(arg) = arg + .strip_prefix(quote) + .and_then(|arg| arg.strip_suffix(quote)) + { + // and the command itself is wrapped as a literal, that + // means the prefix exists to interpret a literal as a + // command. So strip the quotes, quote the command, and + // re-add the quotes if they are missing after requoting + let quoted = self.try_quote(arg)?; + return Some(if quoted.starts_with(['\'', '"']) { + Cow::Owned(self.prepend_command_prefix("ed).into_owned()) + } else { + Cow::Owned( + self.prepend_command_prefix(&format!("{quote}{quoted}{quote}")) + .into_owned(), + ) + }); + } + } + return self + .try_quote(arg) + .map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned())); + } + } + self.try_quote(arg).map(|quoted| match quoted { + unquoted @ Cow::Borrowed(_) => unquoted, + Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix("ed).into_owned()), }) } + + pub fn split(&self, input: &str) -> Option> { + shlex::split(input) + } + + pub const fn activate_keyword(&self) -> &'static str { + match self { + ShellKind::Cmd => "", + ShellKind::Nushell => "overlay use", + ShellKind::PowerShell | ShellKind::Pwsh => ".", + ShellKind::Fish + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Posix + | ShellKind::Rc + | ShellKind::Xonsh + | ShellKind::Elvish => "source", + } + } + + pub const fn clear_screen_command(&self) -> &'static str { + match self { + ShellKind::Cmd => "cls", + ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::PowerShell + | ShellKind::Pwsh + | ShellKind::Nushell + | ShellKind::Xonsh + | ShellKind::Elvish => "clear", + } + } + + #[cfg(windows)] + /// We do not want to escape arguments if we are using CMD as our shell. + /// If we do we end up with too many quotes/escaped quotes for CMD to handle. + pub const fn tty_escape_args(&self) -> bool { + match self { + ShellKind::Cmd => false, + ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::PowerShell + | ShellKind::Pwsh + | ShellKind::Nushell + | ShellKind::Xonsh + | ShellKind::Elvish => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Examples + // WSL + // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello" + // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello" + // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53 + // PowerShell from Nushell + // nu -c overlay use "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\activate.nu"; ^"C:\Program Files\PowerShell\7\pwsh.exe" -C "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\python.exe -m pytest \"test_foo.py::test_foo\"" + // PowerShell from CMD + // cmd /C \" \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\activate.bat\"& \"C:\\\\Program Files\\\\PowerShell\\\\7\\\\pwsh.exe\" -C \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"\" + + #[test] + fn test_try_quote_powershell() { + let shell_kind = ShellKind::PowerShell; + assert_eq!( + shell_kind + .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"") + .unwrap() + .into_owned(), + "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string() + ); + } + + #[test] + fn test_try_quote_cmd() { + let shell_kind = ShellKind::Cmd; + assert_eq!( + shell_kind + .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"") + .unwrap() + .into_owned(), + "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string() + ); + } + + #[test] + fn test_try_quote_powershell_edge_cases() { + let shell_kind = ShellKind::PowerShell; + + // Empty string + assert_eq!( + shell_kind.try_quote("").unwrap().into_owned(), + "'\"\"'".to_string() + ); + + // String without special characters (no quoting needed) + assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple"); + + // String with spaces + assert_eq!( + shell_kind.try_quote("hello world").unwrap().into_owned(), + "'hello world'".to_string() + ); + + // String with dollar signs + assert_eq!( + shell_kind.try_quote("$variable").unwrap().into_owned(), + "'$variable'".to_string() + ); + + // String with backticks + assert_eq!( + shell_kind.try_quote("test`command").unwrap().into_owned(), + "'test`command'".to_string() + ); + + // String with multiple special characters + assert_eq!( + shell_kind + .try_quote("test `\"$var`\" end") + .unwrap() + .into_owned(), + "'test `\\\"$var`\\\" end'".to_string() + ); + + // String with backslashes and colon (path without spaces doesn't need quoting) + assert_eq!( + shell_kind.try_quote("C:\\path\\to\\file").unwrap(), + "C:\\path\\to\\file" + ); + } + + #[test] + fn test_try_quote_cmd_edge_cases() { + let shell_kind = ShellKind::Cmd; + + // Empty string + assert_eq!( + shell_kind.try_quote("").unwrap().into_owned(), + "^\"^\"".to_string() + ); + + // String without special characters (no quoting needed) + assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple"); + + // String with spaces + assert_eq!( + shell_kind.try_quote("hello world").unwrap().into_owned(), + "^\"hello world^\"".to_string() + ); + + // String with space and backslash (backslash not at end, so not doubled) + assert_eq!( + shell_kind.try_quote("path\\ test").unwrap().into_owned(), + "^\"path\\ test^\"".to_string() + ); + + // String ending with backslash (must be doubled before closing quote) + assert_eq!( + shell_kind.try_quote("test path\\").unwrap().into_owned(), + "^\"test path\\\\^\"".to_string() + ); + + // String ending with multiple backslashes (all doubled before closing quote) + assert_eq!( + shell_kind.try_quote("test path\\\\").unwrap().into_owned(), + "^\"test path\\\\\\\\^\"".to_string() + ); + + // String with embedded quote (quote is escaped, backslash before it is doubled) + assert_eq!( + shell_kind.try_quote("test\\\"quote").unwrap().into_owned(), + "^\"test\\\\\\^\"quote^\"".to_string() + ); + + // String with multiple backslashes before embedded quote (all doubled) + assert_eq!( + shell_kind + .try_quote("test\\\\\"quote") + .unwrap() + .into_owned(), + "^\"test\\\\\\\\\\^\"quote^\"".to_string() + ); + + // String with backslashes not before quotes (path without spaces doesn't need quoting) + assert_eq!( + shell_kind.try_quote("C:\\path\\to\\file").unwrap(), + "C:\\path\\to\\file" + ); + } + + #[test] + fn test_try_quote_nu_command() { + let shell_kind = ShellKind::Nushell; + assert_eq!( + shell_kind.try_quote("'uname'").unwrap().into_owned(), + "\"'uname'\"".to_string() + ); + assert_eq!( + shell_kind + .try_quote_prefix_aware("'uname'") + .unwrap() + .into_owned(), + "^\"'uname'\"".to_string() + ); + assert_eq!( + shell_kind.try_quote("^uname").unwrap().into_owned(), + "'^uname'".to_string() + ); + assert_eq!( + shell_kind + .try_quote_prefix_aware("^uname") + .unwrap() + .into_owned(), + "^uname".to_string() + ); + assert_eq!( + shell_kind.try_quote("^'uname'").unwrap().into_owned(), + "'^'\"'uname\'\"".to_string() + ); + assert_eq!( + shell_kind + .try_quote_prefix_aware("^'uname'") + .unwrap() + .into_owned(), + "^'uname'".to_string() + ); + assert_eq!( + shell_kind.try_quote("'uname a'").unwrap().into_owned(), + "\"'uname a'\"".to_string() + ); + assert_eq!( + shell_kind + .try_quote_prefix_aware("'uname a'") + .unwrap() + .into_owned(), + "^\"'uname a'\"".to_string() + ); + assert_eq!( + shell_kind.try_quote("^'uname a'").unwrap().into_owned(), + "'^'\"'uname a'\"".to_string() + ); + assert_eq!( + shell_kind + .try_quote_prefix_aware("^'uname a'") + .unwrap() + .into_owned(), + "^'uname a'".to_string() + ); + assert_eq!( + shell_kind.try_quote("uname").unwrap().into_owned(), + "uname".to_string() + ); + assert_eq!( + shell_kind + .try_quote_prefix_aware("uname") + .unwrap() + .into_owned(), + "uname".to_string() + ); + } } diff --git a/crates/util/src/shell_builder.rs b/crates/util/src/shell_builder.rs new file mode 100644 index 0000000000..436c071723 --- /dev/null +++ b/crates/util/src/shell_builder.rs @@ -0,0 +1,298 @@ +use std::borrow::Cow; + +use crate::shell::get_system_shell; +use crate::shell::{Shell, ShellKind}; + +/// ShellBuilder is used to turn a user-requested task into a +/// program that can be executed by the shell. +pub struct ShellBuilder { + /// The shell to run + program: String, + args: Vec, + interactive: bool, + /// Whether to redirect stdin to /dev/null for the spawned command as a subshell. + redirect_stdin: bool, + kind: ShellKind, +} + +impl ShellBuilder { + /// Create a new ShellBuilder as configured. + pub fn new(shell: &Shell, is_windows: bool) -> Self { + let (program, args) = match shell { + Shell::System => (get_system_shell(), Vec::new()), + Shell::Program(shell) => (shell.clone(), Vec::new()), + Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()), + }; + + let kind = ShellKind::new(&program, is_windows); + Self { + program, + args, + interactive: true, + kind, + redirect_stdin: false, + } + } + pub fn non_interactive(mut self) -> Self { + self.interactive = false; + self + } + + /// Returns the label to show in the terminal tab + pub fn command_label(&self, command_to_use_in_label: &str) -> String { + if command_to_use_in_label.trim().is_empty() { + self.program.clone() + } else { + match self.kind { + ShellKind::PowerShell | ShellKind::Pwsh => { + format!("{} -C '{}'", self.program, command_to_use_in_label) + } + ShellKind::Cmd => { + format!("{} /C \"{}\"", self.program, command_to_use_in_label) + } + ShellKind::Posix + | ShellKind::Nushell + | ShellKind::Fish + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Xonsh + | ShellKind::Elvish => { + let interactivity = self.interactive.then_some("-i ").unwrap_or_default(); + format!( + "{PROGRAM} {interactivity}-c '{command_to_use_in_label}'", + PROGRAM = self.program + ) + } + } + } + } + + pub fn redirect_stdin_to_dev_null(mut self) -> Self { + self.redirect_stdin = true; + self + } + + /// Returns the program and arguments to run this task in a shell. + pub fn build( + mut self, + task_command: Option, + task_args: &[String], + ) -> (String, Vec) { + if let Some(task_command) = task_command { + let task_command = if !task_args.is_empty() { + match self.kind.try_quote_prefix_aware(&task_command) { + Some(task_command) => task_command.into_owned(), + None => task_command, + } + } else { + task_command + }; + let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| { + command.push(' '); + let shell_variable = self.kind.to_shell_variable(arg); + command.push_str(&match self.kind.try_quote(&shell_variable) { + Some(shell_variable) => shell_variable, + None => Cow::Owned(shell_variable), + }); + command + }); + if self.redirect_stdin { + match self.kind { + ShellKind::Fish => { + combined_command.insert_str(0, "begin; "); + combined_command.push_str("; end { + combined_command.insert(0, '('); + combined_command.push_str(") { + combined_command.insert_str(0, "$null | & {"); + combined_command.push_str("}"); + } + ShellKind::Cmd => { + combined_command.push_str("< NUL"); + } + } + } + + self.args + .extend(self.kind.args_for_shell(self.interactive, combined_command)); + } + + (self.program, self.args) + } + + // This should not exist, but our task infra is broken beyond repair right now + #[doc(hidden)] + pub fn build_no_quote( + mut self, + task_command: Option, + task_args: &[String], + ) -> (String, Vec) { + if let Some(task_command) = task_command { + let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| { + command.push(' '); + command.push_str(&self.kind.to_shell_variable(arg)); + command + }); + if self.redirect_stdin { + match self.kind { + ShellKind::Fish => { + combined_command.insert_str(0, "begin; "); + combined_command.push_str("; end { + combined_command.insert(0, '('); + combined_command.push_str(") { + combined_command.insert_str(0, "$null | & {"); + combined_command.push_str("}"); + } + ShellKind::Cmd => { + combined_command.push_str("< NUL"); + } + } + } + + self.args + .extend(self.kind.args_for_shell(self.interactive, combined_command)); + } + + (self.program, self.args) + } + + /// Builds a command with the given task command and arguments. + /// + /// Prefer this over manually constructing a command with the output of `Self::build`, + /// as this method handles `cmd` weirdness on windows correctly. + pub fn build_command( + self, + mut task_command: Option, + task_args: &[String], + ) -> smol::process::Command { + #[cfg(windows)] + let kind = self.kind; + if task_args.is_empty() { + task_command = task_command + .as_ref() + .map(|cmd| self.kind.try_quote_prefix_aware(&cmd).map(Cow::into_owned)) + .unwrap_or(task_command); + } + let (program, args) = self.build(task_command, task_args); + + let mut child = crate::command::new_smol_command(program); + + #[cfg(windows)] + if kind == ShellKind::Cmd { + use smol::process::windows::CommandExt; + + for arg in args { + child.raw_arg(arg); + } + } else { + child.args(args); + } + + #[cfg(not(windows))] + child.args(args); + + child + } + + pub fn kind(&self) -> ShellKind { + self.kind + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_nu_shell_variable_substitution() { + let shell = Shell::Program("nu".to_owned()); + let shell_builder = ShellBuilder::new(&shell, false); + + let (program, args) = shell_builder.build( + Some("echo".into()), + &[ + "${hello}".to_string(), + "$world".to_string(), + "nothing".to_string(), + "--$something".to_string(), + "$".to_string(), + "${test".to_string(), + ], + ); + + assert_eq!(program, "nu"); + assert_eq!( + args, + vec![ + "-i", + "-c", + "echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'" + ] + ); + } + + #[test] + fn redirect_stdin_to_dev_null_precedence() { + let shell = Shell::Program("nu".to_owned()); + let shell_builder = ShellBuilder::new(&shell, false); + + let (program, args) = shell_builder + .redirect_stdin_to_dev_null() + .build(Some("echo".into()), &["nothing".to_string()]); + + assert_eq!(program, "nu"); + assert_eq!(args, vec!["-i", "-c", "(echo nothing) Result> { use std::os::unix::process::CommandExt; - use std::process::Stdio; - let zed_path = super::get_shell_safe_zed_path()?; - let shell_kind = ShellKind::new(shell_path); + use crate::command::new_std_command; + + let shell_kind = ShellKind::new(shell_path, false); + let zed_path = super::get_shell_safe_zed_path(shell_kind)?; let mut command_string = String::new(); - let mut command = std::process::Command::new(shell_path); + let mut command = new_std_command(shell_path); command.args(args); // In some shells, file descriptors greater than 2 cannot be used in interactive mode, // so file descriptor 0 (stdin) is used instead. This impacts zsh, old bash; perhaps others. // See: https://github.com/zed-industries/zed/pull/32136#issuecomment-2999645482 const FD_STDIN: std::os::fd::RawFd = 0; const FD_STDOUT: std::os::fd::RawFd = 1; + const FD_STDERR: std::os::fd::RawFd = 2; let (fd_num, redir) = match shell_kind { ShellKind::Rc => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]` ShellKind::Nushell | ShellKind::Tcsh => (FD_STDOUT, "".to_string()), + // xonsh doesn't support redirecting to stdin, and control sequences are printed to + // stdout on startup + ShellKind::Xonsh => (FD_STDERR, "o>e".to_string()), + ShellKind::PowerShell => (FD_STDIN, format!(">{}", FD_STDIN)), _ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0` }; - command.stdin(Stdio::null()); - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); match shell_kind { ShellKind::Csh | ShellKind::Tcsh => { @@ -93,7 +96,9 @@ async fn capture_unix( // Parse the JSON output from zed --printenv let env_map: collections::HashMap = serde_json::from_str(&env_output) - .with_context(|| "Failed to deserialize environment variables from json")?; + .with_context(|| { + format!("Failed to deserialize environment variables from json: {env_output}") + })?; Ok(env_map) } @@ -103,7 +108,7 @@ async fn spawn_and_read_fd( child_fd: std::os::fd::RawFd, ) -> anyhow::Result<(Vec, std::process::Output)> { use command_fds::{CommandFdExt, FdMapping}; - use std::io::Read; + use std::{io::Read, process::Stdio}; let (mut reader, writer) = std::io::pipe()?; @@ -112,7 +117,11 @@ async fn spawn_and_read_fd( child_fd, }])?; - let process = smol::process::Command::from(command).spawn()?; + let process = smol::process::Command::from(command) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; let mut buffer = Vec::new(); reader.read_to_end(&mut buffer)?; @@ -123,7 +132,7 @@ async fn spawn_and_read_fd( #[cfg(windows)] async fn capture_windows( shell_path: &Path, - _args: &[String], + args: &[String], directory: &Path, ) -> Result> { use std::process::Stdio; @@ -131,93 +140,82 @@ async fn capture_windows( let zed_path = std::env::current_exe().context("Failed to determine current zed executable path.")?; - let shell_kind = ShellKind::new(shell_path); - let env_output = match shell_kind { - ShellKind::Posix | ShellKind::Csh | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish => { - return Err(anyhow::anyhow!("unsupported shell kind")); - } - ShellKind::PowerShell => { - let output = crate::command::new_smol_command(shell_path) - .args([ - "-NonInteractive", - "-NoProfile", - "-Command", - &format!( - "Set-Location '{}'; & '{}' --printenv", - directory.display(), - zed_path.display() - ), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "PowerShell command failed with {}. stdout: {:?}, stderr: {:?}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - output - } - ShellKind::Nushell => { - let output = crate::command::new_smol_command(shell_path) - .args([ - "-c", - &format!( - "cd '{}'; {} --printenv", - directory.display(), - zed_path.display() - ), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "Nushell command failed with {}. stdout: {:?}, stderr: {:?}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - output - } - ShellKind::Cmd => { - let output = crate::command::new_smol_command(shell_path) - .args([ - "/c", - &format!( - "cd '{}'; {} --printenv", - directory.display(), - zed_path.display() - ), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "Cmd command failed with {}. stdout: {:?}, stderr: {:?}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - output - } - }; - - let env_output = String::from_utf8_lossy(&env_output.stdout); + let shell_kind = ShellKind::new(shell_path, true); + let mut cmd = crate::command::new_smol_command(shell_path); + cmd.args(args); + let cmd = match shell_kind { + ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::Xonsh + | ShellKind::Posix => cmd.args([ + "-l", + "-i", + "-c", + &format!( + "cd '{}'; '{}' --printenv", + directory.display(), + zed_path.display() + ), + ]), + ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([ + "-NonInteractive", + "-NoProfile", + "-Command", + &format!( + "Set-Location '{}'; & '{}' --printenv", + directory.display(), + zed_path.display() + ), + ]), + ShellKind::Elvish => cmd.args([ + "-c", + &format!( + "cd '{}'; '{}' --printenv", + directory.display(), + zed_path.display() + ), + ]), + ShellKind::Nushell => cmd.args([ + "-c", + &format!( + "cd '{}'; {}'{}' --printenv", + directory.display(), + shell_kind + .command_prefix() + .map(|prefix| prefix.to_string()) + .unwrap_or_default(), + zed_path.display() + ), + ]), + ShellKind::Cmd => cmd.args([ + "/c", + "cd", + &directory.display().to_string(), + "&&", + &zed_path.display().to_string(), + "--printenv", + ]), + } + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = cmd + .output() + .await + .with_context(|| format!("command {cmd:?}"))?; + anyhow::ensure!( + output.status.success(), + "Command {cmd:?} failed with {}. stdout: {:?}, stderr: {:?}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let env_output = String::from_utf8_lossy(&output.stdout); // Parse the JSON output from zed --printenv - serde_json::from_str(&env_output) - .with_context(|| "Failed to deserialize environment variables from json") + serde_json::from_str(&env_output).with_context(|| { + format!("Failed to deserialize environment variables from json: {env_output}") + }) } diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index f2efc4532a..4ea3590196 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -9,6 +9,7 @@ pub mod rel_path; pub mod schemars; pub mod serde; pub mod shell; +pub mod shell_builder; pub mod shell_env; pub mod size; #[cfg(any(test, feature = "test-support"))] @@ -50,6 +51,12 @@ macro_rules! debug_panic { }; } +#[inline] +pub const fn is_utf8_char_boundary(u8: u8) -> bool { + // This is bit magic equivalent to: b < 128 || b >= 192 + (u8 as i8) >= -0x40 +} + pub fn truncate(s: &str, max_chars: usize) -> &str { match s.char_indices().nth(max_chars) { None => s, @@ -279,7 +286,11 @@ fn load_shell_from_passwd() -> Result<()> { ); let shell = unsafe { std::ffi::CStr::from_ptr(entry.pw_shell).to_str().unwrap() }; - if env::var("SHELL").map_or(true, |shell_env| shell_env != shell) { + let should_set_shell = env::var("SHELL").map_or(true, |shell_env| { + shell_env != shell && !std::path::Path::new(&shell_env).exists() + }); + + if should_set_shell { log::info!( "updating SHELL environment variable to value from passwd entry: {:?}", shell, @@ -291,12 +302,12 @@ fn load_shell_from_passwd() -> Result<()> { } /// Returns a shell escaped path for the current zed executable -pub fn get_shell_safe_zed_path() -> anyhow::Result { +pub fn get_shell_safe_zed_path(shell_kind: shell::ShellKind) -> anyhow::Result { let zed_path = std::env::current_exe().context("Failed to determine current zed executable path.")?; zed_path - .try_shell_safe() + .try_shell_safe(shell_kind) .context("Failed to shell-escape Zed executable path.") } @@ -349,7 +360,10 @@ pub async fn load_login_shell_environment() -> Result<()> { // into shell's `cd` command (and hooks) to manipulate env. // We do this so that we get the env a user would have when spawning a shell // in home directory. - for (name, value) in shell_env::capture(get_system_shell(), &[], paths::home_dir()).await? { + for (name, value) in shell_env::capture(get_system_shell(), &[], paths::home_dir()) + .await + .with_context(|| format!("capturing environment with {:?}", get_system_shell()))? + { unsafe { env::set_var(&name, &value) }; } @@ -376,6 +390,8 @@ pub fn set_pre_exec_to_start_new_session( use std::os::unix::process::CommandExt; command.pre_exec(|| { libc::setsid(); + #[cfg(target_os = "macos")] + crate::command::reset_exception_ports(); Ok(()) }); }; @@ -603,17 +619,21 @@ where let file = caller.file().replace('\\', "/"); // In this codebase all crates reside in a `crates` directory, // so discard the prefix up to that segment to find the crate name - let target = file - .split_once("crates/") - .and_then(|(_, s)| s.split_once("/src/")); + let file = file.split_once("crates/"); + let target = file.as_ref().and_then(|(_, s)| s.split_once("/src/")); let module_path = target.map(|(krate, module)| { - krate.to_owned() + "::" + &module.trim_end_matches(".rs").replace('/', "::") + if module.starts_with(krate) { + module.trim_end_matches(".rs").replace('/', "::") + } else { + krate.to_owned() + "::" + &module.trim_end_matches(".rs").replace('/', "::") + } }); + let file = file.map(|(_, file)| format!("crates/{file}")); log::logger().log( &log::Record::builder() - .target(target.map_or("", |(krate, _)| krate)) - .module_path(module_path.as_deref()) + .target(module_path.as_deref().unwrap_or("")) + .module_path(file.as_deref()) .args(format_args!("{:?}", error)) .file(Some(caller.file())) .line(Some(caller.line())) @@ -623,7 +643,7 @@ where } pub fn log_err(error: &E) { - log_error_with_caller(*Location::caller(), error, log::Level::Warn); + log_error_with_caller(*Location::caller(), error, log::Level::Error); } pub trait TryFutureExt { @@ -923,7 +943,7 @@ impl PartialOrd for NumericPrefixWithSuffix<'_> { /// # Examples /// /// ``` -/// use zed_util::capitalize; +/// use util::capitalize; /// /// assert_eq!(capitalize("hello"), "Hello"); /// assert_eq!(capitalize("WORLD"), "WORLD"); diff --git a/crates/util_macros/Cargo.toml b/crates/util_macros/Cargo.toml index b1c0334c87..f72955b3ae 100644 --- a/crates/util_macros/Cargo.toml +++ b/crates/util_macros/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "zed-util-macros" +name = "util_macros" version = "0.1.0" edition.workspace = true -publish = true +publish = false license = "Apache-2.0" description = "Utility macros for Zed" @@ -18,7 +18,6 @@ doctest = false quote.workspace = true syn.workspace = true perf.workspace = true -workspace-hack.workspace = true [features] perf-enabled = [] diff --git a/crates/util_macros/src/util_macros.rs b/crates/util_macros/src/util_macros.rs index 2cdc7f46f5..4973e41de2 100644 --- a/crates/util_macros/src/util_macros.rs +++ b/crates/util_macros/src/util_macros.rs @@ -12,7 +12,7 @@ use syn::{ItemFn, LitStr, parse_macro_input, parse_quote}; /// /// # Example /// ```rust -/// use zed_util_macros::path; +/// use util_macros::path; /// /// let path = path!("/Users/user/file.txt"); /// #[cfg(target_os = "windows")] @@ -43,7 +43,7 @@ pub fn path(input: TokenStream) -> TokenStream { /// /// # Example /// ```rust -/// use zed_util_macros::uri; +/// use util_macros::uri; /// /// let uri = uri!("file:///path/to/file"); /// #[cfg(target_os = "windows")] @@ -69,7 +69,7 @@ pub fn uri(input: TokenStream) -> TokenStream { /// /// # Example /// ```rust -/// use zed_util_macros::line_endings; +/// use util_macros::line_endings; /// /// let text = line_endings!("Hello\nWorld"); /// #[cfg(target_os = "windows")] @@ -156,7 +156,7 @@ impl PerfArgs { /// /// # Examples /// ```rust -/// use zed_util_macros::perf; +/// use util_macros::perf; /// /// #[perf] /// fn generic_test() { @@ -172,7 +172,7 @@ impl PerfArgs { /// This also works with `#[gpui::test]`s, though in most cases it shouldn't /// be used with automatic iterations. /// ```rust,ignore -/// use zed_util_macros::perf; +/// use util_macros::perf; /// /// #[perf(iterations = 1, critical)] /// #[gpui::test] diff --git a/crates/vercel/Cargo.toml b/crates/vercel/Cargo.toml index 60fa1a2390..98b26c9104 100644 --- a/crates/vercel/Cargo.toml +++ b/crates/vercel/Cargo.toml @@ -20,4 +20,3 @@ anyhow.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index ad84eecd91..2db1b51e72 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -26,6 +26,7 @@ db.workspace = true editor.workspace = true env_logger.workspace = true futures.workspace = true +fuzzy.workspace = true gpui.workspace = true itertools.workspace = true language.workspace = true @@ -34,7 +35,6 @@ multi_buffer.workspace = true nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", rev = "764dd270c642f77f10f3e19d05cc178a6cbe69f3", features = ["use_tokio"], optional = true } picker.workspace = true project.workspace = true -project_panel.workspace = true regex.workspace = true schemars.workspace = true search.workspace = true @@ -52,11 +52,10 @@ util_macros.workspace = true vim_mode_setting.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] assets.workspace = true -command_palette.workspace = true +command_palette = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } git_ui.workspace = true gpui = { workspace = true, features = ["test-support"] } @@ -64,9 +63,13 @@ indoc.workspace = true language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } +markdown_preview.workspace = true parking_lot.workspace = true project_panel.workspace = true +outline_panel.workspace = true release_channel.workspace = true +semver.workspace = true +settings_ui.workspace = true settings.workspace = true perf.workspace = true util = { workspace = true, features = ["test-support"] } diff --git a/crates/vim/src/change_list.rs b/crates/vim/src/change_list.rs index c92ce4720e..21cd332800 100644 --- a/crates/vim/src/change_list.rs +++ b/crates/vim/src/change_list.rs @@ -38,7 +38,7 @@ impl Vim { .map(|s| s.to_vec()) { editor.change_selections(Default::default(), window, cx, |s| { - let map = s.display_map(); + let map = s.display_snapshot(); s.select_display_ranges(selections.iter().map(|a| { let point = a.to_display_point(&map); point..point @@ -50,7 +50,8 @@ impl Vim { pub(crate) fn push_to_change_list(&mut self, window: &mut Window, cx: &mut Context) { let Some((new_positions, buffer)) = self.update_editor(cx, |vim, editor, cx| { - let (map, selections) = editor.selections.all_adjusted_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_adjusted_display(&display_map); let buffer = editor.buffer().clone(); let pop_state = editor @@ -59,7 +60,7 @@ impl Vim { .map(|previous| { previous.len() == selections.len() && previous.iter().enumerate().all(|(ix, p)| { - p.to_display_point(&map).row() == selections[ix].head().row() + p.to_display_point(&display_map).row() == selections[ix].head().row() }) }) .unwrap_or(false); @@ -68,11 +69,11 @@ impl Vim { .into_iter() .map(|s| { let point = if vim.mode == Mode::Insert { - movement::saturating_left(&map, s.head()) + movement::saturating_left(&display_map, s.head()) } else { s.head() }; - map.display_point_to_anchor(point, Bias::Left) + display_map.display_point_to_anchor(point, Bias::Left) }) .collect::>(); diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index ef19d41ed8..5bf0fca041 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1,13 +1,15 @@ use anyhow::{Result, anyhow}; use collections::{HashMap, HashSet}; -use command_palette_hooks::CommandInterceptResult; +use command_palette_hooks::{CommandInterceptItem, CommandInterceptResult}; use editor::{ Bias, Editor, EditorSettings, SelectionEffects, ToPoint, actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive}, display_map::ToDisplayPoint, }; use futures::AsyncWriteExt as _; -use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Task, Window, actions}; +use gpui::{ + Action, App, AppContext as _, Context, Global, Keystroke, Task, WeakEntity, Window, actions, +}; use itertools::Itertools; use language::Point; use multi_buffer::MultiBufferRow; @@ -20,7 +22,7 @@ use settings::{Settings, SettingsStore}; use std::{ iter::Peekable, ops::{Deref, Range}, - path::Path, + path::{Path, PathBuf}, process::Stdio, str::Chars, sync::OnceLock, @@ -28,8 +30,12 @@ use std::{ }; use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId}; use ui::ActiveTheme; -use util::{ResultExt, rel_path::RelPath}; -use workspace::{Item, SaveIntent, notifications::NotifyResultExt}; +use util::{ + ResultExt, + paths::PathStyle, + rel_path::{RelPath, RelPathBuf}, +}; +use workspace::{Item, SaveIntent, Workspace, notifications::NotifyResultExt}; use workspace::{SplitDirection, notifications::DetachAndPromptErr}; use zed_actions::{OpenDocs, RevealTarget}; @@ -85,7 +91,7 @@ pub enum VimOption { } impl VimOption { - fn possible_commands(query: &str) -> Vec { + fn possible_commands(query: &str) -> Vec { let mut prefix_of_options = Vec::new(); let mut options = query.split(" ").collect::>(); let prefix = options.pop().unwrap_or_default(); @@ -102,7 +108,7 @@ impl VimOption { let mut options = prefix_of_options.clone(); options.push(possible); - CommandInterceptResult { + CommandInterceptItem { string: format!( ":set {}", options.iter().map(|opt| opt.to_string()).join(" ") @@ -183,6 +189,7 @@ pub struct VimSet { #[derive(Clone, PartialEq, Action)] #[action(namespace = vim, no_json, no_register)] struct VimSave { + pub range: Option, pub save_intent: Option, pub filename: String, } @@ -318,6 +325,134 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, action: &VimSave, window, cx| { + if let Some(range) = &action.range { + vim.update_editor(cx, |vim, editor, cx| { + let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else { + return; + }; + let Some((line_ending, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| { + Some(multi.as_singleton()?.update(cx, |buffer, _| { + ( + buffer.line_ending(), + buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1), + range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(), + ) + })) + }) else { + return; + }; + + let filename = action.filename.clone(); + let filename = if filename.is_empty() { + let Some(file) = editor + .buffer() + .read(cx) + .as_singleton() + .and_then(|buffer| buffer.read(cx).file()) + else { + let _ = window.prompt( + gpui::PromptLevel::Warning, + "No file name", + Some("Partial buffer write requires file name."), + &["Cancel"], + cx, + ); + return; + }; + file.path().display(file.path_style(cx)).to_string() + } else { + filename + }; + + if action.filename.is_empty() { + if whole_buffer { + if let Some(workspace) = vim.workspace(window) { + workspace.update(cx, |workspace, cx| { + workspace + .save_active_item( + action.save_intent.unwrap_or(SaveIntent::Save), + window, + cx, + ) + .detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None); + }); + } + return; + } + if Some(SaveIntent::Overwrite) != action.save_intent { + let _ = window.prompt( + gpui::PromptLevel::Warning, + "Use ! to write partial buffer", + Some("Overwriting the current file with selected buffer content requires '!'."), + &["Cancel"], + cx, + ); + return; + } + editor.buffer().update(cx, |multi, cx| { + if let Some(buffer) = multi.as_singleton() { + buffer.update(cx, |buffer, _| buffer.set_conflict()); + } + }); + }; + + editor.project().unwrap().update(cx, |project, cx| { + let worktree = project.visible_worktrees(cx).next().unwrap(); + + worktree.update(cx, |worktree, cx| { + let path_style = worktree.path_style(); + let Some(path) = RelPath::new(Path::new(&filename), path_style).ok() else { + return; + }; + + let rx = (worktree.entry_for_path(&path).is_some() && Some(SaveIntent::Overwrite) != action.save_intent).then(|| { + window.prompt( + gpui::PromptLevel::Warning, + &format!("{path:?} already exists. Do you want to replace it?"), + Some( + "A file or folder with the same name already exists. Replacing it will overwrite its current contents.", + ), + &["Replace", "Cancel"], + cx + ) + }); + let filename = filename.clone(); + cx.spawn_in(window, async move |this, cx| { + if let Some(rx) = rx + && Ok(0) != rx.await + { + return; + } + + let _ = this.update_in(cx, |worktree, window, cx| { + let Some(path) = RelPath::new(Path::new(&filename), path_style).ok() else { + return; + }; + worktree + .write_file(path.into_arc(), text.clone(), line_ending, cx) + .detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None); + }); + }) + .detach(); + }); + }); + }); + return; + } + if action.filename.is_empty() { + if let Some(workspace) = vim.workspace(window) { + workspace.update(cx, |workspace, cx| { + workspace + .save_active_item( + action.save_intent.unwrap_or(SaveIntent::Save), + window, + cx, + ) + .detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None); + }); + } + return; + } vim.update_editor(cx, |_, editor, cx| { let Some(project) = editor.project().cloned() else { return; @@ -600,7 +735,9 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { let result = vim.update_editor(cx, |vim, editor, cx| { let snapshot = editor.snapshot(window, cx); let buffer_row = action.range.head().buffer_row(vim, editor, window, cx)?; - let current = editor.selections.newest::(cx); + let current = editor + .selections + .newest::(&editor.display_snapshot(cx)); let target = snapshot .buffer_snapshot() .clip_point(Point::new(buffer_row.0, current.head().column), Bias::Left); @@ -674,8 +811,9 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { .disjoint_anchor_ranges() .collect::>() }); + let snapshot = editor.buffer().read(cx).snapshot(cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - let end = Point::new(range.end.0, s.buffer().line_len(range.end)); + let end = Point::new(range.end.0, snapshot.line_len(range.end)); s.select_ranges([end..Point::new(range.start.0, 0)]); }); selections @@ -716,6 +854,8 @@ struct VimCommand { args: Option< Box, String) -> Option> + Send + Sync + 'static>, >, + /// Optional range Range to use if no range is specified. + default_range: Option, range: Option< Box< dyn Fn(Box, &CommandRange) -> Option> @@ -725,6 +865,13 @@ struct VimCommand { >, >, has_count: bool, + has_filename: bool, +} + +struct ParsedQuery { + args: String, + has_bang: bool, + has_space: bool, } impl VimCommand { @@ -760,6 +907,15 @@ impl VimCommand { self } + fn filename( + mut self, + f: impl Fn(Box, String) -> Option> + Send + Sync + 'static, + ) -> Self { + self.args = Some(Box::new(f)); + self.has_filename = true; + self + } + fn range( mut self, f: impl Fn(Box, &CommandRange) -> Option> + Send + Sync + 'static, @@ -768,19 +924,90 @@ impl VimCommand { self } + fn default_range(mut self, range: CommandRange) -> Self { + self.default_range = Some(range); + self + } + fn count(mut self) -> Self { self.has_count = true; self } - fn parse( - &self, - query: &str, - range: &Option, - cx: &App, - ) -> Option> { + fn generate_filename_completions( + parsed_query: &ParsedQuery, + workspace: WeakEntity, + cx: &mut App, + ) -> Task> { + let ParsedQuery { + args, + has_bang: _, + has_space: _, + } = parsed_query; + let Some(workspace) = workspace.upgrade() else { + return Task::ready(Vec::new()); + }; + + let (task, args_path) = workspace.update(cx, |workspace, cx| { + let prefix = workspace + .project() + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .next() + .or_else(std::env::home_dir) + .unwrap_or_else(|| PathBuf::from("")); + + let rel_path = match RelPath::new(Path::new(&args), PathStyle::local()) { + Ok(path) => path.to_rel_path_buf(), + Err(_) => { + return (Task::ready(Ok(Vec::new())), RelPathBuf::new()); + } + }; + + let rel_path = if args.ends_with(PathStyle::local().primary_separator()) { + rel_path + } else { + rel_path + .parent() + .map(|rel_path| rel_path.to_rel_path_buf()) + .unwrap_or(RelPathBuf::new()) + }; + + let task = workspace.project().update(cx, |project, cx| { + let path = prefix + .join(rel_path.as_std_path()) + .to_string_lossy() + .to_string(); + project.list_directory(path, cx) + }); + + (task, rel_path) + }); + + cx.background_spawn(async move { + let directories = task.await.unwrap_or_default(); + directories + .iter() + .map(|dir| { + let path = RelPath::new(dir.path.as_path(), PathStyle::local()) + .map(|cow| cow.into_owned()) + .unwrap_or(RelPathBuf::new()); + let mut path_string = args_path + .join(&path) + .display(PathStyle::local()) + .to_string(); + if dir.is_dir { + path_string.push_str(PathStyle::local().primary_separator()); + } + path_string + }) + .collect() + }) + } + + fn get_parsed_query(&self, query: String) -> Option { let rest = query - .to_string() .strip_prefix(self.prefix)? .to_string() .chars() @@ -789,6 +1016,7 @@ impl VimCommand { .filter_map(|e| e.left()) .collect::(); let has_bang = rest.starts_with('!'); + let has_space = rest.starts_with("! ") || rest.starts_with(' '); let args = if has_bang { rest.strip_prefix('!')?.trim().to_string() } else if rest.is_empty() { @@ -796,7 +1024,24 @@ impl VimCommand { } else { rest.strip_prefix(' ')?.trim().to_string() }; + Some(ParsedQuery { + args, + has_bang, + has_space, + }) + } + fn parse( + &self, + query: &str, + range: &Option, + cx: &App, + ) -> Option> { + let ParsedQuery { + args, + has_bang, + has_space: _, + } = self.get_parsed_query(query.to_string())?; let action = if has_bang && self.bang_action.is_some() { self.bang_action.as_ref().unwrap().boxed_clone() } else if let Some(action) = self.action.as_ref() { @@ -814,6 +1059,7 @@ impl VimCommand { self.args.as_ref()?(action, args)? }; + let range = range.as_ref().or(self.default_range.as_ref()); if let Some(range) = range { self.range.as_ref().and_then(|f| f(action, range)) } else { @@ -1012,6 +1258,7 @@ impl CommandRange { self.end.as_ref().unwrap_or(&self.start) } + /// Convert the `CommandRange` into a `Range`. pub(crate) fn buffer_range( &self, vim: &Vim, @@ -1043,31 +1290,74 @@ impl CommandRange { None } } + + /// The `CommandRange` representing the entire buffer. + fn buffer() -> Self { + Self { + start: Position::Line { row: 1, offset: 0 }, + end: Some(Position::LastLine { offset: 0 }), + } + } } fn generate_commands(_: &App) -> Vec { vec![ VimCommand::new( ("w", "rite"), - workspace::Save { + VimSave { save_intent: Some(SaveIntent::Save), + filename: "".into(), + range: None, }, ) - .bang(workspace::Save { + .bang(VimSave { save_intent: Some(SaveIntent::Overwrite), + filename: "".into(), + range: None, }) - .args(|action, args| { + .filename(|action, filename| { Some( VimSave { save_intent: action .as_any() - .downcast_ref::() + .downcast_ref::() .and_then(|action| action.save_intent), - filename: args, + filename, + range: None, + } + .boxed_clone(), + ) + }) + .range(|action, range| { + let mut action: VimSave = action.as_any().downcast_ref::().unwrap().clone(); + action.range.replace(range.clone()); + Some(Box::new(action)) + }), + VimCommand::new(("e", "dit"), editor::actions::ReloadFile) + .bang(editor::actions::ReloadFile) + .filename(|_, filename| Some(VimEdit { filename }.boxed_clone())), + VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| { + Some( + VimSplit { + vertical: false, + filename, } .boxed_clone(), ) }), + VimCommand::new(("vs", "plit"), workspace::SplitVertical).filename(|_, filename| { + Some( + VimSplit { + vertical: true, + filename, + } + .boxed_clone(), + ) + }), + VimCommand::new(("tabe", "dit"), workspace::NewFile) + .filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())), + VimCommand::new(("tabnew", ""), workspace::NewFile) + .filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())), VimCommand::new( ("q", "uit"), workspace::CloseActiveItem { @@ -1164,24 +1454,6 @@ fn generate_commands(_: &App) -> Vec { save_intent: Some(SaveIntent::Overwrite), }), VimCommand::new(("cq", "uit"), zed_actions::Quit), - VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).args(|_, args| { - Some( - VimSplit { - vertical: false, - filename: args, - } - .boxed_clone(), - ) - }), - VimCommand::new(("vs", "plit"), workspace::SplitVertical).args(|_, args| { - Some( - VimSplit { - vertical: true, - filename: args, - } - .boxed_clone(), - ) - }), VimCommand::new( ("bd", "elete"), workspace::CloseActiveItem { @@ -1224,10 +1496,6 @@ fn generate_commands(_: &App) -> Vec { VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"), VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal), VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical), - VimCommand::new(("tabe", "dit"), workspace::NewFile) - .args(|_action, args| Some(VimEdit { filename: args }.boxed_clone())), - VimCommand::new(("tabnew", ""), workspace::NewFile) - .args(|_action, args| Some(VimEdit { filename: args }.boxed_clone())), VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(), VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(), VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(), @@ -1309,8 +1577,12 @@ fn generate_commands(_: &App) -> Vec { VimCommand::new(("delm", "arks"), ArgumentRequired) .bang(DeleteMarks::AllLocal) .args(|_, args| Some(DeleteMarks::Marks(args).boxed_clone())), - VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(select_range), - VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(select_range), + VimCommand::new(("sor", "t"), SortLinesCaseSensitive) + .range(select_range) + .default_range(CommandRange::buffer()), + VimCommand::new(("sort i", ""), SortLinesCaseInsensitive) + .range(select_range) + .default_range(CommandRange::buffer()), VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"), VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"), VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"), @@ -1327,9 +1599,6 @@ fn generate_commands(_: &App) -> Vec { VimCommand::new(("$", ""), EndOfDocument), VimCommand::new(("%", ""), EndOfDocument), VimCommand::new(("0", ""), StartOfDocument), - VimCommand::new(("e", "dit"), editor::actions::ReloadFile) - .bang(editor::actions::ReloadFile) - .args(|_, args| Some(VimEdit { filename: args }.boxed_clone())), VimCommand::new(("ex", ""), editor::actions::ReloadFile).bang(editor::actions::ReloadFile), VimCommand::new(("cpp", "link"), editor::actions::CopyPermalinkToLine).range(act_on_range), VimCommand::str(("opt", "ions"), "zed::OpenDefaultSettings"), @@ -1383,18 +1652,30 @@ fn wrap_count(action: Box, range: &CommandRange) -> Option Vec { - // NOTE: We also need to support passing arguments to commands like :w - // (ideally with filename autocompletion). +pub fn command_interceptor( + mut input: &str, + workspace: WeakEntity, + cx: &mut App, +) -> Task { while input.starts_with(':') { input = &input[1..]; } let (range, query) = VimCommand::parse_range(input); let range_prefix = input[0..(input.len() - query.len())].to_string(); - let query = query.as_str().trim(); + let has_trailing_space = query.ends_with(" "); + let mut query = query.as_str().trim(); - let action = if range.is_some() && query.is_empty() { + let on_matching_lines = (query.starts_with('g') || query.starts_with('v')) + .then(|| { + let (pattern, range, search, invert) = OnMatchingLines::parse(query, &range)?; + let start_idx = query.len() - pattern.len(); + query = query[start_idx..].trim(); + Some((range, search, invert)) + }) + .flatten(); + + let mut action = if range.is_some() && query.is_empty() { Some( GoToLine { range: range.clone().unwrap(), @@ -1418,7 +1699,10 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Vec Vec = positions.iter().map(|&pos| pos + offset).collect(); + positions.splice(0..0, no_args_positions.clone()); + let string = format!("{display_string} {string}"); + let (range, query) = VimCommand::parse_range(&string[1..]); + let action = + match cx.update(|cx| commands(cx).get(cmd_idx)?.parse(&query, &range, cx)) { + Ok(Some(action)) => action, + _ => continue, + }; + results.push(CommandInterceptItem { + action, + string, + positions, + }); + } + CommandInterceptResult { + results, + exclusive: true, + } + }) + } else { + Task::ready(CommandInterceptResult { + results, + exclusive: false, + }) } - Vec::default() } fn generate_positions(string: &str, query: &str) -> Vec { @@ -1530,19 +1894,40 @@ impl OnMatchingLines { // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern, // and convert \0..\9 to $0..$9 in the replacement so that common idioms work. pub(crate) fn parse( - mut chars: Peekable, - invert: bool, - range: CommandRange, - cx: &App, - ) -> Option { - let delimiter = chars.next().filter(|c| { + query: &str, + range: &Option, + ) -> Option<(String, CommandRange, String, bool)> { + let mut global = "global".chars().peekable(); + let mut query_chars = query.chars().peekable(); + let mut invert = false; + if query_chars.peek() == Some(&'v') { + invert = true; + query_chars.next(); + } + while global + .peek() + .is_some_and(|char| Some(char) == query_chars.peek()) + { + global.next(); + query_chars.next(); + } + if !invert && query_chars.peek() == Some(&'!') { + invert = true; + query_chars.next(); + } + let range = range.clone().unwrap_or(CommandRange { + start: Position::Line { row: 0, offset: 0 }, + end: Some(Position::LastLine { offset: 0 }), + }); + + let delimiter = query_chars.next().filter(|c| { !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!' })?; let mut search = String::new(); let mut escaped = false; - for c in chars.by_ref() { + for c in query_chars.by_ref() { if escaped { escaped = false; // unescape escaped parens @@ -1563,21 +1948,7 @@ impl OnMatchingLines { } } - let command: String = chars.collect(); - - let action = WrappedAction( - command_interceptor(&command, cx) - .first()? - .action - .boxed_clone(), - ); - - Some(Self { - range, - search, - invert, - action, - }) + Some((query_chars.collect::(), range, search, invert)) } pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context) { @@ -1695,7 +2066,9 @@ impl OnMatchingLines { }); window.dispatch_action(action, cx); cx.defer_in(window, move |editor, window, cx| { - let newest = editor.selections.newest::(cx); + let newest = editor + .selections + .newest::(&editor.display_snapshot(cx)); editor.change_selections( SelectionEffects::no_scroll(), window, @@ -1792,7 +2165,9 @@ impl Vim { }; let command = self.update_editor(cx, |_, editor, cx| { let snapshot = editor.snapshot(window, cx); - let start = editor.selections.newest_display(cx); + let start = editor + .selections + .newest_display(&editor.display_snapshot(cx)); let text_layout_details = editor.text_layout_details(window); let (mut range, _) = motion .range( @@ -1839,7 +2214,9 @@ impl Vim { }; let command = self.update_editor(cx, |_, editor, cx| { let snapshot = editor.snapshot(window, cx); - let start = editor.selections.newest_display(cx); + let start = editor + .selections + .newest_display(&editor.display_snapshot(cx)); let range = object .range(&snapshot, start.clone(), around, None) .unwrap_or(start.range()); @@ -1948,7 +2325,11 @@ impl ShellExec { Point::new(range.start.0, 0) ..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right) } else { - let mut end = editor.selections.newest::(cx).range().end; + let mut end = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .range() + .end; end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right); needs_newline_prefix = end == snapshot.max_point(); end..end @@ -1974,21 +2355,23 @@ impl ShellExec { let Some(range) = input_range else { return }; - let Some(mut process) = project.read(cx).exec_in_shell(command, cx).log_err() else { - return; - }; - process.stdout(Stdio::piped()); - process.stderr(Stdio::piped()); - - if input_snapshot.is_some() { - process.stdin(Stdio::piped()); - } else { - process.stdin(Stdio::null()); - }; + let process_task = project.update(cx, |project, cx| project.exec_in_shell(command, cx)); let is_read = self.is_read; let task = cx.spawn_in(window, async move |vim, cx| { + let Some(mut process) = process_task.await.log_err() else { + return; + }; + process.stdout(Stdio::piped()); + process.stderr(Stdio::piped()); + + if input_snapshot.is_some() { + process.stdin(Stdio::piped()); + } else { + process.stdin(Stdio::null()); + }; + let Some(mut running) = process.spawn().log_err() else { vim.update_in(cx, |vim, window, cx| { vim.cancel_running_command(window, cx); @@ -2058,7 +2441,7 @@ impl ShellExec { #[cfg(test)] mod test { - use std::path::Path; + use std::path::{Path, PathBuf}; use crate::{ VimAddon, @@ -2070,7 +2453,7 @@ mod test { use indoc::indoc; use settings::Settings; use util::path; - use workspace::Workspace; + use workspace::{OpenOptions, Workspace}; #[gpui::test] async fn test_command_basics(cx: &mut TestAppContext) { @@ -2184,7 +2567,8 @@ mod test { assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "oops\n"); assert!(!cx.has_pending_prompt()); - cx.simulate_keystrokes(": w ! enter"); + cx.simulate_keystrokes(": w !"); + cx.simulate_keystrokes("enter"); assert!(!cx.has_pending_prompt()); assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n"); } @@ -2342,7 +2726,7 @@ mod test { } #[gpui::test] - async fn test_w_command(cx: &mut TestAppContext) { + async fn test_command_write_filename(cx: &mut TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.workspace(|workspace, _, cx| { @@ -2374,6 +2758,48 @@ mod test { }); } + #[gpui::test] + async fn test_command_write_range(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.workspace(|workspace, _, cx| { + assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx); + }); + + cx.set_state( + indoc! {" + The quick + brown« fox + jumpsˇ» over + the lazy dog + "}, + Mode::Visual, + ); + + cx.simulate_keystrokes(": w space dir/other.rs"); + cx.simulate_keystrokes("enter"); + + let other = path!("/root/dir/other.rs"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path(PathBuf::from(other), OpenOptions::default(), window, cx) + }) + .await; + + cx.workspace(|workspace, _, cx| { + assert_active_item( + workspace, + other, + indoc! {" + brown fox + jumps over + "}, + cx, + ); + }); + } + #[gpui::test] async fn test_command_matching_lines(cx: &mut TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; @@ -2674,4 +3100,112 @@ mod test { ); }); } + + #[gpui::test] + async fn test_sort_commands(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + «hornet + quirrel + elderbug + cornifer + idaˇ» + "}, + Mode::Visual, + ); + + cx.simulate_keystrokes(": sort"); + cx.simulate_keystrokes("enter"); + + cx.assert_state( + indoc! {" + ˇcornifer + elderbug + hornet + ida + quirrel + "}, + Mode::Normal, + ); + + // Assert that, by default, `:sort` takes case into consideration. + cx.set_state( + indoc! {" + «hornet + quirrel + Elderbug + cornifer + idaˇ» + "}, + Mode::Visual, + ); + + cx.simulate_keystrokes(": sort"); + cx.simulate_keystrokes("enter"); + + cx.assert_state( + indoc! {" + ˇElderbug + cornifer + hornet + ida + quirrel + "}, + Mode::Normal, + ); + + // Assert that, if the `i` option is passed, `:sort` ignores case. + cx.set_state( + indoc! {" + «hornet + quirrel + Elderbug + cornifer + idaˇ» + "}, + Mode::Visual, + ); + + cx.simulate_keystrokes(": sort space i"); + cx.simulate_keystrokes("enter"); + + cx.assert_state( + indoc! {" + ˇcornifer + Elderbug + hornet + ida + quirrel + "}, + Mode::Normal, + ); + + // When no range is provided, sorts the whole buffer. + cx.set_state( + indoc! {" + ˇhornet + quirrel + elderbug + cornifer + ida + "}, + Mode::Normal, + ); + + cx.simulate_keystrokes(": sort"); + cx.simulate_keystrokes("enter"); + + cx.assert_state( + indoc! {" + ˇcornifer + elderbug + hornet + ida + quirrel + "}, + Mode::Normal, + ); + } } diff --git a/crates/vim/src/digraph.rs b/crates/vim/src/digraph.rs index 7a2ae08cac..39014fea5b 100644 --- a/crates/vim/src/digraph.rs +++ b/crates/vim/src/digraph.rs @@ -63,18 +63,22 @@ impl Vim { } fn literal(&mut self, action: &Literal, window: &mut Window, cx: &mut Context) { - if let Some(Operator::Literal { prefix }) = self.active_operator() - && let Some(prefix) = prefix - { - if let Some(keystroke) = Keystroke::parse(&action.0).ok() { - window.defer(cx, |window, cx| { - window.dispatch_keystroke(keystroke, cx); - }); + match self.active_operator() { + Some(Operator::Literal { + prefix: Some(prefix), + }) => { + if let Some(keystroke) = Keystroke::parse(&action.0).ok() { + window.defer(cx, |window, cx| { + window.dispatch_keystroke(keystroke, cx); + }); + } + return self.handle_literal_input(prefix, "", window, cx); } - return self.handle_literal_input(prefix, "", window, cx); + Some(_) => self.insert_literal(Some(action.1), "", window, cx), + None => log::error!( + "Literal called when no operator was on the stack. This likely means there is an invalid keymap config" + ), } - - self.insert_literal(Some(action.1), "", window, cx); } pub fn handle_literal_keystroke( diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index d2c270e523..f902a8ff6e 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1,12 +1,13 @@ mod boundary; +mod duplicate; mod object; mod paste; mod select; use editor::display_map::DisplaySnapshot; use editor::{ - DisplayPoint, Editor, EditorSettings, HideMouseCursorOrigin, SelectionEffects, ToOffset, - ToPoint, movement, + DisplayPoint, Editor, EditorSettings, HideMouseCursorOrigin, MultiBufferOffset, + SelectionEffects, ToOffset, ToPoint, movement, }; use gpui::actions; use gpui::{Context, Window}; @@ -14,10 +15,10 @@ use language::{CharClassifier, CharKind, Point}; use search::{BufferSearchBar, SearchOptions}; use settings::Settings; use text::{Bias, SelectionGoal}; -use workspace::searchable; use workspace::searchable::FilteredSearchRange; +use workspace::searchable::{self, Direction}; -use crate::motion; +use crate::motion::{self, MotionKind}; use crate::state::SearchState; use crate::{ Vim, @@ -40,6 +41,21 @@ actions!( HelixSelectLine, /// Select all matches of a given pattern within the current selection. HelixSelectRegex, + /// Removes all but the one selection that was created last. + /// `Newest` can eventually be `Primary`. + HelixKeepNewestSelection, + /// Copies all selections below. + HelixDuplicateBelow, + /// Copies all selections above. + HelixDuplicateAbove, + /// Delete the selection and enter edit mode. + HelixSubstitute, + /// Delete the selection and enter edit mode, without yanking the selection. + HelixSubstituteNoYank, + /// Delete the selection and enter edit mode. + HelixSelectNext, + /// Delete the selection and enter edit mode, without yanking the selection. + HelixSelectPrevious, ] ); @@ -51,6 +67,19 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_goto_last_modification); Vim::action(editor, cx, Vim::helix_paste); Vim::action(editor, cx, Vim::helix_select_regex); + Vim::action(editor, cx, Vim::helix_keep_newest_selection); + Vim::action(editor, cx, |vim, _: &HelixDuplicateBelow, window, cx| { + let times = Vim::take_count(cx); + vim.helix_duplicate_selections_below(times, window, cx); + }); + Vim::action(editor, cx, |vim, _: &HelixDuplicateAbove, window, cx| { + let times = Vim::take_count(cx); + vim.helix_duplicate_selections_above(times, window, cx); + }); + Vim::action(editor, cx, Vim::helix_substitute); + Vim::action(editor, cx, Vim::helix_substitute_no_yank); + Vim::action(editor, cx, Vim::helix_select_next); + Vim::action(editor, cx, Vim::helix_select_previous); } impl Vim { @@ -74,20 +103,82 @@ impl Vim { self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.change_selections(Default::default(), window, cx, |s| { - s.move_with(|map, selection| { - let current_head = selection.head(); + if let Motion::ZedSearchResult { new_selections, .. } = &motion { + s.select_anchor_ranges(new_selections.clone()); + return; + }; - let Some((new_head, goal)) = motion.move_point( - map, - current_head, - selection.goal, - times, - &text_layout_details, - ) else { - return; + s.move_with(|map, selection| { + let was_reversed = selection.reversed; + let mut current_head = selection.head(); + + // our motions assume the current character is after the cursor, + // but in (forward) visual mode the current character is just + // before the end of the selection. + + // If the file ends with a newline (which is common) we don't do this. + // so that if you go to the end of such a file you can use "up" to go + // to the previous line and have it work somewhat as expected. + if !selection.reversed + && !selection.is_empty() + && !(selection.end.column() == 0 && selection.end == map.max_point()) + { + current_head = movement::left(map, selection.end) + } + + let (new_head, goal) = match motion { + // Going to next word start is special cased + // since Vim differs from Helix in that motion + // Vim: `w` goes to the first character of a word + // Helix: `w` goes to the character before a word + Motion::NextWordStart { ignore_punctuation } => { + let mut head = movement::right(map, current_head); + let classifier = + map.buffer_snapshot().char_classifier_at(head.to_point(map)); + for _ in 0..times.unwrap_or(1) { + let (_, new_head) = + movement::find_boundary_trail(map, head, |left, right| { + Self::is_boundary_right(ignore_punctuation)( + left, + right, + &classifier, + ) + }); + head = new_head; + } + head = movement::left(map, head); + (head, SelectionGoal::None) + } + _ => motion + .move_point( + map, + current_head, + selection.goal, + times, + &text_layout_details, + ) + .unwrap_or((current_head, selection.goal)), }; selection.set_head(new_head, goal); + + // ensure the current character is included in the selection. + if !selection.reversed { + let next_point = movement::right(map, selection.end); + + if !(next_point.column() == 0 && next_point == map.max_point()) { + selection.end = next_point; + } + } + + // vim always ensures the anchor character stays selected. + // if our selection has reversed, we need to move the opposite end + // to ensure the anchor is still selected. + if was_reversed && !selection.reversed { + selection.start = movement::left(map, selection.start); + } else if !was_reversed && selection.reversed { + selection.end = movement::right(map, selection.end); + } }) }); }); @@ -221,6 +312,30 @@ impl Vim { }); } + fn is_boundary_right( + ignore_punctuation: bool, + ) -> impl FnMut(char, char, &CharClassifier) -> bool { + move |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = (left == '\n') ^ (right == '\n'); + + (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline + } + } + + fn is_boundary_left( + ignore_punctuation: bool, + ) -> impl FnMut(char, char, &CharClassifier) -> bool { + move |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = (left == '\n') ^ (right == '\n'); + + (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline + } + } + pub fn helix_move_cursor( &mut self, motion: Motion, @@ -229,41 +344,54 @@ impl Vim { cx: &mut Context, ) { match motion { - Motion::NextWordStart { ignore_punctuation } => { - self.helix_find_range_forward(times, window, cx, |left, right, classifier| { - let left_kind = classifier.kind_with(left, ignore_punctuation); - let right_kind = classifier.kind_with(right, ignore_punctuation); - let at_newline = (left == '\n') ^ (right == '\n'); + Motion::NextWordStart { ignore_punctuation } => self.helix_find_range_forward( + times, + window, + cx, + Self::is_boundary_right(ignore_punctuation), + ), + Motion::NextWordEnd { ignore_punctuation } => self.helix_find_range_forward( + times, + window, + cx, + Self::is_boundary_left(ignore_punctuation), + ), + Motion::PreviousWordStart { ignore_punctuation } => self.helix_find_range_backward( + times, + window, + cx, + Self::is_boundary_left(ignore_punctuation), + ), + Motion::PreviousWordEnd { ignore_punctuation } => self.helix_find_range_backward( + times, + window, + cx, + Self::is_boundary_right(ignore_punctuation), + ), + Motion::EndOfLine { .. } => { + // In Helix mode, EndOfLine should position cursor ON the last character, + // not after it. We therefore need special handling for it. + self.update_editor(cx, |_, editor, cx| { + let text_layout_details = editor.text_layout_details(window); + editor.change_selections(Default::default(), window, cx, |s| { + s.move_with(|map, selection| { + let goal = selection.goal; + let cursor = if selection.is_empty() || selection.reversed { + selection.head() + } else { + movement::left(map, selection.head()) + }; - (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline - }) - } - Motion::NextWordEnd { ignore_punctuation } => { - self.helix_find_range_forward(times, window, cx, |left, right, classifier| { - let left_kind = classifier.kind_with(left, ignore_punctuation); - let right_kind = classifier.kind_with(right, ignore_punctuation); - let at_newline = (left == '\n') ^ (right == '\n'); + let (point, _goal) = motion + .move_point(map, cursor, goal, times, &text_layout_details) + .unwrap_or((cursor, goal)); - (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline - }) - } - Motion::PreviousWordStart { ignore_punctuation } => { - self.helix_find_range_backward(times, window, cx, |left, right, classifier| { - let left_kind = classifier.kind_with(left, ignore_punctuation); - let right_kind = classifier.kind_with(right, ignore_punctuation); - let at_newline = (left == '\n') ^ (right == '\n'); - - (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline - }) - } - Motion::PreviousWordEnd { ignore_punctuation } => { - self.helix_find_range_backward(times, window, cx, |left, right, classifier| { - let left_kind = classifier.kind_with(left, ignore_punctuation); - let right_kind = classifier.kind_with(right, ignore_punctuation); - let at_newline = (left == '\n') ^ (right == '\n'); - - (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline - }) + // Move left by one character to position on the last character + let adjusted_point = movement::saturating_left(map, point); + selection.collapse_to(adjusted_point, SelectionGoal::None) + }) + }); + }); } Motion::FindForward { before, @@ -322,7 +450,7 @@ impl Vim { self.update_editor(cx, |vim, editor, cx| { let has_selection = editor .selections - .all_adjusted(cx) + .all_adjusted(&editor.display_snapshot(cx)) .iter() .any(|selection| !selection.is_empty()); @@ -455,19 +583,20 @@ impl Vim { pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { - let (map, selections) = editor.selections.all_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_display(&display_map); // Store selection info for positioning after edit let selection_info: Vec<_> = selections .iter() .map(|selection| { let range = selection.range(); - let start_offset = range.start.to_offset(&map, Bias::Left); - let end_offset = range.end.to_offset(&map, Bias::Left); + let start_offset = range.start.to_offset(&display_map, Bias::Left); + let end_offset = range.end.to_offset(&display_map, Bias::Left); let was_empty = range.is_empty(); let was_reversed = selection.reversed; ( - map.buffer_snapshot().anchor_before(start_offset), + display_map.buffer_snapshot().anchor_before(start_offset), end_offset - start_offset, was_empty, was_reversed, @@ -481,14 +610,14 @@ impl Vim { // For empty selections, extend to replace one character if range.is_empty() { - range.end = movement::saturating_right(&map, range.start); + range.end = movement::saturating_right(&display_map, range.start); } - let byte_range = range.start.to_offset(&map, Bias::Left) - ..range.end.to_offset(&map, Bias::Left); + let byte_range = range.start.to_offset(&display_map, Bias::Left) + ..range.end.to_offset(&display_map, Bias::Left); if !byte_range.is_empty() { - let replacement_text = text.repeat(byte_range.len()); + let replacement_text = text.repeat(byte_range.end - byte_range.start); edits.push((byte_range, replacement_text)); } } @@ -545,7 +674,7 @@ impl Vim { self.update_editor(cx, |_, editor, cx| { editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); - let mut selections = editor.selections.all::(cx); + let mut selections = editor.selections.all::(&display_map); let max_point = display_map.buffer_snapshot().max_point(); let buffer_snapshot = &display_map.buffer_snapshot(); @@ -575,6 +704,133 @@ impl Vim { }); }); } + + fn helix_keep_newest_selection( + &mut self, + _: &HelixKeepNewestSelection, + window: &mut Window, + cx: &mut Context, + ) { + self.update_editor(cx, |_, editor, cx| { + let newest = editor + .selections + .newest::(&editor.display_snapshot(cx)); + editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest])); + }); + } + + fn do_helix_substitute(&mut self, yank: bool, window: &mut Window, cx: &mut Context) { + self.update_editor(cx, |vim, editor, cx| { + editor.set_clip_at_line_ends(false, cx); + editor.transact(window, cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.move_with(|map, selection| { + if selection.start == selection.end { + selection.end = movement::right(map, selection.end); + } + + // If the selection starts and ends on a newline, we exclude the last one. + if !selection.is_empty() + && selection.start.column() == 0 + && selection.end.column() == 0 + { + selection.end = movement::left(map, selection.end); + } + }) + }); + if yank { + vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx); + } + let selections = editor + .selections + .all::(&editor.display_snapshot(cx)) + .into_iter(); + let edits = selections.map(|selection| (selection.start..selection.end, "")); + editor.edit(edits, cx); + }); + }); + self.switch_mode(Mode::Insert, true, window, cx); + } + + fn helix_substitute( + &mut self, + _: &HelixSubstitute, + window: &mut Window, + cx: &mut Context, + ) { + self.do_helix_substitute(true, window, cx); + } + + fn helix_substitute_no_yank( + &mut self, + _: &HelixSubstituteNoYank, + window: &mut Window, + cx: &mut Context, + ) { + self.do_helix_substitute(false, window, cx); + } + + fn helix_select_next( + &mut self, + _: &HelixSelectNext, + window: &mut Window, + cx: &mut Context, + ) { + self.do_helix_select(Direction::Next, window, cx); + } + + fn helix_select_previous( + &mut self, + _: &HelixSelectPrevious, + window: &mut Window, + cx: &mut Context, + ) { + self.do_helix_select(Direction::Prev, window, cx); + } + + fn do_helix_select( + &mut self, + direction: searchable::Direction, + window: &mut Window, + cx: &mut Context, + ) { + let Some(pane) = self.pane(window, cx) else { + return; + }; + let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); + let prior_selections = self.editor_selections(window, cx); + + let success = pane.update(cx, |pane, cx| { + let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() else { + return false; + }; + search_bar.update(cx, |search_bar, cx| { + if !search_bar.has_active_match() || !search_bar.show(window, cx) { + return false; + } + search_bar.select_match(direction, count, window, cx); + true + }) + }); + + if !success { + return; + } + if self.mode == Mode::HelixSelect { + self.update_editor(cx, |_vim, editor, cx| { + let snapshot = editor.snapshot(window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_anchor_ranges( + prior_selections + .iter() + .cloned() + .chain(s.all_anchors(&snapshot).iter().map(|s| s.range())), + ); + }) + }); + } + } } #[cfg(test)] @@ -1133,11 +1389,12 @@ mod test { Mode::HelixNormal, ); cx.simulate_keystrokes("x"); + // Adjacent line selections stay separate (not merged) cx.assert_state( indoc! {" «line one line two - line three + ˇ»«line three line four ˇ»line five"}, Mode::HelixNormal, @@ -1189,6 +1446,48 @@ mod test { cx.assert_state("«one ˇ»two", Mode::HelixSelect); } + #[gpui::test] + async fn test_exit_visual_mode(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇone two", Mode::Normal); + cx.simulate_keystrokes("v w"); + cx.assert_state("«one tˇ»wo", Mode::Visual); + cx.simulate_keystrokes("escape"); + cx.assert_state("one ˇtwo", Mode::Normal); + + cx.enable_helix(); + cx.set_state("ˇone two", Mode::HelixNormal); + cx.simulate_keystrokes("v w"); + cx.assert_state("«one ˇ»two", Mode::HelixSelect); + cx.simulate_keystrokes("escape"); + cx.assert_state("«one ˇ»two", Mode::HelixNormal); + } + + #[gpui::test] + async fn test_helix_select_motion(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state("«ˇ»one two three", Mode::HelixSelect); + cx.simulate_keystrokes("w"); + cx.assert_state("«one ˇ»two three", Mode::HelixSelect); + + cx.set_state("«ˇ»one two three", Mode::HelixSelect); + cx.simulate_keystrokes("e"); + cx.assert_state("«oneˇ» two three", Mode::HelixSelect); + } + + #[gpui::test] + async fn test_helix_full_cursor_selection(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state("ˇone two three", Mode::HelixNormal); + cx.simulate_keystrokes("l l v h h h"); + cx.assert_state("«ˇone» two three", Mode::HelixSelect); + } + #[gpui::test] async fn test_helix_select_regex(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; @@ -1208,8 +1507,164 @@ mod test { cx.simulate_keystrokes("enter"); cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal); - cx.set_state("ˇone two one", Mode::HelixNormal); - cx.simulate_keystrokes("s o n e enter"); - cx.assert_state("ˇone two one", Mode::HelixNormal); + // TODO: change "search_in_selection" to not perform any search when in helix select mode with no selection + // cx.set_state("ˇstuff one two one", Mode::HelixNormal); + // cx.simulate_keystrokes("s o n e enter"); + // cx.assert_state("ˇstuff one two one", Mode::HelixNormal); + } + + #[gpui::test] + async fn test_helix_select_next_match(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇhello two one two one two one", Mode::Visual); + cx.simulate_keystrokes("/ o n e"); + cx.simulate_keystrokes("enter"); + cx.simulate_keystrokes("n n"); + cx.assert_state("«hello two one two one two oˇ»ne", Mode::Visual); + + cx.set_state("ˇhello two one two one two one", Mode::Normal); + cx.simulate_keystrokes("/ o n e"); + cx.simulate_keystrokes("enter"); + cx.simulate_keystrokes("n n"); + cx.assert_state("hello two one two one two ˇone", Mode::Normal); + + cx.set_state("ˇhello two one two one two one", Mode::Normal); + cx.simulate_keystrokes("/ o n e"); + cx.simulate_keystrokes("enter"); + cx.simulate_keystrokes("n g n g n"); + cx.assert_state("hello two one two «one two oneˇ»", Mode::Visual); + + cx.enable_helix(); + + cx.set_state("ˇhello two one two one two one", Mode::HelixNormal); + cx.simulate_keystrokes("/ o n e"); + cx.simulate_keystrokes("enter"); + cx.simulate_keystrokes("n n"); + cx.assert_state("hello two one two one two «oneˇ»", Mode::HelixNormal); + + cx.set_state("ˇhello two one two one two one", Mode::HelixSelect); + cx.simulate_keystrokes("/ o n e"); + cx.simulate_keystrokes("enter"); + cx.simulate_keystrokes("n n"); + cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect); + } + + #[gpui::test] + async fn test_helix_substitute(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇone two", Mode::HelixNormal); + cx.simulate_keystrokes("c"); + cx.assert_state("ˇne two", Mode::Insert); + + cx.set_state("«oneˇ» two", Mode::HelixNormal); + cx.simulate_keystrokes("c"); + cx.assert_state("ˇ two", Mode::Insert); + + cx.set_state( + indoc! {" + oneˇ two + three + "}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x c"); + cx.assert_state( + indoc! {" + ˇ + three + "}, + Mode::Insert, + ); + + cx.set_state( + indoc! {" + one twoˇ + three + "}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("c"); + cx.assert_state( + indoc! {" + one twoˇthree + "}, + Mode::Insert, + ); + + // Helix doesn't set the cursor to the first non-blank one when + // replacing lines: it uses language-dependent indent queries instead. + cx.set_state( + indoc! {" + one two + « indented + three not indentedˇ» + "}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("c"); + cx.set_state( + indoc! {" + one two + ˇ + "}, + Mode::Insert, + ); + } + + #[gpui::test] + async fn test_g_l_end_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Test g l moves to last character, not after it + cx.set_state("hello ˇworld!", Mode::HelixNormal); + cx.simulate_keystrokes("g l"); + cx.assert_state("hello worldˇ!", Mode::HelixNormal); + + // Test with Chinese characters, test if work with UTF-8? + cx.set_state("ˇ你好世界", Mode::HelixNormal); + cx.simulate_keystrokes("g l"); + cx.assert_state("你好世ˇ界", Mode::HelixNormal); + + // Test with end of line + cx.set_state("endˇ", Mode::HelixNormal); + cx.simulate_keystrokes("g l"); + cx.assert_state("enˇd", Mode::HelixNormal); + + // Test with empty line + cx.set_state( + indoc! {" + hello + ˇ + world"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("g l"); + cx.assert_state( + indoc! {" + hello + ˇ + world"}, + Mode::HelixNormal, + ); + + // Test with multiple lines + cx.set_state( + indoc! {" + ˇfirst line + second line + third line"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("g l"); + cx.assert_state( + indoc! {" + first linˇe + second line + third line"}, + Mode::HelixNormal, + ); } } diff --git a/crates/vim/src/helix/boundary.rs b/crates/vim/src/helix/boundary.rs index a6de926bc5..0c2ebbeef0 100644 --- a/crates/vim/src/helix/boundary.rs +++ b/crates/vim/src/helix/boundary.rs @@ -1,10 +1,7 @@ -use std::{ - cmp::Ordering, - ops::{Deref, DerefMut, Range}, -}; +use std::{cmp::Ordering, ops::Range}; use editor::{ - DisplayPoint, + DisplayPoint, MultiBufferOffset, display_map::{DisplaySnapshot, ToDisplayPoint}, movement, }; @@ -104,8 +101,8 @@ trait BoundedObject { let next_end = self.next_end(map, end_search_start, outer)?; let maybe_next_start = self.next_start(map, start_search_start, outer); if let Some(next_start) = maybe_next_start - && (*next_start < *next_end - || *next_start == *next_end && self.can_be_zero_width(outer)) + && (next_start.0 < next_end.0 + || next_start.0 == next_end.0 && self.can_be_zero_width(outer)) && !self.ambiguous_outer() { let closing = self.close_at_end(next_start, map, outer)?; @@ -133,8 +130,8 @@ trait BoundedObject { let previous_start = self.previous_start(map, start_search_end, outer)?; let maybe_previous_end = self.previous_end(map, end_search_end, outer); if let Some(previous_end) = maybe_previous_end - && (*previous_end > *previous_start - || *previous_end == *previous_start && self.can_be_zero_width(outer)) + && (previous_end.0 > previous_start.0 + || previous_end.0 == previous_start.0 && self.can_be_zero_width(outer)) && !self.ambiguous_outer() { let closing = self.close_at_start(previous_end, map, outer)?; @@ -151,30 +148,22 @@ trait BoundedObject { } } -#[derive(Clone, Copy, PartialEq, Debug)] -struct Offset(usize); -impl Deref for Offset { - type Target = usize; - fn deref(&self) -> &Self::Target { - &self.0 - } -} -impl DerefMut for Offset { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} +#[derive(Clone, Copy, PartialEq, Debug, PartialOrd, Ord, Eq)] +struct Offset(MultiBufferOffset); impl Offset { fn next(self, map: &DisplaySnapshot) -> Option { - let next = Self(map.buffer_snapshot().clip_offset(*self + 1, Bias::Right)); - (*next > *self).then(|| next) + let next = Self( + map.buffer_snapshot() + .clip_offset(self.0 + 1usize, Bias::Right), + ); + (next.0 > self.0).then(|| next) } fn previous(self, map: &DisplaySnapshot) -> Option { - if *self == 0 { + if self.0 == MultiBufferOffset(0) { return None; } Some(Self( - map.buffer_snapshot().clip_offset(*self - 1, Bias::Left), + map.buffer_snapshot().clip_offset(self.0 - 1, Bias::Left), )) } fn range( @@ -211,7 +200,7 @@ impl HelixTextObject for B { let max_end = self.close_at_end(search_start, map, find_outer)?; let min_start = self.close_at_start(max_end, map, find_outer)?; - (*min_start <= *relative_to.start).then(|| min_start..max_end) + (min_start <= relative_to.start).then(|| min_start..max_end) }) } @@ -279,8 +268,8 @@ fn relative_range( min_start..max_end }; - let start = wanted_range.start.clone().to_display_point(map); - let end = wanted_range.end.clone().to_display_point(map); + let start = wanted_range.start.0.to_display_point(map); + let end = wanted_range.end.0.to_display_point(map); Some(start..end) } @@ -390,7 +379,7 @@ impl ImmediateBoundary { impl BoundedObject for ImmediateBoundary { fn next_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option { try_find_boundary(map, from, |left, right| { - let classifier = map.buffer_snapshot().char_classifier_at(*from); + let classifier = map.buffer_snapshot().char_classifier_at(from.0); if outer { self.is_outer_start(left, right, classifier) } else { @@ -400,7 +389,7 @@ impl BoundedObject for ImmediateBoundary { } fn next_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option { try_find_boundary(map, from, |left, right| { - let classifier = map.buffer_snapshot().char_classifier_at(*from); + let classifier = map.buffer_snapshot().char_classifier_at(from.0); if outer { self.is_outer_end(left, right, classifier) } else { @@ -410,7 +399,7 @@ impl BoundedObject for ImmediateBoundary { } fn previous_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option { try_find_preceding_boundary(map, from, |left, right| { - let classifier = map.buffer_snapshot().char_classifier_at(*from); + let classifier = map.buffer_snapshot().char_classifier_at(from.0); if outer { self.is_outer_start(left, right, classifier) } else { @@ -420,7 +409,7 @@ impl BoundedObject for ImmediateBoundary { } fn previous_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option { try_find_preceding_boundary(map, from, |left, right| { - let classifier = map.buffer_snapshot().char_classifier_at(*from); + let classifier = map.buffer_snapshot().char_classifier_at(from.0); if outer { self.is_outer_end(left, right, classifier) } else { @@ -572,7 +561,7 @@ impl FuzzyBoundary { boundary_kind: Boundary, ) -> Option { let generate_boundary_data = |left, right, point: Offset| { - let classifier = map.buffer_snapshot().char_classifier_at(*from); + let classifier = map.buffer_snapshot().char_classifier_at(from.0); let reach_boundary = if outer && boundary_kind == Boundary::Start { self.is_near_potential_outer_start(left, right, &classifier) } else if !outer && boundary_kind == Boundary::Start { @@ -598,9 +587,9 @@ impl FuzzyBoundary { Ordering::Greater => !backward, }); if backward { - boundaries.max_by_key(|boundary| **boundary) + boundaries.max_by_key(|boundary| *boundary) } else { - boundaries.min_by_key(|boundary| **boundary) + boundaries.min_by_key(|boundary| *boundary) } } } @@ -662,15 +651,15 @@ fn try_find_boundary_data( ) -> Option { let mut prev_ch = map .buffer_snapshot() - .reversed_chars_at(*from) + .reversed_chars_at(from.0) .next() .unwrap_or('\0'); - for ch in map.buffer_snapshot().chars_at(*from).chain(['\0']) { + for ch in map.buffer_snapshot().chars_at(from.0).chain(['\0']) { if let Some(boundary_information) = boundary_information(prev_ch, ch, from) { return Some(boundary_information); } - *from += ch.len_utf8(); + from.0 += ch.len_utf8(); prev_ch = ch; } @@ -702,13 +691,21 @@ fn try_find_preceding_boundary_data( mut from: Offset, is_boundary: impl Fn(char, char, Offset) -> Option, ) -> Option { - let mut prev_ch = map.buffer_snapshot().chars_at(*from).next().unwrap_or('\0'); + let mut prev_ch = map + .buffer_snapshot() + .chars_at(from.0) + .next() + .unwrap_or('\0'); - for ch in map.buffer_snapshot().reversed_chars_at(*from).chain(['\0']) { + for ch in map + .buffer_snapshot() + .reversed_chars_at(from.0) + .chain(['\0']) + { if let Some(boundary_information) = is_boundary(ch, prev_ch, from) { return Some(boundary_information); } - from.0 = from.0.saturating_sub(ch.len_utf8()); + from.0.0 = from.0.0.saturating_sub(ch.len_utf8()); prev_ch = ch; } diff --git a/crates/vim/src/helix/duplicate.rs b/crates/vim/src/helix/duplicate.rs new file mode 100644 index 0000000000..37796c57aa --- /dev/null +++ b/crates/vim/src/helix/duplicate.rs @@ -0,0 +1,234 @@ +use std::ops::Range; + +use editor::{DisplayPoint, MultiBufferOffset, display_map::DisplaySnapshot}; +use gpui::Context; +use text::Bias; +use ui::Window; + +use crate::Vim; + +impl Vim { + /// Creates a duplicate of every selection below it in the first place that has both its start + /// and end + pub(super) fn helix_duplicate_selections_below( + &mut self, + times: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.duplicate_selections( + times, + window, + cx, + |prev_point| *prev_point.row_mut() += 1, + |prev_range, map| prev_range.end.row() >= map.max_point().row(), + false, + ); + } + + /// Creates a duplicate of every selection above it in the first place that has both its start + /// and end + pub(super) fn helix_duplicate_selections_above( + &mut self, + times: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.duplicate_selections( + times, + window, + cx, + |prev_point| *prev_point.row_mut() = prev_point.row().0.saturating_sub(1), + |prev_range, _| prev_range.start.row() == DisplayPoint::zero().row(), + true, + ); + } + + fn duplicate_selections( + &mut self, + times: Option, + window: &mut Window, + cx: &mut Context, + advance_search: impl Fn(&mut DisplayPoint), + end_search: impl Fn(&Range, &DisplaySnapshot) -> bool, + above: bool, + ) { + let times = times.unwrap_or(1); + self.update_editor(cx, |_, editor, cx| { + let mut selections = Vec::new(); + let map = editor.display_snapshot(cx); + let mut original_selections = editor.selections.all_display(&map); + // The order matters, because it is recorded when the selections are added. + if above { + original_selections.reverse(); + } + + for origin in original_selections { + let origin = origin.tail()..origin.head(); + selections.push(display_point_range_to_offset_range(&origin, &map)); + let mut last_origin = origin; + for _ in 1..=times { + if let Some(duplicate) = find_next_valid_duplicate_space( + last_origin.clone(), + &map, + &advance_search, + &end_search, + ) { + selections.push(display_point_range_to_offset_range(&duplicate, &map)); + last_origin = duplicate; + } else { + break; + } + } + } + + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(selections); + }); + }); + } +} + +fn find_next_valid_duplicate_space( + mut origin: Range, + map: &DisplaySnapshot, + advance_search: &impl Fn(&mut DisplayPoint), + end_search: &impl Fn(&Range, &DisplaySnapshot) -> bool, +) -> Option> { + while !end_search(&origin, map) { + advance_search(&mut origin.start); + advance_search(&mut origin.end); + + if map.clip_point(origin.start, Bias::Left) == origin.start + && map.clip_point(origin.end, Bias::Right) == origin.end + { + return Some(origin); + } + } + None +} + +fn display_point_range_to_offset_range( + range: &Range, + map: &DisplaySnapshot, +) -> Range { + range.start.to_offset(map, Bias::Left)..range.end.to_offset(map, Bias::Right) +} + +#[cfg(test)] +mod tests { + use db::indoc; + + use crate::{state::Mode, test::VimTestContext}; + + #[gpui::test] + async fn test_selection_duplication(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state( + indoc! {" + The quick brown + fox «jumpsˇ» + over the + lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("C"); + + cx.assert_state( + indoc! {" + The quick brown + fox «jumpsˇ» + over the + lazy« dog.ˇ»"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("C"); + + cx.assert_state( + indoc! {" + The quick brown + fox «jumpsˇ» + over the + lazy« dog.ˇ»"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("alt-C"); + + cx.assert_state( + indoc! {" + The «quickˇ» brown + fox «jumpsˇ» + over the + lazy« dog.ˇ»"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes(","); + + cx.assert_state( + indoc! {" + The «quickˇ» brown + fox jumps + over the + lazy dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_selection_duplication_backwards(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state( + indoc! {" + The quick brown + «ˇfox» jumps + over the + lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("C C alt-C"); + + cx.assert_state( + indoc! {" + «ˇThe» quick brown + «ˇfox» jumps + «ˇove»r the + «ˇlaz»y dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_selection_duplication_count(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state( + indoc! {" + The «qˇ»uick brown + fox jumps + over the + lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("9 C"); + + cx.assert_state( + indoc! {" + The «qˇ»uick brown + fox «jˇ»umps + over« ˇ»the + lazy« ˇ»dog."}, + Mode::HelixNormal, + ); + } +} diff --git a/crates/vim/src/helix/paste.rs b/crates/vim/src/helix/paste.rs index 9b6b6e454a..d91b138853 100644 --- a/crates/vim/src/helix/paste.rs +++ b/crates/vim/src/helix/paste.rs @@ -44,7 +44,8 @@ impl Vim { return; }; - let (display_map, current_selections) = editor.selections.all_adjusted_display(cx); + let display_map = editor.display_snapshot(cx); + let current_selections = editor.selections.all_adjusted_display(&display_map); // The clipboard can have multiple selections, and there can // be multiple selections. Helix zips them together, so the first @@ -119,12 +120,12 @@ impl Vim { editor.edit(edits, cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); editor.change_selections(Default::default(), window, cx, |s| { - let snapshot = s.buffer().clone(); s.select_ranges(new_selections.into_iter().map(|(anchor, len)| { let offset = anchor.to_offset(&snapshot); if action.before { - offset.saturating_sub(len)..offset + offset.saturating_sub_usize(len)..offset } else { offset..(offset + len) } diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 5b9fef402a..d5323f31dc 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -50,17 +50,23 @@ impl Vim { if count <= 1 || Vim::globals(cx).dot_replaying { self.create_mark("^".into(), window, cx); + if HelixModeSetting::get_global(cx).0 { + self.update_editor(cx, |_, editor, cx| { + editor.dismiss_menus_and_popups(false, window, cx); + }); + self.switch_mode(Mode::HelixNormal, false, window, cx); + return; + } + self.update_editor(cx, |_, editor, cx| { editor.dismiss_menus_and_popups(false, window, cx); - if !HelixModeSetting::get_global(cx).0 { - editor.change_selections(Default::default(), window, cx, |s| { - s.move_cursors_with(|map, mut cursor, _| { - *cursor.column_mut() = cursor.column().saturating_sub(1); - (map.clip_point(cursor, Bias::Left), SelectionGoal::None) - }); + editor.change_selections(Default::default(), window, cx, |s| { + s.move_cursors_with(|map, mut cursor, _| { + *cursor.column_mut() = cursor.column().saturating_sub(1); + (map.clip_point(cursor, Bias::Left), SelectionGoal::None) }); - } + }); }); self.switch_mode(Mode::Normal, false, window, cx); @@ -84,7 +90,7 @@ impl Vim { self.update_editor(cx, |_, editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let mut edits = Vec::new(); - for selection in editor.selections.all::(cx) { + for selection in editor.selections.all::(&editor.display_snapshot(cx)) { let point = selection.head(); let new_row = match direction { Direction::Next => point.row + 1, diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index da25919342..42d4915fc5 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -1,4 +1,4 @@ -use gpui::{Context, Element, Entity, Render, Subscription, WeakEntity, Window, div}; +use gpui::{Context, Element, Entity, FontWeight, Render, Subscription, WeakEntity, Window, div}; use ui::text_for_keystrokes; use workspace::{StatusItemView, item::ItemHandle, ui::prelude::*}; @@ -89,17 +89,37 @@ impl Render for ModeIndicator { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let vim = self.vim(); let Some(vim) = vim else { - return div().into_any(); + return div().hidden().into_any_element(); }; let vim_readable = vim.read(cx); - let label = if let Some(label) = vim_readable.status_label.clone() { - label + let status_label = vim_readable.status_label.clone(); + let temp_mode = vim_readable.temp_mode; + let mode = vim_readable.mode; + + let theme = cx.theme(); + let colors = theme.colors(); + let system_transparent = gpui::hsla(0.0, 0.0, 0.0, 0.0); + let vim_mode_text = colors.vim_mode_text; + let bg_color = match mode { + crate::state::Mode::Normal => colors.vim_normal_background, + crate::state::Mode::Insert => colors.vim_insert_background, + crate::state::Mode::Replace => colors.vim_replace_background, + crate::state::Mode::Visual => colors.vim_visual_background, + crate::state::Mode::VisualLine => colors.vim_visual_line_background, + crate::state::Mode::VisualBlock => colors.vim_visual_block_background, + crate::state::Mode::HelixNormal => colors.vim_helix_normal_background, + crate::state::Mode::HelixSelect => colors.vim_helix_select_background, + }; + + let (label, mode): (SharedString, Option) = if let Some(label) = status_label + { + (label, None) } else { - let mode = if vim_readable.temp_mode { - format!("(insert) {}", vim_readable.mode) + let mode_str = if temp_mode { + format!("(insert) {}", mode) } else { - vim_readable.mode.to_string() + mode.to_string() }; let current_operators_description = self.current_operators_description(vim.clone(), cx); @@ -107,13 +127,45 @@ impl Render for ModeIndicator { .pending_keys .as_ref() .unwrap_or(¤t_operators_description); - format!("{} -- {} --", pending, mode).into() + let mode = if bg_color != system_transparent { + mode_str.into() + } else { + format!("-- {} --", mode_str).into() + }; + (pending.into(), Some(mode)) }; - - Label::new(label) - .size(LabelSize::Small) - .line_height_style(LineHeightStyle::UiLabel) - .into_any_element() + h_flex() + .gap_1() + .when(!label.is_empty(), |el| { + el.child( + Label::new(label) + .line_height_style(LineHeightStyle::UiLabel) + .weight(FontWeight::MEDIUM), + ) + }) + .when_some(mode, |el, mode| { + el.child( + v_flex() + .when(bg_color != system_transparent, |el| el.px_2()) + // match with other icons at the bottom that use default buttons + .h(ButtonSize::Default.rems()) + .justify_center() + .rounded_sm() + .bg(bg_color) + .child( + Label::new(mode) + .size(LabelSize::Small) + .line_height_style(LineHeightStyle::UiLabel) + .weight(FontWeight::MEDIUM) + .when( + bg_color != system_transparent + && vim_mode_text != system_transparent, + |el| el.color(Color::Custom(vim_mode_text)), + ), + ), + ) + }) + .into_any() } } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index d1d645d385..6ba28a1c23 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1,5 +1,5 @@ use editor::{ - Anchor, Bias, DisplayPoint, Editor, RowExt, ToOffset, ToPoint, + Anchor, Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, RowExt, ToOffset, ToPoint, display_map::{DisplayRow, DisplaySnapshot, FoldPoint, ToDisplayPoint}, movement::{ self, FindRange, TextLayoutDetails, find_boundary, find_preceding_boundary_display_point, @@ -691,7 +691,6 @@ impl Vim { return; } } - Mode::HelixNormal | Mode::HelixSelect => {} } } @@ -1525,29 +1524,6 @@ fn wrapping_right_single(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayP } } -/// Given a point, returns the start of the buffer row that is a given number of -/// buffer rows away from the current position. -/// -/// This moves by buffer rows instead of display rows, a distinction that is -/// important when soft wrapping is enabled. -pub(crate) fn start_of_relative_buffer_row( - map: &DisplaySnapshot, - point: DisplayPoint, - times: isize, -) -> DisplayPoint { - let start = map.display_point_to_fold_point(point, Bias::Left); - let target = start.row() as isize + times; - let new_row = (target.max(0) as u32).min(map.fold_snapshot().max_point().row()); - - map.clip_point( - map.fold_point_to_display_point( - map.fold_snapshot() - .clip_point(FoldPoint::new(new_row, 0), Bias::Right), - ), - Bias::Right, - ) -} - fn up_down_buffer_rows( map: &DisplaySnapshot, mut point: DisplayPoint, @@ -2127,7 +2103,7 @@ pub(crate) fn end_of_line( times: usize, ) -> DisplayPoint { if times > 1 { - point = start_of_relative_buffer_row(map, point, times as isize - 1); + point = map.start_of_relative_buffer_row(point, times as isize - 1); } if display_lines { map.clip_point( @@ -2167,7 +2143,7 @@ pub(crate) fn sentence_backwards( if start_of_next_sentence < start { times = times.saturating_sub(1); } - if times == 0 || offset == 0 { + if times == 0 || offset.0 == 0 { return map.clip_point( start_of_next_sentence .to_offset(&map.buffer_snapshot()) @@ -2231,7 +2207,7 @@ pub(crate) fn sentence_forwards( map.max_point() } -fn next_non_blank(map: &DisplaySnapshot, start: usize) -> usize { +fn next_non_blank(map: &DisplaySnapshot, start: MultiBufferOffset) -> MultiBufferOffset { for (c, o) in map.buffer_chars_at(start) { if c == '\n' || !c.is_whitespace() { return o; @@ -2243,7 +2219,10 @@ fn next_non_blank(map: &DisplaySnapshot, start: usize) -> usize { // given the offset after a ., !, or ? find the start of the next sentence. // if this is not a sentence boundary, returns None. -fn start_of_next_sentence(map: &DisplaySnapshot, end_of_sentence: usize) -> Option { +fn start_of_next_sentence( + map: &DisplaySnapshot, + end_of_sentence: MultiBufferOffset, +) -> Option { let chars = map.buffer_chars_at(end_of_sentence); let mut seen_space = false; @@ -2277,10 +2256,10 @@ fn go_to_line(map: &DisplaySnapshot, display_point: DisplayPoint, line: usize) - .clip_point(Point::new((line - 1) as u32, point.column), Bias::Left), ); let buffer_range = excerpt.buffer_range(); - if offset >= buffer_range.start && offset <= buffer_range.end { + if offset >= buffer_range.start.0 && offset <= buffer_range.end.0 { let point = map .buffer_snapshot() - .offset_to_point(excerpt.map_offset_from_buffer(offset)); + .offset_to_point(excerpt.map_offset_from_buffer(BufferOffset(offset))); return map.clip_point(map.point_to_display_point(point, Bias::Left), Bias::Left); } let mut last_position = None; @@ -2289,17 +2268,13 @@ fn go_to_line(map: &DisplaySnapshot, display_point: DisplayPoint, line: usize) - ..language::ToOffset::to_offset(&range.context.end, buffer); if offset >= excerpt_range.start && offset <= excerpt_range.end { let text_anchor = buffer.anchor_after(offset); - let anchor = Anchor::in_buffer(excerpt, buffer.remote_id(), text_anchor); + let anchor = Anchor::in_buffer(excerpt, text_anchor); return anchor.to_display_point(map); } else if offset <= excerpt_range.start { - let anchor = Anchor::in_buffer(excerpt, buffer.remote_id(), range.context.start); + let anchor = Anchor::in_buffer(excerpt, range.context.start); return anchor.to_display_point(map); } else { - last_position = Some(Anchor::in_buffer( - excerpt, - buffer.remote_id(), - range.context.end, - )); + last_position = Some(Anchor::in_buffer(excerpt, range.context.end)); } } @@ -2384,10 +2359,14 @@ fn matching_tag(map: &DisplaySnapshot, head: DisplayPoint) -> Option DisplayPoint { + if !map.is_singleton() { + return display_point; + } // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1200 let display_point = map.clip_at_line_end(display_point); let point = display_point.to_point(map); let offset = point.to_offset(&map.buffer_snapshot()); + let snapshot = map.buffer_snapshot(); // Ensure the range is contained by the current line. let mut line_end = map.next_line_boundary(point).0; @@ -2395,14 +2374,30 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint line_end = map.max_point().to_point(map); } - if let Some((opening_range, closing_range)) = map - .buffer_snapshot() - .innermost_enclosing_bracket_ranges(offset..offset, None) - { - if opening_range.contains(&offset) { - return closing_range.start.to_display_point(map); - } else if closing_range.contains(&offset) { - return opening_range.start.to_display_point(map); + // Attempt to find the smallest enclosing bracket range that also contains + // the offset, which only happens if the cursor is currently in a bracket. + let range_filter = |_buffer: &language::BufferSnapshot, + opening_range: Range, + closing_range: Range| { + opening_range.contains(&BufferOffset(offset.0)) + || closing_range.contains(&BufferOffset(offset.0)) + }; + + let bracket_ranges = snapshot + .innermost_enclosing_bracket_ranges(offset..offset, Some(&range_filter)) + .or_else(|| snapshot.innermost_enclosing_bracket_ranges(offset..offset, None)); + + if let Some((opening_range, closing_range)) = bracket_ranges { + let mut chars = map.buffer_snapshot().chars_at(offset); + match chars.next() { + Some('/') => {} + _ => { + if opening_range.contains(&offset) { + return closing_range.start.to_display_point(map); + } else if closing_range.contains(&offset) { + return opening_range.start.to_display_point(map); + } + } } } @@ -2440,7 +2435,6 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint if distance < closest_distance { closest_pair_destination = Some(close_range.start); closest_distance = distance; - continue; } } @@ -2451,7 +2445,6 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint if distance < closest_distance { closest_pair_destination = Some(open_range.start); closest_distance = distance; - continue; } } @@ -2724,17 +2717,17 @@ fn sneak_backward( } fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint { - let correct_line = start_of_relative_buffer_row(map, point, times as isize); + let correct_line = map.start_of_relative_buffer_row(point, times as isize); first_non_whitespace(map, false, correct_line) } fn previous_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint { - let correct_line = start_of_relative_buffer_row(map, point, -(times as isize)); + let correct_line = map.start_of_relative_buffer_row(point, -(times as isize)); first_non_whitespace(map, false, correct_line) } fn go_to_column(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint { - let correct_line = start_of_relative_buffer_row(map, point, 0); + let correct_line = map.start_of_relative_buffer_row(point, 0); right(map, correct_line, times.saturating_sub(1)) } @@ -2744,7 +2737,7 @@ pub(crate) fn next_line_end( times: usize, ) -> DisplayPoint { if times > 1 { - point = start_of_relative_buffer_row(map, point, times as isize - 1); + point = map.start_of_relative_buffer_row(point, times as isize - 1); } end_of_line(map, false, point, 1) } @@ -2856,7 +2849,7 @@ fn method_motion( for _ in 0..times { let point = map.display_point_to_point(display_point, Bias::Left); - let offset = point.to_offset(&map.buffer_snapshot()); + let offset = point.to_offset(&map.buffer_snapshot()).0; let range = if direction == Direction::Prev { 0..offset } else { @@ -2885,7 +2878,7 @@ fn method_motion( } else { possibilities.min().unwrap_or(offset) }; - let new_point = map.clip_point(dest.to_display_point(map), Bias::Left); + let new_point = map.clip_point(MultiBufferOffset(dest).to_display_point(map), Bias::Left); if new_point == display_point { break; } @@ -2906,7 +2899,7 @@ fn comment_motion( for _ in 0..times { let point = map.display_point_to_point(display_point, Bias::Left); - let offset = point.to_offset(&map.buffer_snapshot()); + let offset = point.to_offset(&map.buffer_snapshot()).0; let range = if direction == Direction::Prev { 0..offset } else { @@ -2939,7 +2932,7 @@ fn comment_motion( } else { possibilities.min().unwrap_or(offset) }; - let new_point = map.clip_point(dest.to_display_point(map), Bias::Left); + let new_point = map.clip_point(MultiBufferOffset(dest).to_display_point(map), Bias::Left); if new_point == display_point { break; } @@ -2962,7 +2955,7 @@ fn section_motion( .display_point_to_point(display_point, Bias::Left) .to_offset(&map.buffer_snapshot()); let range = if direction == Direction::Prev { - 0..offset + MultiBufferOffset(0)..offset } else { offset..map.buffer_snapshot().len() }; @@ -2993,7 +2986,7 @@ fn section_motion( let relevant = if is_start { range.start } else { range.end }; if direction == Direction::Prev && relevant < offset { Some(relevant) - } else if direction == Direction::Next && relevant > offset + 1 { + } else if direction == Direction::Next && relevant > offset + 1usize { Some(relevant) } else { None @@ -3001,7 +2994,7 @@ fn section_motion( }); let offset = if direction == Direction::Prev { - possibilities.max().unwrap_or(0) + possibilities.max().unwrap_or(MultiBufferOffset(0)) } else { possibilities.min().unwrap_or(map.buffer_snapshot().len()) }; @@ -3098,7 +3091,7 @@ mod test { state::Mode, test::{NeovimBackedTestContext, VimTestContext}, }; - use editor::display_map::Inlay; + use editor::Inlay; use indoc::indoc; use language::Point; use multi_buffer::MultiBufferRow; @@ -3318,6 +3311,96 @@ mod test { cx.shared_state().await.assert_eq("ˇ(\n {()} \n)"); } + #[gpui::test] + async fn test_unmatched_forward_markdown(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new_markdown_with_rust(cx).await; + + cx.neovim.exec("set filetype=markdown").await; + + cx.set_shared_state(indoc! {r" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + ˇ } + } + ``` + "}) + .await; + cx.simulate_shared_keystrokes("] }").await; + cx.shared_state().await.assert_eq(indoc! {r" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + ˇ} + } + ``` + "}); + + cx.set_shared_state(indoc! {r" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } ˇ + } + ``` + "}) + .await; + cx.simulate_shared_keystrokes("] }").await; + cx.shared_state().await.assert_eq(indoc! {r" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } • + ˇ} + ``` + "}); + } + + #[gpui::test] + async fn test_unmatched_backward_markdown(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new_markdown_with_rust(cx).await; + + cx.neovim.exec("set filetype=markdown").await; + + cx.set_shared_state(indoc! {r" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + ˇ } + } + ``` + "}) + .await; + cx.simulate_shared_keystrokes("[ {").await; + cx.shared_state().await.assert_eq(indoc! {r" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> ˇ{ + } + } + ``` + "}); + + cx.set_shared_state(indoc! {r" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } ˇ + } + ``` + "}) + .await; + cx.simulate_shared_keystrokes("[ {").await; + cx.shared_state().await.assert_eq(indoc! {r" + ```rs + impl Worktree ˇ{ + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } • + } + ``` + "}); + } + #[gpui::test] async fn test_matching_tags(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new_html(cx).await; @@ -3366,6 +3449,23 @@ mod test { test = "test" />
"#}); + + // test nested closing tag + cx.set_shared_state(indoc! {r#" + + + "#}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r#" + + <ˇ/body> + "#}); + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r#" + <ˇbody> + + "#}); } #[gpui::test] @@ -3391,6 +3491,22 @@ mod test { }"}); } + #[gpui::test] + async fn test_matching_nested_brackets(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new_tsx(cx).await; + + cx.set_shared_state(indoc! {r""}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state() + .await + .assert_eq(indoc! {r""}); + cx.simulate_shared_keystrokes("%").await; + cx.shared_state() + .await + .assert_eq(indoc! {r""}); + } + #[gpui::test] async fn test_comma_semicolon(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index bf45129021..aee0b424f0 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -3,7 +3,7 @@ mod convert; mod delete; mod increment; pub(crate) mod mark; -mod paste; +pub(crate) mod paste; pub(crate) mod repeat; mod scroll; pub(crate) mod search; @@ -28,7 +28,7 @@ use editor::Editor; use editor::{Anchor, SelectionEffects}; use editor::{Bias, ToPoint}; use editor::{display_map::ToDisplayPoint, movement}; -use gpui::{Action, Context, Window, actions}; +use gpui::{Context, Window, actions}; use language::{Point, SelectionGoal}; use log::error; use multi_buffer::MultiBufferRow; @@ -100,6 +100,10 @@ actions!( GoToTab, /// Go to previous tab page (with count support). GoToPreviousTab, + /// Go to tab page (with count support). + GoToPreviousReference, + /// Go to previous tab page (with count support). + GoToNextReference, ] ); @@ -123,8 +127,6 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::toggle_comments); Vim::action(editor, cx, Vim::paste); Vim::action(editor, cx, Vim::show_location); - Vim::action(editor, cx, Vim::go_to_tab); - Vim::action(editor, cx, Vim::go_to_previous_tab); Vim::action(editor, cx, |vim, _: &DeleteLeft, window, cx| { vim.record_current_action(cx); @@ -204,6 +206,36 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { vim.join_lines_impl(false, window, cx); }); + Vim::action(editor, cx, |vim, _: &GoToPreviousReference, window, cx| { + let count = Vim::take_count(cx); + vim.update_editor(cx, |_, editor, cx| { + let task = editor.go_to_reference_before_or_after_position( + editor::Direction::Prev, + count.unwrap_or(1), + window, + cx, + ); + if let Some(task) = task { + task.detach_and_log_err(cx); + }; + }); + }); + + Vim::action(editor, cx, |vim, _: &GoToNextReference, window, cx| { + let count = Vim::take_count(cx); + vim.update_editor(cx, |_, editor, cx| { + let task = editor.go_to_reference_before_or_after_position( + editor::Direction::Next, + count.unwrap_or(1), + window, + cx, + ); + if let Some(task) = task { + task.detach_and_log_err(cx); + }; + }); + }); + Vim::action(editor, cx, |vim, _: &Undo, window, cx| { let times = Vim::take_count(cx); Vim::take_forced_motion(cx); @@ -450,6 +482,7 @@ impl Vim { &mut self, object: Object, times: Option, + opening: bool, window: &mut Window, cx: &mut Context, ) { @@ -520,10 +553,11 @@ impl Vim { Some(Operator::DeleteSurrounds) => { waiting_operator = Some(Operator::DeleteSurrounds); } - Some(Operator::ChangeSurrounds { target: None }) => { + Some(Operator::ChangeSurrounds { target: None, .. }) => { if self.check_and_move_to_valid_bracket_pair(object, window, cx) { waiting_operator = Some(Operator::ChangeSurrounds { target: Some(object), + opening, }); } } @@ -544,8 +578,21 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(cx, |_, editor, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window); + + // If vim is in temporary mode and the motion being used is + // `EndOfLine` ($), we'll want to disable clipping at line ends so + // that the newline character can be selected so that, when moving + // back to visual mode, the cursor will be placed after the last + // character and not before it. + let clip_at_line_ends = editor.clip_at_line_ends(cx); + let should_disable_clip = matches!(motion, Motion::EndOfLine { .. }) && vim.temp_mode; + + if should_disable_clip { + editor.set_clip_at_line_ends(false, cx) + }; + editor.change_selections( SelectionEffects::default().nav_history(motion.push_to_jump_list()), window, @@ -557,7 +604,11 @@ impl Vim { .unwrap_or((cursor, goal)) }) }, - ) + ); + + if should_disable_clip { + editor.set_clip_at_line_ends(clip_at_line_ends, cx); + }; }); } @@ -637,13 +688,13 @@ impl Vim { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); self.update_editor(cx, |vim, editor, cx| { - let Some(Mark::Local(marks)) = vim.get_mark("^", editor, window, cx) else { - return; - }; - - editor.change_selections(Default::default(), window, cx, |s| { - s.select_anchor_ranges(marks.iter().map(|mark| *mark..*mark)) - }); + if let Some(Mark::Local(marks)) = vim.get_mark("^", editor, window, cx) + && !marks.is_empty() + { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_anchor_ranges(marks.iter().map(|mark| *mark..*mark)) + }); + } }); } @@ -657,7 +708,7 @@ impl Vim { self.switch_mode(Mode::Insert, false, window, cx); self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { - let selections = editor.selections.all::(cx); + let selections = editor.selections.all::(&editor.display_snapshot(cx)); let snapshot = editor.buffer().read(cx).snapshot(cx); let selection_start_rows: BTreeSet = selections @@ -679,7 +730,7 @@ impl Vim { editor.edit_with_autoindent(edits, cx); editor.change_selections(Default::default(), window, cx, |s| { s.move_cursors_with(|map, cursor, _| { - let previous_line = motion::start_of_relative_buffer_row(map, cursor, -1); + let previous_line = map.start_of_relative_buffer_row(cursor, -1); let insert_point = motion::end_of_line(map, false, previous_line, 1); (insert_point, SelectionGoal::None) }); @@ -699,7 +750,7 @@ impl Vim { self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { - let selections = editor.selections.all::(cx); + let selections = editor.selections.all::(&editor.display_snapshot(cx)); let snapshot = editor.buffer().read(cx).snapshot(cx); let selection_end_rows: BTreeSet = selections @@ -745,7 +796,7 @@ impl Vim { Vim::take_forced_motion(cx); self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, _, cx| { - let selections = editor.selections.all::(cx); + let selections = editor.selections.all::(&editor.display_snapshot(cx)); let selection_start_rows: BTreeSet = selections .into_iter() @@ -774,9 +825,10 @@ impl Vim { Vim::take_forced_motion(cx); self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { - let selections = editor.selections.all::(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all::(&display_map); let snapshot = editor.buffer().read(cx).snapshot(cx); - let (_map, display_selections) = editor.selections.all_display(cx); + let display_selections = editor.selections.all_display(&display_map); let original_positions = display_selections .iter() .map(|s| (s.id, s.head())) @@ -930,20 +982,30 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { + // We need to use `text.chars().count()` instead of `text.len()` here as + // `len()` counts bytes, not characters. + let char_count = text.chars().count(); + let count = Vim::take_count(cx).unwrap_or(char_count); let is_return_char = text == "\n".into() || text == "\r".into(); - let count = Vim::take_count(cx).unwrap_or(1); + let repeat_count = match (is_return_char, char_count) { + (true, _) => 0, + (_, 1) => count, + (_, _) => 1, + }; + Vim::take_forced_motion(cx); self.stop_recording(cx); self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); - let (map, display_selections) = editor.selections.all_display(cx); + let display_map = editor.display_snapshot(cx); + let display_selections = editor.selections.all_display(&display_map); - let mut edits = Vec::new(); + let mut edits = Vec::with_capacity(display_selections.len()); for selection in &display_selections { let mut range = selection.range(); for _ in 0..count { - let new_point = movement::saturating_right(&map, range.end); + let new_point = movement::saturating_right(&display_map, range.end); if range.end == new_point { return; } @@ -951,9 +1013,9 @@ impl Vim { } edits.push(( - range.start.to_offset(&map, Bias::Left) - ..range.end.to_offset(&map, Bias::Left), - text.repeat(if is_return_char { 0 } else { count }), + range.start.to_offset(&display_map, Bias::Left) + ..range.end.to_offset(&display_map, Bias::Left), + text.repeat(repeat_count), )); } @@ -976,16 +1038,16 @@ impl Vim { pub fn save_selection_starts( &self, editor: &Editor, - cx: &mut Context, ) -> HashMap { - let (map, selections) = editor.selections.all_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_display(&display_map); selections .iter() .map(|selection| { ( selection.id, - map.display_point_to_anchor(selection.start, Bias::Right), + display_map.display_point_to_anchor(selection.start, Bias::Right), ) }) .collect::>() @@ -1012,55 +1074,8 @@ impl Vim { self.switch_mode(Mode::Insert, true, window, cx); } } - - fn go_to_tab(&mut self, _: &GoToTab, window: &mut Window, cx: &mut Context) { - let count = Vim::take_count(cx); - Vim::take_forced_motion(cx); - - if let Some(tab_index) = count { - // gt goes to tab (1-based). - let zero_based_index = tab_index.saturating_sub(1); - window.dispatch_action( - workspace::pane::ActivateItem(zero_based_index).boxed_clone(), - cx, - ); - } else { - // If no count is provided, go to the next tab. - window.dispatch_action(workspace::pane::ActivateNextItem.boxed_clone(), cx); - } - } - - fn go_to_previous_tab( - &mut self, - _: &GoToPreviousTab, - window: &mut Window, - cx: &mut Context, - ) { - let count = Vim::take_count(cx); - Vim::take_forced_motion(cx); - - if let Some(count) = count { - // gT with count goes back that many tabs with wraparound (not the same as gt!). - if let Some(workspace) = self.workspace(window) { - let pane = workspace.read(cx).active_pane().read(cx); - let item_count = pane.items().count(); - if item_count > 0 { - let current_index = pane.active_item_index(); - let target_index = (current_index as isize - count as isize) - .rem_euclid(item_count as isize) - as usize; - window.dispatch_action( - workspace::pane::ActivateItem(target_index).boxed_clone(), - cx, - ); - } - } - } else { - // No count provided, go to the previous tab. - window.dispatch_action(workspace::pane::ActivatePreviousItem.boxed_clone(), cx); - } - } } + #[cfg(test)] mod test { use gpui::{KeyBinding, TestAppContext, UpdateGlobal}; @@ -2271,4 +2286,35 @@ mod test { assert_eq!(workspace.active_pane().read(cx).active_item_index(), 1); }); } + + #[gpui::test] + async fn test_temporary_mode(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // Test jumping to the end of the line ($). + cx.set_shared_state(indoc! {"lorem ˇipsum"}).await; + cx.simulate_shared_keystrokes("i").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("ctrl-o $").await; + cx.shared_state().await.assert_eq(indoc! {"lorem ipsumˇ"}); + + // Test jumping to the next word. + cx.set_shared_state(indoc! {"loremˇ ipsum dolor"}).await; + cx.simulate_shared_keystrokes("a").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("a n d space ctrl-o w").await; + cx.shared_state() + .await + .assert_eq(indoc! {"lorem and ipsum ˇdolor"}); + + // Test yanking to end of line ($). + cx.set_shared_state(indoc! {"lorem ˇipsum dolor"}).await; + cx.simulate_shared_keystrokes("i").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("a n d space ctrl-o y $") + .await; + cx.shared_state() + .await + .assert_eq(indoc! {"lorem and ˇipsum dolor"}); + } } diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 4735c64792..b0b0bddae1 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -121,7 +121,11 @@ impl Vim { }); }); if objects_found { - vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx); + let kind = match object.target_visual_mode(vim.mode, around) { + Mode::VisualLine => MotionKind::Linewise, + _ => MotionKind::Exclusive, + }; + vim.copy_selections_content(editor, kind, window, cx); editor.insert("", window, cx); editor.refresh_edit_prediction(true, false, window, cx); } diff --git a/crates/vim/src/normal/convert.rs b/crates/vim/src/normal/convert.rs index 11d040850d..0ee132a44d 100644 --- a/crates/vim/src/normal/convert.rs +++ b/crates/vim/src/normal/convert.rs @@ -199,7 +199,7 @@ impl Vim { let mut ranges = Vec::new(); let mut cursor_positions = Vec::new(); let snapshot = editor.buffer().read(cx).snapshot(cx); - for selection in editor.selections.all_adjusted(cx) { + for selection in editor.selections.all_adjusted(&editor.display_snapshot(cx)) { match vim.mode { Mode::Visual | Mode::VisualLine => { ranges.push(selection.start..selection.end); diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index 34ac4aab1f..d9ef32deba 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -58,7 +58,7 @@ impl Vim { let mut new_anchors = Vec::new(); let snapshot = editor.buffer().read(cx).snapshot(cx); - for selection in editor.selections.all_adjusted(cx) { + for selection in editor.selections.all_adjusted(&editor.display_snapshot(cx)) { if !selection.is_empty() && (vim.mode != Mode::VisualBlock || new_anchors.is_empty()) { @@ -76,17 +76,18 @@ impl Vim { Point::new(row, snapshot.line_len(multi_buffer::MultiBufferRow(row))) }; - let number_result = if !selection.is_empty() { - find_number_in_range(&snapshot, start, end) + let find_result = if !selection.is_empty() { + find_target(&snapshot, start, end, true) } else { - find_number(&snapshot, start) + find_target(&snapshot, start, end, false) }; - if let Some((range, num, radix)) = number_result { + if let Some((range, target, radix)) = find_result { let replace = match radix { - 10 => increment_decimal_string(&num, delta), - 16 => increment_hex_string(&num, delta), - 2 => increment_binary_string(&num, delta), + 10 => increment_decimal_string(&target, delta), + 16 => increment_hex_string(&target, delta), + 2 => increment_binary_string(&target, delta), + 0 => increment_toggle_string(&target), _ => unreachable!(), }; delta += step as i64; @@ -94,13 +95,6 @@ impl Vim { if selection.is_empty() { new_anchors.push((false, snapshot.anchor_after(range.end))) } - } else if let Some((range, boolean)) = find_boolean(&snapshot, start) { - let replace = toggle_boolean(&boolean); - delta += step as i64; - edits.push((range.clone(), replace)); - if selection.is_empty() { - new_anchors.push((false, snapshot.anchor_after(range.end))) - } } else if selection.is_empty() { new_anchors.push((true, snapshot.anchor_after(start))) } @@ -200,83 +194,132 @@ fn increment_binary_string(num: &str, delta: i64) -> String { format!("{:0width$b}", result, width = num.len()) } -fn find_number_in_range( +fn find_target( snapshot: &MultiBufferSnapshot, start: Point, end: Point, + need_range: bool, ) -> Option<(Range, String, u32)> { let start_offset = start.to_offset(snapshot); let end_offset = end.to_offset(snapshot); let mut offset = start_offset; + let mut first_char_is_num = snapshot + .chars_at(offset) + .next() + .map_or(false, |ch| ch.is_ascii_hexdigit()); + let mut pre_char = String::new(); + let next_offset = offset + + snapshot + .chars_at(start_offset) + .next() + .map_or(0, |ch| ch.len_utf8()); // Backward scan to find the start of the number, but stop at start_offset - for ch in snapshot.reversed_chars_at(offset) { - if ch.is_ascii_hexdigit() || ch == '-' || ch == 'b' || ch == 'x' { - if offset == 0 { - break; - } - offset -= ch.len_utf8(); - if offset < start_offset { - offset = start_offset; - break; - } - } else { + for ch in snapshot.reversed_chars_at(next_offset) { + // Search boundaries + if offset.0 == 0 || ch.is_whitespace() || (need_range && offset <= start_offset) { break; } + + // Avoid the influence of hexadecimal letters + if first_char_is_num + && !ch.is_ascii_hexdigit() + && (ch != 'b' && ch != 'B') + && (ch != 'x' && ch != 'X') + && ch != '-' + { + // Used to determine if the initial character is a number. + if is_numeric_string(&pre_char) { + break; + } else { + first_char_is_num = false; + } + } + + pre_char.insert(0, ch); + offset -= ch.len_utf8(); } let mut begin = None; - let mut end_num = None; - let mut num = String::new(); + let mut end = None; + let mut target = String::new(); let mut radix = 10; + let mut is_num = false; let mut chars = snapshot.chars_at(offset).peekable(); while let Some(ch) = chars.next() { - if offset >= end_offset { + if need_range && offset >= end_offset { break; // stop at end of selection } - if num == "0" && ch == 'b' && chars.peek().is_some() && chars.peek().unwrap().is_digit(2) { + if target == "0" + && (ch == 'b' || ch == 'B') + && chars.peek().is_some() + && chars.peek().unwrap().is_digit(2) + { radix = 2; begin = None; - num = String::new(); - } else if num == "0" - && ch == 'x' + target = String::new(); + } else if target == "0" + && (ch == 'x' || ch == 'X') && chars.peek().is_some() && chars.peek().unwrap().is_ascii_hexdigit() { radix = 16; begin = None; - num = String::new(); - } - - if ch.is_digit(radix) - || (begin.is_none() + target = String::new(); + } else if ch == '.' { + is_num = false; + begin = None; + target = String::new(); + } else if ch.is_digit(radix) + || ((begin.is_none() || !is_num) && ch == '-' && chars.peek().is_some() && chars.peek().unwrap().is_digit(radix)) { + if !is_num { + is_num = true; + begin = Some(offset); + target = String::new(); + } else if begin.is_none() { + begin = Some(offset); + } + target.push(ch); + } else if ch.is_ascii_alphabetic() && !is_num { if begin.is_none() { begin = Some(offset); } - num.push(ch); - } else if begin.is_some() { - end_num = Some(offset); + target.push(ch); + } else if begin.is_some() && (is_num || !is_num && is_toggle_word(&target)) { + // End of matching + end = Some(offset); break; } else if ch == '\n' { break; + } else { + // To match the next word + is_num = false; + begin = None; + target = String::new(); } offset += ch.len_utf8(); } - if let Some(begin) = begin { - let end_num = end_num.unwrap_or(offset); + if let Some(begin) = begin + && (is_num || !is_num && is_toggle_word(&target)) + { + if !is_num { + radix = 0; + } + + let end = end.unwrap_or(offset); Some(( - begin.to_point(snapshot)..end_num.to_point(snapshot), - num, + begin.to_point(snapshot)..end.to_point(snapshot), + target, radix, )) } else { @@ -284,133 +327,38 @@ fn find_number_in_range( } } -fn find_number( - snapshot: &MultiBufferSnapshot, - start: Point, -) -> Option<(Range, String, u32)> { - let mut offset = start.to_offset(snapshot); - - let ch0 = snapshot.chars_at(offset).next(); - if ch0.as_ref().is_some_and(char::is_ascii_hexdigit) || matches!(ch0, Some('-' | 'b' | 'x')) { - // go backwards to the start of any number the selection is within - for ch in snapshot.reversed_chars_at(offset) { - if ch.is_ascii_hexdigit() || ch == '-' || ch == 'b' || ch == 'x' { - offset -= ch.len_utf8(); - continue; - } - break; - } +fn is_numeric_string(s: &str) -> bool { + if s.is_empty() { + return false; } - let mut begin = None; - let mut end = None; - let mut num = String::new(); - let mut radix = 10; - - let mut chars = snapshot.chars_at(offset).peekable(); - // find the next number on the line (may start after the original cursor position) - while let Some(ch) = chars.next() { - if num == "0" && ch == 'b' && chars.peek().is_some() && chars.peek().unwrap().is_digit(2) { - radix = 2; - begin = None; - num = String::new(); - } - if num == "0" - && ch == 'x' - && chars.peek().is_some() - && chars.peek().unwrap().is_ascii_hexdigit() - { - radix = 16; - begin = None; - num = String::new(); - } - - if ch.is_digit(radix) - || (begin.is_none() - && ch == '-' - && chars.peek().is_some() - && chars.peek().unwrap().is_digit(radix)) - { - if begin.is_none() { - begin = Some(offset); - } - num.push(ch); - } else if begin.is_some() { - end = Some(offset); - break; - } else if ch == '\n' { - break; - } - offset += ch.len_utf8(); - } - if let Some(begin) = begin { - let end = end.unwrap_or(offset); - Some((begin.to_point(snapshot)..end.to_point(snapshot), num, radix)) + let (_, rest) = if let Some(r) = s.strip_prefix('-') { + (true, r) } else { - None + (false, s) + }; + + if rest.is_empty() { + return false; + } + + if let Some(digits) = rest.strip_prefix("0b").or_else(|| rest.strip_prefix("0B")) { + digits.is_empty() || digits.chars().all(|c| c == '0' || c == '1') + } else if let Some(digits) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) { + digits.is_empty() || digits.chars().all(|c| c.is_ascii_hexdigit()) + } else { + !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) } } -fn find_boolean(snapshot: &MultiBufferSnapshot, start: Point) -> Option<(Range, String)> { - let mut offset = start.to_offset(snapshot); - - let ch0 = snapshot.chars_at(offset).next(); - if ch0.as_ref().is_some_and(|c| c.is_ascii_alphabetic()) { - for ch in snapshot.reversed_chars_at(offset) { - if ch.is_ascii_alphabetic() { - offset -= ch.len_utf8(); - continue; - } - break; - } - } - - let mut begin = None; - let mut end = None; - let mut word = String::new(); - - let chars = snapshot.chars_at(offset); - - for ch in chars { - if ch.is_ascii_alphabetic() { - if begin.is_none() { - begin = Some(offset); - } - word.push(ch); - } else if begin.is_some() { - end = Some(offset); - let word_lower = word.to_lowercase(); - if BOOLEAN_PAIRS - .iter() - .any(|(a, b)| word_lower == *a || word_lower == *b) - { - return Some(( - begin.unwrap().to_point(snapshot)..end.unwrap().to_point(snapshot), - word, - )); - } - begin = None; - end = None; - word = String::new(); - } else if ch == '\n' { - break; - } - offset += ch.len_utf8(); - } - if let Some(begin) = begin { - let end = end.unwrap_or(offset); - let word_lower = word.to_lowercase(); - if BOOLEAN_PAIRS - .iter() - .any(|(a, b)| word_lower == *a || word_lower == *b) - { - return Some((begin.to_point(snapshot)..end.to_point(snapshot), word)); - } - } - None +fn is_toggle_word(word: &str) -> bool { + let lower = word.to_lowercase(); + BOOLEAN_PAIRS + .iter() + .any(|(a, b)| lower == *a || lower == *b) } -fn toggle_boolean(boolean: &str) -> String { +fn increment_toggle_string(boolean: &str) -> String { let lower = boolean.to_lowercase(); let target = BOOLEAN_PAIRS @@ -802,7 +750,7 @@ mod test { } #[gpui::test] - async fn test_toggle_boolean(cx: &mut gpui::TestAppContext) { + async fn test_increment_toggle(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state("let enabled = trˇue;", Mode::Normal); @@ -860,6 +808,31 @@ mod test { cx.assert_state("let enabled = ˇOff;", Mode::Normal); } + #[gpui::test] + async fn test_increment_order(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("aaˇa false 1 2 3", Mode::Normal); + cx.simulate_keystrokes("ctrl-a"); + cx.assert_state("aaa truˇe 1 2 3", Mode::Normal); + + cx.set_state("aaˇa 1 false 2 3", Mode::Normal); + cx.simulate_keystrokes("ctrl-a"); + cx.assert_state("aaa ˇ2 false 2 3", Mode::Normal); + + cx.set_state("trueˇ 1 2 3", Mode::Normal); + cx.simulate_keystrokes("ctrl-a"); + cx.assert_state("true ˇ2 2 3", Mode::Normal); + + cx.set_state("falseˇ", Mode::Normal); + cx.simulate_keystrokes("ctrl-a"); + cx.assert_state("truˇe", Mode::Normal); + + cx.set_state("⚡️ˇ⚡️", Mode::Normal); + cx.simulate_keystrokes("ctrl-a"); + cx.assert_state("⚡️ˇ⚡️", Mode::Normal); + } + #[gpui::test] async fn test_increment_visual_partial_number(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal/mark.rs b/crates/vim/src/normal/mark.rs index ea9aafe131..a4d85e87b2 100644 --- a/crates/vim/src/normal/mark.rs +++ b/crates/vim/src/normal/mark.rs @@ -50,16 +50,19 @@ impl Vim { let mut reversed = vec![]; self.update_editor(cx, |vim, editor, cx| { - let (map, selections) = editor.selections.all_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_display(&display_map); for selection in selections { - let end = movement::saturating_left(&map, selection.end); + let end = movement::saturating_left(&display_map, selection.end); ends.push( - map.buffer_snapshot() - .anchor_before(end.to_offset(&map, Bias::Left)), + display_map + .buffer_snapshot() + .anchor_before(end.to_offset(&display_map, Bias::Left)), ); starts.push( - map.buffer_snapshot() - .anchor_before(selection.start.to_offset(&map, Bias::Left)), + display_map + .buffer_snapshot() + .anchor_before(selection.start.to_offset(&display_map, Bias::Left)), ); reversed.push(selection.reversed) } @@ -301,19 +304,21 @@ impl Vim { name = "'"; } if matches!(name, "{" | "}" | "(" | ")") { - let (map, selections) = editor.selections.all_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_display(&display_map); let anchors = selections .into_iter() .map(|selection| { let point = match name { - "{" => movement::start_of_paragraph(&map, selection.head(), 1), - "}" => movement::end_of_paragraph(&map, selection.head(), 1), - "(" => motion::sentence_backwards(&map, selection.head(), 1), - ")" => motion::sentence_forwards(&map, selection.head(), 1), + "{" => movement::start_of_paragraph(&display_map, selection.head(), 1), + "}" => movement::end_of_paragraph(&display_map, selection.head(), 1), + "(" => motion::sentence_backwards(&display_map, selection.head(), 1), + ")" => motion::sentence_forwards(&display_map, selection.head(), 1), _ => unreachable!(), }; - map.buffer_snapshot() - .anchor_before(point.to_offset(&map, Bias::Left)) + display_map + .buffer_snapshot() + .anchor_before(point.to_offset(&display_map, Bias::Left)) }) .collect::>(); return Some(Mark::Local(anchors)); @@ -367,9 +372,12 @@ pub fn jump_motion( #[cfg(test)] mod test { + use crate::test::{NeovimBackedTestContext, VimTestContext}; + use editor::Editor; use gpui::TestAppContext; - - use crate::test::NeovimBackedTestContext; + use std::path::Path; + use util::path; + use workspace::{CloseActiveItem, OpenOptions}; #[gpui::test] async fn test_quote_mark(cx: &mut TestAppContext) { @@ -389,4 +397,69 @@ mod test { cx.simulate_shared_keystrokes("^ ` `").await; cx.shared_state().await.assert_eq("Hello, worldˇ!"); } + + #[gpui::test] + async fn test_global_mark_overwrite(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let path = Path::new(path!("/first.rs")); + let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake().insert_file(path, "one".into()).await; + let path = Path::new(path!("/second.rs")); + fs.as_fake().insert_file(path, "two".into()).await; + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path( + path!("/first.rs").into(), + OpenOptions::default(), + window, + cx, + ) + }) + .await; + + cx.simulate_keystrokes("m A"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path( + path!("/second.rs").into(), + OpenOptions::default(), + window, + cx, + ) + }) + .await; + + cx.simulate_keystrokes("m A"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) + }) + }) + .await; + + cx.simulate_keystrokes("m B"); + + cx.simulate_keystrokes("' A"); + + cx.workspace(|workspace, _, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + + let buffer = active_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + + let file = buffer.read(cx).file().unwrap(); + let file_path = file.as_local().unwrap().abs_path(cx); + + assert_eq!(file_path.to_str().unwrap(), path!("/second.rs")); + }) + } } diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 2a45695928..82af828deb 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -1,4 +1,7 @@ -use editor::{DisplayPoint, RowExt, SelectionEffects, display_map::ToDisplayPoint, movement}; +use editor::{ + DisplayPoint, MultiBufferOffset, RowExt, SelectionEffects, display_map::ToDisplayPoint, + movement, +}; use gpui::{Action, Context, Window}; use language::{Bias, SelectionGoal}; use schemars::JsonSchema; @@ -15,7 +18,7 @@ use crate::{ }; /// Pastes text from the specified register at the cursor position. -#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)] +#[derive(Clone, Default, Deserialize, JsonSchema, PartialEq, Action)] #[action(namespace = vim)] #[serde(deny_unknown_fields)] pub struct Paste { @@ -56,7 +59,8 @@ impl Vim { vim.copy_selections_content(editor, MotionKind::for_mode(vim.mode), window, cx); } - let (display_map, current_selections) = editor.selections.all_adjusted_display(cx); + let display_map = editor.display_snapshot(cx); + let current_selections = editor.selections.all_adjusted_display(&display_map); // unlike zed, if you have a multi-cursor selection from vim block mode, // pasting it will paste it on subsequent lines, even if you don't yet @@ -173,7 +177,10 @@ impl Vim { original_indent_columns.push(original_indent_column); } - let cursor_offset = editor.selections.last::(cx).head(); + let cursor_offset = editor + .selections + .last::(&display_map) + .head(); if editor .buffer() .read(cx) @@ -710,7 +717,7 @@ mod test { cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| { settings.project.all_languages.languages.0.insert( - LanguageName::new("Rust").0, + LanguageName::new_static("Rust").0, LanguageSettingsContent { auto_indent_on_paste: Some(false), ..Default::default() @@ -766,6 +773,52 @@ mod test { "}); } + #[gpui::test] + async fn test_paste_system_clipboard_never(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |s| { + s.vim.get_or_insert_default().use_system_clipboard = Some(UseSystemClipboard::Never) + }); + }); + + cx.set_state( + indoc! {" + ˇThe quick brown + fox jumps over + the lazy dog"}, + Mode::Normal, + ); + + cx.write_to_clipboard(ClipboardItem::new_string("something else".to_string())); + + cx.simulate_keystrokes("d d"); + cx.assert_state( + indoc! {" + ˇfox jumps over + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v p"); + cx.assert_state( + indoc! {" + ˇThe quick brown + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v"); + cx.dispatch_action(editor::actions::Paste); + cx.assert_state( + indoc! {" + ˇsomething else + the lazy dog"}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_numbered_registers(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 2d79274808..e47b2b350f 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -110,7 +110,24 @@ impl Replayer { } lock.running = true; let this = self.clone(); - window.defer(cx, move |window, cx| this.next(window, cx)) + window.defer(cx, move |window, cx| { + this.next(window, cx); + let Some(Some(workspace)) = window.root::() else { + return; + }; + let Some(editor) = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + else { + return; + }; + editor.update(cx, |editor, cx| { + editor + .buffer() + .update(cx, |multi, cx| multi.finalize_last_transaction(cx)) + }); + }) } pub fn stop(self) { @@ -213,8 +230,19 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - let count = Vim::take_count(cx); + if self.active_operator().is_some() { + Vim::update_globals(cx, |globals, _| { + globals.recording_actions.clear(); + globals.recording_count = None; + globals.dot_recording = false; + globals.stop_recording_after_next_action = false; + }); + self.clear_operator(window, cx); + return; + } + Vim::take_forced_motion(cx); + let count = Vim::take_count(cx); let Some((mut actions, selection, mode)) = Vim::update_globals(cx, |globals, _| { let actions = globals.recorded_actions.clone(); @@ -793,4 +821,91 @@ mod test { cx.simulate_shared_keystrokes("@ b").await; cx.shared_state().await.assert_eq("aaaaaaabbbˇd"); } + + #[gpui::test] + async fn test_repeat_clear(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Check that, when repeat is preceded by something other than a number, + // the current operator is cleared, in order to prevent infinite loops. + cx.set_state("ˇhello world", Mode::Normal); + cx.simulate_keystrokes("d ."); + assert_eq!(cx.active_operator(), None); + } + + #[gpui::test] + async fn test_repeat_clear_repeat(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes("d d").await; + cx.shared_state().await.assert_eq(indoc! { + "ˇfox jumps over + the lazy dog" + }); + cx.simulate_shared_keystrokes("d . .").await; + cx.shared_state().await.assert_eq(indoc! { + "ˇthe lazy dog" + }); + } + + #[gpui::test] + async fn test_repeat_clear_count(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes("d d").await; + cx.shared_state().await.assert_eq(indoc! { + "ˇfox jumps over + the lazy dog" + }); + cx.simulate_shared_keystrokes("2 d .").await; + cx.shared_state().await.assert_eq(indoc! { + "ˇfox jumps over + the lazy dog" + }); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state().await.assert_eq(indoc! { + "ˇthe lazy dog" + }); + + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog + the quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes("2 d d").await; + cx.shared_state().await.assert_eq(indoc! { + "ˇthe lazy dog + the quick brown + fox jumps over + the lazy dog" + }); + cx.simulate_shared_keystrokes("5 d .").await; + cx.shared_state().await.assert_eq(indoc! { + "ˇthe lazy dog + the quick brown + fox jumps over + the lazy dog" + }); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state().await.assert_eq(indoc! { + "ˇfox jumps over + the lazy dog" + }); + } } diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index edb3d7f215..73209c8873 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -294,11 +294,10 @@ mod test { async fn test_scroll(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; - let (line_height, visible_line_count) = cx.editor(|editor, window, _cx| { + let (line_height, visible_line_count) = cx.update_editor(|editor, window, cx| { ( editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()), editor.visible_line_count().unwrap(), @@ -363,7 +362,10 @@ mod test { point(0., 3.0) ); assert_eq!( - editor.selections.newest(cx).range(), + editor + .selections + .newest(&editor.display_snapshot(cx)) + .range(), Point::new(6, 0)..Point::new(6, 0) ) }); @@ -380,7 +382,10 @@ mod test { point(0., 3.0) ); assert_eq!( - editor.selections.newest(cx).range(), + editor + .selections + .newest(&editor.display_snapshot(cx)) + .range(), Point::new(0, 0)..Point::new(6, 1) ) }); diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 6c4294a474..36a529da5d 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -506,7 +506,12 @@ impl Vim { search_bar.is_contains_uppercase(&search), ); } else { - options.set(SearchOptions::CASE_SENSITIVE, false) + // Fallback: no explicit i/I flags and smartcase disabled; + // use global editor.search.case_sensitive. + options.set( + SearchOptions::CASE_SENSITIVE, + EditorSettings::get_global(cx).search.case_sensitive, + ) } if !replacement.flag_g { diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index 889d487170..df8d7b4879 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -94,7 +94,10 @@ impl Vim { MotionKind::Exclusive }; vim.copy_selections_content(editor, kind, window, cx); - let selections = editor.selections.all::(cx).into_iter(); + let selections = editor + .selections + .all::(&editor.display_snapshot(cx)) + .into_iter(); let edits = selections.map(|selection| (selection.start..selection.end, "")); editor.edit(edits, cx); }); diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index fe8180ffff..9920b8fc88 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -11,7 +11,6 @@ use editor::{ClipboardSelection, Editor, SelectionEffects}; use gpui::Context; use gpui::Window; use language::Point; -use multi_buffer::MultiBufferRow; use settings::Settings; struct HighlightOnYank; @@ -81,7 +80,11 @@ impl Vim { start_positions.insert(selection.id, start_position); }); }); - vim.yank_selections_content(editor, MotionKind::Exclusive, window, cx); + let kind = match object.target_visual_mode(vim.mode, around) { + Mode::VisualLine => MotionKind::Linewise, + _ => MotionKind::Exclusive, + }; + vim.yank_selections_content(editor, kind, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|_, selection| { let (head, goal) = start_positions.remove(&selection.id).unwrap(); @@ -106,7 +109,7 @@ impl Vim { true, editor .selections - .all_adjusted(cx) + .all_adjusted(&editor.display_snapshot(cx)) .iter() .map(|s| s.range()) .collect(), @@ -128,7 +131,7 @@ impl Vim { false, editor .selections - .all_adjusted(cx) + .all_adjusted(&editor.display_snapshot(cx)) .iter() .map(|s| s.range()) .collect(), @@ -194,11 +197,14 @@ impl Vim { if kind.linewise() { text.push('\n'); } - clipboard_selections.push(ClipboardSelection { - len: text.len() - initial_len, - is_entire_line: false, - first_line_indent: buffer.indent_size_for_line(MultiBufferRow(start.row)).len, - }); + clipboard_selections.push(ClipboardSelection::for_buffer( + text.len() - initial_len, + false, + start..end, + &buffer, + editor.project(), + cx, + )); } } @@ -223,7 +229,7 @@ impl Vim { editor.highlight_background::( &ranges_to_highlight, - |colors| colors.colors().editor_document_highlight_read_background, + |_, colors| colors.colors().editor_document_highlight_read_background, cx, ); cx.spawn(async move |this, cx| { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 41b5a17c21..f11386d02d 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -6,7 +6,7 @@ use crate::{ state::{Mode, Operator}, }; use editor::{ - Bias, DisplayPoint, Editor, ToOffset, + Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, ToOffset, display_map::{DisplaySnapshot, ToDisplayPoint}, movement::{self, FindRange}, }; @@ -81,30 +81,59 @@ pub struct CandidateRange { #[derive(Debug, Clone)] pub struct CandidateWithRanges { candidate: CandidateRange, - open_range: Range, - close_range: Range, + open_range: Range, + close_range: Range, } -fn cover_or_next, Range)>>( +/// Selects text at the same indentation level. +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = vim)] +#[serde(deny_unknown_fields)] +struct Parentheses { + #[serde(default)] + opening: bool, +} + +/// Selects text at the same indentation level. +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = vim)] +#[serde(deny_unknown_fields)] +struct SquareBrackets { + #[serde(default)] + opening: bool, +} + +/// Selects text at the same indentation level. +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = vim)] +#[serde(deny_unknown_fields)] +struct AngleBrackets { + #[serde(default)] + opening: bool, +} +/// Selects text at the same indentation level. +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = vim)] +#[serde(deny_unknown_fields)] +struct CurlyBrackets { + #[serde(default)] + opening: bool, +} + +fn cover_or_next, Range)>>( candidates: Option, caret: DisplayPoint, map: &DisplaySnapshot, - range_filter: Option<&dyn Fn(Range, Range) -> bool>, ) -> Option { let caret_offset = caret.to_offset(map, Bias::Left); let mut covering = vec![]; let mut next_ones = vec![]; - let snapshot = &map.buffer_snapshot(); + let snapshot = map.buffer_snapshot(); if let Some(ranges) = candidates { for (open_range, close_range) in ranges { let start_off = open_range.start; let end_off = close_range.end; - if let Some(range_filter) = range_filter - && !range_filter(open_range.clone(), close_range.clone()) - { - continue; - } let candidate = CandidateWithRanges { candidate: CandidateRange { start: start_off.to_display_point(map), @@ -142,7 +171,7 @@ fn cover_or_next, Range)>>( if !next_ones.is_empty() { return next_ones.into_iter().min_by_key(|r| { let start = r.candidate.start.to_offset(map, Bias::Left); - (start as isize - caret_offset as isize).abs() + (start.0 as isize - caret_offset.0 as isize).abs() }); } @@ -152,8 +181,8 @@ fn cover_or_next, Range)>>( type DelimiterPredicate = dyn Fn(&BufferSnapshot, usize, usize) -> bool; struct DelimiterRange { - open: Range, - close: Range, + open: Range, + close: Range, } impl DelimiterRange { @@ -179,16 +208,35 @@ fn find_mini_delimiters( let visible_line_range = get_visible_line_range(&line_range); let snapshot = &map.buffer_snapshot(); - let excerpt = snapshot.excerpt_containing(offset..offset)?; + let mut excerpt = snapshot.excerpt_containing(offset..offset)?; let buffer = excerpt.buffer(); + let buffer_offset = excerpt.map_offset_to_buffer(offset); let bracket_filter = |open: Range, close: Range| { is_valid_delimiter(buffer, open.start, close.start) }; // Try to find delimiters in visible range first - let ranges = map.buffer_snapshot().bracket_ranges(visible_line_range); - if let Some(candidate) = cover_or_next(ranges, display_point, map, Some(&bracket_filter)) { + let ranges = map + .buffer_snapshot() + .bracket_ranges(visible_line_range) + .map(|ranges| { + ranges.filter_map(|(open, close)| { + // Convert the ranges from multibuffer space to buffer space as + // that is what `is_valid_delimiter` expects, otherwise it might + // panic as the values might be out of bounds. + let buffer_open = excerpt.map_range_to_buffer(open.clone()); + let buffer_close = excerpt.map_range_to_buffer(close.clone()); + + if is_valid_delimiter(buffer, buffer_open.start.0, buffer_close.start.0) { + Some((open, close)) + } else { + None + } + }) + }); + + if let Some(candidate) = cover_or_next(ranges, display_point, map) { return Some( DelimiterRange { open: candidate.open_range, @@ -199,13 +247,17 @@ fn find_mini_delimiters( } // Fall back to innermost enclosing brackets - let (open_bracket, close_bracket) = - buffer.innermost_enclosing_bracket_ranges(offset..offset, Some(&bracket_filter))?; + let (open_bracket, close_bracket) = buffer + .innermost_enclosing_bracket_ranges(buffer_offset..buffer_offset, Some(&bracket_filter))?; Some( DelimiterRange { - open: open_bracket, - close: close_bracket, + open: excerpt.map_range_from_buffer( + BufferOffset(open_bracket.start)..BufferOffset(open_bracket.end), + ), + close: excerpt.map_range_from_buffer( + BufferOffset(close_bracket.start)..BufferOffset(close_bracket.end), + ), } .to_display_range(map, around), ) @@ -275,18 +327,10 @@ actions!( DoubleQuotes, /// Selects text within vertical bars (pipes). VerticalBars, - /// Selects text within parentheses. - Parentheses, /// Selects text within the nearest brackets. MiniBrackets, /// Selects text within any type of brackets. AnyBrackets, - /// Selects text within square brackets. - SquareBrackets, - /// Selects text within curly brackets. - CurlyBrackets, - /// Selects text within angle brackets. - AngleBrackets, /// Selects a function argument. Argument, /// Selects an HTML/XML tag. @@ -350,17 +394,17 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &DoubleQuotes, window, cx| { vim.object(Object::DoubleQuotes, window, cx) }); - Vim::action(editor, cx, |vim, _: &Parentheses, window, cx| { - vim.object(Object::Parentheses, window, cx) + Vim::action(editor, cx, |vim, action: &Parentheses, window, cx| { + vim.object_impl(Object::Parentheses, action.opening, window, cx) }); - Vim::action(editor, cx, |vim, _: &SquareBrackets, window, cx| { - vim.object(Object::SquareBrackets, window, cx) + Vim::action(editor, cx, |vim, action: &SquareBrackets, window, cx| { + vim.object_impl(Object::SquareBrackets, action.opening, window, cx) }); - Vim::action(editor, cx, |vim, _: &CurlyBrackets, window, cx| { - vim.object(Object::CurlyBrackets, window, cx) + Vim::action(editor, cx, |vim, action: &CurlyBrackets, window, cx| { + vim.object_impl(Object::CurlyBrackets, action.opening, window, cx) }); - Vim::action(editor, cx, |vim, _: &AngleBrackets, window, cx| { - vim.object(Object::AngleBrackets, window, cx) + Vim::action(editor, cx, |vim, action: &AngleBrackets, window, cx| { + vim.object_impl(Object::AngleBrackets, action.opening, window, cx) }); Vim::action(editor, cx, |vim, _: &VerticalBars, window, cx| { vim.object(Object::VerticalBars, window, cx) @@ -394,10 +438,22 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { impl Vim { fn object(&mut self, object: Object, window: &mut Window, cx: &mut Context) { + self.object_impl(object, false, window, cx); + } + + fn object_impl( + &mut self, + object: Object, + opening: bool, + window: &mut Window, + cx: &mut Context, + ) { let count = Self::take_count(cx); match self.mode { - Mode::Normal | Mode::HelixNormal => self.normal_object(object, count, window, cx), + Mode::Normal | Mode::HelixNormal => { + self.normal_object(object, count, opening, window, cx) + } Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::HelixSelect => { self.visual_object(object, count, window, cx) } @@ -847,7 +903,7 @@ pub fn surrounding_html_tag( // Find the most closest to current offset let mut cursor = buffer.syntax_layer_at(offset)?.node().walk(); let mut last_child_node = cursor.node(); - while cursor.goto_first_child_for_byte(offset).is_some() { + while cursor.goto_first_child_for_byte(offset.0).is_some() { last_child_node = cursor.node(); } @@ -864,10 +920,16 @@ pub fn surrounding_html_tag( - range.start.to_offset(map, Bias::Left) <= 1 { - offset <= last_child.end_byte() + offset.0 <= last_child.end_byte() } else { - range.start.to_offset(map, Bias::Left) >= first_child.start_byte() - && range.end.to_offset(map, Bias::Left) <= last_child.start_byte() + 1 + excerpt + .map_offset_to_buffer(range.start.to_offset(map, Bias::Left)) + .0 + >= first_child.start_byte() + && excerpt + .map_offset_to_buffer(range.end.to_offset(map, Bias::Left)) + .0 + <= last_child.start_byte() + 1 }; if open_tag.is_some() && open_tag == close_tag && is_valid { let range = if around { @@ -875,6 +937,7 @@ pub fn surrounding_html_tag( } else { first_child.byte_range().end..last_child.byte_range().start }; + let range = BufferOffset(range.start)..BufferOffset(range.end); if excerpt.contains_buffer_range(range.clone()) { let result = excerpt.map_range_from_buffer(range); return Some( @@ -1041,7 +1104,8 @@ fn text_object( .collect(); matches.sort_by_key(|r| r.end - r.start); if let Some(buffer_range) = matches.first() { - let range = excerpt.map_range_from_buffer(buffer_range.clone()); + let buffer_range = BufferOffset(buffer_range.start)..BufferOffset(buffer_range.end); + let range = excerpt.map_range_from_buffer(buffer_range); return Some(range.start.to_display_point(map)..range.end.to_display_point(map)); } @@ -1061,10 +1125,12 @@ fn text_object( if let Some(buffer_range) = matches.first() && !buffer_range.is_empty() { - let range = excerpt.map_range_from_buffer(buffer_range.clone()); + let buffer_range = BufferOffset(buffer_range.start)..BufferOffset(buffer_range.end); + let range = excerpt.map_range_from_buffer(buffer_range); return Some(range.start.to_display_point(map)..range.end.to_display_point(map)); } - let buffer_range = excerpt.map_range_from_buffer(around_range.clone()); + let around_range = BufferOffset(around_range.start)..BufferOffset(around_range.end); + let buffer_range = excerpt.map_range_from_buffer(around_range); return Some(buffer_range.start.to_display_point(map)..buffer_range.end.to_display_point(map)); } @@ -1082,9 +1148,9 @@ fn argument( fn comma_delimited_range_at( buffer: &BufferSnapshot, - mut offset: usize, + mut offset: BufferOffset, include_comma: bool, - ) -> Option> { + ) -> Option> { // Seek to the first non-whitespace character offset += buffer .chars_at(offset) @@ -1099,7 +1165,7 @@ fn argument( } // If the cursor is outside the brackets, ignore them - if open.start == offset || close.end == offset { + if open.start == offset.0 || close.end == offset.0 { return false; } @@ -1115,7 +1181,7 @@ fn argument( let (open_bracket, close_bracket) = buffer.innermost_enclosing_bracket_ranges(offset..offset, Some(&bracket_filter))?; - let inner_bracket_range = open_bracket.end..close_bracket.start; + let inner_bracket_range = BufferOffset(open_bracket.end)..BufferOffset(close_bracket.start); let layer = buffer.syntax_layer_at(offset)?; let node = layer.node(); @@ -1134,7 +1200,7 @@ fn argument( parent_covers_bracket_range = covers_bracket_range; // Unable to find a child node with a parent that covers the bracket range, so no argument to select - cursor.goto_first_child_for_byte(offset)?; + cursor.goto_first_child_for_byte(offset.0)?; } let mut argument_node = cursor.node(); @@ -1204,7 +1270,7 @@ fn argument( } } - Some(start..end) + Some(BufferOffset(start)..BufferOffset(end)) } let result = comma_delimited_range_at(buffer, excerpt.map_offset_to_buffer(offset), around)?; @@ -1335,7 +1401,7 @@ fn is_possible_sentence_start(character: char) -> bool { const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?']; const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\'']; const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n']; -fn is_sentence_end(map: &DisplaySnapshot, offset: usize) -> bool { +fn is_sentence_end(map: &DisplaySnapshot, offset: MultiBufferOffset) -> bool { let mut next_chars = map.buffer_chars_at(offset).peekable(); if let Some((char, _)) = next_chars.next() { // We are at a double newline. This position is a sentence end. @@ -1697,8 +1763,10 @@ pub fn surrounding_markers( #[cfg(test)] mod test { + use editor::{Editor, EditorMode, MultiBuffer, test::editor_test_context::EditorTestContext}; use gpui::KeyBinding; use indoc::indoc; + use text::Point; use crate::{ object::{AnyBrackets, AnyQuotes, MiniBrackets}, @@ -2314,9 +2382,10 @@ mod test { Mode::Insert, ); - cx.set_state("let a = (test::call(), 'p', my_macro!{ˇ});", Mode::Normal); - cx.simulate_keystrokes("c a a"); - cx.assert_state("let a = (test::call(), 'p'ˇ);", Mode::Insert); + // TODO regressed with the up-to-date Rust grammar. + // cx.set_state("let a = (test::call(), 'p', my_macro!{ˇ});", Mode::Normal); + // cx.simulate_keystrokes("c a a"); + // cx.assert_state("let a = (test::call(), 'p'ˇ);", Mode::Insert); cx.set_state("let a = [test::call(ˇ), 300];", Mode::Normal); cx.simulate_keystrokes("c i a"); @@ -3146,6 +3215,78 @@ mod test { } } + #[gpui::test] + async fn test_minibrackets_multibuffer(cx: &mut gpui::TestAppContext) { + // Initialize test context with the TypeScript language loaded, so we + // can actually get brackets definition. + let mut cx = VimTestContext::new(cx, true).await; + + // Update `b` to `MiniBrackets` so we can later use it when simulating + // keystrokes. + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new("b", MiniBrackets, None)]); + }); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("111\n222\n333\n444\n", vec![Point::row_range(0..2)]), + ("111\na {bracket} example\n", vec![Point::row_range(0..2)]), + ], + cx, + ); + + // In order for the brackets to actually be found, we need to update + // the language used for the second buffer. This is something that + // is handled automatically when simply using `VimTestContext::new` + // but, since this is being set manually, the language isn't + // automatically set. + let editor = Editor::new(EditorMode::full(), multi_buffer.clone(), None, window, cx); + let buffer_ids = multi_buffer.read(cx).excerpt_buffer_ids(); + if let Some(buffer) = multi_buffer.read(cx).buffer(buffer_ids[1]) { + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(language::rust_lang()), cx); + }) + }; + + editor + }); + + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + ˇ111 + 222 + [EXCERPT] + 111 + a {bracket} example + " + }); + + cx.simulate_keystrokes("j j j j f r"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 111 + 222 + [EXCERPT] + 111 + a {bˇracket} example + " + }); + + cx.simulate_keystrokes("d i b"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 111 + 222 + [EXCERPT] + 111 + a {ˇ} example + " + }); + } + #[gpui::test] async fn test_minibrackets_trailing_space(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index 40fe4f213e..63d452f84b 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -1,5 +1,5 @@ use crate::{ - Vim, + Operator, Vim, motion::{self, Motion}, object::Object, state::Mode, @@ -8,7 +8,7 @@ use editor::{ Anchor, Bias, Editor, EditorSnapshot, SelectionEffects, ToOffset, ToPoint, display_map::ToDisplayPoint, }; -use gpui::{Context, Window, actions}; +use gpui::{ClipboardEntry, Context, Window, actions}; use language::{Point, SelectionGoal}; use std::ops::Range; use std::sync::Arc; @@ -53,7 +53,7 @@ impl Vim { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); let map = editor.snapshot(window, cx); - let display_selections = editor.selections.all::(cx); + let display_selections = editor.selections.all::(&map.display_snapshot); // Handles all string that require manipulation, including inserts and replaces let edits = display_selections @@ -98,7 +98,7 @@ impl Vim { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); let map = editor.snapshot(window, cx); - let selections = editor.selections.all::(cx); + let selections = editor.selections.all::(&map.display_snapshot); let mut new_selections = vec![]; let edits: Vec<(Range, String)> = selections .into_iter() @@ -150,7 +150,9 @@ impl Vim { self.stop_recording(cx); self.update_editor(cx, |vim, editor, cx| { editor.set_clip_at_line_ends(false, cx); - let mut selection = editor.selections.newest_display(cx); + let mut selection = editor + .selections + .newest_display(&editor.display_snapshot(cx)); let snapshot = editor.snapshot(window, cx); object.expand_selection(&snapshot, &mut selection, around, None); let start = snapshot @@ -196,7 +198,9 @@ impl Vim { self.update_editor(cx, |vim, editor, cx| { editor.set_clip_at_line_ends(false, cx); let text_layout_details = editor.text_layout_details(window); - let mut selection = editor.selections.newest_display(cx); + let mut selection = editor + .selections + .newest_display(&editor.display_snapshot(cx)); let snapshot = editor.snapshot(window, cx); motion.expand_selection( &snapshot, @@ -269,15 +273,32 @@ impl Vim { let ranges = [new_range]; editor.highlight_background::( &ranges, - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); } } + + /// Pastes the clipboard contents, replacing the same number of characters + /// as the clipboard's contents. + pub fn paste_replace(&mut self, window: &mut Window, cx: &mut Context) { + let clipboard_text = + cx.read_from_clipboard() + .and_then(|item| match item.entries().first() { + Some(ClipboardEntry::String(text)) => Some(text.text().to_string()), + _ => None, + }); + + if let Some(text) = clipboard_text { + self.push_operator(Operator::Replace, window, cx); + self.normal_replace(Arc::from(text), window, cx); + } + } } #[cfg(test)] mod test { + use gpui::ClipboardItem; use indoc::indoc; use crate::{ @@ -517,4 +538,22 @@ mod test { assert_eq!(0, highlights.len()); }); } + + #[gpui::test] + async fn test_paste_replace(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state(indoc! {"ˇ123"}, Mode::Replace); + cx.write_to_clipboard(ClipboardItem::new_string("456".to_string())); + cx.dispatch_action(editor::actions::Paste); + cx.assert_state(indoc! {"45ˇ6"}, Mode::Replace); + + // If the clipboard's contents length is greater than the remaining text + // length, nothing sould be replace and cursor should remain in the same + // position. + cx.set_state(indoc! {"ˇ123"}, Mode::Replace); + cx.write_to_clipboard(ClipboardItem::new_string("4567".to_string())); + cx.dispatch_action(editor::actions::Paste); + cx.assert_state(indoc! {"ˇ123"}, Mode::Replace); + } } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 3458a92442..2a8aa91063 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -6,7 +6,7 @@ use crate::{ToggleMarksView, ToggleRegistersView, UseSystemClipboard, Vim, VimAd use crate::{motion::Motion, object::Object}; use anyhow::Result; use collections::HashMap; -use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor}; +use command_palette_hooks::{CommandPaletteFilter, GlobalCommandPaletteInterceptor}; use db::{ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, sqlez_macros::sql, @@ -38,8 +38,9 @@ use util::rel_path::RelPath; use workspace::searchable::Direction; use workspace::{Workspace, WorkspaceDb, WorkspaceId}; -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Copy, Default, Debug, PartialEq, Serialize, Deserialize)] pub enum Mode { + #[default] Normal, Insert, Replace, @@ -74,12 +75,6 @@ impl Mode { } } -impl Default for Mode { - fn default() -> Self { - Self::Normal - } -} - #[derive(Clone, Debug, PartialEq)] pub enum Operator { Change, @@ -109,6 +104,9 @@ pub enum Operator { }, ChangeSurrounds { target: Option, + /// Represents whether the opening bracket was used for the target + /// object. + opening: bool, }, DeleteSurrounds, Mark, @@ -219,6 +217,7 @@ pub struct VimGlobals { pub forced_motion: bool, pub stop_recording_after_next_action: bool, pub ignore_current_insertion: bool, + pub recording_count: Option, pub recorded_count: Option, pub recording_actions: Vec, pub recorded_actions: Vec, @@ -303,8 +302,8 @@ impl MarksState { fn load(&mut self, cx: &mut Context) { cx.spawn(async move |this, cx| { - let Some(workspace_id) = this.update(cx, |this, cx| this.workspace_id(cx))? else { - return Ok(()); + let Some(workspace_id) = this.update(cx, |this, cx| this.workspace_id(cx)).ok()? else { + return None; }; let (marks, paths) = cx .background_spawn(async move { @@ -312,10 +311,12 @@ impl MarksState { let paths = DB.get_global_marks_paths(workspace_id)?; anyhow::Ok((marks, paths)) }) - .await?; + .await + .log_err()?; this.update(cx, |this, cx| this.loaded(marks, paths, cx)) + .ok() }) - .detach_and_log_err(cx); + .detach(); } fn loaded( @@ -549,6 +550,10 @@ impl MarksState { let buffer = multibuffer.read(cx).as_singleton(); let abs_path = buffer.as_ref().and_then(|b| self.path_for_buffer(b, cx)); + if self.is_global_mark(&name) && self.global_marks.contains_key(&name) { + self.delete_mark(name.clone(), multibuffer, cx); + } + let Some(abs_path) = abs_path else { self.multibuffer_marks .entry(multibuffer.entity_id()) @@ -572,7 +577,7 @@ impl MarksState { let buffer_id = buffer.read(cx).remote_id(); self.buffer_marks.entry(buffer_id).or_default().insert( - name, + name.clone(), anchors .into_iter() .map(|anchor| anchor.text_anchor) @@ -581,6 +586,10 @@ impl MarksState { if !self.watched_buffers.contains_key(&buffer_id) { self.watch_buffer(MarkLocation::Path(abs_path.clone()), &buffer, cx) } + if self.is_global_mark(&name) { + self.global_marks + .insert(name, MarkLocation::Path(abs_path.clone())); + } self.serialize_buffer_marks(abs_path, &buffer, cx) } @@ -605,7 +614,7 @@ impl MarksState { let text_anchors = anchors.get(name)?; let anchors = text_anchors .iter() - .map(|anchor| Anchor::in_buffer(excerpt_id, buffer_id, *anchor)) + .map(|anchor| Anchor::in_buffer(excerpt_id, *anchor)) .collect(); return Some(Mark::Local(anchors)); } @@ -715,9 +724,7 @@ impl VimGlobals { CommandPaletteFilter::update_global(cx, |filter, _| { filter.show_namespace(Vim::NAMESPACE); }); - CommandPaletteInterceptor::update_global(cx, |interceptor, _| { - interceptor.set(Box::new(command_interceptor)); - }); + GlobalCommandPaletteInterceptor::set(cx, command_interceptor); for window in cx.windows() { if let Some(workspace) = window.downcast::() { workspace @@ -732,9 +739,7 @@ impl VimGlobals { } else { KeyBinding::set_vim_mode(cx, false); *Vim::globals(cx) = VimGlobals::default(); - CommandPaletteInterceptor::update_global(cx, |interceptor, _| { - interceptor.clear(); - }); + GlobalCommandPaletteInterceptor::clear(cx); CommandPaletteFilter::update_global(cx, |filter, _| { filter.hide_namespace(Vim::NAMESPACE); }); @@ -864,7 +869,9 @@ impl VimGlobals { } } '%' => editor.and_then(|editor| { - let selection = editor.selections.newest::(cx); + let selection = editor + .selections + .newest::(&editor.display_snapshot(cx)); if let Some((_, buffer, _)) = editor .buffer() .read(cx) @@ -900,6 +907,7 @@ impl VimGlobals { if self.stop_recording_after_next_action { self.dot_recording = false; self.recorded_actions = std::mem::take(&mut self.recording_actions); + self.recorded_count = self.recording_count.take(); self.stop_recording_after_next_action = false; } } @@ -926,6 +934,7 @@ impl VimGlobals { if self.stop_recording_after_next_action { self.dot_recording = false; self.recorded_actions = std::mem::take(&mut self.recording_actions); + self.recorded_count = self.recording_count.take(); self.stop_recording_after_next_action = false; } } @@ -1077,7 +1086,9 @@ impl Operator { | Operator::Replace | Operator::Digraph { .. } | Operator::Literal { .. } - | Operator::ChangeSurrounds { target: Some(_) } + | Operator::ChangeSurrounds { + target: Some(_), .. + } | Operator::DeleteSurrounds => true, Operator::Change | Operator::Delete @@ -1094,7 +1105,7 @@ impl Operator { | Operator::ReplaceWithRegister | Operator::Exchange | Operator::Object { .. } - | Operator::ChangeSurrounds { target: None } + | Operator::ChangeSurrounds { target: None, .. } | Operator::OppositeCase | Operator::ToggleComments | Operator::HelixMatch @@ -1121,7 +1132,7 @@ impl Operator { | Operator::Rewrap | Operator::ShellCommand | Operator::AddSurrounds { target: None } - | Operator::ChangeSurrounds { target: None } + | Operator::ChangeSurrounds { target: None, .. } | Operator::DeleteSurrounds | Operator::Exchange | Operator::HelixNext { .. } diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index 78fa02f695..b3f9307aac 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -4,7 +4,7 @@ use crate::{ object::{Object, surrounding_markers}, state::Mode, }; -use editor::{Bias, movement}; +use editor::{Bias, MultiBufferOffset, movement}; use gpui::{Context, Window}; use language::BracketPair; @@ -45,7 +45,8 @@ impl Vim { }, }; let surround = pair.end != surround_alias((*text).as_ref()); - let (display_map, display_selections) = editor.selections.all_adjusted_display(cx); + let display_map = editor.display_snapshot(cx); + let display_selections = editor.selections.all_adjusted_display(&display_map); let mut edits = Vec::new(); let mut anchors = Vec::new(); @@ -144,7 +145,8 @@ impl Vim { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); - let (display_map, display_selections) = editor.selections.all_display(cx); + let display_map = editor.display_snapshot(cx); + let display_selections = editor.selections.all_display(&display_map); let mut edits = Vec::new(); let mut anchors = Vec::new(); @@ -173,7 +175,7 @@ impl Vim { while let Some((ch, offset)) = chars_and_offset.next() { if ch.to_string() == pair.start { let start = offset; - let mut end = start + 1; + let mut end = start + 1usize; if surround && let Some((next_ch, _)) = chars_and_offset.peek() && next_ch.eq(&' ') @@ -191,7 +193,7 @@ impl Vim { while let Some((ch, offset)) = reverse_chars_and_offsets.next() { if ch.to_string() == pair.end { let mut start = offset; - let end = start + 1; + let end = start + 1usize; if surround && let Some((next_ch, _)) = reverse_chars_and_offsets.peek() && next_ch.eq(&' ') @@ -221,6 +223,7 @@ impl Vim { &mut self, text: Arc, target: Object, + opening: bool, window: &mut Window, cx: &mut Context, ) { @@ -241,18 +244,22 @@ impl Vim { }, }; - // Determines whether space should be added after - // and before the surround pairs. - // Space is only added in the following cases: - // - new surround is not quote and is opening bracket (({[<) - // - new surround is quote and original was also quote - let surround = if pair.start != pair.end { - pair.end != surround_alias((*text).as_ref()) - } else { - will_replace_pair.start == will_replace_pair.end - }; + // A single space should be added if the new surround is a + // bracket and not a quote (pair.start != pair.end) and if + // the bracket used is the opening bracket. + let add_space = + !(pair.start == pair.end) && (pair.end != surround_alias((*text).as_ref())); - let (display_map, selections) = editor.selections.all_adjusted_display(cx); + // Space should be preserved if either the surrounding + // characters being updated are quotes + // (will_replace_pair.start == will_replace_pair.end) or if + // the bracket used in the command is not an opening + // bracket. + let preserve_space = + will_replace_pair.start == will_replace_pair.end || !opening; + + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_adjusted_display(&display_map); let mut edits = Vec::new(); let mut anchors = Vec::new(); @@ -269,24 +276,38 @@ impl Vim { continue; } } + + // Keeps track of the length of the string that is + // going to be edited on the start so we can ensure + // that the end replacement string does not exceed + // this value. Helpful when dealing with newlines. + let mut edit_len = 0; + let mut open_range_end = MultiBufferOffset(0); let mut chars_and_offset = display_map .buffer_chars_at(range.start.to_offset(&display_map, Bias::Left)) .peekable(); + while let Some((ch, offset)) = chars_and_offset.next() { if ch.to_string() == will_replace_pair.start { let mut open_str = pair.start.clone(); let start = offset; - let mut end = start + 1; - if let Some((next_ch, _)) = chars_and_offset.peek() { - // If the next position is already a space or line break, - // we don't need to splice another space even under around - if surround && !next_ch.is_whitespace() { - open_str.push(' '); - } else if !surround && next_ch.to_string() == " " { - end += 1; + open_range_end = start + 1usize; + while let Some((next_ch, _)) = chars_and_offset.next() + && next_ch == ' ' + { + open_range_end += 1; + + if preserve_space { + open_str.push(next_ch); } } - edits.push((start..end, open_str)); + + if add_space { + open_str.push(' '); + }; + + edit_len = open_range_end - start; + edits.push((start..open_range_end, open_str)); anchors.push(start..start); break; } @@ -299,16 +320,26 @@ impl Vim { .peekable(); while let Some((ch, offset)) = reverse_chars_and_offsets.next() { if ch.to_string() == will_replace_pair.end { - let mut close_str = pair.end.clone(); + let mut close_str = String::new(); let mut start = offset; - let end = start + 1; - if let Some((next_ch, _)) = reverse_chars_and_offsets.peek() { - if surround && !next_ch.is_whitespace() { - close_str.insert(0, ' ') - } else if !surround && next_ch.to_string() == " " { - start -= 1; + let end = start + 1usize; + while let Some((next_ch, _)) = reverse_chars_and_offsets.next() + && next_ch == ' ' + && close_str.len() < edit_len - 1 + && start > open_range_end + { + start -= 1; + + if preserve_space { + close_str.push(next_ch); } } + + if add_space { + close_str.push(' '); + }; + + close_str.push_str(&pair.end); edits.push((start..end, close_str)); break; } @@ -356,7 +387,8 @@ impl Vim { self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); - let (display_map, selections) = editor.selections.all_adjusted_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_adjusted_display(&display_map); let mut anchors = Vec::new(); for selection in &selections { @@ -448,7 +480,7 @@ impl Vim { surround: true, newline: false, }), - Object::CurlyBrackets => Some(BracketPair { + Object::CurlyBrackets { .. } => Some(BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, @@ -474,7 +506,8 @@ impl Vim { let mut min_range_size = usize::MAX; let _ = self.editor.update(cx, |editor, cx| { - let (display_map, selections) = editor.selections.all_adjusted_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_adjusted_display(&display_map); // Even if there's multiple cursors, we'll simply rely on // the first one to understand what bracket pair to map to. // I believe we could, if worth it, go one step above and @@ -1194,7 +1227,47 @@ mod test { };"}, Mode::Normal, ); - cx.simulate_keystrokes("c s { ["); + cx.simulate_keystrokes("c s } ]"); + cx.assert_state( + indoc! {" + fn test_surround() ˇ[ + if 2 > 1 ˇ[ + println!(\"it is fine\"); + ] + ];"}, + Mode::Normal, + ); + + // test spaces with quote change surrounds + cx.set_state( + indoc! {" + fn test_surround() { + \"ˇ \" + };"}, + Mode::Normal, + ); + cx.simulate_keystrokes("c s \" '"); + cx.assert_state( + indoc! {" + fn test_surround() { + ˇ' ' + };"}, + Mode::Normal, + ); + + // Currently, the same test case but using the closing bracket `]` + // actually removes a whitespace before the closing bracket, something + // that might need to be fixed? + cx.set_state( + indoc! {" + fn test_surround() { + ifˇ 2 > 1 { + ˇprintln!(\"it is fine\"); + } + };"}, + Mode::Normal, + ); + cx.simulate_keystrokes("c s { ]"); cx.assert_state( indoc! {" fn test_surround() ˇ[ @@ -1270,7 +1343,7 @@ mod test { cx.assert_state(indoc! {"ˇ[ bracketed ]"}, Mode::Normal); cx.set_state(indoc! {"(< name: ˇ'Zed' >)"}, Mode::Normal); - cx.simulate_keystrokes("c s b {"); + cx.simulate_keystrokes("c s b }"); cx.assert_state(indoc! {"(ˇ{ name: 'Zed' })"}, Mode::Normal); cx.set_state( @@ -1290,6 +1363,66 @@ mod test { ); } + // The following test cases all follow tpope/vim-surround's behaviour + // and are more focused on how whitespace is handled. + #[gpui::test] + async fn test_change_surrounds_vim(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Changing quote to quote should never change the surrounding + // whitespace. + cx.set_state(indoc! {"' ˇa '"}, Mode::Normal); + cx.simulate_keystrokes("c s ' \""); + cx.assert_state(indoc! {"ˇ\" a \""}, Mode::Normal); + + cx.set_state(indoc! {"\" ˇa \""}, Mode::Normal); + cx.simulate_keystrokes("c s \" '"); + cx.assert_state(indoc! {"ˇ' a '"}, Mode::Normal); + + // Changing quote to bracket adds one more space when the opening + // bracket is used, does not affect whitespace when the closing bracket + // is used. + cx.set_state(indoc! {"' ˇa '"}, Mode::Normal); + cx.simulate_keystrokes("c s ' {"); + cx.assert_state(indoc! {"ˇ{ a }"}, Mode::Normal); + + cx.set_state(indoc! {"' ˇa '"}, Mode::Normal); + cx.simulate_keystrokes("c s ' }"); + cx.assert_state(indoc! {"ˇ{ a }"}, Mode::Normal); + + // Changing bracket to quote should remove all space when the + // opening bracket is used and preserve all space when the + // closing one is used. + cx.set_state(indoc! {"{ ˇa }"}, Mode::Normal); + cx.simulate_keystrokes("c s { '"); + cx.assert_state(indoc! {"ˇ'a'"}, Mode::Normal); + + cx.set_state(indoc! {"{ ˇa }"}, Mode::Normal); + cx.simulate_keystrokes("c s } '"); + cx.assert_state(indoc! {"ˇ' a '"}, Mode::Normal); + + // Changing bracket to bracket follows these rules: + // * opening → opening – keeps only one space. + // * opening → closing – removes all space. + // * closing → opening – adds one space. + // * closing → closing – does not change space. + cx.set_state(indoc! {"{ ˇa }"}, Mode::Normal); + cx.simulate_keystrokes("c s { ["); + cx.assert_state(indoc! {"ˇ[ a ]"}, Mode::Normal); + + cx.set_state(indoc! {"{ ˇa }"}, Mode::Normal); + cx.simulate_keystrokes("c s { ]"); + cx.assert_state(indoc! {"ˇ[a]"}, Mode::Normal); + + cx.set_state(indoc! {"{ ˇa }"}, Mode::Normal); + cx.simulate_keystrokes("c s } ["); + cx.assert_state(indoc! {"ˇ[ a ]"}, Mode::Normal); + + cx.set_state(indoc! {"{ ˇa }"}, Mode::Normal); + cx.simulate_keystrokes("c s } ]"); + cx.assert_state(indoc! {"ˇ[ a ]"}, Mode::Normal); + } + #[gpui::test] async fn test_surrounds(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 2f130356a7..4c61479157 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -2,18 +2,21 @@ mod neovim_backed_test_context; mod neovim_connection; mod vim_test_context; -use std::time::Duration; +use std::{sync::Arc, time::Duration}; use collections::HashMap; use command_palette::CommandPalette; use editor::{ - AnchorRangeExt, DisplayPoint, Editor, EditorMode, MultiBuffer, actions::DeleteLine, - code_context_menus::CodeContextMenu, display_map::DisplayRow, + AnchorRangeExt, DisplayPoint, Editor, EditorMode, MultiBuffer, MultiBufferOffset, + actions::{DeleteLine, WrapSelectionsInTag}, + code_context_menus::CodeContextMenu, + display_map::DisplayRow, test::editor_test_context::EditorTestContext, }; use futures::StreamExt; use gpui::{KeyBinding, Modifiers, MouseButton, TestAppContext, px}; -use language::Point; +use itertools::Itertools; +use language::{CursorShape, Language, LanguageConfig, Point}; pub use neovim_backed_test_context::*; use settings::SettingsStore; use ui::Pixels; @@ -905,6 +908,9 @@ fn assert_pending_input(cx: &mut VimTestContext, expected: &str) { .map(|highlight| highlight.to_offset(&snapshot.buffer_snapshot())) .collect::>(), ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>() ) }); } @@ -964,7 +970,7 @@ async fn test_jk_delay(cx: &mut gpui::TestAppContext) { .iter() .map(|highlight| highlight.to_offset(&snapshot.buffer_snapshot())) .collect::>(), - vec![0..1] + vec![MultiBufferOffset(0)..MultiBufferOffset(1)] ) }); cx.executor().advance_clock(Duration::from_millis(500)); @@ -974,6 +980,21 @@ async fn test_jk_delay(cx: &mut gpui::TestAppContext) { cx.assert_state("jˇkhello", Mode::Normal); } +#[perf] +#[gpui::test] +async fn test_jk_max_count(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("1\nˇ2\n3").await; + cx.simulate_shared_keystrokes("9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 j") + .await; + cx.shared_state().await.assert_eq("1\n2\nˇ3"); + + let number: String = usize::MAX.to_string().split("").join(" "); + cx.simulate_shared_keystrokes(&format!("{number} k")).await; + cx.shared_state().await.assert_eq("ˇ1\n2\n3"); +} + #[perf] #[gpui::test] async fn test_comma_w(cx: &mut gpui::TestAppContext) { @@ -1121,6 +1142,26 @@ async fn test_rename(cx: &mut gpui::TestAppContext) { cx.assert_state("const afterˇ = 2; console.log(after)", Mode::Normal) } +#[gpui::test] +async fn test_go_to_definition(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new_typescript(cx).await; + + cx.set_state("const before = 2; console.log(beforˇe)", Mode::Normal); + let def_range = cx.lsp_range("const «beforeˇ» = 2; console.log(before)"); + let mut go_to_request = + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new(url.clone(), def_range), + ))) + }); + + cx.simulate_keystrokes("g d"); + go_to_request.next().await.unwrap(); + cx.run_until_parked(); + + cx.assert_state("const ˇbefore = 2; console.log(before)", Mode::Normal); +} + #[perf] #[gpui::test] async fn test_remap(cx: &mut gpui::TestAppContext) { @@ -2212,6 +2253,79 @@ async fn test_paragraph_multi_delete(cx: &mut gpui::TestAppContext) { cx.shared_state().await.assert_eq(indoc! {"ˇ"}); } +#[perf] +#[gpui::test] +async fn test_yank_paragraph_with_paste(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + " + first paragraph + ˇstill first + + second paragraph + still second + + third paragraph + " + }) + .await; + + cx.simulate_shared_keystrokes("y a p").await; + cx.shared_clipboard() + .await + .assert_eq("first paragraph\nstill first\n\n"); + + cx.simulate_shared_keystrokes("j j p").await; + cx.shared_state().await.assert_eq(indoc! { + " + first paragraph + still first + + ˇfirst paragraph + still first + + second paragraph + still second + + third paragraph + " + }); +} + +#[perf] +#[gpui::test] +async fn test_change_paragraph(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + " + first paragraph + ˇstill first + + second paragraph + still second + + third paragraph + " + }) + .await; + + cx.simulate_shared_keystrokes("c a p").await; + cx.shared_clipboard() + .await + .assert_eq("first paragraph\nstill first\n\n"); + + cx.simulate_shared_keystrokes("escape").await; + cx.shared_state().await.assert_eq(indoc! { + " + ˇ + second paragraph + still second + + third paragraph + " + }); +} + #[perf] #[gpui::test] async fn test_multi_cursor_replay(cx: &mut gpui::TestAppContext) { @@ -2279,10 +2393,13 @@ async fn test_clipping_on_mode_change(cx: &mut gpui::TestAppContext) { let mut pixel_position = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); - let current_head = editor.selections.newest_display(cx).end; + let current_head = editor + .selections + .newest_display(&snapshot.display_snapshot) + .end; editor.last_bounds().unwrap().origin + editor - .display_to_pixel_point(current_head, &snapshot, window) + .display_to_pixel_point(current_head, &snapshot, window, cx) .unwrap() }); pixel_position.x += px(100.); @@ -2300,3 +2417,87 @@ async fn test_clipping_on_mode_change(cx: &mut gpui::TestAppContext) { Mode::Normal, ); } + +#[gpui::test] +async fn test_wrap_selections_in_tag_line_mode(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state( + indoc! { + " + ˇaaaaa + bbbbb + " + }, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v j"); + cx.dispatch_action(WrapSelectionsInTag); + + cx.assert_state( + indoc! { + " + <ˇ>aaaaa + bbbbb + " + }, + Mode::VisualLine, + ); +} + +#[gpui::test] +async fn test_repeat_grouping_41735(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // typically transaction gropuing is disabled in tests, but here we need to test it. + cx.update_buffer(|buffer, _cx| buffer.set_group_interval(Duration::from_millis(300))); + + cx.set_shared_state("ˇ").await; + + cx.simulate_shared_keystrokes("i a escape").await; + cx.simulate_shared_keystrokes(". . .").await; + cx.shared_state().await.assert_eq("ˇaaaa"); + cx.simulate_shared_keystrokes("u").await; + cx.shared_state().await.assert_eq("ˇaaa"); +} + +#[gpui::test] +async fn test_deactivate(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.cursor_shape = Some(settings::CursorShape::Underline); + }); + }); + + // Assert that, while in `Normal` mode, the cursor shape is `Block` but, + // after deactivating vim mode, it should revert to the one specified in the + // user's settings, if set. + cx.update_editor(|editor, _window, _cx| { + assert_eq!(editor.cursor_shape(), CursorShape::Block); + }); + + cx.disable_vim(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!(editor.cursor_shape(), CursorShape::Underline); + }); +} diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index bc4d47d8ea..d20464ccc4 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -31,6 +31,7 @@ pub struct SharedState { } impl SharedState { + /// Assert that both Zed and NeoVim have the same content and mode. #[track_caller] pub fn assert_matches(&self) { if self.neovim != self.editor || self.neovim_mode != self.editor_mode { @@ -183,6 +184,26 @@ impl NeovimBackedTestContext { } } + pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext { + #[cfg(feature = "neovim")] + cx.executor().allow_parking(); + let thread = thread::current(); + let test_name = thread + .name() + .expect("thread is not named") + .split(':') + .next_back() + .unwrap() + .to_string(); + Self { + cx: VimTestContext::new_markdown_with_rust(cx).await, + neovim: NeovimConnection::new(test_name).await, + + last_set_state: None, + recent_keystrokes: Default::default(), + } + } + pub async fn new_typescript(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext { #[cfg(feature = "neovim")] cx.executor().allow_parking(); @@ -207,6 +228,26 @@ impl NeovimBackedTestContext { } } + pub async fn new_tsx(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext { + #[cfg(feature = "neovim")] + cx.executor().allow_parking(); + let thread = thread::current(); + let test_name = thread + .name() + .expect("thread is not named") + .split(':') + .next_back() + .unwrap() + .to_string(); + Self { + cx: VimTestContext::new_tsx(cx).await, + neovim: NeovimConnection::new(test_name).await, + + last_set_state: None, + recent_keystrokes: Default::default(), + } + } + pub async fn set_shared_state(&mut self, marked_text: &str) { let mode = if marked_text.contains('»') { Mode::Visual @@ -263,11 +304,10 @@ impl NeovimBackedTestContext { self.neovim.set_option(&format!("scrolloff={}", 3)).await; // +2 to account for the vim command UI at the bottom. self.neovim.set_option(&format!("lines={}", rows + 2)).await; - let (line_height, visible_line_count) = self.editor(|editor, window, _cx| { + let (line_height, visible_line_count) = self.update_editor(|editor, window, cx| { ( editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()), editor.visible_line_count().unwrap(), diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index a2db0493d9..2d5ed4227d 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -1,8 +1,9 @@ use std::ops::{Deref, DerefMut}; use editor::test::editor_lsp_test_context::EditorLspTestContext; -use gpui::{Context, Entity, SemanticVersion, UpdateGlobal}; +use gpui::{Context, Entity, UpdateGlobal}; use search::{BufferSearchBar, project_search::ProjectSearchBar}; +use semver::Version; use crate::{state::Operator, *}; @@ -19,17 +20,16 @@ impl VimTestContext { cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(Version::new(0, 0, 0), cx); command_palette::init(cx); project_panel::init(cx); + outline_panel::init(cx); git_ui::init(cx); crate::init(cx); search::init(cx); - workspace::init_settings(cx); - language::init(cx); - editor::init_settings(cx); - project::Project::init_settings(cx); theme::init(theme::LoadThemes::JustBase, cx); + settings_ui::init(cx); + markdown_preview::init(cx); }); } @@ -44,10 +44,38 @@ impl VimTestContext { Self::new_with_lsp(EditorLspTestContext::new_html(cx).await, true) } + pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> VimTestContext { + Self::init(cx); + Self::new_with_lsp(EditorLspTestContext::new_markdown_with_rust(cx).await, true) + } + pub async fn new_typescript(cx: &mut gpui::TestAppContext) -> VimTestContext { Self::init(cx); Self::new_with_lsp( EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..Default::default() + }), + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: Default::default(), + })), + definition_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await, + true, + ) + } + + pub async fn new_tsx(cx: &mut gpui::TestAppContext) -> VimTestContext { + Self::init(cx); + Self::new_with_lsp( + EditorLspTestContext::new_tsx( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index c9ca7cb325..26fec968fb 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -19,10 +19,12 @@ mod state; mod surrounds; mod visual; +use crate::normal::paste::Paste as VimPaste; use collections::HashMap; use editor::{ - Anchor, Bias, Editor, EditorEvent, EditorSettings, HideMouseCursorOrigin, SelectionEffects, - ToPoint, + Anchor, Bias, Editor, EditorEvent, EditorSettings, HideMouseCursorOrigin, MultiBufferOffset, + SelectionEffects, ToPoint, + actions::Paste, movement::{self, FindRange}, }; use gpui::{ @@ -39,6 +41,7 @@ use normal::search::SearchSubmit; use object::Object; use schemars::JsonSchema; use serde::Deserialize; +use settings::RegisterSetting; pub use settings::{ ModeContent, Settings, SettingsStore, UseSystemClipboard, update_settings_file, }; @@ -51,7 +54,10 @@ use vim_mode_setting::HelixModeSetting; use vim_mode_setting::VimModeSetting; use workspace::{self, Pane, Workspace}; -use crate::state::ReplayableAction; +use crate::{ + normal::{GoToPreviousTab, GoToTab}, + state::ReplayableAction, +}; /// Number is used to manage vim's count. Pushing a digit /// multiplies the current value by 10 and adds the digit. @@ -178,8 +184,6 @@ actions!( InnerObject, /// Maximizes the current pane. MaximizePane, - /// Opens the default keymap file. - OpenDefaultKeymap, /// Resets all pane sizes to default. ResetPaneSizes, /// Resizes the pane to the right. @@ -257,13 +261,13 @@ actions!( [ /// Toggles Vim mode on or off. ToggleVimMode, + /// Toggles Helix mode on or off. + ToggleHelixMode, ] ); /// Initializes the `vim` crate. pub fn init(cx: &mut App) { - vim_mode_setting::init(cx); - VimSettings::register(cx); VimGlobals::register(cx); cx.observe_new(Vim::register).detach(); @@ -271,9 +275,23 @@ pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { workspace.register_action(|workspace, _: &ToggleVimMode, _, cx| { let fs = workspace.app_state().fs.clone(); - let currently_enabled = Vim::enabled(cx); + let currently_enabled = VimModeSetting::get_global(cx).0; update_settings_file(fs, cx, move |setting, _| { - setting.vim_mode = Some(!currently_enabled) + setting.vim_mode = Some(!currently_enabled); + if let Some(helix_mode) = &mut setting.helix_mode { + *helix_mode = false; + } + }) + }); + + workspace.register_action(|workspace, _: &ToggleHelixMode, _, cx| { + let fs = workspace.app_state().fs.clone(); + let currently_enabled = HelixModeSetting::get_global(cx).0; + update_settings_file(fs, cx, move |setting, _| { + setting.helix_mode = Some(!currently_enabled); + if let Some(vim_mode) = &mut setting.vim_mode { + *vim_mode = false; + } }) }); @@ -295,7 +313,7 @@ pub fn init(cx: &mut App) { workspace.register_action(|_, _: &ToggleProjectPanelFocus, window, cx| { if Vim::take_count(cx).is_none() { - window.dispatch_action(project_panel::ToggleFocus.boxed_clone(), cx); + window.dispatch_action(zed_actions::project_panel::ToggleFocus.boxed_clone(), cx); } }); @@ -324,7 +342,7 @@ pub fn init(cx: &mut App) { }; }); - workspace.register_action(|_, _: &OpenDefaultKeymap, _, cx| { + workspace.register_action(|_, _: &zed_actions::vim::OpenDefaultKeymap, _, cx| { cx.emit(workspace::Event::OpenBundledFile { text: settings::vim_keymap(), title: "Default Vim Bindings", @@ -409,6 +427,46 @@ pub fn init(cx: &mut App) { cx.defer_in(window, |vim, window, cx| vim.search_submit(window, cx)) }) }); + workspace.register_action(|_, _: &GoToTab, window, cx| { + let count = Vim::take_count(cx); + Vim::take_forced_motion(cx); + + if let Some(tab_index) = count { + // gt goes to tab (1-based). + let zero_based_index = tab_index.saturating_sub(1); + window.dispatch_action( + workspace::pane::ActivateItem(zero_based_index).boxed_clone(), + cx, + ); + } else { + // If no count is provided, go to the next tab. + window.dispatch_action(workspace::pane::ActivateNextItem.boxed_clone(), cx); + } + }); + + workspace.register_action(|workspace, _: &GoToPreviousTab, window, cx| { + let count = Vim::take_count(cx); + Vim::take_forced_motion(cx); + + if let Some(count) = count { + // gT with count goes back that many tabs with wraparound (not the same as gt!). + let pane = workspace.active_pane().read(cx); + let item_count = pane.items().count(); + if item_count > 0 { + let current_index = pane.active_item_index(); + let target_index = (current_index as isize - count as isize) + .rem_euclid(item_count as isize) + as usize; + window.dispatch_action( + workspace::pane::ActivateItem(target_index).boxed_clone(), + cx, + ); + } + } else { + // No count provided, go to the previous tab. + window.dispatch_action(workspace::pane::ActivatePreviousItem.boxed_clone(), cx); + } + }); }) .detach(); } @@ -609,7 +667,7 @@ impl Vim { editor, cx, |vim, _: &SwitchToHelixNormalMode, window, cx| { - vim.switch_mode(Mode::HelixNormal, false, window, cx) + vim.switch_mode(Mode::HelixNormal, true, window, cx) }, ); Vim::action(editor, cx, |_, _: &PushForcedMotion, _, cx| { @@ -678,6 +736,7 @@ impl Vim { vim.push_operator( Operator::ChangeSurrounds { target: action.target, + opening: false, }, window, cx, @@ -859,6 +918,21 @@ impl Vim { ); }); + Vim::action( + editor, + cx, + |vim, _: &editor::actions::Paste, window, cx| match vim.mode { + Mode::Replace => vim.paste_replace(window, cx), + Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { + vim.selected_register.replace('+'); + vim.paste(&VimPaste::default(), window, cx); + } + _ => { + vim.update_editor(cx, |_, editor, cx| editor.paste(&Paste, window, cx)); + } + }, + ); + normal::register(editor, cx); insert::register(editor, cx); helix::register(editor, cx); @@ -872,14 +946,21 @@ impl Vim { change_list::register(editor, cx); digraph::register(editor, cx); - cx.defer_in(window, |vim, window, cx| { - vim.focused(false, window, cx); - }) + if editor.is_focused(window) { + cx.defer_in(window, |vim, window, cx| { + vim.focused(false, window, cx); + }) + } }) } fn deactivate(editor: &mut Editor, cx: &mut Context) { - editor.set_cursor_shape(CursorShape::Bar, cx); + editor.set_cursor_shape( + EditorSettings::get_global(cx) + .cursor_shape + .unwrap_or_default(), + cx, + ); editor.set_clip_at_line_ends(false, cx); editor.set_collapse_matches(false); editor.set_input_enabled(true); @@ -945,6 +1026,7 @@ impl Vim { self.update_editor(cx, |_, editor, cx| { editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx) }); + return; } } else if window.has_pending_keystrokes() || keystroke_event.keystroke.is_ime_in_progress() @@ -1136,7 +1218,7 @@ impl Vim { s.select_anchor_ranges(vec![pos..pos]) } - let snapshot = s.display_map(); + let snapshot = s.display_snapshot(); if let Some(pending) = s.pending_anchor_mut() && pending.reversed && mode.is_visual() @@ -1182,7 +1264,7 @@ impl Vim { }; if global_state.dot_recording { - global_state.recorded_count = count; + global_state.recording_count = count; } count } @@ -1314,7 +1396,10 @@ impl Vim { return; }; let newest_selection_empty = editor.update(cx, |editor, cx| { - editor.selections.newest::(cx).is_empty() + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .is_empty() }); let editor = editor.read(cx); let editor_mode = editor.mode(); @@ -1410,9 +1495,11 @@ impl Vim { cx: &mut Context, ) -> Option { self.update_editor(cx, |_, editor, cx| { - let selection = editor.selections.newest::(cx); + let snapshot = &editor.snapshot(window, cx); + let selection = editor + .selections + .newest::(&snapshot.display_snapshot); - let snapshot = editor.snapshot(window, cx); let snapshot = snapshot.buffer_snapshot(); let (range, kind) = snapshot.surrounding_word(selection.start, Some(CharScopeContext::Completion)); @@ -1435,13 +1522,15 @@ impl Vim { if !globals.dot_replaying { globals.dot_recording = true; globals.recording_actions = Default::default(); - globals.recorded_count = None; + globals.recording_count = None; let selections = self.editor().map(|editor| { editor.update(cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + ( - editor.selections.oldest::(cx), - editor.selections.newest::(cx), + editor.selections.oldest::(&snapshot), + editor.selections.newest::(&snapshot), ) }) }); @@ -1503,6 +1592,7 @@ impl Vim { .recording_actions .push(ReplayableAction::Action(action.boxed_clone())); globals.recorded_actions = mem::take(&mut globals.recording_actions); + globals.recorded_count = globals.recording_count.take(); globals.dot_recording = false; globals.stop_recording_after_next_action = false; } @@ -1523,6 +1613,7 @@ impl Vim { post_count .checked_mul(10) .and_then(|post_count| post_count.checked_add(number)) + .filter(|post_count| *post_count < isize::MAX as usize) .unwrap_or(post_count), ) } else { @@ -1532,6 +1623,7 @@ impl Vim { pre_count .checked_mul(10) .and_then(|pre_count| pre_count.checked_add(number)) + .filter(|pre_count| *pre_count < isize::MAX as usize) .unwrap_or(pre_count), ) } @@ -1780,10 +1872,10 @@ impl Vim { } _ => self.clear_operator(window, cx), }, - Some(Operator::ChangeSurrounds { target }) => match self.mode { + Some(Operator::ChangeSurrounds { target, opening }) => match self.mode { Mode::Normal => { if let Some(target) = target { - self.change_surrounds(text, target, window, cx); + self.change_surrounds(text, target, opening, window, cx); self.clear_operator(window, cx); } } @@ -1847,9 +1939,11 @@ impl Vim { self.update_editor(cx, |vim, editor, cx| { editor.set_cursor_shape(vim.cursor_shape(cx), cx); editor.set_clip_at_line_ends(vim.clip_at_line_ends(), cx); - editor.set_collapse_matches(true); + let collapse_matches = !HelixModeSetting::get_global(cx).0; + editor.set_collapse_matches(collapse_matches); editor.set_input_enabled(vim.editor_input_enabled()); editor.set_autoindent(vim.should_autoindent()); + editor.set_cursor_offset_on_selection(vim.mode.is_visual()); editor .selections .set_line_mode(matches!(vim.mode, Mode::VisualLine)); @@ -1861,6 +1955,7 @@ impl Vim { } } +#[derive(RegisterSetting)] struct VimSettings { pub default_mode: Mode, pub toggle_relative_line_numbers: bool, @@ -1913,7 +2008,7 @@ impl From for Mode { } impl Settings for VimSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let vim = content.vim.clone().unwrap(); Self { default_mode: vim.default_mode.unwrap().into(), diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index f8ef8e3258..3c6f237435 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use collections::HashMap; use editor::{ - Bias, DisplayPoint, Editor, SelectionEffects, + Bias, DisplayPoint, Editor, MultiBufferOffset, SelectionEffects, display_map::{DisplaySnapshot, ToDisplayPoint}, movement, }; @@ -15,10 +15,7 @@ use workspace::searchable::Direction; use crate::{ Vim, - motion::{ - Motion, MotionKind, first_non_whitespace, next_line_end, start_of_line, - start_of_relative_buffer_row, - }, + motion::{Motion, MotionKind, first_non_whitespace, next_line_end, start_of_line}, object::Object, state::{Mark, Mode, Operator}, }; @@ -182,7 +179,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { vim.update_editor(cx, |_, editor, cx| { editor.set_clip_at_line_ends(false, cx); editor.change_selections(Default::default(), window, cx, |s| { - let map = s.display_map(); + let map = s.display_snapshot(); let ranges = ranges .into_iter() .map(|(start, end, reversed)| { @@ -307,7 +304,7 @@ impl Vim { ) { let text_layout_details = editor.text_layout_details(window); editor.change_selections(Default::default(), window, cx, |s| { - let map = &s.display_map(); + let map = &s.display_snapshot(); let mut head = s.newest_anchor().head().to_display_point(map); let mut tail = s.oldest_anchor().tail().to_display_point(map); @@ -369,6 +366,8 @@ impl Vim { let mut selections = Vec::new(); let mut row = tail.row(); + let going_up = tail.row() > head.row(); + let direction = if going_up { -1 } else { 1 }; loop { let laid_out_line = map.layout_row(row, &text_layout_details); @@ -399,14 +398,21 @@ impl Vim { selections.push(selection); } - if row == head.row() { + + // When dealing with soft wrapped lines, it's possible that + // `row` ends up being set to a value other than `head.row()` as + // `head.row()` might be a `DisplayPoint` mapped to a soft + // wrapped line, hence the need for `<=` and `>=` instead of + // `==`. + if going_up && row <= head.row() || !going_up && row >= head.row() { break; } - // Move to the next or previous buffer row, ensuring that - // wrapped lines are handled correctly. - let direction = if tail.row() > head.row() { -1 } else { 1 }; - row = start_of_relative_buffer_row(map, DisplayPoint::new(row, 0), direction).row(); + // Find the next or previous buffer row where the `row` should + // be moved to, so that wrapped lines are skipped. + row = map + .start_of_relative_buffer_row(DisplayPoint::new(row, 0), direction) + .row(); } s.select(selections); @@ -748,7 +754,8 @@ impl Vim { self.stop_recording(cx); self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { - let (display_map, selections) = editor.selections.all_adjusted_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_adjusted_display(&display_map); // Selections are biased right at the start. So we need to store // anchors that are biased left so that we can restore the selections @@ -771,7 +778,7 @@ impl Vim { { let range = row_range.start.to_offset(&display_map, Bias::Right) ..row_range.end.to_offset(&display_map, Bias::Right); - let text = text.repeat(range.len()); + let text = text.repeat(range.end - range.start); edits.push((range, text)); } } @@ -837,8 +844,8 @@ impl Vim { return; }; let vim_is_normal = self.mode == Mode::Normal; - let mut start_selection = 0usize; - let mut end_selection = 0usize; + let mut start_selection = MultiBufferOffset(0); + let mut end_selection = MultiBufferOffset(0); self.update_editor(cx, |_, editor, _| { editor.set_collapse_matches(false); @@ -859,7 +866,9 @@ impl Vim { }); } self.update_editor(cx, |_, editor, cx| { - let latest = editor.selections.newest::(cx); + let latest = editor + .selections + .newest::(&editor.display_snapshot(cx)); start_selection = latest.start; end_selection = latest.end; }); @@ -880,7 +889,9 @@ impl Vim { return; } self.update_editor(cx, |_, editor, cx| { - let latest = editor.selections.newest::(cx); + let latest = editor + .selections + .newest::(&editor.display_snapshot(cx)); if vim_is_normal { start_selection = latest.start; end_selection = latest.end; diff --git a/crates/vim/test_data/test_change_paragraph.json b/crates/vim/test_data/test_change_paragraph.json new file mode 100644 index 0000000000..6d235d9f36 --- /dev/null +++ b/crates/vim/test_data/test_change_paragraph.json @@ -0,0 +1,8 @@ +{"Put":{"state":"first paragraph\nˇstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇ\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Insert"}} +{"ReadRegister":{"name":"\"","value":"first paragraph\nstill first\n\n"}} +{"Key":"escape"} +{"Get":{"state":"ˇ\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_jk_max_count.json b/crates/vim/test_data/test_jk_max_count.json new file mode 100644 index 0000000000..83eab46a18 --- /dev/null +++ b/crates/vim/test_data/test_jk_max_count.json @@ -0,0 +1,47 @@ +{"Put":{"state":"1\nˇ2\n3"}} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"9"} +{"Key":"j"} +{"Get":{"state":"1\n2\nˇ3","mode":"Normal"}} +{"Key":""} +{"Key":"1"} +{"Key":"8"} +{"Key":"4"} +{"Key":"4"} +{"Key":"6"} +{"Key":"7"} +{"Key":"4"} +{"Key":"4"} +{"Key":"0"} +{"Key":"7"} +{"Key":"3"} +{"Key":"7"} +{"Key":"0"} +{"Key":"9"} +{"Key":"5"} +{"Key":"5"} +{"Key":"1"} +{"Key":"6"} +{"Key":"1"} +{"Key":"5"} +{"Key":""} +{"Key":"k"} +{"Get":{"state":"ˇ1\n2\n3","mode":"Normal"}} diff --git a/crates/vim/test_data/test_matching_nested_brackets.json b/crates/vim/test_data/test_matching_nested_brackets.json new file mode 100644 index 0000000000..d90b38416e --- /dev/null +++ b/crates/vim/test_data/test_matching_nested_brackets.json @@ -0,0 +1,5 @@ +{"Put":{"state":""}} +{"Key":"%"} +{"Get":{"state":"","mode":"Normal"}} +{"Key":"%"} +{"Get":{"state":"","mode":"Normal"}} diff --git a/crates/vim/test_data/test_matching_tags.json b/crates/vim/test_data/test_matching_tags.json index bb4f5fd450..b401033a94 100644 --- a/crates/vim/test_data/test_matching_tags.json +++ b/crates/vim/test_data/test_matching_tags.json @@ -13,3 +13,8 @@ {"Put":{"state":"\n \n"}} {"Key":"%"} {"Get":{"state":"\n ˇ\n","mode":"Normal"}} +{"Put":{"state":"\n \n \n"}} +{"Key":"%"} +{"Get":{"state":"\n \n <ˇ/body>\n","mode":"Normal"}} +{"Key":"%"} +{"Get":{"state":"\n <ˇbody>\n \n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_repeat_clear_count.json b/crates/vim/test_data/test_repeat_clear_count.json new file mode 100644 index 0000000000..352c6ca4a8 --- /dev/null +++ b/crates/vim/test_data/test_repeat_clear_count.json @@ -0,0 +1,21 @@ +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"d"} +{"Key":"d"} +{"Get":{"state":"ˇfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"2"} +{"Key":"d"} +{"Key":"."} +{"Get":{"state":"ˇfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"ˇthe lazy dog","mode":"Normal"}} +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog\nthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"2"} +{"Key":"d"} +{"Key":"d"} +{"Get":{"state":"ˇthe lazy dog\nthe quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"5"} +{"Key":"d"} +{"Key":"."} +{"Get":{"state":"ˇthe lazy dog\nthe quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"ˇfox jumps over\nthe lazy dog","mode":"Normal"}} diff --git a/crates/vim/test_data/test_repeat_clear_repeat.json b/crates/vim/test_data/test_repeat_clear_repeat.json new file mode 100644 index 0000000000..39d96e2a37 --- /dev/null +++ b/crates/vim/test_data/test_repeat_clear_repeat.json @@ -0,0 +1,8 @@ +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"d"} +{"Key":"d"} +{"Get":{"state":"ˇfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"d"} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"ˇthe lazy dog","mode":"Normal"}} diff --git a/crates/vim/test_data/test_repeat_grouping_41735.json b/crates/vim/test_data/test_repeat_grouping_41735.json new file mode 100644 index 0000000000..6523be6e4b --- /dev/null +++ b/crates/vim/test_data/test_repeat_grouping_41735.json @@ -0,0 +1,10 @@ +{"Put":{"state":"ˇ"}} +{"Key":"i"} +{"Key":"a"} +{"Key":"escape"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"ˇaaaa","mode":"Normal"}} +{"Key":"u"} +{"Get":{"state":"ˇaaa","mode":"Normal"}} diff --git a/crates/vim/test_data/test_temporary_mode.json b/crates/vim/test_data/test_temporary_mode.json new file mode 100644 index 0000000000..be370cf744 --- /dev/null +++ b/crates/vim/test_data/test_temporary_mode.json @@ -0,0 +1,27 @@ +{"Put":{"state":"lorem ˇipsum"}} +{"Key":"i"} +{"Get":{"state":"lorem ˇipsum","mode":"Insert"}} +{"Key":"ctrl-o"} +{"Key":"$"} +{"Get":{"state":"lorem ipsumˇ","mode":"Insert"}} +{"Put":{"state":"loremˇ ipsum dolor"}} +{"Key":"a"} +{"Get":{"state":"lorem ˇipsum dolor","mode":"Insert"}} +{"Key":"a"} +{"Key":"n"} +{"Key":"d"} +{"Key":"space"} +{"Key":"ctrl-o"} +{"Key":"w"} +{"Get":{"state":"lorem and ipsum ˇdolor","mode":"Insert"}} +{"Put":{"state":"lorem ˇipsum dolor"}} +{"Key":"i"} +{"Get":{"state":"lorem ˇipsum dolor","mode":"Insert"}} +{"Key":"a"} +{"Key":"n"} +{"Key":"d"} +{"Key":"space"} +{"Key":"ctrl-o"} +{"Key":"y"} +{"Key":"$"} +{"Get":{"state":"lorem and ˇipsum dolor","mode":"Insert"}} diff --git a/crates/vim/test_data/test_unmatched_backward_markdown.json b/crates/vim/test_data/test_unmatched_backward_markdown.json new file mode 100644 index 0000000000..c2df848b81 --- /dev/null +++ b/crates/vim/test_data/test_unmatched_backward_markdown.json @@ -0,0 +1,9 @@ +{"Exec":{"command":"set filetype=markdown"}} +{"Put":{"state":"```rs\nimpl Worktree {\n pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\nˇ }\n}\n```\n"}} +{"Key":"["} +{"Key":"{"} +{"Get":{"state":"```rs\nimpl Worktree {\n pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> ˇ{\n }\n}\n```\n","mode":"Normal"}} +{"Put":{"state":"```rs\nimpl Worktree {\n pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n } ˇ\n}\n```\n"}} +{"Key":"["} +{"Key":"{"} +{"Get":{"state":"```rs\nimpl Worktree ˇ{\n pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n } \n}\n```\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_unmatched_forward_markdown.json b/crates/vim/test_data/test_unmatched_forward_markdown.json new file mode 100644 index 0000000000..753f68d04f --- /dev/null +++ b/crates/vim/test_data/test_unmatched_forward_markdown.json @@ -0,0 +1,9 @@ +{"Exec":{"command":"set filetype=markdown"}} +{"Put":{"state":"```rs\nimpl Worktree {\n pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\nˇ }\n}\n```\n"}} +{"Key":"]"} +{"Key":"}"} +{"Get":{"state":"```rs\nimpl Worktree {\n pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n ˇ}\n}\n```\n","mode":"Normal"}} +{"Put":{"state":"```rs\nimpl Worktree {\n pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n } ˇ\n}\n```\n"}} +{"Key":"]"} +{"Key":"}"} +{"Get":{"state":"```rs\nimpl Worktree {\n pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n } \nˇ}\n```\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_yank_paragraph_with_paste.json b/crates/vim/test_data/test_yank_paragraph_with_paste.json new file mode 100644 index 0000000000..d73d1f6d3b --- /dev/null +++ b/crates/vim/test_data/test_yank_paragraph_with_paste.json @@ -0,0 +1,10 @@ +{"Put":{"state":"first paragraph\nˇstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n"}} +{"Key":"y"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇfirst paragraph\nstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} +{"ReadRegister":{"name":"\"","value":"first paragraph\nstill first\n\n"}} +{"Key":"j"} +{"Key":"j"} +{"Key":"p"} +{"Get":{"state":"first paragraph\nstill first\n\nˇfirst paragraph\nstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} diff --git a/crates/vim_mode_setting/Cargo.toml b/crates/vim_mode_setting/Cargo.toml index 8371cca401..0ae75d9d55 100644 --- a/crates/vim_mode_setting/Cargo.toml +++ b/crates/vim_mode_setting/Cargo.toml @@ -12,6 +12,4 @@ workspace = true path = "src/vim_mode_setting.rs" [dependencies] -gpui.workspace = true settings.workspace = true -workspace-hack.workspace = true diff --git a/crates/vim_mode_setting/src/vim_mode_setting.rs b/crates/vim_mode_setting/src/vim_mode_setting.rs index c82109f6b1..e229913a80 100644 --- a/crates/vim_mode_setting/src/vim_mode_setting.rs +++ b/crates/vim_mode_setting/src/vim_mode_setting.rs @@ -4,33 +4,22 @@ //! disable Vim/Helix modes without having to depend on the `vim` crate in its //! entirety. -use gpui::App; -use settings::{Settings, SettingsContent}; - -/// Initializes the `vim_mode_setting` crate. -pub fn init(cx: &mut App) { - VimModeSetting::register(cx); - HelixModeSetting::register(cx); -} +use settings::{RegisterSetting, Settings, SettingsContent}; +#[derive(RegisterSetting)] pub struct VimModeSetting(pub bool); impl Settings for VimModeSetting { - fn from_settings(content: &SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &SettingsContent) -> Self { Self(content.vim_mode.unwrap()) } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _content: &mut SettingsContent) { - // TODO: could possibly check if any of the `vim.` keys are set? - } } +#[derive(RegisterSetting)] pub struct HelixModeSetting(pub bool); impl Settings for HelixModeSetting { - fn from_settings(content: &SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &SettingsContent) -> Self { Self(content.helix_mode.unwrap()) } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut SettingsContent) {} } diff --git a/crates/watch/Cargo.toml b/crates/watch/Cargo.toml index 439a9af49f..9d77eaedde 100644 --- a/crates/watch/Cargo.toml +++ b/crates/watch/Cargo.toml @@ -14,7 +14,6 @@ doctest = true [dependencies] parking_lot.workspace = true -workspace-hack.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/web_search/Cargo.toml b/crates/web_search/Cargo.toml index 4ba46faec4..d0e32e71f0 100644 --- a/crates/web_search/Cargo.toml +++ b/crates/web_search/Cargo.toml @@ -17,4 +17,3 @@ cloud_llm_client.workspace = true collections.workspace = true gpui.workspace = true serde.workspace = true -workspace-hack.workspace = true diff --git a/crates/web_search_providers/Cargo.toml b/crates/web_search_providers/Cargo.toml index f7a248d106..ecdca5883f 100644 --- a/crates/web_search_providers/Cargo.toml +++ b/crates/web_search_providers/Cargo.toml @@ -22,4 +22,3 @@ language_model.workspace = true serde.workspace = true serde_json.workspace = true web_search.workspace = true -workspace-hack.workspace = true diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 869aa5322e..acf95df37f 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -35,6 +35,7 @@ clock.workspace = true collections.workspace = true component.workspace = true db.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true @@ -63,7 +64,6 @@ ui.workspace = true util.workspace = true uuid.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index d67d3c81a9..edc5705a28 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -1,8 +1,10 @@ use crate::persistence::model::DockData; +use crate::utility_pane::utility_slot_for_dock_position; use crate::{DraggedDock, Event, ModalLayer, Pane}; use crate::{Workspace, status_bar::StatusItemView}; use anyhow::Context as _; use client::proto; + use gpui::{ Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement, KeyContext, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement, @@ -13,6 +15,7 @@ use settings::SettingsStore; use std::sync::Arc; use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex}; use ui::{prelude::*, right_click_menu}; +use util::ResultExt as _; pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.); @@ -25,8 +28,75 @@ pub enum PanelEvent { pub use proto::PanelId; +pub struct MinimizePane; +pub struct ClosePane; + +pub trait UtilityPane: EventEmitter + EventEmitter + Render { + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; + /// The icon to render in the adjacent pane's tab bar for toggling this utility pane + fn toggle_icon(&self, cx: &App) -> IconName; + fn expanded(&self, cx: &App) -> bool; + fn set_expanded(&mut self, expanded: bool, cx: &mut Context); + fn width(&self, cx: &App) -> Pixels; + fn set_width(&mut self, width: Option, cx: &mut Context); +} + +pub trait UtilityPaneHandle: 'static + Send + Sync { + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; + fn toggle_icon(&self, cx: &App) -> IconName; + fn expanded(&self, cx: &App) -> bool; + fn set_expanded(&self, expanded: bool, cx: &mut App); + fn width(&self, cx: &App) -> Pixels; + fn set_width(&self, width: Option, cx: &mut App); + fn to_any(&self) -> AnyView; + fn box_clone(&self) -> Box; +} + +impl UtilityPaneHandle for Entity +where + T: UtilityPane, +{ + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition { + self.read(cx).position(window, cx) + } + + fn toggle_icon(&self, cx: &App) -> IconName { + self.read(cx).toggle_icon(cx) + } + + fn expanded(&self, cx: &App) -> bool { + self.read(cx).expanded(cx) + } + + fn set_expanded(&self, expanded: bool, cx: &mut App) { + self.update(cx, |this, cx| this.set_expanded(expanded, cx)) + } + + fn width(&self, cx: &App) -> Pixels { + self.read(cx).width(cx) + } + + fn set_width(&self, width: Option, cx: &mut App) { + self.update(cx, |this, cx| this.set_width(width, cx)) + } + + fn to_any(&self) -> AnyView { + self.clone().into() + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } +} + +pub enum UtilityPanePosition { + Left, + Right, +} + pub trait Panel: Focusable + EventEmitter + Render + Sized { fn persistent_name() -> &'static str; + fn panel_key() -> &'static str; fn position(&self, window: &Window, cx: &App) -> DockPosition; fn position_is_valid(&self, position: DockPosition) -> bool; fn set_position(&mut self, position: DockPosition, window: &mut Window, cx: &mut Context); @@ -61,6 +131,7 @@ pub trait Panel: Focusable + EventEmitter + Render + Sized { pub trait PanelHandle: Send + Sync { fn panel_id(&self) -> EntityId; fn persistent_name(&self) -> &'static str; + fn panel_key(&self) -> &'static str; fn position(&self, window: &Window, cx: &App) -> DockPosition; fn position_is_valid(&self, position: DockPosition, cx: &App) -> bool; fn set_position(&self, position: DockPosition, window: &mut Window, cx: &mut App); @@ -108,6 +179,10 @@ where T::persistent_name() } + fn panel_key(&self) -> &'static str { + T::panel_key() + } + fn position(&self, window: &Window, cx: &App) -> DockPosition { self.read(cx).position(window, cx) } @@ -378,6 +453,13 @@ impl Dock { .position(|entry| entry.panel.remote_id() == Some(panel_id)) } + pub fn panel_for_id(&self, panel_id: EntityId) -> Option<&Arc> { + self.panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel_id) + .map(|entry| &entry.panel) + } + pub fn first_enabled_panel_idx(&mut self, cx: &mut Context) -> anyhow::Result { self.panel_entries .iter() @@ -485,6 +567,9 @@ impl Dock { new_dock.update(cx, |new_dock, cx| { new_dock.remove_panel(&panel, window, cx); + }); + + new_dock.update(cx, |new_dock, cx| { let index = new_dock.add_panel(panel.clone(), workspace.clone(), window, cx); if was_visible { @@ -492,6 +577,12 @@ impl Dock { new_dock.activate_panel(index, window, cx); } }); + + workspace + .update(cx, |workspace, cx| { + workspace.serialize_workspace(window, cx); + }) + .ok(); } }), cx.subscribe_in( @@ -554,7 +645,16 @@ impl Dock { .binary_search_by_key(&panel.read(cx).activation_priority(), |entry| { entry.panel.activation_priority(cx) }) { - Ok(ix) => ix, + Ok(ix) => { + if cfg!(debug_assertions) { + panic!( + "Panels `{}` and `{}` have the same activation priority. Each panel must have a unique priority so the status bar order is deterministic.", + T::panel_key(), + self.panel_entries[ix].panel.panel_key() + ); + } + ix + } Err(ix) => ix, }; if let Some(active_index) = self.active_panel_index.as_mut() @@ -571,6 +671,7 @@ impl Dock { ); self.restore_state(window, cx); + if panel.read(cx).starts_open(window, cx) { self.activate_panel(index, window, cx); self.set_open(true, window, cx); @@ -622,6 +723,14 @@ impl Dock { std::cmp::Ordering::Greater => {} } } + + let slot = utility_slot_for_dock_position(self.position); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx); + }); + } + self.panel_entries.remove(panel_ix); cx.notify(); } @@ -876,7 +985,13 @@ impl Render for PanelButtons { .enumerate() .filter_map(|(i, entry)| { let icon = entry.panel.icon(window, cx)?; - let icon_tooltip = entry.panel.icon_tooltip(window, cx)?; + let icon_tooltip = entry + .panel + .icon_tooltip(window, cx) + .ok_or_else(|| { + anyhow::anyhow!("can't render a panel button without an icon tooltip") + }) + .log_err()?; let name = entry.panel.persistent_name(); let panel = entry.panel.clone(); @@ -942,8 +1057,8 @@ impl Render for PanelButtons { } }) .when(!is_active, |this| { - this.tooltip(move |window, cx| { - Tooltip::for_action(tooltip.clone(), &*action, window, cx) + this.tooltip(move |_window, cx| { + Tooltip::for_action(tooltip.clone(), &*action, cx) }) }) }), @@ -988,19 +1103,21 @@ pub mod test { pub active: bool, pub focus_handle: FocusHandle, pub size: Pixels, + pub activation_priority: u32, } actions!(test_only, [ToggleTestPanel]); impl EventEmitter for TestPanel {} impl TestPanel { - pub fn new(position: DockPosition, cx: &mut App) -> Self { + pub fn new(position: DockPosition, activation_priority: u32, cx: &mut App) -> Self { Self { position, zoomed: false, active: false, focus_handle: cx.focus_handle(), size: px(300.), + activation_priority, } } } @@ -1016,6 +1133,10 @@ pub mod test { "TestPanel" } + fn panel_key() -> &'static str { + "TestPanel" + } + fn position(&self, _window: &Window, _: &App) -> super::DockPosition { self.position } @@ -1062,7 +1183,7 @@ pub mod test { } fn activation_priority(&self) -> u32 { - 100 + self.activation_priority } } diff --git a/crates/workspace/src/history_manager.rs b/crates/workspace/src/history_manager.rs index f68b58ff82..1b80e7c012 100644 --- a/crates/workspace/src/history_manager.rs +++ b/crates/workspace/src/history_manager.rs @@ -128,8 +128,7 @@ impl HistoryManager { impl HistoryManagerEntry { pub fn new(id: WorkspaceId, paths: &PathList) -> Self { let path = paths - .paths() - .iter() + .ordered_paths() .map(|path| path.compact()) .collect::>(); Self { id, path } diff --git a/crates/workspace/src/invalid_buffer_view.rs b/crates/workspace/src/invalid_item_view.rs similarity index 81% rename from crates/workspace/src/invalid_buffer_view.rs rename to crates/workspace/src/invalid_item_view.rs index 05f409653b..08242a1ed0 100644 --- a/crates/workspace/src/invalid_buffer_view.rs +++ b/crates/workspace/src/invalid_item_view.rs @@ -10,17 +10,18 @@ use zed_actions::workspace::OpenWithSystem; use crate::Item; -/// A view to display when a certain buffer fails to open. -pub struct InvalidBufferView { +/// A view to display when a certain buffer/image/other item fails to open. +#[derive(Debug)] +pub struct InvalidItemView { /// Which path was attempted to open. pub abs_path: Arc, - /// An error message, happened when opening the buffer. + /// An error message, happened when opening the item. pub error: SharedString, is_local: bool, focus_handle: FocusHandle, } -impl InvalidBufferView { +impl InvalidItemView { pub fn new( abs_path: &Path, is_local: bool, @@ -37,7 +38,7 @@ impl InvalidBufferView { } } -impl Item for InvalidBufferView { +impl Item for InvalidItemView { type Event = (); fn tab_content_text(&self, mut detail: usize, _: &App) -> SharedString { @@ -66,16 +67,16 @@ impl Item for InvalidBufferView { } } -impl EventEmitter<()> for InvalidBufferView {} +impl EventEmitter<()> for InvalidItemView {} -impl Focusable for InvalidBufferView { +impl Focusable for InvalidItemView { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } -impl Render for InvalidBufferView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl gpui::IntoElement { +impl Render for InvalidItemView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl gpui::IntoElement { let abs_path = self.abs_path.clone(); v_flex() .size_full() @@ -83,7 +84,7 @@ impl Render for InvalidBufferView { .flex_none() .justify_center() .overflow_hidden() - .key_context("InvalidBuffer") + .key_context("InvalidItem") .child( h_flex().size_full().justify_center().child( v_flex() @@ -103,11 +104,7 @@ impl Render for InvalidBufferView { cx.open_with_system(&abs_path); }) .style(ButtonStyle::Outlined) - .key_binding(KeyBinding::for_action( - &OpenWithSystem, - window, - cx, - )), + .key_binding(KeyBinding::for_action(&OpenWithSystem, cx)), ), ) }), diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 572dd26cd7..bb4b10fa63 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -1,7 +1,7 @@ use crate::{ CollaboratorId, DelayedDebouncedEditAction, FollowableViewRegistry, ItemNavHistory, SerializableItemRegistry, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, - invalid_buffer_view::InvalidBufferView, + invalid_item_view::InvalidItemView, pane::{self, Pane}, persistence::model::ItemId, searchable::SearchableItemHandle, @@ -11,12 +11,14 @@ use anyhow::Result; use client::{Client, proto}; use futures::{StreamExt, channel::mpsc}; use gpui::{ - Action, AnyElement, AnyView, App, Context, Entity, EntityId, EventEmitter, FocusHandle, - Focusable, Font, HighlightStyle, Pixels, Point, Render, SharedString, Task, WeakEntity, Window, + Action, AnyElement, AnyEntity, AnyView, App, AppContext, Context, Entity, EntityId, + EventEmitter, FocusHandle, Focusable, Font, HighlightStyle, Pixels, Point, Render, + SharedString, Task, WeakEntity, Window, }; use project::{Project, ProjectEntryId, ProjectPath}; pub use settings::{ - ActivateOnClose, ClosePosition, Settings, SettingsLocation, ShowCloseButton, ShowDiagnostics, + ActivateOnClose, ClosePosition, RegisterSetting, Settings, SettingsLocation, ShowCloseButton, + ShowDiagnostics, }; use smallvec::SmallVec; use std::{ @@ -49,6 +51,7 @@ impl Default for SaveOptions { } } +#[derive(RegisterSetting)] pub struct ItemSettings { pub git_status: bool, pub close_position: ClosePosition, @@ -58,14 +61,19 @@ pub struct ItemSettings { pub show_close_button: ShowCloseButton, } +#[derive(RegisterSetting)] pub struct PreviewTabsSettings { pub enabled: bool, + pub enable_preview_from_project_panel: bool, pub enable_preview_from_file_finder: bool, - pub enable_preview_from_code_navigation: bool, + pub enable_preview_from_multibuffer: bool, + pub enable_preview_multibuffer_from_code_navigation: bool, + pub enable_preview_file_from_code_navigation: bool, + pub enable_keep_preview_on_code_navigation: bool, } impl Settings for ItemSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let tabs = content.tabs.as_ref().unwrap(); Self { git_status: tabs.git_status.unwrap(), @@ -76,76 +84,27 @@ impl Settings for ItemSettings { show_close_button: tabs.show_close_button.unwrap(), } } - - fn import_from_vscode( - vscode: &settings::VsCodeSettings, - current: &mut settings::SettingsContent, - ) { - if let Some(b) = vscode.read_bool("workbench.editor.tabActionCloseVisibility") { - current.tabs.get_or_insert_default().show_close_button = Some(if b { - ShowCloseButton::Always - } else { - ShowCloseButton::Hidden - }) - } - if let Some(s) = vscode.read_enum("workbench.editor.tabActionLocation", |s| match s { - "right" => Some(ClosePosition::Right), - "left" => Some(ClosePosition::Left), - _ => None, - }) { - current.tabs.get_or_insert_default().close_position = Some(s) - } - if let Some(b) = vscode.read_bool("workbench.editor.focusRecentEditorAfterClose") { - current.tabs.get_or_insert_default().activate_on_close = Some(if b { - ActivateOnClose::History - } else { - ActivateOnClose::LeftNeighbour - }) - } - - if let Some(b) = vscode.read_bool("workbench.editor.showIcons") { - current.tabs.get_or_insert_default().file_icons = Some(b); - }; - if let Some(b) = vscode.read_bool("git.decorations.enabled") { - current.tabs.get_or_insert_default().git_status = Some(b); - } - } } impl Settings for PreviewTabsSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let preview_tabs = content.preview_tabs.as_ref().unwrap(); Self { enabled: preview_tabs.enabled.unwrap(), - enable_preview_from_file_finder: preview_tabs.enable_preview_from_file_finder.unwrap(), - enable_preview_from_code_navigation: preview_tabs - .enable_preview_from_code_navigation + enable_preview_from_project_panel: preview_tabs + .enable_preview_from_project_panel + .unwrap(), + enable_preview_from_file_finder: preview_tabs.enable_preview_from_file_finder.unwrap(), + enable_preview_from_multibuffer: preview_tabs.enable_preview_from_multibuffer.unwrap(), + enable_preview_multibuffer_from_code_navigation: preview_tabs + .enable_preview_multibuffer_from_code_navigation + .unwrap(), + enable_preview_file_from_code_navigation: preview_tabs + .enable_preview_file_from_code_navigation + .unwrap(), + enable_keep_preview_on_code_navigation: preview_tabs + .enable_keep_preview_on_code_navigation .unwrap(), - } - } - - fn import_from_vscode( - vscode: &settings::VsCodeSettings, - current: &mut settings::SettingsContent, - ) { - if let Some(enabled) = vscode.read_bool("workbench.editor.enablePreview") { - current.preview_tabs.get_or_insert_default().enabled = Some(enabled); - } - if let Some(enable_preview_from_code_navigation) = - vscode.read_bool("workbench.editor.enablePreviewFromCodeNavigation") - { - current - .preview_tabs - .get_or_insert_default() - .enable_preview_from_code_navigation = Some(enable_preview_from_code_navigation) - } - if let Some(enable_preview_from_file_finder) = - vscode.read_bool("workbench.editor.enablePreviewFromQuickOpen") - { - current - .preview_tabs - .get_or_insert_default() - .enable_preview_from_file_finder = Some(enable_preview_from_file_finder) } } } @@ -271,16 +230,21 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { ItemBufferKind::None } fn set_nav_history(&mut self, _: ItemNavHistory, _window: &mut Window, _: &mut Context) {} + + fn can_split(&self) -> bool { + false + } fn clone_on_split( &self, - _workspace_id: Option, - _window: &mut Window, - _: &mut Context, - ) -> Option> + workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> where Self: Sized, { - None + _ = (workspace_id, window, cx); + unimplemented!("clone_on_split() must be implemented if can_split() returns true") } fn is_dirty(&self, _: &App) -> bool { false @@ -329,7 +293,7 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { type_id: TypeId, self_handle: &'a Entity, _: &'a App, - ) -> Option { + ) -> Option { if TypeId::of::() == type_id { Some(self_handle.clone().into()) } else { @@ -337,7 +301,7 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { } } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { None } @@ -349,6 +313,15 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { None } + /// Returns optional elements to render to the left of the breadcrumb. + fn breadcrumb_prefix( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + fn added_to_workspace( &mut self, _workspace: &mut Workspace, @@ -476,12 +449,13 @@ pub trait ItemHandle: 'static + Send { ); fn buffer_kind(&self, cx: &App) -> ItemBufferKind; fn boxed_clone(&self) -> Box; + fn can_split(&self, cx: &App) -> bool; fn clone_on_split( &self, workspace_id: Option, window: &mut Window, cx: &mut App, - ) -> Option>; + ) -> Task>>; fn added_to_pane( &self, workspace: &mut Workspace, @@ -494,7 +468,7 @@ pub trait ItemHandle: 'static + Send { fn workspace_deactivated(&self, window: &mut Window, cx: &mut App); fn navigate(&self, data: Box, window: &mut Window, cx: &mut App) -> bool; fn item_id(&self) -> EntityId; - fn to_any(&self) -> AnyView; + fn to_any_view(&self) -> AnyView; fn is_dirty(&self, cx: &App) -> bool; fn has_deleted_file(&self, cx: &App) -> bool; fn has_conflict(&self, cx: &App) -> bool; @@ -520,7 +494,7 @@ pub trait ItemHandle: 'static + Send { window: &mut Window, cx: &mut App, ) -> Task>; - fn act_as_type(&self, type_id: TypeId, cx: &App) -> Option; + fn act_as_type(&self, type_id: TypeId, cx: &App) -> Option; fn to_followable_item_handle(&self, cx: &App) -> Option>; fn to_serializable_item_handle(&self, cx: &App) -> Option>; fn on_release( @@ -531,6 +505,7 @@ pub trait ItemHandle: 'static + Send { fn to_searchable_item_handle(&self, cx: &App) -> Option>; fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation; fn breadcrumbs(&self, theme: &Theme, cx: &App) -> Option>; + fn breadcrumb_prefix(&self, window: &mut Window, cx: &mut App) -> Option; fn show_toolbar(&self, cx: &App) -> bool; fn pixel_position_of_cursor(&self, cx: &App) -> Option>; fn downgrade_item(&self) -> Box; @@ -552,7 +527,7 @@ pub trait WeakItemHandle: Send + Sync { impl dyn ItemHandle { pub fn downcast(&self) -> Option> { - self.to_any().downcast().ok() + self.to_any_view().downcast().ok() } pub fn act_as(&self, cx: &App) -> Option> { @@ -689,14 +664,21 @@ impl ItemHandle for Entity { Box::new(self.clone()) } + fn can_split(&self, cx: &App) -> bool { + self.read(cx).can_split() + } + fn clone_on_split( &self, workspace_id: Option, window: &mut Window, cx: &mut App, - ) -> Option> { - self.update(cx, |item, cx| item.clone_on_split(workspace_id, window, cx)) - .map(|handle| Box::new(handle) as Box) + ) -> Task>> { + let task = self.update(cx, |item, cx| item.clone_on_split(workspace_id, window, cx)); + cx.background_spawn(async move { + task.await + .map(|handle| Box::new(handle) as Box) + }) } fn added_to_pane( @@ -870,7 +852,7 @@ impl ItemHandle for Entity { let autosave = item.workspace_settings(cx).autosave; if let AutosaveSetting::AfterDelay { milliseconds } = autosave { - let delay = Duration::from_millis(milliseconds); + let delay = Duration::from_millis(milliseconds.0); let item = item.clone(); pending_autosave.fire_new( delay, @@ -901,8 +883,14 @@ impl ItemHandle for Entity { if let Some(item) = weak_item.upgrade() && item.workspace_settings(cx).autosave == AutosaveSetting::OnFocusChange { - Pane::autosave_item(&item, workspace.project.clone(), window, cx) - .detach_and_log_err(cx); + // Only trigger autosave if focus has truly left the item. + // If focus is still within the item's hierarchy (e.g., moved to a context menu), + // don't trigger autosave to avoid unwanted formatting and cursor jumps. + let focus_handle = item.item_focus_handle(cx); + if !focus_handle.contains_focused(window, cx) { + Pane::autosave_item(&item, workspace.project.clone(), window, cx) + .detach_and_log_err(cx); + } } }, ) @@ -943,7 +931,7 @@ impl ItemHandle for Entity { self.entity_id() } - fn to_any(&self) -> AnyView { + fn to_any_view(&self) -> AnyView { self.clone().into() } @@ -996,7 +984,7 @@ impl ItemHandle for Entity { self.update(cx, |item, cx| item.reload(project, window, cx)) } - fn act_as_type<'a>(&'a self, type_id: TypeId, cx: &'a App) -> Option { + fn act_as_type<'a>(&'a self, type_id: TypeId, cx: &'a App) -> Option { self.read(cx).act_as_type(type_id, self, cx) } @@ -1013,7 +1001,7 @@ impl ItemHandle for Entity { } fn to_searchable_item_handle(&self, cx: &App) -> Option> { - self.read(cx).as_searchable(self) + self.read(cx).as_searchable(self, cx) } fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { @@ -1024,6 +1012,10 @@ impl ItemHandle for Entity { self.read(cx).breadcrumbs(theme, cx) } + fn breadcrumb_prefix(&self, window: &mut Window, cx: &mut App) -> Option { + self.update(cx, |item, cx| item.breadcrumb_prefix(window, cx)) + } + fn show_toolbar(&self, cx: &App) -> bool { self.read(cx).show_toolbar() } @@ -1037,7 +1029,7 @@ impl ItemHandle for Entity { } fn to_serializable_item_handle(&self, cx: &App) -> Option> { - SerializableItemRegistry::view_to_serializable_item_handle(self.to_any(), cx) + SerializableItemRegistry::view_to_serializable_item_handle(self.to_any_view(), cx) } fn preserve_preview(&self, cx: &App) -> bool { @@ -1058,13 +1050,13 @@ impl ItemHandle for Entity { impl From> for AnyView { fn from(val: Box) -> Self { - val.to_any() + val.to_any_view() } } impl From<&Box> for AnyView { fn from(val: &Box) -> Self { - val.to_any() + val.to_any_view() } } @@ -1117,7 +1109,7 @@ pub trait ProjectItem: Item { _e: &anyhow::Error, _window: &mut Window, _cx: &mut App, - ) -> Option + ) -> Option where Self: Sized, { @@ -1275,7 +1267,7 @@ impl FollowableItemHandle for Entity { window: &mut Window, cx: &mut App, ) -> Option { - let existing = existing.to_any().downcast::().ok()?; + let existing = existing.to_any_view().downcast::().ok()?; self.read(cx).dedup(existing.read(cx), window, cx) } @@ -1558,16 +1550,20 @@ pub mod test { self.push_to_nav_history(cx); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, _: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| Self { + Task::ready(Some(cx.new(|cx| Self { state: self.state.clone(), label: self.label.clone(), save_count: self.save_count, @@ -1584,7 +1580,7 @@ pub mod test { workspace_id: self.workspace_id, focus_handle: cx.focus_handle(), serialize: None, - })) + }))) } fn is_dirty(&self, _: &App) -> bool { diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 768b10abe4..6d37ea4d2a 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -41,7 +41,7 @@ pub enum NotificationId { impl NotificationId { /// Returns a unique [`NotificationId`] for the given type. - pub fn unique() -> Self { + pub const fn unique() -> Self { Self::Unique(TypeId::of::()) } @@ -315,19 +315,17 @@ impl Render for LanguageServerPrompt { ) .child( IconButton::new(close_id, close_icon) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if suppress { Tooltip::for_action( "Suppress.\nClose with click.", &SuppressNotification, - window, cx, ) } else { Tooltip::for_action( "Close.\nSuppress with shift-click.", &menu::Cancel, - window, cx, ) } @@ -499,7 +497,7 @@ impl NotificationFrame { } /// Determines whether the given notification ID should be suppressible - /// Suppressed motifications will not be shown anymore + /// Suppressed notifications will not be shown anymore pub fn show_suppress_button(mut self, show: bool) -> Self { self.show_suppress_button = show; self @@ -556,23 +554,21 @@ impl RenderOnce for NotificationFrame { this.on_modifiers_changed(move |_, _, cx| cx.notify(entity)) .child( IconButton::new(close_id, close_icon) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if suppress { Tooltip::for_action( "Suppress.\nClose with click.", &SuppressNotification, - window, cx, ) } else if show_suppress_button { Tooltip::for_action( "Close.\nSuppress with shift-click.", &menu::Cancel, - window, cx, ) } else { - Tooltip::for_action("Close", &menu::Cancel, window, cx) + Tooltip::for_action("Close", &menu::Cancel, cx) } }) .on_click({ @@ -597,9 +593,9 @@ pub mod simple_message_notification { use gpui::{ AnyElement, DismissEvent, EventEmitter, FocusHandle, Focusable, ParentElement, Render, - SharedString, Styled, + ScrollHandle, SharedString, Styled, }; - use ui::prelude::*; + use ui::{WithScrollbar, prelude::*}; use crate::notifications::NotificationFrame; @@ -621,6 +617,7 @@ pub mod simple_message_notification { show_close_button: bool, show_suppress_button: bool, title: Option, + scroll_handle: ScrollHandle, } impl Focusable for MessageNotification { @@ -665,6 +662,7 @@ pub mod simple_message_notification { show_suppress_button: true, title: None, focus_handle: cx.focus_handle(), + scroll_handle: ScrollHandle::new(), } } @@ -761,8 +759,8 @@ pub mod simple_message_notification { self } - /// Determines whether the given notification ID should be supressable - /// Suppressed motifications will not be shown anymor + /// Determines whether the given notification ID should be suppressible + /// Suppressed notifications will not be shown anymor pub fn show_suppress_button(mut self, show: bool) -> Self { self.show_suppress_button = show; self @@ -781,7 +779,18 @@ pub mod simple_message_notification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { NotificationFrame::new() .with_title(self.title.clone()) - .with_content((self.build_content)(window, cx)) + .with_content( + div() + .child( + div() + .id("message-notification-content") + .max_h(vh(0.6, window)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle.clone()) + .child((self.build_content)(window, cx)), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx), + ) .show_close_button(self.show_close_button) .show_suppress_button(self.show_suppress_button) .on_close(cx.listener(|_, suppress, _, cx| { @@ -1075,9 +1084,9 @@ where window.spawn(cx, async move |cx| { let result = self.await; if let Err(err) = result.as_ref() { - log::error!("{err:?}"); + log::error!("{err:#}"); if let Ok(prompt) = cx.update(|window, cx| { - let mut display = format!("{err}"); + let mut display = format!("{err:#}"); if !display.ends_with('\n') { display.push('.'); display.push(' ') diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 29e95de6f3..338a858f3c 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1,8 +1,8 @@ use crate::{ CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible, SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace, - WorkspaceItemBuilder, - invalid_buffer_view::InvalidBufferView, + WorkspaceItemBuilder, ZoomIn, ZoomOut, + invalid_item_view::InvalidItemView, item::{ ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings, PreviewTabsSettings, ProjectItemKind, SaveOptions, ShowCloseButton, ShowDiagnostics, @@ -11,15 +11,17 @@ use crate::{ move_item, notifications::NotifyResultExt, toolbar::Toolbar, + utility_pane::UtilityPaneSlot, workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings}, }; use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{StreamExt, stream::FuturesUnordered}; use gpui::{ Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div, DragMoveEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusOutEvent, - Focusable, IsZero, KeyContext, MouseButton, MouseDownEvent, NavigationDirection, Pixels, Point, + Focusable, KeyContext, MouseButton, MouseDownEvent, NavigationDirection, Pixels, Point, PromptLevel, Render, ScrollHandle, Subscription, Task, WeakEntity, WeakFocusHandle, Window, actions, anchored, deferred, prelude::*, }; @@ -376,6 +378,7 @@ pub struct Pane { render_tab_bar: Rc) -> AnyElement>, show_tab_bar_buttons: bool, max_tabs: Option, + use_max_tabs: bool, _subscriptions: Vec, tab_bar_scroll_handle: ScrollHandle, /// This is set to true if a user scroll has occurred more recently than a system scroll @@ -395,6 +398,10 @@ pub struct Pane { diagnostic_summary_update: Task<()>, /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here. pub project_item_restoration_data: HashMap>, + + pub in_center_group: bool, + pub is_upper_left: bool, + pub is_upper_right: bool, } pub struct ActivationHistoryEntry { @@ -421,8 +428,9 @@ struct NavHistoryState { next_timestamp: Arc, } -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Default, Copy, Clone)] pub enum NavigationMode { + #[default] Normal, GoingBack, GoingForward, @@ -431,12 +439,6 @@ pub enum NavigationMode { Disabled, } -impl Default for NavigationMode { - fn default() -> Self { - Self::Normal - } -} - pub struct NavigationEntry { pub item: Arc, pub data: Option>, @@ -473,10 +475,16 @@ impl Pane { next_timestamp: Arc, can_drop_predicate: Option bool + 'static>>, double_click_dispatch_action: Box, + use_max_tabs: bool, window: &mut Window, cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); + let max_tabs = if use_max_tabs { + WorkspaceSettings::get_global(cx).max_tabs + } else { + None + }; let subscriptions = vec![ cx.on_focus(&focus_handle, window, Pane::focus_in), @@ -498,7 +506,8 @@ impl Pane { zoomed: false, active_item_index: 0, preview_item_id: None, - max_tabs: WorkspaceSettings::get_global(cx).max_tabs, + max_tabs, + use_max_tabs, last_focus_handle_by_item: Default::default(), nav_history: NavHistory(Arc::new(Mutex::new(NavHistoryState { mode: NavigationMode::Normal, @@ -537,6 +546,9 @@ impl Pane { zoom_out_on_close: true, diagnostic_summary_update: Task::ready(()), project_item_restoration_data: HashMap::default(), + in_center_group: false, + is_upper_left: false, + is_upper_right: false, } } @@ -706,7 +718,7 @@ impl Pane { self.preview_item_id = None; } - if new_max_tabs != self.max_tabs { + if self.use_max_tabs && new_max_tabs != self.max_tabs { self.max_tabs = new_max_tabs; self.close_items_on_settings_change(window, cx); } @@ -870,10 +882,35 @@ impl Pane { self.preview_item_id == Some(item_id) } + /// Promotes the item with the given ID to not be a preview item. + /// This does nothing if it wasn't already a preview item. + pub fn unpreview_item_if_preview(&mut self, item_id: EntityId) { + if self.is_active_preview_item(item_id) { + self.preview_item_id = None; + } + } + /// Marks the item with the given ID as the preview item. /// This will be ignored if the global setting `preview_tabs` is disabled. - pub fn set_preview_item_id(&mut self, item_id: Option, cx: &App) { - if PreviewTabsSettings::get_global(cx).enabled { + /// + /// The old preview item (if there was one) is closed and its index is returned. + pub fn replace_preview_item_id( + &mut self, + item_id: EntityId, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let idx = self.close_current_preview_item(window, cx); + self.set_preview_item_id(Some(item_id), cx); + idx + } + + /// Marks the item with the given ID as the preview item. + /// This will be ignored if the global setting `preview_tabs` is disabled. + /// + /// This is a low-level method. Prefer `unpreview_item_if_preview()` or `set_new_preview_item()`. + pub(crate) fn set_preview_item_id(&mut self, item_id: Option, cx: &App) { + if item_id.is_none() || PreviewTabsSettings::get_global(cx).enabled { self.preview_item_id = item_id; } } @@ -892,7 +929,7 @@ impl Pane { && preview_item.item_id() == item_id && !preview_item.preserve_preview(cx) { - self.set_preview_item_id(None, cx); + self.unpreview_item_if_preview(item_id); } } @@ -933,14 +970,8 @@ impl Pane { let set_up_existing_item = |index: usize, pane: &mut Self, window: &mut Window, cx: &mut Context| { - // If the item is already open, and the item is a preview item - // and we are not allowing items to open as preview, mark the item as persistent. - if let Some(preview_item_id) = pane.preview_item_id - && let Some(tab) = pane.items.get(index) - && tab.item_id() == preview_item_id - && !allow_preview - { - pane.set_preview_item_id(None, cx); + if !allow_preview && let Some(item) = pane.items.get(index) { + pane.unpreview_item_if_preview(item.item_id()); } if activate { pane.activate_item(index, focus_item, focus_item, window, cx); @@ -952,8 +983,13 @@ impl Pane { window: &mut Window, cx: &mut Context| { if allow_preview { - pane.set_preview_item_id(Some(new_item.item_id()), cx); + pane.replace_preview_item_id(new_item.item_id(), window, cx); } + + if let Some(text) = new_item.telemetry_event_text(cx) { + telemetry::event!(text); + } + pane.add_item_inner( new_item, true, @@ -979,11 +1015,11 @@ impl Pane { let new_item = build_item(self, window, cx); // A special case that won't ever get a `project_entry_id` but has to be deduplicated nonetheless. - if let Some(invalid_buffer_view) = new_item.downcast::() { + if let Some(invalid_buffer_view) = new_item.downcast::() { let mut already_open_view = None; let mut views_to_close = HashSet::default(); for existing_error_view in self - .items_of_type::() + .items_of_type::() .filter(|item| item.read(cx).abs_path == invalid_buffer_view.read(cx).abs_path) { if already_open_view.is_none() @@ -1028,6 +1064,7 @@ impl Pane { ) -> Option { let item_idx = self.preview_item_idx()?; let id = self.preview_item_id()?; + self.set_preview_item_id(None, cx); let prev_active_item_index = self.active_item_index; self.remove_item(id, false, false, window, cx); @@ -1110,7 +1147,6 @@ impl Pane { false } }); - if let Some(existing_item_index) = existing_item_index { // If the item already exists, move it to the desired destination and activate it @@ -1170,6 +1206,10 @@ impl Pane { window: &mut Window, cx: &mut Context, ) { + if let Some(text) = item.telemetry_event_text(cx) { + telemetry::event!(text); + } + self.add_item_inner( item, activate_pane, @@ -1192,7 +1232,7 @@ impl Pane { pub fn items_of_type(&self) -> impl '_ + Iterator> { self.items .iter() - .filter_map(|item| item.to_any().downcast().ok()) + .filter_map(|item| item.to_any_view().downcast().ok()) } pub fn active_item(&self) -> Option> { @@ -1266,6 +1306,25 @@ impl Pane { } } + pub fn zoom_in(&mut self, _: &ZoomIn, window: &mut Window, cx: &mut Context) { + if !self.can_toggle_zoom { + cx.propagate(); + } else if !self.zoomed && !self.items.is_empty() { + if !self.focus_handle.contains_focused(window, cx) { + cx.focus_self(window); + } + cx.emit(Event::ZoomIn); + } + } + + pub fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context) { + if !self.can_toggle_zoom { + cx.propagate(); + } else if self.zoomed { + cx.emit(Event::ZoomOut); + } + } + pub fn activate_item( &mut self, index: usize, @@ -1970,9 +2029,7 @@ impl Pane { item.on_removed(cx); self.nav_history.set_mode(mode); - if self.is_active_preview_item(item.item_id()) { - self.set_preview_item_id(None, cx); - } + self.unpreview_item_if_preview(item.item_id()); if let Some(path) = item.project_path(cx) { let abs_path = self @@ -2183,9 +2240,7 @@ impl Pane { if can_save { pane.update_in(cx, |pane, window, cx| { - if pane.is_active_preview_item(item.item_id()) { - pane.set_preview_item_id(None, cx); - } + pane.unpreview_item_if_preview(item.item_id()); item.save( SaveOptions { format: should_format, @@ -2439,8 +2494,8 @@ impl Pane { let id = self.item_for_index(ix)?.item_id(); let should_activate = ix == self.active_item_index; - if matches!(operation, PinOperation::Pin) && self.is_active_preview_item(id) { - self.set_preview_item_id(None, cx); + if matches!(operation, PinOperation::Pin) { + self.unpreview_item_if_preview(id); } match operation { @@ -2580,6 +2635,7 @@ impl Pane { let close_side = &settings.close_position; let show_close_button = &settings.show_close_button; let indicator = render_item_indicator(item.boxed_clone(), cx); + let tab_tooltip_content = item.tab_tooltip_content(cx); let item_id = item.item_id(); let is_first_item = ix == 0; let is_last_item = ix == self.items.len() - 1; @@ -2612,12 +2668,9 @@ impl Pane { ) .on_mouse_down( MouseButton::Left, - cx.listener(move |pane, event: &MouseDownEvent, _, cx| { - if let Some(id) = pane.preview_item_id - && id == item_id - && event.click_count > 1 - { - pane.set_preview_item_id(None, cx); + cx.listener(move |pane, event: &MouseDownEvent, _, _| { + if event.click_count > 1 { + pane.unpreview_item_if_preview(item_id); } }), ) @@ -2667,12 +2720,6 @@ impl Pane { this.drag_split_direction = None; this.handle_external_paths_drop(paths, window, cx) })) - .when_some(item.tab_tooltip_content(cx), |tab, content| match content { - TabTooltipContent::Text(text) => tab.tooltip(Tooltip::text(text)), - TabTooltipContent::Custom(element_fn) => { - tab.tooltip(move |window, cx| element_fn(window, cx)) - } - }) .start_slot::(indicator) .map(|this| { let end_slot_action: &'static dyn Action; @@ -2717,8 +2764,7 @@ impl Pane { Tooltip::for_action_in( end_slot_tooltip_text, end_slot_action, - &focus_handle, - window, + &window.focused(cx).unwrap_or_else(|| focus_handle.clone()), cx, ) }) @@ -2740,7 +2786,15 @@ impl Pane { }) .flatten(), ) - .child(label), + .child(label) + .id(("pane-tab-content", ix)) + .map(|this| match tab_tooltip_content { + Some(TabTooltipContent::Text(text)) => this.tooltip(Tooltip::text(text)), + Some(TabTooltipContent::Custom(element_fn)) => { + this.tooltip(move |window, cx| element_fn(window, cx)) + } + None => this, + }), ); let single_entry_to_resolve = (self.items[ix].buffer_kind(cx) == ItemBufferKind::Singleton) @@ -3007,7 +3061,13 @@ impl Pane { } fn render_tab_bar(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(workspace) = self.workspace.upgrade() else { + return gpui::Empty.into_any(); + }; + let focus_handle = self.focus_handle.clone(); + let is_pane_focused = self.has_focus(window, cx); + let navigate_backward = IconButton::new("navigate_backward", IconName::ArrowLeft) .icon_size(IconSize::Small) .on_click({ @@ -3022,10 +3082,79 @@ impl Pane { .tooltip({ let focus_handle = focus_handle.clone(); move |window, cx| { - Tooltip::for_action_in("Go Back", &GoBack, &focus_handle, window, cx) + Tooltip::for_action_in( + "Go Back", + &GoBack, + &window.focused(cx).unwrap_or_else(|| focus_handle.clone()), + cx, + ) } }); + let open_aside_left = { + let workspace = workspace.read(cx); + workspace.utility_pane(UtilityPaneSlot::Left).map(|pane| { + let toggle_icon = pane.toggle_icon(cx); + let workspace_handle = self.workspace.clone(); + + h_flex() + .h_full() + .pr_1p5() + .border_r_1() + .border_color(cx.theme().colors().border) + .child( + IconButton::new("open_aside_left", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane( + UtilityPaneSlot::Left, + window, + cx, + ) + }) + .ok(); + }), + ) + .into_any_element() + }) + }; + + let open_aside_right = { + let workspace = workspace.read(cx); + workspace.utility_pane(UtilityPaneSlot::Right).map(|pane| { + let toggle_icon = pane.toggle_icon(cx); + let workspace_handle = self.workspace.clone(); + + h_flex() + .h_full() + .when(is_pane_focused, |this| { + this.pl(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + }) + .child( + IconButton::new("open_aside_right", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane( + UtilityPaneSlot::Right, + window, + cx, + ) + }) + .ok(); + }), + ) + .into_any_element() + }) + }; + let navigate_forward = IconButton::new("navigate_forward", IconName::ArrowRight) .icon_size(IconSize::Small) .on_click({ @@ -3040,7 +3169,12 @@ impl Pane { .tooltip({ let focus_handle = focus_handle.clone(); move |window, cx| { - Tooltip::for_action_in("Go Forward", &GoForward, &focus_handle, window, cx) + Tooltip::for_action_in( + "Go Forward", + &GoForward, + &window.focused(cx).unwrap_or_else(|| focus_handle.clone()), + cx, + ) } }); @@ -3066,7 +3200,45 @@ impl Pane { } let unpinned_tabs = tab_items.split_off(self.pinned_tab_count); let pinned_tabs = tab_items; + + let render_aside_toggle_left = cx.has_flag::() + && self + .is_upper_left + .then(|| { + self.workspace.upgrade().and_then(|entity| { + let workspace = entity.read(cx); + workspace + .utility_pane(UtilityPaneSlot::Left) + .map(|pane| !pane.expanded(cx)) + }) + }) + .flatten() + .unwrap_or(false); + + let render_aside_toggle_right = cx.has_flag::() + && self + .is_upper_right + .then(|| { + self.workspace.upgrade().and_then(|entity| { + let workspace = entity.read(cx); + workspace + .utility_pane(UtilityPaneSlot::Right) + .map(|pane| !pane.expanded(cx)) + }) + }) + .flatten() + .unwrap_or(false); + TabBar::new("tab_bar") + .map(|tab_bar| { + if let Some(open_aside_left) = open_aside_left + && render_aside_toggle_left + { + tab_bar.start_child(open_aside_left) + } else { + tab_bar + } + }) .when( self.display_nav_history_buttons.unwrap_or_default(), |tab_bar| { @@ -3089,8 +3261,10 @@ impl Pane { .children(pinned_tabs.len().ne(&0).then(|| { let max_scroll = self.tab_bar_scroll_handle.max_offset().width; // We need to check both because offset returns delta values even when the scroll handle is not scrollable - let is_scrollable = !max_scroll.is_zero(); let is_scrolled = self.tab_bar_scroll_handle.offset().x < px(0.); + // Avoid flickering when max_offset is very small (< 2px). + // The border adds 1-2px which can push max_offset back to 0, creating a loop. + let is_scrollable = max_scroll > px(2.0); let has_active_unpinned_tab = self.active_item_index >= self.pinned_tab_count; h_flex() .children(pinned_tabs) @@ -3157,6 +3331,15 @@ impl Pane { })), ), ) + .map(|tab_bar| { + if let Some(open_aside_right) = open_aside_right + && render_aside_toggle_right + { + tab_bar.end_child(open_aside_right) + } else { + tab_bar + } + }) .into_any_element() } @@ -3246,11 +3429,7 @@ impl Pane { let mut to_pane = cx.entity(); let split_direction = self.drag_split_direction; let item_id = dragged_tab.item.item_id(); - if let Some(preview_item_id) = self.preview_item_id - && item_id == preview_item_id - { - self.set_preview_item_id(None, cx); - } + self.unpreview_item_if_preview(item_id); let is_clone = cfg!(target_os = "macos") && window.modifiers().alt || cfg!(not(target_os = "macos")) && window.modifiers().control; @@ -3278,10 +3457,21 @@ impl Pane { else { return; }; - if let Some(item) = item.clone_on_split(database_id, window, cx) { - to_pane.update(cx, |pane, cx| { - pane.add_item(item, true, true, None, window, cx); + if item.can_split(cx) { + let task = item.clone_on_split(database_id, window, cx); + let to_pane = to_pane.downgrade(); + cx.spawn_in(window, async move |_, cx| { + if let Some(item) = task.await { + to_pane + .update_in(cx, |pane, window, cx| { + pane.add_item(item, true, true, None, window, cx) + }) + .ok(); + } }) + .detach(); + } else { + move_item(&from_pane, &to_pane, item_id, ix, true, window, cx); } } else { move_item(&from_pane, &to_pane, item_id, ix, true, window, cx); @@ -3576,6 +3766,11 @@ fn default_render_tab_bar_buttons( if !pane.has_focus(window, cx) && !pane.context_menu_focused(window, cx) { return (None, None); } + let (can_clone, can_split_move) = match pane.active_item() { + Some(active_item) if active_item.can_split(cx) => (true, false), + Some(_) => (false, pane.items_len() > 1), + None => (false, false), + }; // Ideally we would return a vec of elements here to pass directly to the [TabBar]'s // `end_slot`, but due to needing a view here that isn't possible. let right_children = h_flex() @@ -3612,17 +3807,26 @@ fn default_render_tab_bar_buttons( .child( PopoverMenu::new("pane-tab-bar-split") .trigger_with_tooltip( - IconButton::new("split", IconName::Split).icon_size(IconSize::Small), + IconButton::new("split", IconName::Split) + .icon_size(IconSize::Small) + .disabled(!can_clone && !can_split_move), Tooltip::text("Split Pane"), ) .anchor(Corner::TopRight) .with_handle(pane.split_item_context_menu_handle.clone()) .menu(move |window, cx| { ContextMenu::build(window, cx, |menu, _, _| { - menu.action("Split Right", SplitRight.boxed_clone()) - .action("Split Left", SplitLeft.boxed_clone()) - .action("Split Up", SplitUp.boxed_clone()) - .action("Split Down", SplitDown.boxed_clone()) + if can_split_move { + menu.action("Split Right", SplitAndMoveRight.boxed_clone()) + .action("Split Left", SplitAndMoveLeft.boxed_clone()) + .action("Split Up", SplitAndMoveUp.boxed_clone()) + .action("Split Down", SplitAndMoveDown.boxed_clone()) + } else { + menu.action("Split Right", SplitRight.boxed_clone()) + .action("Split Left", SplitLeft.boxed_clone()) + .action("Split Up", SplitUp.boxed_clone()) + .action("Split Down", SplitDown.boxed_clone()) + } }) .into() }), @@ -3636,11 +3840,10 @@ fn default_render_tab_bar_buttons( .on_click(cx.listener(|pane, _, window, cx| { pane.toggle_zoom(&crate::ToggleZoom, window, cx); })) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::for_action( if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, - window, cx, ) }) @@ -3664,6 +3867,10 @@ impl Render for Pane { key_context.add("EmptyPane"); } + self.toolbar + .read(cx) + .contribute_context(&mut key_context, cx); + let should_display_tab_bar = self.should_display_tab_bar.clone(); let display_tab_bar = should_display_tab_bar(window, cx); let Some(project) = self.project.upgrade() else { @@ -3712,6 +3919,8 @@ impl Render for Pane { cx.emit(Event::JoinAll); })) .on_action(cx.listener(Pane::toggle_zoom)) + .on_action(cx.listener(Pane::zoom_in)) + .on_action(cx.listener(Pane::zoom_out)) .on_action(cx.listener(Self::navigate_backward)) .on_action(cx.listener(Self::navigate_forward)) .on_action( @@ -3734,15 +3943,17 @@ impl Render for Pane { .on_action(cx.listener(Self::toggle_pin_tab)) .on_action(cx.listener(Self::unpin_all_tabs)) .when(PreviewTabsSettings::get_global(cx).enabled, |this| { - this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, _, cx| { - if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) { - if pane.is_active_preview_item(active_item_id) { - pane.set_preview_item_id(None, cx); - } else { - pane.set_preview_item_id(Some(active_item_id), cx); + this.on_action( + cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, window, cx| { + if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) { + if pane.is_active_preview_item(active_item_id) { + pane.unpreview_item_if_preview(active_item_id); + } else { + pane.replace_preview_item_id(active_item_id, window, cx); + } } - } - })) + }), + ) }) .on_action( cx.listener(|pane: &mut Self, action: &CloseActiveItem, window, cx| { @@ -3830,7 +4041,7 @@ impl Render for Pane { .size_full() .overflow_hidden() .child(self.toolbar.clone()) - .child(item.to_any()) + .child(item.to_any_view()) } else { let placeholder = div .id("pane_placeholder") @@ -4000,6 +4211,25 @@ impl NavHistory { self.0.lock().mode = NavigationMode::Normal; } + pub fn clear(&mut self, cx: &mut App) { + let mut state = self.0.lock(); + + if state.backward_stack.is_empty() + && state.forward_stack.is_empty() + && state.closed_stack.is_empty() + && state.paths_by_item.is_empty() + { + return; + } + + state.mode = NavigationMode::Normal; + state.backward_stack.clear(); + state.forward_stack.clear(); + state.closed_stack.clear(); + state.paths_by_item.clear(); + state.did_update(cx); + } + pub fn pop(&mut self, mode: NavigationMode, cx: &mut App) -> Option { let mut state = self.0.lock(); let entry = match mode { @@ -4061,6 +4291,7 @@ impl NavHistory { is_preview, }); } + NavigationMode::ClosingItem if is_preview => return, NavigationMode::ClosingItem => { if state.closed_stack.len() >= MAX_NAVIGATION_HISTORY_LEN { state.closed_stack.pop_front(); @@ -4090,6 +4321,20 @@ impl NavHistory { .retain(|entry| entry.item.id() != item_id); } + pub fn rename_item( + &mut self, + item_id: EntityId, + project_path: ProjectPath, + abs_path: Option, + ) { + let mut state = self.0.lock(); + let path_for_item = state.paths_by_item.get_mut(&item_id); + if let Some(path_for_item) = path_for_item { + path_for_item.0 = project_path; + path_for_item.1 = abs_path; + } + } + pub fn path_for_item(&self, item_id: EntityId) -> Option<(ProjectPath, Option)> { self.0.lock().paths_by_item.get(&item_id).cloned() } @@ -6560,13 +6805,13 @@ mod tests { let tab_bar_scroll_handle = pane.update_in(cx, |pane, _window, _cx| pane.tab_bar_scroll_handle.clone()); assert_eq!(tab_bar_scroll_handle.children_count(), 6); - let tab_bounds = cx.debug_bounds("TAB-3").unwrap(); + let tab_bounds = cx.debug_bounds("TAB-4").unwrap(); let new_tab_button_bounds = cx.debug_bounds("ICON-Plus").unwrap(); let scroll_bounds = tab_bar_scroll_handle.bounds(); let scroll_offset = tab_bar_scroll_handle.offset(); - assert!(tab_bounds.right() <= scroll_bounds.right() + scroll_offset.x); - // -39.75 is the magic number for this setup - assert_eq!(scroll_offset.x, px(-39.75)); + assert!(tab_bounds.right() <= scroll_bounds.right()); + // -43.0 is the magic number for this setup + assert_eq!(scroll_offset.x, px(-43.0)); assert!( !tab_bounds.intersects(&new_tab_button_bounds), "Tab should not overlap with the new tab button, if this is failing check if there's been a redesign!" @@ -6818,8 +7063,6 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); theme::init(LoadThemes::JustBase, cx); - crate::init_settings(cx); - Project::init_settings(cx); }); } @@ -6886,7 +7129,7 @@ mod tests { .enumerate() .map(|(ix, item)| { let mut state = item - .to_any() + .to_any_view() .downcast::() .unwrap() .read(cx) diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 127eae6de0..393ed74e30 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -28,6 +28,7 @@ const VERTICAL_MIN_SIZE: f32 = 100.; #[derive(Clone)] pub struct PaneGroup { pub root: Member, + pub is_center: bool, } pub struct PaneRenderResult { @@ -37,22 +38,31 @@ pub struct PaneRenderResult { impl PaneGroup { pub fn with_root(root: Member) -> Self { - Self { root } + Self { + root, + is_center: false, + } } pub fn new(pane: Entity) -> Self { Self { root: Member::Pane(pane), + is_center: false, } } + pub fn set_is_center(&mut self, is_center: bool) { + self.is_center = is_center; + } + pub fn split( &mut self, old_pane: &Entity, new_pane: &Entity, direction: SplitDirection, + cx: &mut App, ) -> Result<()> { - match &mut self.root { + let result = match &mut self.root { Member::Pane(pane) => { if pane == old_pane { self.root = Member::new_axis(old_pane.clone(), new_pane.clone(), direction); @@ -62,7 +72,11 @@ impl PaneGroup { } } Member::Axis(axis) => axis.split(old_pane, new_pane, direction), + }; + if result.is_ok() { + self.mark_positions(cx); } + result } pub fn bounding_box_for_pane(&self, pane: &Entity) -> Option> { @@ -79,11 +93,72 @@ impl PaneGroup { } } + /// Moves active pane to span the entire border in the given direction, + /// similar to Vim ctrl+w shift-[hjkl] motion. + /// + /// Returns: + /// - Ok(true) if it found and moved a pane + /// - Ok(false) if it found but did not move the pane + /// - Err(_) if it did not find the pane + pub fn move_to_border( + &mut self, + active_pane: &Entity, + direction: SplitDirection, + cx: &mut App, + ) -> Result { + if let Some(pane) = self.find_pane_at_border(direction) + && pane == active_pane + { + return Ok(false); + } + + if !self.remove_internal(active_pane)? { + return Ok(false); + } + + if let Member::Axis(root) = &mut self.root + && direction.axis() == root.axis + { + let idx = if direction.increasing() { + root.members.len() + } else { + 0 + }; + root.insert_pane(idx, active_pane); + self.mark_positions(cx); + return Ok(true); + } + + let members = if direction.increasing() { + vec![self.root.clone(), Member::Pane(active_pane.clone())] + } else { + vec![Member::Pane(active_pane.clone()), self.root.clone()] + }; + self.root = Member::Axis(PaneAxis::new(direction.axis(), members)); + self.mark_positions(cx); + Ok(true) + } + + fn find_pane_at_border(&self, direction: SplitDirection) -> Option<&Entity> { + match &self.root { + Member::Pane(pane) => Some(pane), + Member::Axis(axis) => axis.find_pane_at_border(direction), + } + } + /// Returns: /// - Ok(true) if it found and removed a pane /// - Ok(false) if it found but did not remove the pane /// - Err(_) if it did not find the pane - pub fn remove(&mut self, pane: &Entity) -> Result { + pub fn remove(&mut self, pane: &Entity, cx: &mut App) -> Result { + let result = self.remove_internal(pane); + if let Ok(true) = result { + self.mark_positions(cx); + } + result + } + + fn remove_internal(&mut self, pane: &Entity) -> Result { match &mut self.root { Member::Pane(_) => Ok(false), Member::Axis(axis) => { @@ -101,6 +176,7 @@ impl PaneGroup { direction: Axis, amount: Pixels, bounds: &Bounds, + cx: &mut App, ) { match &mut self.root { Member::Pane(_) => {} @@ -108,22 +184,29 @@ impl PaneGroup { let _ = axis.resize(pane, direction, amount, bounds); } }; + self.mark_positions(cx); } - pub fn reset_pane_sizes(&mut self) { + pub fn reset_pane_sizes(&mut self, cx: &mut App) { match &mut self.root { Member::Pane(_) => {} Member::Axis(axis) => { let _ = axis.reset_pane_sizes(); } }; + self.mark_positions(cx); } - pub fn swap(&mut self, from: &Entity, to: &Entity) { + pub fn swap(&mut self, from: &Entity, to: &Entity, cx: &mut App) { match &mut self.root { Member::Pane(_) => {} Member::Axis(axis) => axis.swap(from, to), }; + self.mark_positions(cx); + } + + pub fn mark_positions(&mut self, cx: &mut App) { + self.root.mark_positions(self.is_center, true, true, cx); } pub fn render( @@ -182,8 +265,9 @@ impl PaneGroup { self.pane_at_pixel_position(target) } - pub fn invert_axies(&mut self) { + pub fn invert_axies(&mut self, cx: &mut App) { self.root.invert_pane_axies(); + self.mark_positions(cx); } } @@ -193,6 +277,43 @@ pub enum Member { Pane(Entity), } +impl Member { + pub fn mark_positions( + &mut self, + in_center_group: bool, + is_upper_left: bool, + is_upper_right: bool, + cx: &mut App, + ) { + match self { + Member::Axis(pane_axis) => { + let len = pane_axis.members.len(); + for (idx, member) in pane_axis.members.iter_mut().enumerate() { + let member_upper_left = match pane_axis.axis { + Axis::Vertical => is_upper_left && idx == 0, + Axis::Horizontal => is_upper_left && idx == 0, + }; + let member_upper_right = match pane_axis.axis { + Axis::Vertical => is_upper_right && idx == 0, + Axis::Horizontal => is_upper_right && idx == len - 1, + }; + member.mark_positions( + in_center_group, + member_upper_left, + member_upper_right, + cx, + ); + } + } + Member::Pane(entity) => entity.update(cx, |pane, _| { + pane.in_center_group = in_center_group; + pane.is_upper_left = is_upper_left; + pane.is_upper_right = is_upper_right; + }), + } + } +} + #[derive(Clone, Copy)] pub struct PaneRenderContext<'a> { pub project: &'a Entity, @@ -526,9 +647,7 @@ impl PaneAxis { if direction.increasing() { idx += 1; } - - self.members.insert(idx, Member::Pane(new_pane.clone())); - *self.flexes.lock() = vec![1.; self.members.len()]; + self.insert_pane(idx, new_pane); } else { *member = Member::new_axis(old_pane.clone(), new_pane.clone(), direction); @@ -541,6 +660,26 @@ impl PaneAxis { anyhow::bail!("Pane not found"); } + fn insert_pane(&mut self, idx: usize, new_pane: &Entity) { + self.members.insert(idx, Member::Pane(new_pane.clone())); + *self.flexes.lock() = vec![1.; self.members.len()]; + } + + fn find_pane_at_border(&self, direction: SplitDirection) -> Option<&Entity> { + if self.axis != direction.axis() { + return None; + } + let member = if direction.increasing() { + self.members.last() + } else { + self.members.first() + }; + member.and_then(|e| match e { + Member::Pane(pane) => Some(pane), + Member::Axis(_) => None, + }) + } + fn remove(&mut self, pane_to_remove: &Entity) -> Result> { let mut found_pane = false; let mut remove_member = None; @@ -895,6 +1034,15 @@ impl SplitDirection { Self::Down | Self::Right => true, } } + + pub fn opposite(&self) -> SplitDirection { + match self { + Self::Down => Self::Up, + Self::Up => Self::Down, + Self::Left => Self::Right, + Self::Right => Self::Left, + } + } } mod element { @@ -1238,7 +1386,7 @@ mod element { let overlay_opacity = WorkspaceSettings::get(None, cx) .active_pane_modifiers .inactive_opacity - .map(|val| val.clamp(0.0, 1.0)) + .map(|val| val.0.clamp(0.0, 1.0)) .and_then(|val| (val <= 1.).then_some(val)); let mut overlay_background = cx.theme().colors().editor_background; diff --git a/crates/workspace/src/path_list.rs b/crates/workspace/src/path_list.rs index 01e2ffda94..035f9e44fc 100644 --- a/crates/workspace/src/path_list.rs +++ b/crates/workspace/src/path_list.rs @@ -3,15 +3,22 @@ use std::{ sync::Arc, }; +use itertools::Itertools; use util::paths::SanitizedPath; /// A list of absolute paths, in a specific order. /// /// The paths are stored in lexicographic order, so that they can be compared to /// other path lists without regard to the order of the paths. +/// +/// The paths can be retrieved in the original order using `ordered_paths()`. #[derive(Default, PartialEq, Eq, Debug, Clone)] pub struct PathList { + /// The paths, in lexicographic order. paths: Arc<[PathBuf]>, + /// The order in which the paths were provided. + /// + /// See `ordered_paths()` for a way to get the paths in the original order. order: Arc<[usize]>, } @@ -42,14 +49,25 @@ impl PathList { self.paths.is_empty() } + /// Get the paths in lexicographic order. pub fn paths(&self) -> &[PathBuf] { self.paths.as_ref() } + /// Get the order in which the paths were provided. pub fn order(&self) -> &[usize] { self.order.as_ref() } + /// Get the paths in the original order. + pub fn ordered_paths(&self) -> impl Iterator { + self.order + .iter() + .zip(self.paths.iter()) + .sorted_by_key(|(i, _)| **i) + .map(|(_, path)| path) + } + pub fn is_lexicographically_ordered(&self) -> bool { self.order.iter().enumerate().all(|(i, &j)| i == j) } @@ -109,15 +127,70 @@ mod tests { let list1 = PathList::new(&["a/d", "a/c"]); let list2 = PathList::new(&["a/c", "a/d"]); - assert_eq!(list1.paths(), list2.paths()); - assert_ne!(list1, list2); - assert_eq!(list1.order(), &[1, 0]); - assert_eq!(list2.order(), &[0, 1]); + assert_eq!(list1.paths(), list2.paths(), "paths differ"); + assert_eq!(list1.order(), &[1, 0], "list1 order incorrect"); + assert_eq!(list2.order(), &[0, 1], "list2 order incorrect"); let list1_deserialized = PathList::deserialize(&list1.serialize()); - assert_eq!(list1_deserialized, list1); + assert_eq!(list1_deserialized, list1, "list1 deserialization failed"); let list2_deserialized = PathList::deserialize(&list2.serialize()); - assert_eq!(list2_deserialized, list2); + assert_eq!(list2_deserialized, list2, "list2 deserialization failed"); + + assert_eq!( + list1.ordered_paths().collect_array().unwrap(), + [&PathBuf::from("a/d"), &PathBuf::from("a/c")], + "list1 ordered paths incorrect" + ); + assert_eq!( + list2.ordered_paths().collect_array().unwrap(), + [&PathBuf::from("a/c"), &PathBuf::from("a/d")], + "list2 ordered paths incorrect" + ); + } + + #[test] + fn test_path_list_ordering() { + let list = PathList::new(&["b", "a", "c"]); + assert_eq!( + list.paths(), + &[PathBuf::from("a"), PathBuf::from("b"), PathBuf::from("c")] + ); + assert_eq!(list.order(), &[1, 0, 2]); + assert!(!list.is_lexicographically_ordered()); + + let serialized = list.serialize(); + let deserialized = PathList::deserialize(&serialized); + assert_eq!(deserialized, list); + + assert_eq!( + deserialized.ordered_paths().collect_array().unwrap(), + [ + &PathBuf::from("b"), + &PathBuf::from("a"), + &PathBuf::from("c") + ] + ); + + let list = PathList::new(&["b", "c", "a"]); + assert_eq!( + list.paths(), + &[PathBuf::from("a"), PathBuf::from("b"), PathBuf::from("c")] + ); + assert_eq!(list.order(), &[2, 0, 1]); + assert!(!list.is_lexicographically_ordered()); + + let serialized = list.serialize(); + let deserialized = PathList::deserialize(&serialized); + assert_eq!(deserialized, list); + + assert_eq!( + deserialized.ordered_paths().collect_array().unwrap(), + [ + &PathBuf::from("b"), + &PathBuf::from("c"), + &PathBuf::from("a"), + ] + ); } } diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index efd5116dc3..f1835caf8d 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -20,7 +20,9 @@ use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}; use language::{LanguageName, Toolchain, ToolchainScope}; use project::WorktreeId; -use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions}; +use remote::{ + DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, +}; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -702,6 +704,10 @@ impl Domain for WorkspaceDb { sql!( DROP TABLE ssh_connections; ), + sql!( + ALTER TABLE remote_connections ADD COLUMN name TEXT; + ALTER TABLE remote_connections ADD COLUMN container_id TEXT; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -728,9 +734,9 @@ impl WorkspaceDb { pub(crate) fn remote_workspace_for_roots>( &self, worktree_roots: &[P], - ssh_project_id: RemoteConnectionId, + remote_project_id: RemoteConnectionId, ) -> Option { - self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id)) + self.workspace_for_roots_internal(worktree_roots, Some(remote_project_id)) } pub(crate) fn workspace_for_roots_internal>( @@ -791,12 +797,11 @@ impl WorkspaceDb { remote_connection_id IS ? LIMIT 1 }) - .map(|mut prepared_statement| { + .and_then(|mut prepared_statement| { (prepared_statement)(( root_paths.serialize().paths, remote_connection_id.map(|id| id.0 as i32), )) - .unwrap() }) .context("No workspaces found") .warn_on_err() @@ -807,9 +812,20 @@ impl WorkspaceDb { order: paths_order, }); + let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id { + self.remote_connection(remote_connection_id) + .context("Get remote connection") + .log_err() + } else { + None + }; + Some(SerializedWorkspace { id: workspace_id, - location: SerializedWorkspaceLocation::Local, + location: match remote_connection_options { + Some(options) => SerializedWorkspaceLocation::Remote(options), + None => SerializedWorkspaceLocation::Local, + }, paths, center_group: self .get_center_pane_group(workspace_id) @@ -1111,10 +1127,12 @@ impl WorkspaceDb { options: RemoteConnectionOptions, ) -> Result { let kind; - let user; + let mut user = None; let mut host = None; let mut port = None; let mut distro = None; + let mut name = None; + let mut container_id = None; match options { RemoteConnectionOptions::Ssh(options) => { kind = RemoteConnectionKind::Ssh; @@ -1127,8 +1145,22 @@ impl WorkspaceDb { distro = Some(options.distro_name); user = options.user; } + RemoteConnectionOptions::Docker(options) => { + kind = RemoteConnectionKind::Docker; + container_id = Some(options.container_id); + name = Some(options.name); + } } - Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro) + Self::get_or_create_remote_connection_query( + this, + kind, + host, + port, + user, + distro, + name, + container_id, + ) } fn get_or_create_remote_connection_query( @@ -1138,6 +1170,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + name: Option, + container_id: Option, ) -> Result { if let Some(id) = this.select_row_bound(sql!( SELECT id @@ -1147,7 +1181,9 @@ impl WorkspaceDb { host IS ? AND port IS ? AND user IS ? AND - distro IS ? + distro IS ? AND + name IS ? AND + container_id IS ? LIMIT 1 ))?(( kind.serialize(), @@ -1155,6 +1191,8 @@ impl WorkspaceDb { port, user.clone(), distro.clone(), + name.clone(), + container_id.clone(), ))? { Ok(RemoteConnectionId(id)) } else { @@ -1164,10 +1202,20 @@ impl WorkspaceDb { host, port, user, - distro - ) VALUES (?1, ?2, ?3, ?4, ?5) + distro, + name, + container_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id - ))?((kind.serialize(), host, port, user, distro))? + ))?(( + kind.serialize(), + host, + port, + user, + distro, + name, + container_id, + ))? .context("failed to insert remote project")?; Ok(RemoteConnectionId(id)) } @@ -1250,15 +1298,23 @@ impl WorkspaceDb { fn remote_connections(&self) -> Result> { Ok(self.select(sql!( SELECT - id, kind, host, port, user, distro + id, kind, host, port, user, distro, container_id, name FROM remote_connections ))?()? .into_iter() - .filter_map(|(id, kind, host, port, user, distro)| { + .filter_map(|(id, kind, host, port, user, distro, container_id, name)| { Some(( RemoteConnectionId(id), - Self::remote_connection_from_row(kind, host, port, user, distro)?, + Self::remote_connection_from_row( + kind, + host, + port, + user, + distro, + container_id, + name, + )?, )) }) .collect()) @@ -1268,13 +1324,13 @@ impl WorkspaceDb { &self, id: RemoteConnectionId, ) -> Result { - let (kind, host, port, user, distro) = self.select_row_bound(sql!( - SELECT kind, host, port, user, distro + let (kind, host, port, user, distro, container_id, name) = self.select_row_bound(sql!( + SELECT kind, host, port, user, distro, container_id, name FROM remote_connections WHERE id = ? ))?(id.0)? .context("no such remote connection")?; - Self::remote_connection_from_row(kind, host, port, user, distro) + Self::remote_connection_from_row(kind, host, port, user, distro, container_id, name) .context("invalid remote_connection row") } @@ -1284,6 +1340,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + container_id: Option, + name: Option, ) -> Option { match RemoteConnectionKind::deserialize(&kind)? { RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions { @@ -1296,6 +1354,13 @@ impl WorkspaceDb { username: user, ..Default::default() })), + RemoteConnectionKind::Docker => { + Some(RemoteConnectionOptions::Docker(DockerConnectionOptions { + container_id: container_id?, + name: name?, + upload_binary_over_docker_exec: false, + })) + } } } @@ -1347,10 +1412,24 @@ impl WorkspaceDb { continue; } - if paths.paths().iter().all(|path| path.exists()) - && paths.paths().iter().any(|path| path.is_dir()) - { - result.push((id, SerializedWorkspaceLocation::Local, paths)); + let has_wsl_path = if cfg!(windows) { + paths + .paths() + .iter() + .any(|path| util::paths::WslPath::from_path(path).is_some()) + } else { + false + }; + + // Delete the workspace if any of the paths are WSL paths. + // If a local workspace points to WSL, this check will cause us to wait for the + // WSL VM and file server to boot up. This can block for many seconds. + // Supported scenarios use remote workspaces. + if !has_wsl_path && paths.paths().iter().all(|path| path.exists()) { + // Only show directories in recent projects + if paths.paths().iter().any(|path| path.is_dir()) { + result.push((id, SerializedWorkspaceLocation::Local, paths)); + } } else { delete_tasks.push(self.delete_workspace_by_id(id)); } @@ -1643,49 +1722,6 @@ impl WorkspaceDb { } } - pub async fn toolchain( - &self, - workspace_id: WorkspaceId, - worktree_id: WorktreeId, - relative_worktree_path: Arc, - language_name: LanguageName, - ) -> Result> { - self.write(move |this| { - let mut select = this - .select_bound(sql!( - SELECT - name, path, raw_json - FROM toolchains - WHERE - workspace_id = ? AND - language_name = ? AND - worktree_id = ? AND - relative_worktree_path = ? - )) - .context("select toolchain")?; - - let toolchain: Vec<(String, String, String)> = select(( - workspace_id, - language_name.as_ref().to_string(), - worktree_id.to_usize(), - relative_worktree_path.as_unix_str().to_string(), - ))?; - - Ok(toolchain - .into_iter() - .next() - .and_then(|(name, path, raw_json)| { - Some(Toolchain { - name: name.into(), - path: path.into(), - language_name, - as_json: serde_json::Value::from_str(&raw_json).ok()?, - }) - })) - }) - .await - } - pub(crate) async fn toolchains( &self, workspace_id: WorkspaceId, @@ -2493,7 +2529,7 @@ mod tests { let workspace_6 = SerializedWorkspace { id: WorkspaceId(6), - paths: PathList::new(&["/tmp6a", "/tmp6b", "/tmp6c"]), + paths: PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]), location: SerializedWorkspaceLocation::Local, center_group: Default::default(), window_bounds: Default::default(), @@ -2534,7 +2570,7 @@ mod tests { assert_eq!(locations.len(), 1); assert_eq!( locations[0].0, - PathList::new(&["/tmp6a", "/tmp6b", "/tmp6c"]), + PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]), ); assert_eq!(locations[0].1, Some(60)); } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 08a2f2e38d..08a3adf9eb 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -3,7 +3,7 @@ use crate::{ Member, Pane, PaneAxis, SerializableItemRegistry, Workspace, WorkspaceId, item::ItemHandle, path_list::PathList, }; -use anyhow::Result; +use anyhow::{Context, Result}; use async_recursion::async_recursion; use collections::IndexSet; use db::sqlez::{ @@ -32,6 +32,7 @@ pub(crate) struct RemoteConnectionId(pub u64); pub(crate) enum RemoteConnectionKind { Ssh, Wsl, + Docker, } #[derive(Debug, PartialEq, Clone)] @@ -75,6 +76,7 @@ impl RemoteConnectionKind { match self { RemoteConnectionKind::Ssh => "ssh", RemoteConnectionKind::Wsl => "wsl", + RemoteConnectionKind::Docker => "docker", } } @@ -82,6 +84,7 @@ impl RemoteConnectionKind { match text { "ssh" => Some(Self::Ssh), "wsl" => Some(Self::Wsl), + "docker" => Some(Self::Docker), _ => None, } } @@ -220,6 +223,7 @@ impl SerializedPaneGroup { let new_items = serialized_pane .deserialize_to(project, &pane, workspace_id, workspace.clone(), cx) .await + .context("Could not deserialize pane)") .log_err()?; if pane diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 310fae908d..badfe7d243 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -96,6 +96,7 @@ pub trait SearchableItem: Item + EventEmitter { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ); @@ -165,6 +166,7 @@ pub trait SearchableItem: Item + EventEmitter { window: &mut Window, cx: &mut Context, ) -> Option; + fn set_search_is_case_sensitive(&mut self, _: Option, _: &mut Context) {} } pub trait SearchableItemHandle: ItemHandle { @@ -178,7 +180,13 @@ pub trait SearchableItemHandle: ItemHandle { handler: Box, ) -> Subscription; fn clear_matches(&self, window: &mut Window, cx: &mut App); - fn update_matches(&self, matches: &AnyVec, window: &mut Window, cx: &mut App); + fn update_matches( + &self, + matches: &AnyVec, + active_match_index: Option, + window: &mut Window, + cx: &mut App, + ); fn query_suggestion(&self, window: &mut Window, cx: &mut App) -> String; fn activate_match( &self, @@ -232,6 +240,8 @@ pub trait SearchableItemHandle: ItemHandle { window: &mut Window, cx: &mut App, ); + + fn set_search_is_case_sensitive(&self, is_case_sensitive: Option, cx: &mut App); } impl SearchableItemHandle for Entity { @@ -261,10 +271,16 @@ impl SearchableItemHandle for Entity { fn clear_matches(&self, window: &mut Window, cx: &mut App) { self.update(cx, |this, cx| this.clear_matches(window, cx)); } - fn update_matches(&self, matches: &AnyVec, window: &mut Window, cx: &mut App) { + fn update_matches( + &self, + matches: &AnyVec, + active_match_index: Option, + window: &mut Window, + cx: &mut App, + ) { let matches = matches.downcast_ref().unwrap(); self.update(cx, |this, cx| { - this.update_matches(matches.as_slice(), window, cx) + this.update_matches(matches.as_slice(), active_match_index, window, cx) }); } fn query_suggestion(&self, window: &mut Window, cx: &mut App) -> String { @@ -387,17 +403,22 @@ impl SearchableItemHandle for Entity { this.toggle_filtered_search_ranges(enabled, window, cx) }); } + fn set_search_is_case_sensitive(&self, enabled: Option, cx: &mut App) { + self.update(cx, |this, cx| { + this.set_search_is_case_sensitive(enabled, cx) + }); + } } impl From> for AnyView { fn from(this: Box) -> Self { - this.to_any() + this.to_any_view() } } impl From<&Box> for AnyView { fn from(this: &Box) -> Self { - this.to_any() + this.to_any_view() } } diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index d77be8ed76..5645602746 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -6,7 +6,7 @@ use call::{RemoteVideoTrack, RemoteVideoTrackView, Room}; use client::{User, proto::PeerId}; use gpui::{ AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - ParentElement, Render, SharedString, Styled, div, + ParentElement, Render, SharedString, Styled, Task, div, }; use std::sync::Arc; use ui::{Icon, IconName, prelude::*}; @@ -42,6 +42,11 @@ impl SharedScreen { }) .detach(); + cx.observe_release(&room, |_, _, cx| { + cx.emit(Event::Close); + }) + .detach(); + let view = cx.new(|cx| RemoteVideoTrackView::new(track.clone(), window, cx)); cx.subscribe(&view, |_, _, ev, cx| match ev { call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), @@ -109,19 +114,23 @@ impl Item for SharedScreen { self.nav_history = Some(history); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> { - Some(cx.new(|cx| Self { + ) -> Task>> { + Task::ready(Some(cx.new(|cx| Self { view: self.view.update(cx, |view, cx| view.clone(window, cx)), peer_id: self.peer_id, user: self.user.clone(), nav_history: Default::default(), focus: cx.focus_handle(), - })) + }))) } fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { diff --git a/crates/workspace/src/theme_preview.rs b/crates/workspace/src/theme_preview.rs index 09a5415ca0..f978da706b 100644 --- a/crates/workspace/src/theme_preview.rs +++ b/crates/workspace/src/theme_preview.rs @@ -1,12 +1,14 @@ #![allow(unused, dead_code)] -use gpui::{AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, Hsla, actions, hsla}; +use gpui::{ + AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Task, actions, hsla, +}; use strum::IntoEnumIterator; use theme::all_theme_colors; use ui::{ AudioStatus, Avatar, AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike, - Checkbox, CheckboxWithLabel, CollaboratorAvailability, ContentGroup, DecoratedIcon, - ElevationIndex, Facepile, IconDecoration, Indicator, KeybindingHint, Switch, TintColor, - Tooltip, prelude::*, utils::calculate_contrast_ratio, + Checkbox, CollaboratorAvailability, ContentGroup, DecoratedIcon, ElevationIndex, Facepile, + IconDecoration, Indicator, KeybindingHint, Switch, TintColor, Tooltip, prelude::*, + utils::calculate_contrast_ratio, }; use crate::{Item, Workspace}; @@ -95,16 +97,20 @@ impl Item for ThemePreview { None } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| Self::new(window, cx))) + Task::ready(Some(cx.new(|cx| Self::new(window, cx)))) } } @@ -317,13 +323,7 @@ impl ThemePreview { .style(ButtonStyle::Transparent) .tooltip(move |window, cx| { let name = name.clone(); - Tooltip::with_meta( - name, - None, - format!("{:?}", color), - window, - cx, - ) + Tooltip::with_meta(name, None, format!("{:?}", color), cx) }), ) })), diff --git a/crates/workspace/src/toolbar.rs b/crates/workspace/src/toolbar.rs index 9d6626af80..6e26be6dc7 100644 --- a/crates/workspace/src/toolbar.rs +++ b/crates/workspace/src/toolbar.rs @@ -1,7 +1,7 @@ use crate::ItemHandle; use gpui::{ - AnyView, App, Context, Entity, EntityId, EventEmitter, ParentElement as _, Render, Styled, - Window, + AnyView, App, Context, Entity, EntityId, EventEmitter, KeyContext, ParentElement as _, Render, + Styled, Window, }; use ui::prelude::*; use ui::{h_flex, v_flex}; @@ -25,6 +25,8 @@ pub trait ToolbarItemView: Render + EventEmitter { _cx: &mut Context, ) { } + + fn contribute_context(&self, _context: &mut KeyContext, _cx: &App) {} } trait ToolbarItemViewHandle: Send { @@ -37,6 +39,7 @@ trait ToolbarItemViewHandle: Send { cx: &mut App, ) -> ToolbarItemLocation; fn focus_changed(&mut self, pane_focused: bool, window: &mut Window, cx: &mut App); + fn contribute_context(&self, context: &mut KeyContext, cx: &App); } #[derive(Copy, Clone, Debug, PartialEq)] @@ -236,6 +239,14 @@ impl Toolbar { pub fn hidden(&self) -> bool { self.hidden } + + pub fn contribute_context(&self, context: &mut KeyContext, cx: &App) { + for (item, location) in &self.items { + if *location != ToolbarItemLocation::Hidden { + item.contribute_context(context, cx); + } + } + } } impl ToolbarItemViewHandle for Entity { @@ -264,4 +275,8 @@ impl ToolbarItemViewHandle for Entity { cx.notify(); }); } + + fn contribute_context(&self, context: &mut KeyContext, cx: &App) { + self.read(cx).contribute_context(context, cx) + } } diff --git a/crates/workspace/src/utility_pane.rs b/crates/workspace/src/utility_pane.rs new file mode 100644 index 0000000000..2760000216 --- /dev/null +++ b/crates/workspace/src/utility_pane.rs @@ -0,0 +1,282 @@ +use gpui::{ + AppContext as _, EntityId, MouseButton, Pixels, Render, StatefulInteractiveElement, + Subscription, WeakEntity, deferred, px, +}; +use ui::{ + ActiveTheme as _, Context, FluentBuilder as _, InteractiveElement as _, IntoElement, + ParentElement as _, RenderOnce, Styled as _, Window, div, +}; + +use crate::{ + DockPosition, Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPaneHandle}, +}; + +pub(crate) const UTILITY_PANE_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +pub(crate) const UTILITY_PANE_MIN_WIDTH: Pixels = px(20.0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UtilityPaneSlot { + Left, + Right, +} + +struct UtilityPaneSlotState { + panel_id: EntityId, + utility_pane: Box, + _subscriptions: Vec, +} + +#[derive(Default)] +pub struct UtilityPaneState { + left_slot: Option, + right_slot: Option, +} + +#[derive(Clone)] +pub struct DraggedUtilityPane(pub UtilityPaneSlot); + +impl Render for DraggedUtilityPane { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +pub fn utility_slot_for_dock_position(position: DockPosition) -> UtilityPaneSlot { + match position { + DockPosition::Left => UtilityPaneSlot::Left, + DockPosition::Right => UtilityPaneSlot::Right, + DockPosition::Bottom => UtilityPaneSlot::Left, + } +} + +impl Workspace { + pub fn utility_pane(&self, slot: UtilityPaneSlot) -> Option<&dyn UtilityPaneHandle> { + match slot { + UtilityPaneSlot::Left => self + .utility_panes + .left_slot + .as_ref() + .map(|s| s.utility_pane.as_ref()), + UtilityPaneSlot::Right => self + .utility_panes + .right_slot + .as_ref() + .map(|s| s.utility_pane.as_ref()), + } + } + + pub fn toggle_utility_pane( + &mut self, + slot: UtilityPaneSlot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + let current = handle.expanded(cx); + handle.set_expanded(!current, cx); + } + cx.notify(); + self.serialize_workspace(window, cx); + } + + pub fn register_utility_pane( + &mut self, + slot: UtilityPaneSlot, + panel_id: EntityId, + handle: gpui::Entity, + cx: &mut Context, + ) { + let minimize_subscription = + cx.subscribe(&handle, move |this, _, _event: &MinimizePane, cx| { + if let Some(handle) = this.utility_pane(slot) { + handle.set_expanded(false, cx); + } + cx.notify(); + }); + + let close_subscription = cx.subscribe(&handle, move |this, _, _event: &ClosePane, cx| { + this.clear_utility_pane(slot, cx); + }); + + let subscriptions = vec![minimize_subscription, close_subscription]; + let boxed_handle: Box = Box::new(handle); + + match slot { + UtilityPaneSlot::Left => { + self.utility_panes.left_slot = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + _subscriptions: subscriptions, + }); + } + UtilityPaneSlot::Right => { + self.utility_panes.right_slot = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + _subscriptions: subscriptions, + }); + } + } + cx.notify(); + } + + pub fn clear_utility_pane(&mut self, slot: UtilityPaneSlot, cx: &mut Context) { + match slot { + UtilityPaneSlot::Left => { + self.utility_panes.left_slot = None; + } + UtilityPaneSlot::Right => { + self.utility_panes.right_slot = None; + } + } + cx.notify(); + } + + pub fn clear_utility_pane_if_provider( + &mut self, + slot: UtilityPaneSlot, + provider_panel_id: EntityId, + cx: &mut Context, + ) { + let should_clear = match slot { + UtilityPaneSlot::Left => self + .utility_panes + .left_slot + .as_ref() + .is_some_and(|slot| slot.panel_id == provider_panel_id), + UtilityPaneSlot::Right => self + .utility_panes + .right_slot + .as_ref() + .is_some_and(|slot| slot.panel_id == provider_panel_id), + }; + + if should_clear { + self.clear_utility_pane(slot, cx); + } + } + + pub fn resize_utility_pane( + &mut self, + slot: UtilityPaneSlot, + new_width: Pixels, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + let max_width = self.max_utility_pane_width(window, cx); + let width = new_width.max(UTILITY_PANE_MIN_WIDTH).min(max_width); + handle.set_width(Some(width), cx); + cx.notify(); + self.serialize_workspace(window, cx); + } + } + + pub fn reset_utility_pane_width( + &mut self, + slot: UtilityPaneSlot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + handle.set_width(None, cx); + cx.notify(); + self.serialize_workspace(window, cx); + } + } +} + +#[derive(IntoElement)] +pub struct UtilityPaneFrame { + workspace: WeakEntity, + slot: UtilityPaneSlot, + handle: Box, +} + +impl UtilityPaneFrame { + pub fn new( + slot: UtilityPaneSlot, + handle: Box, + cx: &mut Context, + ) -> Self { + let workspace = cx.weak_entity(); + Self { + workspace, + slot, + handle, + } + } +} + +impl RenderOnce for UtilityPaneFrame { + fn render(self, _window: &mut Window, cx: &mut ui::App) -> impl IntoElement { + let workspace = self.workspace.clone(); + let slot = self.slot; + let width = self.handle.width(cx); + + let create_resize_handle = || { + let workspace_handle = workspace.clone(); + let handle = div() + .id(match slot { + UtilityPaneSlot::Left => "utility-pane-resize-handle-left", + UtilityPaneSlot::Right => "utility-pane-resize-handle-right", + }) + .on_drag(DraggedUtilityPane(slot), move |pane, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| pane.clone()) + }) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + move |e: &gpui::MouseUpEvent, window, cx| { + if e.click_count == 2 { + workspace_handle + .update(cx, |workspace, cx| { + workspace.reset_utility_pane_width(slot, window, cx); + }) + .ok(); + cx.stop_propagation(); + } + }, + ) + .occlude(); + + match slot { + UtilityPaneSlot::Left => deferred( + handle + .absolute() + .right(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize(), + ), + UtilityPaneSlot::Right => deferred( + handle + .absolute() + .left(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize(), + ), + } + }; + + div() + .h_full() + .bg(cx.theme().colors().tab_bar_background) + .w(width) + .border_color(cx.theme().colors().border) + .when(self.slot == UtilityPaneSlot::Left, |this| this.border_r_1()) + .when(self.slot == UtilityPaneSlot::Right, |this| { + this.border_l_1() + }) + .child(create_resize_handle()) + .child(self.handle.to_any()) + .into_any_element() + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d4ecbb9e69..7dfa5d634c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,6 +1,6 @@ pub mod dock; pub mod history_manager; -pub mod invalid_buffer_view; +pub mod invalid_item_view; pub mod item; mod modal_layer; pub mod notifications; @@ -15,6 +15,7 @@ pub mod tasks; mod theme_preview; mod toast_layer; mod toolbar; +pub mod utility_pane; mod workspace_settings; pub use crate::notifications::NotificationFrame; @@ -30,6 +31,7 @@ use client::{ }; use collections::{HashMap, HashSet, hash_map}; use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{ Future, FutureExt, StreamExt, channel::{ @@ -76,11 +78,14 @@ use project::{ debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, toolchain_store::ToolchainStoreEvent, }; -use remote::{RemoteClientDelegate, RemoteConnectionOptions, remote_client::ConnectionIdentifier}; +use remote::{ + RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, + remote_client::ConnectionIdentifier, +}; use schemars::JsonSchema; use serde::Deserialize; use session::AppSession; -use settings::{Settings, SettingsLocation, update_settings_file}; +use settings::{CenteredPaddingSettings, Settings, SettingsLocation, update_settings_file}; use shared_screen::SharedScreen; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, @@ -99,11 +104,14 @@ use std::{ path::{Path, PathBuf}, process::ExitStatus, rc::Rc, - sync::{Arc, LazyLock, Weak, atomic::AtomicUsize}, + sync::{ + Arc, LazyLock, Weak, + atomic::{AtomicBool, AtomicUsize}, + }, time::Duration, }; use task::{DebugScenario, SpawnInTerminal, TaskContext}; -use theme::{ActiveTheme, SystemAppearance, ThemeSettings}; +use theme::{ActiveTheme, GlobalTheme, SystemAppearance, ThemeSettings}; pub use toolbar::{Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; pub use ui; use ui::{Window, prelude::*}; @@ -120,11 +128,16 @@ pub use workspace_settings::{ }; use zed_actions::{Spawn, feedback::FileBugReport}; -use crate::persistence::{ - SerializedAxis, - model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, +use crate::{ + item::ItemBufferKind, notifications::NotificationId, utility_pane::UTILITY_PANE_MIN_WIDTH, +}; +use crate::{ + persistence::{ + SerializedAxis, + model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, + }, + utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState}, }; -use crate::{item::ItemBufferKind, notifications::NotificationId}; pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); @@ -193,10 +206,14 @@ actions!( AddFolderToProject, /// Clears all notifications. ClearAllNotifications, + /// Clears all navigation history, including forward/backward navigation, recently opened files, and recently closed tabs. **This action is irreversible**. + ClearNavigationHistory, /// Closes the active dock. CloseActiveDock, /// Closes all docks. CloseAllDocks, + /// Toggles all docks. + ToggleAllDocks, /// Closes the current window. CloseWindow, /// Opens the feedback dialog. @@ -255,6 +272,10 @@ actions!( ToggleRightDock, /// Toggles zoom on the active pane. ToggleZoom, + /// Zooms in on the active pane. + ZoomIn, + /// Zooms out of the active pane. + ZoomOut, /// Stops following a collaborator. Unfollow, /// Restores the banner. @@ -299,6 +320,12 @@ pub struct MoveItemToPaneInDirection { pub clone: bool, } +/// Creates a new file in a split of the desired direction. +#[derive(Clone, Deserialize, PartialEq, JsonSchema, Action)] +#[action(namespace = workspace)] +#[serde(deny_unknown_fields)] +pub struct NewFileSplit(pub SplitDirection); + fn default_right() -> SplitDirection { SplitDirection::Right } @@ -421,6 +448,16 @@ actions!( SwapPaneUp, /// Swaps the current pane with the one below. SwapPaneDown, + // Swaps the current pane with the first available adjacent pane (searching in order: below, above, right, left) and activates that pane. + SwapPaneAdjacent, + /// Move the current pane to be at the far left. + MovePaneLeft, + /// Move the current pane to be at the far right. + MovePaneRight, + /// Move the current pane to be at the very top. + MovePaneUp, + /// Move the current pane to be at the very bottom. + MovePaneDown, ] ); @@ -505,14 +542,6 @@ impl From for i64 { } } -pub fn init_settings(cx: &mut App) { - WorkspaceSettings::register(cx); - ItemSettings::register(cx); - PreviewTabsSettings::register(cx); - TabBarSettings::register(cx); - StatusBarSettings::register(cx); -} - fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, cx: &mut App) { let paths = cx.prompt_for_paths(options); cx.spawn( @@ -546,50 +575,48 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c } pub fn init(app_state: Arc, cx: &mut App) { - init_settings(cx); component::init(); theme_preview::init(cx); toast_layer::init(cx); history_manager::init(cx); - cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)); - cx.on_action(|_: &Reload, cx| reload(cx)); - - cx.on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &Open, cx: &mut App| { - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories: true, - multiple: true, - prompt: None, - }, - cx, - ); + cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) + .on_action(|_: &Reload, cx| reload(cx)) + .on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &Open, cx: &mut App| { + if let Some(app_state) = app_state.upgrade() { + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: None, + }, + cx, + ); + } } - } - }); - cx.on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &OpenFiles, cx: &mut App| { - let directories = cx.can_select_mixed_files_and_dirs(); - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories, - multiple: true, - prompt: None, - }, - cx, - ); + }) + .on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &OpenFiles, cx: &mut App| { + let directories = cx.can_select_mixed_files_and_dirs(); + if let Some(app_state) = app_state.upgrade() { + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories, + multiple: true, + prompt: None, + }, + cx, + ); + } } - } - }); + }); } type BuildProjectItemFn = @@ -658,6 +685,7 @@ impl ProjectItemRegistry { Ok((project_entry_id, build_workspace_item)) } Err(e) => { + log::warn!("Failed to open a project item: {e:#}"); if e.error_code() == ErrorCode::Internal { if let Some(abs_path) = entry_abs_path.as_deref().filter(|_| is_file) @@ -965,7 +993,6 @@ impl AppState { theme::init(theme::LoadThemes::JustBase, cx); client::init(&client, cx); - crate::init_settings(cx); Arc::new(Self { client, @@ -1156,6 +1183,9 @@ pub struct Workspace { _items_serializer: Task>, session_id: Option, scheduled_tasks: Vec>, + last_open_dock_positions: Vec, + removing: bool, + utility_panes: UtilityPaneState, } impl EventEmitter for Workspace {} @@ -1179,9 +1209,6 @@ struct FollowerView { } impl Workspace { - const DEFAULT_PADDING: f32 = 0.2; - const MAX_PADDING: f32 = 0.4; - pub fn new( workspace_id: Option, project: Entity, @@ -1314,6 +1341,7 @@ impl Workspace { pane_history_timestamp.clone(), None, NewFile.boxed_clone(), + true, window, cx, ); @@ -1435,12 +1463,12 @@ impl Workspace { *SystemAppearance::global_mut(cx) = SystemAppearance(window_appearance.into()); - ThemeSettings::reload_current_theme(cx); - ThemeSettings::reload_current_icon_theme(cx); + GlobalTheme::reload_theme(cx); + GlobalTheme::reload_icon_theme(cx); }), cx.on_release(move |this, cx| { this.app_state.workspace_store.update(cx, move |store, _| { - store.workspaces.remove(&window_handle.clone()); + store.workspaces.remove(&window_handle); }) }), ]; @@ -1449,12 +1477,17 @@ impl Workspace { this.update_window_title(window, cx); this.show_initial_notifications(cx); }); + + let mut center = PaneGroup::new(center_pane.clone()); + center.set_is_center(true); + center.mark_positions(cx); + Workspace { weak_self: weak_handle.clone(), zoomed: None, zoomed_position: None, previous_dock_drag_coordinates: None, - center: PaneGroup::new(center_pane.clone()), + center, panes: vec![center_pane.clone()], panes_by_item: Default::default(), active_pane: center_pane.clone(), @@ -1500,6 +1533,9 @@ impl Workspace { session_id: Some(session_id), scheduled_tasks: Vec::new(), + last_open_dock_positions: Vec::new(), + removing: false, + utility_panes: UtilityPaneState::default(), } } @@ -1539,7 +1575,7 @@ impl Workspace { persistence::DB.workspace_for_roots(paths_to_open.as_slice()); if let Some(paths) = serialized_workspace.as_ref().map(|ws| &ws.paths) { - paths_to_open = paths.paths().to_vec(); + paths_to_open = paths.ordered_paths().cloned().collect(); if !paths.is_lexicographically_ordered() { project_handle .update(cx, |project, cx| { @@ -1623,20 +1659,18 @@ impl Workspace { let (window_bounds, display) = if let Some(bounds) = window_bounds_override { (Some(WindowBounds::Windowed(bounds)), None) - } else { - let restorable_bounds = serialized_workspace - .as_ref() - .and_then(|workspace| Some((workspace.display?, workspace.window_bounds?))) - .or_else(|| { - let (display, window_bounds) = DB.last_window().log_err()?; - Some((display?, window_bounds?)) - }); - - if let Some((serialized_display, serialized_status)) = restorable_bounds { - (Some(serialized_status.0), Some(serialized_display)) + } else if let Some(workspace) = serialized_workspace.as_ref() { + // Reopening an existing workspace - restore its saved bounds + if let (Some(display), Some(bounds)) = + (workspace.display, workspace.window_bounds.as_ref()) + { + (Some(bounds.0), Some(display)) } else { (None, None) } + } else { + // New window - let GPUI's default_bounds() handle cascading + (None, None) }; // Use the serialized workspace to construct the new window @@ -1829,7 +1863,7 @@ impl Workspace { pub fn recent_navigation_history_iter( &self, cx: &App, - ) -> impl Iterator)> { + ) -> impl Iterator)> + use<> { let mut abs_paths_opened: HashMap> = HashMap::default(); let mut history: HashMap, usize)> = HashMap::default(); @@ -1903,6 +1937,12 @@ impl Workspace { .collect() } + pub fn clear_navigation_history(&mut self, _window: &mut Window, cx: &mut Context) { + for pane in &self.panes { + pane.update(cx, |pane, cx| pane.nav_history_mut().clear(cx)); + } + } + fn navigate_history( &mut self, pane: WeakEntity, @@ -2313,12 +2353,13 @@ impl Workspace { ) -> Task> { let active_call = self.active_call().cloned(); - // On Linux and Windows, closing the last window should restore the last workspace. - let save_last_workspace = cfg!(not(target_os = "macos")) - && close_intent != CloseIntent::ReplaceWindow - && cx.windows().len() == 1; - cx.spawn_in(window, async move |this, cx| { + this.update(cx, |this, _| { + if close_intent == CloseIntent::CloseWindow { + this.removing = true; + } + })?; + let workspace_count = cx.update(|_window, cx| { cx.windows() .iter() @@ -2326,6 +2367,28 @@ impl Workspace { .count() })?; + #[cfg(target_os = "macos")] + let save_last_workspace = false; + + // On Linux and Windows, closing the last window should restore the last workspace. + #[cfg(not(target_os = "macos"))] + let save_last_workspace = { + let remaining_workspaces = cx.update(|_window, cx| { + cx.windows() + .iter() + .filter_map(|window| window.downcast::()) + .filter_map(|workspace| { + workspace + .update(cx, |workspace, _, _| workspace.removing) + .ok() + }) + .filter(|removing| !removing) + .count() + })?; + + close_intent != CloseIntent::ReplaceWindow && remaining_workspaces == 0 + }; + if let Some(active_call) = active_call && workspace_count == 1 && active_call.read_with(cx, |call, _| call.room().is_some())? @@ -2406,6 +2469,12 @@ impl Workspace { .0 .split(' ') .flat_map(|k| Keystroke::parse(k).log_err()) + .map(|k| { + cx.keyboard_mapper() + .map_key_equivalent(k, true) + .inner() + .clone() + }) .collect(); let _ = self.send_keystrokes_impl(keystrokes, window, cx); } @@ -2824,7 +2893,7 @@ impl Workspace { pub fn active_item_as(&self, cx: &App) -> Option> { let item = self.active_item(cx)?; - item.to_any().downcast::().ok() + item.to_any_view().downcast::().ok() } fn active_project_path(&self, cx: &App) -> Option { @@ -2969,12 +3038,17 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - let dock = self.dock_at_position(dock_side); let mut focus_center = false; let mut reveal_dock = false; + + let other_is_zoomed = self.zoomed.is_some() && self.zoomed_position != Some(dock_side); + let was_visible = self.is_dock_at_position_open(dock_side, cx) && !other_is_zoomed; + if was_visible { + self.save_open_dock_positions(cx); + } + + let dock = self.dock_at_position(dock_side); dock.update(cx, |dock, cx| { - let other_is_zoomed = self.zoomed.is_some() && self.zoomed_position != Some(dock_side); - let was_visible = dock.is_open() && !other_is_zoomed; dock.set_open(!was_visible, window, cx); if dock.active_panel().is_none() { @@ -3023,7 +3097,8 @@ impl Workspace { } fn close_active_dock(&mut self, window: &mut Window, cx: &mut Context) -> bool { - if let Some(dock) = self.active_dock(window, cx) { + if let Some(dock) = self.active_dock(window, cx).cloned() { + self.save_open_dock_positions(cx); dock.update(cx, |dock, cx| { dock.set_open(false, window, cx); }); @@ -3033,6 +3108,7 @@ impl Workspace { } pub fn close_all_docks(&mut self, window: &mut Window, cx: &mut Context) { + self.save_open_dock_positions(cx); for dock in self.all_docks() { dock.update(cx, |dock, cx| { dock.set_open(false, window, cx); @@ -3044,6 +3120,67 @@ impl Workspace { self.serialize_workspace(window, cx); } + fn get_open_dock_positions(&self, cx: &Context) -> Vec { + self.all_docks() + .into_iter() + .filter_map(|dock| { + let dock_ref = dock.read(cx); + if dock_ref.is_open() { + Some(dock_ref.position()) + } else { + None + } + }) + .collect() + } + + /// Saves the positions of currently open docks. + /// + /// Updates `last_open_dock_positions` with positions of all currently open + /// docks, to later be restored by the 'Toggle All Docks' action. + fn save_open_dock_positions(&mut self, cx: &mut Context) { + let open_dock_positions = self.get_open_dock_positions(cx); + if !open_dock_positions.is_empty() { + self.last_open_dock_positions = open_dock_positions; + } + } + + /// Toggles all docks between open and closed states. + /// + /// If any docks are open, closes all and remembers their positions. If all + /// docks are closed, restores the last remembered dock configuration. + fn toggle_all_docks( + &mut self, + _: &ToggleAllDocks, + window: &mut Window, + cx: &mut Context, + ) { + let open_dock_positions = self.get_open_dock_positions(cx); + + if !open_dock_positions.is_empty() { + self.close_all_docks(window, cx); + } else if !self.last_open_dock_positions.is_empty() { + self.restore_last_open_docks(window, cx); + } + } + + /// Reopens docks from the most recently remembered configuration. + /// + /// Opens all docks whose positions are stored in `last_open_dock_positions` + /// and clears the stored positions. + fn restore_last_open_docks(&mut self, window: &mut Window, cx: &mut Context) { + let positions_to_open = std::mem::take(&mut self.last_open_dock_positions); + + for position in positions_to_open { + let dock = self.dock_at_position(position); + dock.update(cx, |dock, cx| dock.set_open(true, window, cx)); + } + + cx.focus_self(window); + cx.notify(); + self.serialize_workspace(window, cx); + } + /// Transfer focus to the panel of the given type. pub fn focus_panel( &mut self, @@ -3218,6 +3355,7 @@ impl Workspace { self.pane_history_timestamp.clone(), None, NewFile.boxed_clone(), + true, window, cx, ); @@ -3283,10 +3421,6 @@ impl Workspace { window: &mut Window, cx: &mut App, ) { - if let Some(text) = item.telemetry_event_text(cx) { - telemetry::event!(text); - } - pane.update(cx, |pane, cx| { pane.add_item( item, @@ -3524,14 +3658,33 @@ impl Workspace { project_item: Entity, activate_pane: bool, focus_item: bool, + keep_old_preview: bool, + allow_new_preview: bool, window: &mut Window, cx: &mut Context, ) -> Entity where T: ProjectItem, { + let old_item_id = pane.read(cx).active_item().map(|item| item.item_id()); + if let Some(item) = self.find_project_item(&pane, &project_item, cx) { + if !keep_old_preview + && let Some(old_id) = old_item_id + && old_id != item.item_id() + { + // switching to a different item, so unpreview old active item + pane.update(cx, |pane, _| { + pane.unpreview_item_if_preview(old_id); + }); + } + self.activate_item(&item, activate_pane, focus_item, window, cx); + if !allow_new_preview { + pane.update(cx, |pane, _| { + pane.unpreview_item_if_preview(item.item_id()); + }); + } return item; } @@ -3540,16 +3693,14 @@ impl Workspace { T::for_project_item(self.project().clone(), Some(pane), project_item, window, cx) }) }); - let item_id = item.item_id(); let mut destination_index = None; pane.update(cx, |pane, cx| { - if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation - && let Some(preview_item_id) = pane.preview_item_id() - && preview_item_id != item_id - { - destination_index = pane.close_current_preview_item(window, cx); + if !keep_old_preview && let Some(old_id) = old_item_id { + pane.unpreview_item_if_preview(old_id); + } + if allow_new_preview { + destination_index = pane.replace_preview_item_id(item.item_id(), window, cx); } - pane.set_preview_item_id(Some(item.item_id()), cx) }); self.add_item( @@ -3612,7 +3763,8 @@ impl Workspace { if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) { window.focus(&pane.focus_handle(cx)); } else { - self.split_and_clone(self.active_pane.clone(), SplitDirection::Right, window, cx); + self.split_and_clone(self.active_pane.clone(), SplitDirection::Right, window, cx) + .detach(); } } @@ -3636,7 +3788,7 @@ impl Workspace { let new_pane = self.add_pane(window, cx); if self .center - .split(&split_off_pane, &new_pane, direction) + .split(&split_off_pane, &new_pane, direction, cx) .log_err() .is_none() { @@ -3647,24 +3799,31 @@ impl Workspace { }; if action.clone { - clone_active_item( - self.database_id(), - &self.active_pane, - &destination, - action.focus, - window, - cx, - ) - } else { - move_active_item( - &self.active_pane, - &destination, - action.focus, - true, - window, - cx, - ) + if self + .active_pane + .read(cx) + .active_item() + .is_some_and(|item| item.can_split(cx)) + { + clone_active_item( + self.database_id(), + &self.active_pane, + &destination, + action.focus, + window, + cx, + ); + return; + } } + move_active_item( + &self.active_pane, + &destination, + action.focus, + true, + window, + cx, + ) } pub fn activate_next_pane(&mut self, window: &mut Window, cx: &mut App) { @@ -3814,7 +3973,7 @@ impl Workspace { let new_pane = self.add_pane(window, cx); if self .center - .split(&self.active_pane, &new_pane, action.direction) + .split(&self.active_pane, &new_pane, action.direction, cx) .log_err() .is_none() { @@ -3825,24 +3984,31 @@ impl Workspace { }; if action.clone { - clone_active_item( - self.database_id(), - &self.active_pane, - &destination, - action.focus, - window, - cx, - ) - } else { - move_active_item( - &self.active_pane, - &destination, - action.focus, - true, - window, - cx, - ); + if self + .active_pane + .read(cx) + .active_item() + .is_some_and(|item| item.can_split(cx)) + { + clone_active_item( + self.database_id(), + &self.active_pane, + &destination, + action.focus, + window, + cx, + ); + return; + } } + move_active_item( + &self.active_pane, + &destination, + action.focus, + true, + window, + cx, + ); } pub fn bounding_box_for_pane(&self, pane: &Entity) -> Option> { @@ -3861,7 +4027,17 @@ impl Workspace { pub fn swap_pane_in_direction(&mut self, direction: SplitDirection, cx: &mut Context) { if let Some(to) = self.find_pane_in_direction(direction, cx) { - self.center.swap(&self.active_pane, &to); + self.center.swap(&self.active_pane, &to, cx); + cx.notify(); + } + } + + pub fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { + if self + .center + .move_to_border(&self.active_pane, direction, cx) + .unwrap() + { cx.notify(); } } @@ -3889,13 +4065,13 @@ impl Workspace { } } else { self.center - .resize(&self.active_pane, axis, amount, &self.bounds); + .resize(&self.active_pane, axis, amount, &self.bounds, cx); } cx.notify(); } pub fn reset_pane_sizes(&mut self, cx: &mut Context) { - self.center.reset_pane_sizes(); + self.center.reset_pane_sizes(cx); cx.notify(); } @@ -3969,7 +4145,8 @@ impl Workspace { clone_active_item, } => { if *clone_active_item { - self.split_and_clone(pane.clone(), *direction, window, cx); + self.split_and_clone(pane.clone(), *direction, window, cx) + .detach(); } else { self.split_and_move(pane.clone(), *direction, window, cx); } @@ -4080,7 +4257,7 @@ impl Workspace { ) -> Entity { let new_pane = self.add_pane(window, cx); self.center - .split(&pane_to_split, &new_pane, split_direction) + .split(&pane_to_split, &new_pane, split_direction, cx) .unwrap(); cx.notify(); new_pane @@ -4100,7 +4277,7 @@ impl Workspace { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx) }); - self.center.split(&pane, &new_pane, direction).unwrap(); + self.center.split(&pane, &new_pane, direction, cx).unwrap(); cx.notify(); } @@ -4110,21 +4287,30 @@ impl Workspace { direction: SplitDirection, window: &mut Window, cx: &mut Context, - ) -> Option> { - let item = pane.read(cx).active_item()?; - let maybe_pane_handle = - if let Some(clone) = item.clone_on_split(self.database_id(), window, cx) { - let new_pane = self.add_pane(window, cx); - new_pane.update(cx, |pane, cx| { - pane.add_item(clone, true, true, None, window, cx) - }); - self.center.split(&pane, &new_pane, direction).unwrap(); - cx.notify(); - Some(new_pane) + ) -> Task>> { + let Some(item) = pane.read(cx).active_item() else { + return Task::ready(None); + }; + if !item.can_split(cx) { + return Task::ready(None); + } + let task = item.clone_on_split(self.database_id(), window, cx); + cx.spawn_in(window, async move |this, cx| { + if let Some(clone) = task.await { + this.update_in(cx, |this, window, cx| { + let new_pane = this.add_pane(window, cx); + new_pane.update(cx, |pane, cx| { + pane.add_item(clone, true, true, None, window, cx) + }); + this.center.split(&pane, &new_pane, direction, cx).unwrap(); + cx.notify(); + new_pane + }) + .ok() } else { None - }; - maybe_pane_handle + } + }) } pub fn join_all_panes(&mut self, window: &mut Window, cx: &mut Context) { @@ -4163,7 +4349,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - if self.center.remove(&pane).unwrap() { + if self.center.remove(&pane, cx).unwrap() { self.force_remove_pane(&pane, &focus_on, window, cx); self.unfollow_in_pane(&pane, window, cx); self.last_leaders_by_pane.remove(&pane.downgrade()); @@ -4178,6 +4364,10 @@ impl Workspace { cx.emit(Event::PaneRemoved); } + pub fn panes_mut(&mut self) -> &mut [Entity] { + &mut self.panes + } + pub fn panes(&self) -> &[Entity] { &self.panes } @@ -5511,6 +5701,9 @@ impl Workspace { // Swap workspace center group workspace.center = PaneGroup::with_root(center_group); + workspace.center.set_is_center(true); + workspace.center.mark_positions(cx); + if let Some(active_pane) = active_pane { workspace.set_active_pane(&active_pane, window, cx); cx.focus_self(window); @@ -5674,6 +5867,33 @@ impl Workspace { .on_action(cx.listener(|workspace, _: &SwapPaneDown, _, cx| { workspace.swap_pane_in_direction(SplitDirection::Down, cx) })) + .on_action(cx.listener(|workspace, _: &SwapPaneAdjacent, window, cx| { + const DIRECTION_PRIORITY: [SplitDirection; 4] = [ + SplitDirection::Down, + SplitDirection::Up, + SplitDirection::Right, + SplitDirection::Left, + ]; + for dir in DIRECTION_PRIORITY { + if workspace.find_pane_in_direction(dir, cx).is_some() { + workspace.swap_pane_in_direction(dir, cx); + workspace.activate_pane_in_direction(dir.opposite(), window, cx); + break; + } + } + })) + .on_action(cx.listener(|workspace, _: &MovePaneLeft, _, cx| { + workspace.move_pane_to_border(SplitDirection::Left, cx) + })) + .on_action(cx.listener(|workspace, _: &MovePaneRight, _, cx| { + workspace.move_pane_to_border(SplitDirection::Right, cx) + })) + .on_action(cx.listener(|workspace, _: &MovePaneUp, _, cx| { + workspace.move_pane_to_border(SplitDirection::Up, cx) + })) + .on_action(cx.listener(|workspace, _: &MovePaneDown, _, cx| { + workspace.move_pane_to_border(SplitDirection::Down, cx) + })) .on_action(cx.listener(|this, _: &ToggleLeftDock, window, cx| { this.toggle_dock(DockPosition::Left, window, cx); })) @@ -5699,11 +5919,17 @@ impl Workspace { workspace.close_all_docks(window, cx); }), ) + .on_action(cx.listener(Self::toggle_all_docks)) .on_action(cx.listener( |workspace: &mut Workspace, _: &ClearAllNotifications, _, cx| { workspace.clear_all_notifications(cx); }, )) + .on_action(cx.listener( + |workspace: &mut Workspace, _: &ClearNavigationHistory, window, cx| { + workspace.clear_navigation_history(window, cx); + }, + )) .on_action(cx.listener( |workspace: &mut Workspace, _: &SuppressNotification, _, cx| { if let Some((notification_id, _)) = workspace.notifications.pop() { @@ -5783,9 +6009,96 @@ impl Workspace { }, )) .on_action(cx.listener(Workspace::toggle_centered_layout)) + .on_action(cx.listener( + |workspace: &mut Workspace, _action: &pane::ActivateNextItem, window, cx| { + if let Some(active_dock) = workspace.active_dock(window, cx) { + let dock = active_dock.read(cx); + if let Some(active_panel) = dock.active_panel() { + if active_panel.pane(cx).is_none() { + let mut recent_pane: Option> = None; + let mut recent_timestamp = 0; + for pane_handle in workspace.panes() { + let pane = pane_handle.read(cx); + for entry in pane.activation_history() { + if entry.timestamp > recent_timestamp { + recent_timestamp = entry.timestamp; + recent_pane = Some(pane_handle.clone()); + } + } + } + + if let Some(pane) = recent_pane { + pane.update(cx, |pane, cx| { + let current_index = pane.active_item_index(); + let items_len = pane.items_len(); + if items_len > 0 { + let next_index = if current_index + 1 < items_len { + current_index + 1 + } else { + 0 + }; + pane.activate_item( + next_index, false, false, window, cx, + ); + } + }); + return; + } + } + } + } + cx.propagate(); + }, + )) + .on_action(cx.listener( + |workspace: &mut Workspace, _action: &pane::ActivatePreviousItem, window, cx| { + if let Some(active_dock) = workspace.active_dock(window, cx) { + let dock = active_dock.read(cx); + if let Some(active_panel) = dock.active_panel() { + if active_panel.pane(cx).is_none() { + let mut recent_pane: Option> = None; + let mut recent_timestamp = 0; + for pane_handle in workspace.panes() { + let pane = pane_handle.read(cx); + for entry in pane.activation_history() { + if entry.timestamp > recent_timestamp { + recent_timestamp = entry.timestamp; + recent_pane = Some(pane_handle.clone()); + } + } + } + + if let Some(pane) = recent_pane { + pane.update(cx, |pane, cx| { + let current_index = pane.active_item_index(); + let items_len = pane.items_len(); + if items_len > 0 { + let prev_index = if current_index > 0 { + current_index - 1 + } else { + items_len.saturating_sub(1) + }; + pane.activate_item( + prev_index, false, false, window, cx, + ); + } + }); + return; + } + } + } + } + cx.propagate(); + }, + )) .on_action(cx.listener(Workspace::cancel)) } + #[cfg(any(test, feature = "test-support"))] + pub fn set_random_database_id(&mut self) { + self.database_id = Some(WorkspaceId(Uuid::new_v4().as_u64_pair().0 as i64)); + } + #[cfg(any(test, feature = "test-support"))] pub fn test_new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { use node_runtime::NodeRuntime; @@ -5864,6 +6177,11 @@ impl Workspace { }) } + pub fn hide_modal(&mut self, window: &mut Window, cx: &mut App) -> bool { + self.modal_layer + .update(cx, |modal_layer, cx| modal_layer.hide_modal(window, cx)) + } + pub fn toggle_status_toast(&mut self, entity: Entity, cx: &mut App) { self.toast_layer .update(cx, |toast_layer, cx| toast_layer.toggle_toast(cx, entity)) @@ -5885,8 +6203,11 @@ impl Workspace { fn adjust_padding(padding: Option) -> f32 { padding - .unwrap_or(Self::DEFAULT_PADDING) - .clamp(0.0, Self::MAX_PADDING) + .unwrap_or(CenteredPaddingSettings::default().0) + .clamp( + CenteredPaddingSettings::MIN_PADDING, + CenteredPaddingSettings::MAX_PADDING, + ) } fn render_dock( @@ -6008,6 +6329,7 @@ impl Workspace { left_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); } fn resize_right_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6030,6 +6352,7 @@ impl Workspace { right_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); } fn resize_bottom_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6044,6 +6367,42 @@ impl Workspace { bottom_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); + } + + fn max_utility_pane_width(&self, window: &Window, cx: &App) -> Pixels { + let left_dock_width = self + .left_dock + .read(cx) + .active_panel_size(window, cx) + .unwrap_or(px(0.0)); + let right_dock_width = self + .right_dock + .read(cx) + .active_panel_size(window, cx) + .unwrap_or(px(0.0)); + let center_pane_width = self.bounds.size.width - left_dock_width - right_dock_width; + center_pane_width - px(10.0) + } + + fn clamp_utility_pane_widths(&mut self, window: &mut Window, cx: &mut App) { + let max_width = self.max_utility_pane_width(window, cx); + + // Clamp left slot utility pane if it exists + if let Some(handle) = self.utility_pane(UtilityPaneSlot::Left) { + let current_width = handle.width(cx); + if current_width > max_width { + handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); + } + } + + // Clamp right slot utility pane if it exists + if let Some(handle) = self.utility_pane(UtilityPaneSlot::Right) { + let current_width = handle.width(cx); + if current_width > max_width { + handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); + } + } } fn toggle_edit_predictions_all_files( @@ -6316,6 +6675,10 @@ impl Render for DraggedDock { impl Render for Workspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + static FIRST_PAINT: AtomicBool = AtomicBool::new(true); + if FIRST_PAINT.swap(false, std::sync::atomic::Ordering::Relaxed) { + log::info!("Rendered first frame"); + } let mut context = KeyContext::new_with_defaults(); context.add("Workspace"); context.set("keyboard_layout", cx.keyboard_layout().name().to_string()); @@ -6333,6 +6696,24 @@ impl Render for Workspace { } } + if self.left_dock.read(cx).is_open() { + if let Some(active_panel) = self.left_dock.read(cx).active_panel() { + context.set("left_dock", active_panel.panel_key()); + } + } + + if self.right_dock.read(cx).is_open() { + if let Some(active_panel) = self.right_dock.read(cx).active_panel() { + context.set("right_dock", active_panel.panel_key()); + } + } + + if self.bottom_dock.read(cx).is_open() { + if let Some(active_panel) = self.bottom_dock.read(cx).active_panel() { + context.set("bottom_dock", active_panel.panel_key()); + } + } + let centered_layout = self.centered_layout && self.center.panes().len() == 1 && self.active_item(cx).is_some(); @@ -6348,8 +6729,12 @@ impl Render for Workspace { let paddings = if centered_layout { let settings = WorkspaceSettings::get_global(cx).centered_layout; ( - render_padding(Self::adjust_padding(settings.left_padding)), - render_padding(Self::adjust_padding(settings.right_padding)), + render_padding(Self::adjust_padding( + settings.left_padding.map(|padding| padding.0), + )), + render_padding(Self::adjust_padding( + settings.right_padding.map(|padding| padding.0), + )), ) } else { (None, None) @@ -6485,6 +6870,34 @@ impl Render for Workspace { } }, )) + .on_drag_move(cx.listener( + move |workspace, + e: &DragMoveEvent, + window, + cx| { + let slot = e.drag(cx).0; + match slot { + UtilityPaneSlot::Left => { + let left_dock_width = workspace.left_dock.read(cx) + .active_panel_size(window, cx) + .unwrap_or(gpui::px(0.0)); + let new_width = e.event.position.x + - workspace.bounds.left() + - left_dock_width; + workspace.resize_utility_pane(slot, new_width, window, cx); + } + UtilityPaneSlot::Right => { + let right_dock_width = workspace.right_dock.read(cx) + .active_panel_size(window, cx) + .unwrap_or(gpui::px(0.0)); + let new_width = workspace.bounds.right() + - e.event.position.x + - right_dock_width; + workspace.resize_utility_pane(slot, new_width, window, cx); + } + } + }, + )) }) .child({ match bottom_dock_layout { @@ -6504,6 +6917,15 @@ impl Render for Workspace { window, cx, )) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6545,6 +6967,15 @@ impl Render for Workspace { ), ), ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock( DockPosition::Right, &self.right_dock, @@ -6575,6 +7006,15 @@ impl Render for Workspace { .flex_row() .flex_1() .children(self.render_dock(DockPosition::Left, &self.left_dock, window, cx)) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6602,6 +7042,13 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) + .when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) ) .child( div() @@ -6626,6 +7073,15 @@ impl Render for Workspace { window, cx, )) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6664,6 +7120,15 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock(DockPosition::Right, &self.right_dock, window, cx)) ) .child( @@ -6683,6 +7148,13 @@ impl Render for Workspace { window, cx, )) + .when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) .child( div() .flex() @@ -6720,6 +7192,15 @@ impl Render for Workspace { cx, )), ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock( DockPosition::Right, &self.right_dock, @@ -6920,6 +7401,9 @@ actions!( [ /// Opens the channel notes for the current call. /// + /// Use `collab_panel::OpenSelectedChannelNotes` to open the channel notes for the selected + /// channel in the collab panel. + /// /// If you want to open a specific channel, use `zed::OpenZedUrl` with a channel notes URL - /// can be copied via "Copy link to section" in the context menu of the channel notes /// buffer. These URLs look like `https://zed.dev/channel/channel-name-CHANNEL_ID/notes`. @@ -6933,14 +7417,18 @@ actions!( /// Shares the current project with collaborators. ShareProject, /// Shares your screen with collaborators. - ScreenShare + ScreenShare, + /// Copies the current room name and session id for debugging purposes. + CopyRoomId, ] ); actions!( zed, [ /// Opens the Zed log file. - OpenLog + OpenLog, + /// Reveals the Zed log file in the system file manager. + RevealLogInFileManager ] ); @@ -7100,14 +7588,9 @@ pub fn join_channel( ) -> Task> { let active_call = ActiveCall::global(cx); cx.spawn(async move |cx| { - let result = join_channel_internal( - channel_id, - &app_state, - requesting_window, - &active_call, - cx, - ) - .await; + let result = + join_channel_internal(channel_id, &app_state, requesting_window, &active_call, cx) + .await; // join channel succeeded, and opened a window if matches!(result, Ok(true)) { @@ -7115,8 +7598,7 @@ pub fn join_channel( } // find an existing workspace to focus and show call controls - let mut active_window = - requesting_window.or_else(|| activate_any_workspace_window( cx)); + let mut active_window = requesting_window.or_else(|| activate_any_workspace_window(cx)); if active_window.is_none() { // no open workspaces, make one to show the error in (blergh) let (window_handle, _) = cx @@ -7128,7 +7610,8 @@ pub fn join_channel( if result.is_ok() { cx.update(|cx| { cx.dispatch_action(&OpenChannelNotes); - }).log_err(); + }) + .log_err(); } active_window = Some(window_handle); @@ -7140,19 +7623,25 @@ pub fn join_channel( active_window .update(cx, |_, window, cx| { let detail: SharedString = match err.error_code() { - ErrorCode::SignedOut => { - "Please sign in to continue.".into() + ErrorCode::SignedOut => "Please sign in to continue.".into(), + ErrorCode::UpgradeRequired => concat!( + "Your are running an unsupported version of Zed. ", + "Please update to continue." + ) + .into(), + ErrorCode::NoSuchChannel => concat!( + "No matching channel was found. ", + "Please check the link and try again." + ) + .into(), + ErrorCode::Forbidden => concat!( + "This channel is private, and you do not have access. ", + "Please ask someone to add you and try again." + ) + .into(), + ErrorCode::Disconnected => { + "Please check your internet connection and try again.".into() } - ErrorCode::UpgradeRequired => { - "Your are running an unsupported version of Zed. Please update to continue.".into() - } - ErrorCode::NoSuchChannel => { - "No matching channel was found. Please check the link and try again.".into() - } - ErrorCode::Forbidden => { - "This channel is private, and you do not have access. Please ask someone to add you and try again.".into() - } - ErrorCode::Disconnected => "Please check your internet connection and try again.".into(), _ => format!("{}\n\nPlease try again.", err).into(), }; window.prompt( @@ -7160,7 +7649,8 @@ pub fn join_channel( "Failed to join channel", Some(&detail), &["Ok"], - cx) + cx, + ) })? .await .ok(); @@ -7225,6 +7715,7 @@ pub struct OpenOptions { pub visible: Option, pub focus: Option, pub open_new_workspace: Option, + pub prefer_focused_window: bool, pub replace_window: Option>, pub env: Option>, } @@ -7245,6 +7736,10 @@ pub fn open_paths( let mut existing = None; let mut best_match = None; let mut open_visible = OpenVisible::All; + #[cfg(target_os = "windows")] + let wsl_path = abs_paths + .iter() + .find_map(|p| util::paths::WslPath::from_path(p)); cx.spawn(async move |cx| { if open_options.open_new_workspace != Some(true) { @@ -7277,7 +7772,7 @@ pub fn open_paths( })?; if open_options.open_new_workspace.is_none() - && existing.is_none() + && (existing.is_none() || open_options.prefer_focused_window) && all_metadatas.iter().all(|file| !file.is_dir) { cx.update(|cx| { @@ -7308,7 +7803,7 @@ pub fn open_paths( } } - if let Some(existing) = existing { + let result = if let Some(existing) = existing { let open_task = existing .update(cx, |workspace, window, cx| { window.activate_window(); @@ -7345,7 +7840,37 @@ pub fn open_paths( ) })? .await - } + }; + + #[cfg(target_os = "windows")] + if let Some(util::paths::WslPath{distro, path}) = wsl_path + && let Ok((workspace, _)) = &result + { + workspace + .update(cx, move |workspace, _window, cx| { + struct OpenInWsl; + workspace.show_notification(NotificationId::unique::(), cx, move |cx| { + let display_path = util::markdown::MarkdownInlineCode(&path.to_string_lossy()); + let msg = format!("{display_path} is inside a WSL filesystem, some features may not work unless you open it with WSL remote"); + cx.new(move |cx| { + MessageNotification::new(msg, cx) + .primary_message("Open in WSL") + .primary_icon(IconName::FolderOpen) + .primary_on_click(move |window, cx| { + window.dispatch_action(Box::new(remote::OpenWslPath { + distro: remote::WslConnectionOptions { + distro_name: distro.clone(), + user: None, + }, + paths: vec![path.clone().into()], + }), cx) + }) + }) + }); + }) + .unwrap(); + }; + result }) } @@ -7406,22 +7931,23 @@ pub fn create_and_open_local_file( pub fn open_remote_project_with_new_connection( window: WindowHandle, - connection_options: RemoteConnectionOptions, + remote_connection: Arc, cancel_rx: oneshot::Receiver<()>, delegate: Arc, app_state: Arc, paths: Vec, cx: &mut App, -) -> Task> { +) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; + deserialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) + .await?; let session = match cx .update(|cx| { remote::RemoteClient::new( ConnectionIdentifier::Workspace(workspace_id.0), - connection_options, + remote_connection, cancel_rx, delegate, cx, @@ -7430,7 +7956,7 @@ pub fn open_remote_project_with_new_connection( .await? { Some(result) => result, - None => return Ok(()), + None => return Ok(Vec::new()), }; let project = cx.update(|cx| { @@ -7465,10 +7991,10 @@ pub fn open_remote_project_with_existing_connection( app_state: Arc, window: WindowHandle, cx: &mut AsyncApp, -) -> Task> { +) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; + deserialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; open_remote_project_inner( project, @@ -7491,7 +8017,7 @@ async fn open_remote_project_inner( app_state: Arc, window: WindowHandle, cx: &mut AsyncApp, -) -> Result<()> { +) -> Result>>> { let toolchains = DB.toolchains(workspace_id).await?; for (toolchain, worktree_id, path) in toolchains { project @@ -7548,7 +8074,7 @@ async fn open_remote_project_inner( }); })?; - window + let items = window .update(cx, |_, window, cx| { window.activate_window(); open_items(serialized_workspace, project_paths_to_open, window, cx) @@ -7567,10 +8093,10 @@ async fn open_remote_project_inner( } })?; - Ok(()) + Ok(items.into_iter().map(|item| item?.ok()).collect()) } -fn serialize_remote_project( +fn deserialize_remote_project( connection_options: RemoteConnectionOptions, paths: Vec, cx: &AsyncApp, @@ -8109,19 +8635,30 @@ pub fn clone_active_item( let Some(active_item) = source.read(cx).active_item() else { return; }; - destination.update(cx, |target_pane, cx| { - let Some(clone) = active_item.clone_on_split(workspace_id, window, cx) else { - return; - }; - target_pane.add_item( - clone, - focus_destination, - focus_destination, - Some(target_pane.items_len()), - window, - cx, - ); - }); + if !active_item.can_split(cx) { + return; + } + let destination = destination.downgrade(); + let task = active_item.clone_on_split(workspace_id, window, cx); + window + .spawn(cx, async move |cx| { + let Some(clone) = task.await else { + return; + }; + destination + .update_in(cx, |target_pane, window, cx| { + target_pane.add_item( + clone, + focus_destination, + focus_destination, + Some(target_pane.items_len()), + window, + cx, + ); + }) + .log_err(); + }) + .detach(); } #[derive(Debug)] @@ -8628,25 +9165,24 @@ mod tests { cx, ); - let right_pane = workspace - .split_and_clone(left_pane.clone(), SplitDirection::Right, window, cx) - .unwrap(); + let right_pane = + workspace.split_and_clone(left_pane.clone(), SplitDirection::Right, window, cx); - right_pane.update(cx, |pane, cx| { - pane.add_item( - single_entry_items[1].boxed_clone(), - true, - true, - None, - window, - cx, - ); - pane.add_item(Box::new(item_3_4.clone()), true, true, None, window, cx); + let boxed_clone = single_entry_items[1].boxed_clone(); + let right_pane = window.spawn(cx, async move |cx| { + right_pane.await.inspect(|right_pane| { + right_pane + .update_in(cx, |pane, window, cx| { + pane.add_item(boxed_clone, true, true, None, window, cx); + pane.add_item(Box::new(item_3_4.clone()), true, true, None, window, cx); + }) + .unwrap(); + }) }); (left_pane, right_pane) }); - + let right_pane = right_pane.await.unwrap(); cx.focus(&right_pane); let mut close = right_pane.update_in(cx, |pane, window, cx| { @@ -8764,8 +9300,9 @@ mod tests { item.update(cx, |item, cx| { SettingsStore::update_global(cx, |settings, cx| { settings.update_user_settings(cx, |settings| { - settings.workspace.autosave = - Some(AutosaveSetting::AfterDelay { milliseconds: 500 }); + settings.workspace.autosave = Some(AutosaveSetting::AfterDelay { + milliseconds: 500.into(), + }); }) }); item.is_dirty = true; @@ -8921,7 +9458,7 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, cx)); + let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 100, cx)); workspace.add_panel(panel.clone(), window, cx); workspace @@ -9061,6 +9598,337 @@ mod tests { }); } + #[gpui::test] + async fn test_pane_zoom_in_out(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let pane = workspace.update_in(cx, |workspace, _window, _cx| { + workspace.active_pane().clone() + }); + + // Add an item to the pane so it can be zoomed + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(TestItem::new); + workspace.add_item(pane.clone(), Box::new(item), None, true, true, window, cx); + }); + + // Initially not zoomed + workspace.update_in(cx, |workspace, _window, cx| { + assert!(!pane.read(cx).is_zoomed(), "Pane starts unzoomed"); + assert!( + workspace.zoomed.is_none(), + "Workspace should track no zoomed pane" + ); + assert!(pane.read(cx).items_len() > 0, "Pane should have items"); + }); + + // Zoom In + pane.update_in(cx, |pane, window, cx| { + pane.zoom_in(&crate::ZoomIn, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!( + pane.read(cx).is_zoomed(), + "Pane should be zoomed after ZoomIn" + ); + assert!( + workspace.zoomed.is_some(), + "Workspace should track the zoomed pane" + ); + assert!( + pane.read(cx).focus_handle(cx).contains_focused(window, cx), + "ZoomIn should focus the pane" + ); + }); + + // Zoom In again is a no-op + pane.update_in(cx, |pane, window, cx| { + pane.zoom_in(&crate::ZoomIn, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!(pane.read(cx).is_zoomed(), "Second ZoomIn keeps pane zoomed"); + assert!( + workspace.zoomed.is_some(), + "Workspace still tracks zoomed pane" + ); + assert!( + pane.read(cx).focus_handle(cx).contains_focused(window, cx), + "Pane remains focused after repeated ZoomIn" + ); + }); + + // Zoom Out + pane.update_in(cx, |pane, window, cx| { + pane.zoom_out(&crate::ZoomOut, window, cx); + }); + + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !pane.read(cx).is_zoomed(), + "Pane should unzoom after ZoomOut" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace clears zoom tracking after ZoomOut" + ); + }); + + // Zoom Out again is a no-op + pane.update_in(cx, |pane, window, cx| { + pane.zoom_out(&crate::ZoomOut, window, cx); + }); + + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !pane.read(cx).is_zoomed(), + "Second ZoomOut keeps pane unzoomed" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace remains without zoomed pane" + ); + }); + } + + #[gpui::test] + async fn test_toggle_all_docks(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + workspace.update_in(cx, |workspace, window, cx| { + // Open two docks + let left_dock = workspace.dock_at_position(DockPosition::Left); + let right_dock = workspace.dock_at_position(DockPosition::Right); + + left_dock.update(cx, |dock, cx| dock.set_open(true, window, cx)); + right_dock.update(cx, |dock, cx| dock.set_open(true, window, cx)); + + assert!(left_dock.read(cx).is_open()); + assert!(right_dock.read(cx).is_open()); + }); + + workspace.update_in(cx, |workspace, window, cx| { + // Toggle all docks - should close both + workspace.toggle_all_docks(&ToggleAllDocks, window, cx); + + let left_dock = workspace.dock_at_position(DockPosition::Left); + let right_dock = workspace.dock_at_position(DockPosition::Right); + assert!(!left_dock.read(cx).is_open()); + assert!(!right_dock.read(cx).is_open()); + }); + + workspace.update_in(cx, |workspace, window, cx| { + // Toggle again - should reopen both + workspace.toggle_all_docks(&ToggleAllDocks, window, cx); + + let left_dock = workspace.dock_at_position(DockPosition::Left); + let right_dock = workspace.dock_at_position(DockPosition::Right); + assert!(left_dock.read(cx).is_open()); + assert!(right_dock.read(cx).is_open()); + }); + } + + #[gpui::test] + async fn test_toggle_all_with_manual_close(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + workspace.update_in(cx, |workspace, window, cx| { + // Open two docks + let left_dock = workspace.dock_at_position(DockPosition::Left); + let right_dock = workspace.dock_at_position(DockPosition::Right); + + left_dock.update(cx, |dock, cx| dock.set_open(true, window, cx)); + right_dock.update(cx, |dock, cx| dock.set_open(true, window, cx)); + + assert!(left_dock.read(cx).is_open()); + assert!(right_dock.read(cx).is_open()); + }); + + workspace.update_in(cx, |workspace, window, cx| { + // Close them manually + workspace.toggle_dock(DockPosition::Left, window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + + let left_dock = workspace.dock_at_position(DockPosition::Left); + let right_dock = workspace.dock_at_position(DockPosition::Right); + assert!(!left_dock.read(cx).is_open()); + assert!(!right_dock.read(cx).is_open()); + }); + + workspace.update_in(cx, |workspace, window, cx| { + // Toggle all docks - only last closed (right dock) should reopen + workspace.toggle_all_docks(&ToggleAllDocks, window, cx); + + let left_dock = workspace.dock_at_position(DockPosition::Left); + let right_dock = workspace.dock_at_position(DockPosition::Right); + assert!(!left_dock.read(cx).is_open()); + assert!(right_dock.read(cx).is_open()); + }); + } + + #[gpui::test] + async fn test_toggle_all_docks_after_dock_move(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + // Open two docks (left and right) with one panel each + let (left_panel, right_panel) = workspace.update_in(cx, |workspace, window, cx| { + let left_panel = cx.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); + workspace.add_panel(left_panel.clone(), window, cx); + + let right_panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 101, cx)); + workspace.add_panel(right_panel.clone(), window, cx); + + workspace.toggle_dock(DockPosition::Left, window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + + // Verify initial state + assert!( + workspace.left_dock().read(cx).is_open(), + "Left dock should be open" + ); + assert_eq!( + workspace + .left_dock() + .read(cx) + .visible_panel() + .unwrap() + .panel_id(), + left_panel.panel_id(), + "Left panel should be visible in left dock" + ); + assert!( + workspace.right_dock().read(cx).is_open(), + "Right dock should be open" + ); + assert_eq!( + workspace + .right_dock() + .read(cx) + .visible_panel() + .unwrap() + .panel_id(), + right_panel.panel_id(), + "Right panel should be visible in right dock" + ); + assert!( + !workspace.bottom_dock().read(cx).is_open(), + "Bottom dock should be closed" + ); + + (left_panel, right_panel) + }); + + // Focus the left panel and move it to the next position (bottom dock) + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_panel_focus::(window, cx); // Focus left panel + assert!( + left_panel.read(cx).focus_handle(cx).is_focused(window), + "Left panel should be focused" + ); + }); + + cx.dispatch_action(MoveFocusedPanelToNextPosition); + + // Verify the left panel has moved to the bottom dock, and the bottom dock is now open + workspace.update(cx, |workspace, cx| { + assert!( + !workspace.left_dock().read(cx).is_open(), + "Left dock should be closed" + ); + assert!( + workspace.bottom_dock().read(cx).is_open(), + "Bottom dock should now be open" + ); + assert_eq!( + left_panel.read(cx).position, + DockPosition::Bottom, + "Left panel should now be in the bottom dock" + ); + assert_eq!( + workspace + .bottom_dock() + .read(cx) + .visible_panel() + .unwrap() + .panel_id(), + left_panel.panel_id(), + "Left panel should be the visible panel in the bottom dock" + ); + }); + + // Toggle all docks off + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_all_docks(&ToggleAllDocks, window, cx); + assert!( + !workspace.left_dock().read(cx).is_open(), + "Left dock should be closed" + ); + assert!( + !workspace.right_dock().read(cx).is_open(), + "Right dock should be closed" + ); + assert!( + !workspace.bottom_dock().read(cx).is_open(), + "Bottom dock should be closed" + ); + }); + + // Toggle all docks back on and verify positions are restored + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_all_docks(&ToggleAllDocks, window, cx); + assert!( + !workspace.left_dock().read(cx).is_open(), + "Left dock should remain closed" + ); + assert!( + workspace.right_dock().read(cx).is_open(), + "Right dock should remain open" + ); + assert!( + workspace.bottom_dock().read(cx).is_open(), + "Bottom dock should remain open" + ); + assert_eq!( + left_panel.read(cx).position, + DockPosition::Bottom, + "Left panel should remain in the bottom dock" + ); + assert_eq!( + right_panel.read(cx).position, + DockPosition::Right, + "Right panel should remain in the right dock" + ); + assert_eq!( + workspace + .bottom_dock() + .read(cx) + .visible_panel() + .unwrap() + .panel_id(), + left_panel.panel_id(), + "Left panel should be the visible panel in the right dock" + ); + }); + } + #[gpui::test] async fn test_join_pane_into_next(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -9353,10 +10221,10 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); let (panel_1, panel_2) = workspace.update_in(cx, |workspace, window, cx| { - let panel_1 = cx.new(|cx| TestPanel::new(DockPosition::Left, cx)); + let panel_1 = cx.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); workspace.add_panel(panel_1.clone(), window, cx); workspace.toggle_dock(DockPosition::Left, window, cx); - let panel_2 = cx.new(|cx| TestPanel::new(DockPosition::Right, cx)); + let panel_2 = cx.new(|cx| TestPanel::new(DockPosition::Right, 101, cx)); workspace.add_panel(panel_2.clone(), window, cx); workspace.toggle_dock(DockPosition::Right, window, cx); @@ -10263,7 +11131,7 @@ mod tests { // Add a new panel to the right dock, opening the dock and setting the // focus to the new panel. let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, cx)); + let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 100, cx)); workspace.add_panel(panel.clone(), window, cx); workspace @@ -10462,7 +11330,10 @@ mod tests { window, cx, ); + }); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { assert_eq!(workspace.panes.len(), 3, "Two new panes were created"); for pane in workspace.panes() { assert_eq!( @@ -10724,7 +11595,7 @@ mod tests { // Now we can check if the handle we got back errored or not assert_eq!( - handle.to_any().entity_type(), + handle.to_any_view().entity_type(), TypeId::of::() ); @@ -10737,7 +11608,7 @@ mod tests { .unwrap(); assert_eq!( - handle.to_any().entity_type(), + handle.to_any_view().entity_type(), TypeId::of::() ); @@ -10786,7 +11657,7 @@ mod tests { // This _must_ be the second item registered assert_eq!( - handle.to_any().entity_type(), + handle.to_any_view().entity_type(), TypeId::of::() ); @@ -10856,9 +11727,6 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); - crate::init_settings(cx); - Project::init_settings(cx); }); } diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 963fd6de58..4ce0394fe5 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -2,15 +2,13 @@ use std::num::NonZeroUsize; use crate::DockPosition; use collections::HashMap; -use gpui::App; use serde::Deserialize; -pub use settings::AutosaveSetting; -use settings::Settings; pub use settings::{ - BottomDockLayout, PaneSplitDirectionHorizontal, PaneSplitDirectionVertical, - RestoreOnStartupBehavior, + AutosaveSetting, BottomDockLayout, InactiveOpacity, PaneSplitDirectionHorizontal, + PaneSplitDirectionVertical, RegisterSetting, RestoreOnStartupBehavior, Settings, }; +#[derive(RegisterSetting)] pub struct WorkspaceSettings { pub active_pane_modifiers: ActivePanelModifiers, pub bottom_dock_layout: settings::BottomDockLayout, @@ -33,6 +31,7 @@ pub struct WorkspaceSettings { pub close_on_file_delete: bool, pub use_system_window_tabs: bool, pub zoomed_padding: bool, + pub window_decorations: settings::WindowDecorations, } #[derive(Copy, Clone, PartialEq, Debug, Default)] @@ -51,10 +50,10 @@ pub struct ActivePanelModifiers { /// /// Default: `1.0` // TODO: make this not an option, it is never None - pub inactive_opacity: Option, + pub inactive_opacity: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, RegisterSetting)] pub struct TabBarSettings { pub show: bool, pub show_nav_history_buttons: bool, @@ -62,7 +61,7 @@ pub struct TabBarSettings { } impl Settings for WorkspaceSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let workspace = &content.workspace; Self { active_pane_modifiers: ActivePanelModifiers { @@ -107,97 +106,13 @@ impl Settings for WorkspaceSettings { close_on_file_delete: workspace.close_on_file_delete.unwrap(), use_system_window_tabs: workspace.use_system_window_tabs.unwrap(), zoomed_padding: workspace.zoomed_padding.unwrap(), + window_decorations: workspace.window_decorations.unwrap(), } } - - fn import_from_vscode( - vscode: &settings::VsCodeSettings, - current: &mut settings::SettingsContent, - ) { - if vscode - .read_bool("accessibility.dimUnfocused.enabled") - .unwrap_or_default() - && let Some(opacity) = vscode - .read_value("accessibility.dimUnfocused.opacity") - .and_then(|v| v.as_f64()) - { - current - .workspace - .active_pane_modifiers - .get_or_insert_default() - .inactive_opacity = Some(opacity as f32); - } - - vscode.enum_setting( - "window.confirmBeforeClose", - &mut current.workspace.confirm_quit, - |s| match s { - "always" | "keyboardOnly" => Some(true), - "never" => Some(false), - _ => None, - }, - ); - - vscode.bool_setting( - "workbench.editor.restoreViewState", - &mut current.workspace.restore_on_file_reopen, - ); - - if let Some(b) = vscode.read_bool("window.closeWhenEmpty") { - current.workspace.when_closing_with_no_tabs = Some(if b { - settings::CloseWindowWhenNoItems::CloseWindow - } else { - settings::CloseWindowWhenNoItems::KeepWindowOpen - }); - } - - if let Some(b) = vscode.read_bool("files.simpleDialog.enable") { - current.workspace.use_system_path_prompts = Some(!b); - } - - if let Some(v) = vscode.read_enum("files.autoSave", |s| match s { - "off" => Some(AutosaveSetting::Off), - "afterDelay" => Some(AutosaveSetting::AfterDelay { - milliseconds: vscode - .read_value("files.autoSaveDelay") - .and_then(|v| v.as_u64()) - .unwrap_or(1000), - }), - "onFocusChange" => Some(AutosaveSetting::OnFocusChange), - "onWindowChange" => Some(AutosaveSetting::OnWindowChange), - _ => None, - }) { - current.workspace.autosave = Some(v); - } - - // workbench.editor.limit contains "enabled", "value", and "perEditorGroup" - // our semantics match if those are set to true, some N, and true respectively. - // we'll ignore "perEditorGroup" for now since we only support a global max - if let Some(n) = vscode - .read_value("workbench.editor.limit.value") - .and_then(|v| v.as_u64()) - .and_then(|n| NonZeroUsize::new(n as usize)) - && vscode - .read_bool("workbench.editor.limit.enabled") - .unwrap_or_default() - { - current.workspace.max_tabs = Some(n) - } - - if let Some(b) = vscode.read_bool("window.nativeTabs") { - current.workspace.use_system_window_tabs = Some(b); - } - - // some combination of "window.restoreWindows" and "workbench.startupEditor" might - // map to our "restore_on_startup" - - // there doesn't seem to be a way to read whether the bottom dock's "justified" - // setting is enabled in vscode. that'd be our equivalent to "bottom_dock_layout" - } } impl Settings for TabBarSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let tab_bar = content.tab_bar.clone().unwrap(); TabBarSettings { show: tab_bar.show.unwrap(), @@ -205,47 +120,24 @@ impl Settings for TabBarSettings { show_tab_bar_buttons: tab_bar.show_tab_bar_buttons.unwrap(), } } - - fn import_from_vscode( - vscode: &settings::VsCodeSettings, - current: &mut settings::SettingsContent, - ) { - if let Some(b) = vscode.read_enum("workbench.editor.showTabs", |s| match s { - "multiple" => Some(true), - "single" | "none" => Some(false), - _ => None, - }) { - current.tab_bar.get_or_insert_default().show = Some(b); - } - if Some("hidden") == vscode.read_string("workbench.editor.editorActionsLocation") { - current.tab_bar.get_or_insert_default().show_tab_bar_buttons = Some(false) - } - } } -#[derive(Deserialize)] +#[derive(Deserialize, RegisterSetting)] pub struct StatusBarSettings { pub show: bool, pub active_language_button: bool, pub cursor_position_button: bool, + pub line_endings_button: bool, } impl Settings for StatusBarSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let status_bar = content.status_bar.clone().unwrap(); StatusBarSettings { show: status_bar.show.unwrap(), active_language_button: status_bar.active_language_button.unwrap(), cursor_position_button: status_bar.cursor_position_button.unwrap(), - } - } - - fn import_from_vscode( - vscode: &settings::VsCodeSettings, - current: &mut settings::SettingsContent, - ) { - if let Some(show) = vscode.read_bool("workbench.statusBar.visible") { - current.status_bar.get_or_insert_default().show = Some(show); + line_endings_button: status_bar.line_endings_button.unwrap(), } } } diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index fdeca37b7a..6d132fbd2c 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -24,6 +24,7 @@ test-support = [ [dependencies] anyhow.workspace = true +async-lock.workspace = true clock.workspace = true collections.workspace = true fs.workspace = true @@ -46,7 +47,6 @@ smol.workspace = true sum_tree.workspace = true text.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index d1f8901f88..e1ce31c038 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -14,7 +14,7 @@ use futures::{ mpsc::{self, UnboundedSender}, oneshot, }, - select_biased, + select_biased, stream, task::Poll, }; use fuzzy::CharBag; @@ -22,7 +22,8 @@ use git::{ COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, status::GitSummary, }; use gpui::{ - App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Task, + App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority, + Task, }; use ignore::IgnoreStack; use language::DiskState; @@ -52,7 +53,7 @@ use std::{ fmt, future::Future, mem::{self}, - ops::{Deref, DerefMut}, + ops::{Deref, DerefMut, Range}, path::{Path, PathBuf}, pin::Pin, sync::{ @@ -64,7 +65,7 @@ use std::{ use sum_tree::{Bias, Dimensions, Edit, KeyedItem, SeekTarget, SumTree, Summary, TreeMap, TreeSet}; use text::{LineEnding, Rope}; use util::{ - ResultExt, debug_panic, + ResultExt, debug_panic, maybe, paths::{PathMatcher, PathStyle, SanitizedPath, home_dir}, rel_path::RelPath, }; @@ -97,6 +98,7 @@ pub enum CreatedEntry { Excluded { abs_path: PathBuf }, } +#[derive(Debug)] pub struct LoadedFile { pub file: Arc, pub text: String, @@ -129,6 +131,7 @@ pub struct LocalWorktree { next_entry_id: Arc, settings: WorktreeSettings, share_private_files: bool, + scanning_enabled: bool, } pub struct PathPrefixScanRequest { @@ -226,7 +229,7 @@ impl Default for WorkDirectory { } } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct LocalSnapshot { snapshot: Snapshot, global_gitignore: Option>, @@ -236,9 +239,10 @@ pub struct LocalSnapshot { /// All of the git repositories in the worktree, indexed by the project entry /// id of their parent directory. git_repositories: TreeMap, - /// The file handle of the worktree root. `None` if the worktree is a directory. + /// The file handle of the worktree root /// (so we can find it after it's been moved) root_file_handle: Option>, + executor: BackgroundExecutor, } struct BackgroundScannerState { @@ -321,7 +325,6 @@ impl DerefMut for LocalSnapshot { } } -#[derive(Debug)] enum ScanState { Started, Updated { @@ -356,6 +359,7 @@ impl Worktree { visible: bool, fs: Arc, next_entry_id: Arc, + scanning_enabled: bool, cx: &mut AsyncApp, ) -> Result> { let abs_path = path.into(); @@ -402,6 +406,7 @@ impl Worktree { PathStyle::local(), ), root_file_handle, + executor: cx.background_executor().clone(), }; let worktree_id = snapshot.id(); @@ -427,7 +432,7 @@ impl Worktree { let mut entry = Entry::new( RelPath::empty().into(), &metadata, - &next_entry_id, + ProjectEntryId::new(&next_entry_id), snapshot.root_char_bag, None, ); @@ -437,6 +442,7 @@ impl Worktree { && let Ok(path) = RelPath::unix(file_name) { entry.is_private = !share_private_files && settings.is_path_private(path); + entry.is_hidden = settings.is_path_hidden(path); } } snapshot.insert_entry(entry, fs.as_ref()); @@ -457,6 +463,7 @@ impl Worktree { fs_case_sensitive, visible, settings, + scanning_enabled, }; worktree.start_background_scanner(scan_requests_rx, path_prefixes_to_scan_rx, cx); Worktree::Local(worktree) @@ -500,7 +507,7 @@ impl Worktree { project_id, replica_id, snapshot, - file_scan_inclusions: settings.file_scan_inclusions.clone(), + file_scan_inclusions: settings.parent_dir_scan_inclusions.clone(), background_snapshot: background_snapshot.clone(), updates_tx: Some(background_updates_tx), update_observer: None, @@ -515,8 +522,10 @@ impl Worktree { while let Some(update) = background_updates_rx.next().await { { let mut lock = background_snapshot.lock(); - lock.0 - .apply_remote_update(update.clone(), &settings.file_scan_inclusions); + lock.0.apply_remote_update( + update.clone(), + &settings.parent_dir_scan_inclusions, + ); lock.1.push(update); } snapshot_updated_tx.send(()).await.ok(); @@ -651,7 +660,7 @@ impl Worktree { pub fn replica_id(&self) -> ReplicaId { match self { - Worktree::Local(_) => 0, + Worktree::Local(_) => ReplicaId::LOCAL, Worktree::Remote(worktree) => worktree.replica_id, } } @@ -995,7 +1004,7 @@ impl Worktree { }; if worktree_relative_path.components().next().is_some() { - full_path_string.push_str(self.path_style.separator()); + full_path_string.push_str(self.path_style.primary_separator()); full_path_string.push_str(&worktree_relative_path.display(self.path_style)); } @@ -1045,13 +1054,18 @@ impl LocalWorktree { let share_private_files = self.share_private_files; let next_entry_id = self.next_entry_id.clone(); let fs = self.fs.clone(); + let scanning_enabled = self.scanning_enabled; let settings = self.settings.clone(); let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); let background_scanner = cx.background_spawn({ let abs_path = snapshot.abs_path.as_path().to_path_buf(); let background = cx.background_executor().clone(); async move { - let (events, watcher) = fs.watch(&abs_path, FS_WATCH_LATENCY).await; + let (events, watcher) = if scanning_enabled { + fs.watch(&abs_path, FS_WATCH_LATENCY).await + } else { + (Box::pin(stream::pending()) as _, Arc::new(NullWatcher) as _) + }; let fs_case_sensitive = fs.is_case_sensitive().await.unwrap_or_else(|e| { log::error!("Failed to determine whether filesystem is case sensitive: {e:#}"); true @@ -1065,7 +1079,7 @@ impl LocalWorktree { scan_requests_rx, path_prefixes_to_scan_rx, next_entry_id, - state: Mutex::new(BackgroundScannerState { + state: async_lock::Mutex::new(BackgroundScannerState { prev_snapshot: snapshot.snapshot.clone(), snapshot, scanned_dirs: Default::default(), @@ -1076,6 +1090,7 @@ impl LocalWorktree { }), phase: BackgroundScannerPhase::InitialScan, share_private_files, + scanning_enabled, settings, watcher, }; @@ -1313,7 +1328,8 @@ impl LocalWorktree { let entry = self.refresh_entry(path.clone(), None, cx); let is_private = self.is_path_private(path.as_ref()); - cx.spawn(async move |this, _cx| { + let this = cx.weak_entity(); + cx.background_spawn(async move { // WARN: Temporary workaround for #27283. // We are not efficient with our memory usage per file, and use in excess of 64GB for a 10GB file // Therefore, as a temporary workaround to prevent system freezes, we just bail before opening a file @@ -1697,13 +1713,14 @@ impl LocalWorktree { }; let t0 = Instant::now(); let mut refresh = self.refresh_entries_for_paths(paths); + // todo(lw): Hot foreground spawn cx.spawn(async move |this, cx| { refresh.recv().await; log::trace!("refreshed entry {path:?} in {:?}", t0.elapsed()); let new_entry = this.read_with(cx, |this, _| { - this.entry_for_path(&path) - .cloned() - .context("reading path after update") + this.entry_for_path(&path).cloned().with_context(|| { + format!("Could not find entry in worktree for {path:?} after refresh") + }) })??; Ok(Some(new_entry)) }) @@ -2102,8 +2119,8 @@ impl Snapshot { if path.file_name().is_some() { let mut abs_path = self.abs_path.to_string(); for component in path.components() { - if !abs_path.ends_with(self.path_style.separator()) { - abs_path.push_str(self.path_style.separator()); + if !abs_path.ends_with(self.path_style.primary_separator()) { + abs_path.push_str(self.path_style.primary_separator()); } abs_path.push_str(component); } @@ -2346,8 +2363,8 @@ impl Snapshot { self.entries_by_path.first() } - /// TODO: what's the difference between `root_dir` and `abs_path`? - /// is there any? if so, document it. + /// Returns `None` for a single file worktree, or `Some(self.abs_path())` if + /// it is a directory. pub fn root_dir(&self) -> Option> { self.root_entry() .filter(|entry| entry.is_dir()) @@ -2378,6 +2395,36 @@ impl Snapshot { }) } + /// Resolves a path to an executable using the following heuristics: + /// + /// 1. If the path starts with `~`, it is expanded to the user's home directory. + /// 2. If the path is relative and contains more than one component, + /// it is joined to the worktree root path. + /// 3. If the path is relative and exists in the worktree + /// (even if falls under an exclusion filter), + /// it is joined to the worktree root path. + /// 4. Otherwise the path is returned unmodified. + /// + /// Relative paths that do not exist in the worktree may + /// still be found using the `PATH` environment variable. + pub fn resolve_executable_path(&self, path: PathBuf) -> PathBuf { + if let Some(path_str) = path.to_str() { + if let Some(remaining_path) = path_str.strip_prefix("~/") { + return home_dir().join(remaining_path); + } else if path_str == "~" { + return home_dir().to_path_buf(); + } + } + + if let Ok(rel_path) = RelPath::new(&path, self.path_style) + && (path.components().count() > 1 || self.entry_for_path(&rel_path).is_some()) + { + self.abs_path().join(path) + } else { + path + } + } + pub fn entry_for_id(&self, id: ProjectEntryId) -> Option<&Entry> { let entry = self.entries_by_id.get(&id, ())?; self.entry_for_path(&entry.path) @@ -2435,9 +2482,10 @@ impl LocalSnapshot { } fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry { + log::trace!("insert entry {:?}", entry.path); if entry.is_file() && entry.path.file_name() == Some(&GITIGNORE) { let abs_path = self.absolutize(&entry.path); - match smol::block_on(build_gitignore(&abs_path, fs)) { + match self.executor.block(build_gitignore(&abs_path, fs)) { Ok(ignore) => { self.ignores_by_parent_abs_path .insert(abs_path.parent().unwrap().into(), (Arc::new(ignore), true)); @@ -2488,7 +2536,12 @@ impl LocalSnapshot { inodes } - fn ignore_stack_for_abs_path(&self, abs_path: &Path, is_dir: bool, fs: &dyn Fs) -> IgnoreStack { + async fn ignore_stack_for_abs_path( + &self, + abs_path: &Path, + is_dir: bool, + fs: &dyn Fs, + ) -> IgnoreStack { let mut new_ignores = Vec::new(); let mut repo_root = None; for (index, ancestor) in abs_path.ancestors().enumerate() { @@ -2499,9 +2552,8 @@ impl LocalSnapshot { new_ignores.push((ancestor, None)); } } - let metadata = smol::block_on(fs.metadata(&ancestor.join(DOT_GIT))) - .ok() - .flatten(); + + let metadata = fs.metadata(&ancestor.join(DOT_GIT)).await.ok().flatten(); if metadata.is_some() { repo_root = Some(Arc::from(ancestor)); break; @@ -2647,7 +2699,7 @@ impl BackgroundScannerState { .any(|p| entry.path.starts_with(p)) } - fn enqueue_scan_dir( + async fn enqueue_scan_dir( &self, abs_path: Arc, entry: &Entry, @@ -2655,7 +2707,10 @@ impl BackgroundScannerState { fs: &dyn Fs, ) { let path = entry.path.clone(); - let ignore_stack = self.snapshot.ignore_stack_for_abs_path(&abs_path, true, fs); + let ignore_stack = self + .snapshot + .ignore_stack_for_abs_path(&abs_path, true, fs) + .await; let mut ancestor_inodes = self.snapshot.ancestor_inodes_for_path(&path); if !ancestor_inodes.contains(&entry.inode) { @@ -2692,11 +2747,34 @@ impl BackgroundScannerState { } } - fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs, watcher: &dyn Watcher) -> Entry { - self.reuse_entry_id(&mut entry); + fn entry_id_for( + &mut self, + next_entry_id: &AtomicUsize, + path: &RelPath, + metadata: &fs::Metadata, + ) -> ProjectEntryId { + // If an entry with the same inode was removed from the worktree during this scan, + // then it *might* represent the same file or directory. But the OS might also have + // re-used the inode for a completely different file or directory. + // + // Conditionally reuse the old entry's id: + // * if the mtime is the same, the file was probably been renamed. + // * if the path is the same, the file may just have been updated + if let Some(removed_entry) = self.removed_entries.remove(&metadata.inode) { + if removed_entry.mtime == Some(metadata.mtime) || *removed_entry.path == *path { + return removed_entry.id; + } + } else if let Some(existing_entry) = self.snapshot.entry_for_path(path) { + return existing_entry.id; + } + ProjectEntryId::new(next_entry_id) + } + + async fn insert_entry(&mut self, entry: Entry, fs: &dyn Fs, watcher: &dyn Watcher) -> Entry { let entry = self.snapshot.insert_entry(entry, fs); if entry.path.file_name() == Some(&DOT_GIT) { - self.insert_git_repository(entry.path.clone(), fs, watcher); + self.insert_git_repository(entry.path.clone(), fs, watcher) + .await; } #[cfg(test)] @@ -2827,7 +2905,7 @@ impl BackgroundScannerState { self.snapshot.check_invariants(false); } - fn insert_git_repository( + async fn insert_git_repository( &mut self, dot_git_path: Arc, fs: &dyn Fs, @@ -2868,10 +2946,11 @@ impl BackgroundScannerState { fs, watcher, ) + .await .log_err(); } - fn insert_git_repository_for_path( + async fn insert_git_repository_for_path( &mut self, work_directory: WorkDirectory, dot_git_abs_path: Arc, @@ -2893,7 +2972,7 @@ impl BackgroundScannerState { let work_directory_abs_path = self.snapshot.work_directory_abs_path(&work_directory); let (repository_dir_abs_path, common_dir_abs_path) = - discover_git_paths(&dot_git_abs_path, fs); + discover_git_paths(&dot_git_abs_path, fs).await; watcher .add(&common_dir_abs_path) .context("failed to add common directory to watcher") @@ -3154,7 +3233,7 @@ impl File { self.worktree.read(cx).id() } - pub fn project_entry_id(&self, _: &App) -> Option { + pub fn project_entry_id(&self) -> Option { match self.disk_state { DiskState::Deleted => None, _ => self.entry_id, @@ -3177,6 +3256,11 @@ pub struct Entry { /// exclude them from searches. pub is_ignored: bool, + /// Whether this entry is hidden or inside hidden directory. + /// + /// We only scan hidden entries once the directory is expanded. + pub is_hidden: bool, + /// Whether this entry is always included in searches. /// /// This is used for entries that are always included in searches, even @@ -3333,13 +3417,13 @@ impl Entry { fn new( path: Arc, metadata: &fs::Metadata, - next_entry_id: &AtomicUsize, + id: ProjectEntryId, root_char_bag: CharBag, canonical_path: Option>, ) -> Self { let char_bag = char_bag_for_path(root_char_bag, &path); Self { - id: ProjectEntryId::new(next_entry_id), + id, kind: if metadata.is_dir { EntryKind::PendingDir } else { @@ -3351,6 +3435,7 @@ impl Entry { size: metadata.len, canonical_path, is_ignored: false, + is_hidden: false, is_always_included: false, is_external: false, is_private: false, @@ -3531,7 +3616,7 @@ impl<'a> sum_tree::Dimension<'a, EntrySummary> for PathKey { } struct BackgroundScanner { - state: Mutex, + state: async_lock::Mutex, fs: Arc, fs_case_sensitive: bool, status_updates_tx: UnboundedSender, @@ -3543,6 +3628,7 @@ struct BackgroundScanner { watcher: Arc, settings: WorktreeSettings, share_private_files: bool, + scanning_enabled: bool, } #[derive(Copy, Clone, PartialEq)] @@ -3557,69 +3643,99 @@ impl BackgroundScanner { // If the worktree root does not contain a git repository, then find // the git repository in an ancestor directory. Find any gitignore files // in ancestor directories. - let root_abs_path = self.state.lock().snapshot.abs_path.clone(); - let (ignores, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await; - self.state - .lock() - .snapshot - .ignores_by_parent_abs_path - .extend(ignores); - let containing_git_repository = repo.and_then(|(ancestor_dot_git, work_directory)| { + let root_abs_path = self.state.lock().await.snapshot.abs_path.clone(); + + let repo = if self.scanning_enabled { + let (ignores, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await; self.state .lock() - .insert_git_repository_for_path( - work_directory, - ancestor_dot_git.clone().into(), - self.fs.as_ref(), - self.watcher.as_ref(), - ) - .log_err()?; - Some(ancestor_dot_git) - }); + .await + .snapshot + .ignores_by_parent_abs_path + .extend(ignores); + repo + } else { + None + }; + + let containing_git_repository = if let Some((ancestor_dot_git, work_directory)) = repo + && self.scanning_enabled + { + maybe!(async { + self.state + .lock() + .await + .insert_git_repository_for_path( + work_directory, + ancestor_dot_git.clone().into(), + self.fs.as_ref(), + self.watcher.as_ref(), + ) + .await + .log_err()?; + Some(ancestor_dot_git) + }) + .await + } else { + None + }; log::trace!("containing git repository: {containing_git_repository:?}"); - let mut global_gitignore_events = - if let Some(global_gitignore_path) = &paths::global_gitignore_path() { - self.state.lock().snapshot.global_gitignore = - if self.fs.is_file(&global_gitignore_path).await { - build_gitignore(global_gitignore_path, self.fs.as_ref()) - .await - .ok() - .map(Arc::new) - } else { - None - }; + let mut global_gitignore_events = if let Some(global_gitignore_path) = + &paths::global_gitignore_path() + && self.scanning_enabled + { + let is_file = self.fs.is_file(&global_gitignore_path).await; + self.state.lock().await.snapshot.global_gitignore = if is_file { + build_gitignore(global_gitignore_path, self.fs.as_ref()) + .await + .ok() + .map(Arc::new) + } else { + None + }; + if is_file + || matches!(global_gitignore_path.parent(), Some(path) if self.fs.is_dir(path).await) + { self.fs .watch(global_gitignore_path, FS_WATCH_LATENCY) .await .0 } else { - self.state.lock().snapshot.global_gitignore = None; - Box::pin(futures::stream::empty()) - }; + Box::pin(futures::stream::pending()) + } + } else { + self.state.lock().await.snapshot.global_gitignore = None; + Box::pin(futures::stream::pending()) + }; let (scan_job_tx, scan_job_rx) = channel::unbounded(); { - let mut state = self.state.lock(); + let mut state = self.state.lock().await; state.snapshot.scan_id += 1; if let Some(mut root_entry) = state.snapshot.root_entry().cloned() { - let ignore_stack = state.snapshot.ignore_stack_for_abs_path( - root_abs_path.as_path(), - true, - self.fs.as_ref(), - ); + let ignore_stack = state + .snapshot + .ignore_stack_for_abs_path(root_abs_path.as_path(), true, self.fs.as_ref()) + .await; if ignore_stack.is_abs_path_ignored(root_abs_path.as_path(), true) { root_entry.is_ignored = true; - state.insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref()); + let mut root_entry = root_entry.clone(); + state.reuse_entry_id(&mut root_entry); + state + .insert_entry(root_entry, self.fs.as_ref(), self.watcher.as_ref()) + .await; } - if root_entry.is_dir() { - state.enqueue_scan_dir( - root_abs_path.as_path().into(), - &root_entry, - &scan_job_tx, - self.fs.as_ref(), - ); + if root_entry.is_dir() && self.scanning_enabled { + state + .enqueue_scan_dir( + root_abs_path.as_path().into(), + &root_entry, + &scan_job_tx, + self.fs.as_ref(), + ) + .await; } } }; @@ -3628,11 +3744,11 @@ impl BackgroundScanner { drop(scan_job_tx); self.scan_dirs(true, scan_job_rx).await; { - let mut state = self.state.lock(); + let mut state = self.state.lock().await; state.snapshot.completed_scan_id = state.snapshot.scan_id; } - self.send_status_update(false, SmallVec::new()); + self.send_status_update(false, SmallVec::new()).await; // Process any any FS events that occurred while performing the initial scan. // For these events, update events cannot be as precise, because we didn't @@ -3677,7 +3793,7 @@ impl BackgroundScanner { if did_scan { let abs_path = { - let mut state = self.state.lock(); + let mut state = self.state.lock().await; state.path_prefixes_to_scan.insert(request.path.clone()); state.snapshot.absolutize(&request.path) }; @@ -3686,7 +3802,7 @@ impl BackgroundScanner { self.process_events(vec![abs_path]).await; } } - self.send_status_update(false, request.done); + self.send_status_update(false, request.done).await; } paths = fs_events_rx.next().fuse() => { @@ -3702,7 +3818,7 @@ impl BackgroundScanner { Some([event, ..]) => { self.update_global_gitignore(&event.path).await; } - _ => {}, + _ => (), } } } @@ -3715,12 +3831,12 @@ impl BackgroundScanner { request.relative_paths.sort_unstable(); self.forcibly_load_paths(&request.relative_paths).await; - let root_path = self.state.lock().snapshot.abs_path.clone(); + let root_path = self.state.lock().await.snapshot.abs_path.clone(); let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await; let root_canonical_path = match &root_canonical_path { Ok(path) => SanitizedPath::new(path), Err(err) => { - log::error!("failed to canonicalize root path {root_path:?}: {err}"); + log::error!("failed to canonicalize root path {root_path:?}: {err:#}"); return true; } }; @@ -3737,7 +3853,7 @@ impl BackgroundScanner { .collect::>(); { - let mut state = self.state.lock(); + let mut state = self.state.lock().await; let is_idle = state.snapshot.completed_scan_id == state.snapshot.scan_id; state.snapshot.scan_id += 1; if is_idle { @@ -3754,11 +3870,12 @@ impl BackgroundScanner { ) .await; - self.send_status_update(scanning, request.done) + self.send_status_update(scanning, request.done).await } async fn process_events(&self, mut abs_paths: Vec) { - let root_path = self.state.lock().snapshot.abs_path.clone(); + log::trace!("process events: {abs_paths:?}"); + let root_path = self.state.lock().await.snapshot.abs_path.clone(); let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await; let root_canonical_path = match &root_canonical_path { Ok(path) => SanitizedPath::new(path), @@ -3766,6 +3883,7 @@ impl BackgroundScanner { let new_path = self .state .lock() + .await .snapshot .root_file_handle .clone() @@ -3783,7 +3901,7 @@ impl BackgroundScanner { .unbounded_send(ScanState::RootUpdated { new_path }) .ok(); } else { - log::warn!("root path could not be canonicalized: {}", err); + log::warn!("root path could not be canonicalized: {:#}", err); } return; } @@ -3798,24 +3916,37 @@ impl BackgroundScanner { let mut dot_git_abs_paths = Vec::new(); abs_paths.sort_unstable(); abs_paths.dedup_by(|a, b| a.starts_with(b)); - abs_paths.retain(|abs_path| { - let abs_path = &SanitizedPath::new(abs_path); + { + let snapshot = &self.state.lock().await.snapshot; + + let mut ranges_to_drop = SmallVec::<[Range; 4]>::new(); + + fn skip_ix(ranges: &mut SmallVec<[Range; 4]>, ix: usize) { + if let Some(last_range) = ranges.last_mut() + && last_range.end == ix + { + last_range.end += 1; + } else { + ranges.push(ix..ix + 1); + } + } + + for (ix, abs_path) in abs_paths.iter().enumerate() { + let abs_path = &SanitizedPath::new(&abs_path); - let snapshot = &self.state.lock().snapshot; - { let mut is_git_related = false; + let mut dot_git_paths = None; - let dot_git_paths = abs_path.as_path().ancestors().find_map(|ancestor| { - if smol::block_on(is_git_dir(ancestor, self.fs.as_ref())) { + for ancestor in abs_path.as_path().ancestors() { + if is_git_dir(ancestor, self.fs.as_ref()).await { let path_in_git_dir = abs_path .as_path() .strip_prefix(ancestor) .expect("stripping off the ancestor"); - Some((ancestor.to_owned(), path_in_git_dir.to_owned())) - } else { - None + dot_git_paths = Some((ancestor.to_owned(), path_in_git_dir.to_owned())); + break; } - }); + } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { if skipped_files_in_dot_git @@ -3825,8 +3956,11 @@ impl BackgroundScanner { path_in_git_dir.starts_with(skipped_git_subdir) }) { - log::debug!("ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories"); - return false; + log::debug!( + "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" + ); + skip_ix(&mut ranges_to_drop, ix); + continue; } is_git_related = true; @@ -3835,8 +3969,7 @@ impl BackgroundScanner { } } - let relative_path = if let Ok(path) = - abs_path.strip_prefix(&root_canonical_path) + let relative_path = if let Ok(path) = abs_path.strip_prefix(&root_canonical_path) && let Ok(path) = RelPath::new(path, PathStyle::local()) { path @@ -3847,10 +3980,11 @@ impl BackgroundScanner { ); } else { log::error!( - "ignoring event {abs_path:?} outside of root path {root_canonical_path:?}", + "ignoring event {abs_path:?} outside of root path {root_canonical_path:?}", ); } - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; }; if abs_path.file_name() == Some(OsStr::new(GITIGNORE)) { @@ -3874,26 +4008,31 @@ impl BackgroundScanner { }); if !parent_dir_is_loaded { log::debug!("ignoring event {relative_path:?} within unloaded directory"); - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; } if self.settings.is_path_excluded(&relative_path) { if !is_git_related { log::debug!("ignoring FS event for excluded path {relative_path:?}"); } - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; } relative_paths.push(relative_path.into_arc()); - true } - }); + + for range_to_drop in ranges_to_drop.into_iter().rev() { + abs_paths.drain(range_to_drop); + } + } if relative_paths.is_empty() && dot_git_abs_paths.is_empty() { return; } - self.state.lock().snapshot.scan_id += 1; + self.state.lock().await.snapshot.scan_id += 1; let (scan_job_tx, scan_job_rx) = channel::unbounded(); log::debug!("received fs events {:?}", relative_paths); @@ -3907,29 +4046,29 @@ impl BackgroundScanner { .await; let affected_repo_roots = if !dot_git_abs_paths.is_empty() { - self.update_git_repositories(dot_git_abs_paths) + self.update_git_repositories(dot_git_abs_paths).await } else { Vec::new() }; { - let mut ignores_to_update = self.ignores_needing_update(); + let mut ignores_to_update = self.ignores_needing_update().await; ignores_to_update.extend(affected_repo_roots); - let ignores_to_update = self.order_ignores(ignores_to_update); - let snapshot = self.state.lock().snapshot.clone(); + let ignores_to_update = self.order_ignores(ignores_to_update).await; + let snapshot = self.state.lock().await.snapshot.clone(); self.update_ignore_statuses_for_paths(scan_job_tx, snapshot, ignores_to_update) .await; self.scan_dirs(false, scan_job_rx).await; } { - let mut state = self.state.lock(); + let mut state = self.state.lock().await; state.snapshot.completed_scan_id = state.snapshot.scan_id; for (_, entry) in mem::take(&mut state.removed_entries) { state.scanned_dirs.remove(&entry.id); } } - self.send_status_update(false, SmallVec::new()); + self.send_status_update(false, SmallVec::new()).await; } async fn update_global_gitignore(&self, abs_path: &Path) { @@ -3938,30 +4077,30 @@ impl BackgroundScanner { .log_err() .map(Arc::new); let (prev_snapshot, ignore_stack, abs_path) = { - let mut state = self.state.lock(); + let mut state = self.state.lock().await; state.snapshot.global_gitignore = ignore; let abs_path = state.snapshot.abs_path().clone(); - let ignore_stack = - state - .snapshot - .ignore_stack_for_abs_path(&abs_path, true, self.fs.as_ref()); + let ignore_stack = state + .snapshot + .ignore_stack_for_abs_path(&abs_path, true, self.fs.as_ref()) + .await; (state.snapshot.clone(), ignore_stack, abs_path) }; let (scan_job_tx, scan_job_rx) = channel::unbounded(); self.update_ignore_statuses_for_paths( scan_job_tx, prev_snapshot, - vec![(abs_path, ignore_stack)].into_iter(), + vec![(abs_path, ignore_stack)], ) .await; self.scan_dirs(false, scan_job_rx).await; - self.send_status_update(false, SmallVec::new()); + self.send_status_update(false, SmallVec::new()).await; } async fn forcibly_load_paths(&self, paths: &[Arc]) -> bool { let (scan_job_tx, scan_job_rx) = channel::unbounded(); { - let mut state = self.state.lock(); + let mut state = self.state.lock().await; let root_path = state.snapshot.abs_path.clone(); for path in paths { for ancestor in path.ancestors() { @@ -3969,12 +4108,14 @@ impl BackgroundScanner { && entry.kind == EntryKind::UnloadedDir { let abs_path = root_path.join(ancestor.as_std_path()); - state.enqueue_scan_dir( - abs_path.into(), - entry, - &scan_job_tx, - self.fs.as_ref(), - ); + state + .enqueue_scan_dir( + abs_path.into(), + entry, + &scan_job_tx, + self.fs.as_ref(), + ) + .await; state.paths_to_scan.insert(path.clone()); break; } @@ -3986,7 +4127,7 @@ impl BackgroundScanner { self.scan_dir(&job).await.log_err(); } - !mem::take(&mut self.state.lock().paths_to_scan).is_empty() + !mem::take(&mut self.state.lock().await.paths_to_scan).is_empty() } async fn scan_dirs( @@ -4004,7 +4145,7 @@ impl BackgroundScanner { let progress_update_count = AtomicUsize::new(0); self.executor - .scoped(|scope| { + .scoped_priority(Priority::Low, |scope| { for _ in 0..self.executor.num_cpus() { scope.spawn(async { let mut last_progress_update_count = 0; @@ -4034,7 +4175,7 @@ impl BackgroundScanner { ) { Ok(_) => { last_progress_update_count += 1; - self.send_status_update(true, SmallVec::new()); + self.send_status_update(true, SmallVec::new()).await; } Err(count) => { last_progress_update_count = count; @@ -4059,8 +4200,12 @@ impl BackgroundScanner { .await; } - fn send_status_update(&self, scanning: bool, barrier: SmallVec<[barrier::Sender; 1]>) -> bool { - let mut state = self.state.lock(); + async fn send_status_update( + &self, + scanning: bool, + barrier: SmallVec<[barrier::Sender; 1]>, + ) -> bool { + let mut state = self.state.lock().await; if state.changed_paths.is_empty() && scanning { return true; } @@ -4089,7 +4234,7 @@ impl BackgroundScanner { let root_abs_path; let root_char_bag; { - let snapshot = &self.state.lock().snapshot; + let snapshot = &self.state.lock().await.snapshot; if self.settings.is_path_excluded(&job.path) { log::error!("skipping excluded directory {:?}", job.path); return Ok(()); @@ -4142,12 +4287,14 @@ impl BackgroundScanner { }; if child_name == DOT_GIT { - let mut state = self.state.lock(); - state.insert_git_repository( - child_path.clone(), - self.fs.as_ref(), - self.watcher.as_ref(), - ); + let mut state = self.state.lock().await; + state + .insert_git_repository( + child_path.clone(), + self.fs.as_ref(), + self.watcher.as_ref(), + ) + .await; } else if child_name == GITIGNORE { match build_gitignore(&child_abs_path, self.fs.as_ref()).await { Ok(ignore) => { @@ -4167,7 +4314,7 @@ impl BackgroundScanner { if self.settings.is_path_excluded(&child_path) { log::debug!("skipping excluded child entry {child_path:?}"); - self.state.lock().remove_path(&child_path); + self.state.lock().await.remove_path(&child_path); continue; } @@ -4183,7 +4330,7 @@ impl BackgroundScanner { let mut child_entry = Entry::new( child_path.clone(), &child_metadata, - &next_entry_id, + ProjectEntryId::new(&next_entry_id), root_char_bag, None, ); @@ -4221,7 +4368,8 @@ impl BackgroundScanner { if child_entry.is_dir() { child_entry.is_ignored = ignore_stack.is_abs_path_ignored(&child_abs_path, true); - child_entry.is_always_included = self.settings.is_path_always_included(&child_path); + child_entry.is_always_included = + self.settings.is_path_always_included(&child_path, true); // Avoid recursing until crash in the case of a recursive symlink if job.ancestor_inodes.contains(&child_entry.inode) { @@ -4245,7 +4393,8 @@ impl BackgroundScanner { } } else { child_entry.is_ignored = ignore_stack.is_abs_path_ignored(&child_abs_path, false); - child_entry.is_always_included = self.settings.is_path_always_included(&child_path); + child_entry.is_always_included = + self.settings.is_path_always_included(&child_path, false); } { @@ -4256,12 +4405,16 @@ impl BackgroundScanner { log::debug!("detected private file: {relative_path:?}"); child_entry.is_private = true; } + if self.settings.is_path_hidden(&relative_path) { + log::debug!("detected hidden file: {relative_path:?}"); + child_entry.is_hidden = true; + } } new_entries.push(child_entry); } - let mut state = self.state.lock(); + let mut state = self.state.lock().await; // Identify any subdirectories that should not be scanned. let mut job_ix = 0; @@ -4343,7 +4496,7 @@ impl BackgroundScanner { None }; - let mut state = self.state.lock(); + let mut state = self.state.lock().await; let doing_recursive_update = scan_queue_tx.is_some(); // Remove any entries for paths that no longer exist or are being recursively @@ -4351,7 +4504,6 @@ impl BackgroundScanner { // detected regardless of the order of the paths. for (path, metadata) in relative_paths.iter().zip(metadata.iter()) { if matches!(metadata, Ok(None)) || doing_recursive_update { - log::trace!("remove path {:?}", path); state.remove_path(path); } } @@ -4360,16 +4512,16 @@ impl BackgroundScanner { let abs_path: Arc = root_abs_path.join(path.as_std_path()).into(); match metadata { Ok(Some((metadata, canonical_path))) => { - let ignore_stack = state.snapshot.ignore_stack_for_abs_path( - &abs_path, - metadata.is_dir, - self.fs.as_ref(), - ); + let ignore_stack = state + .snapshot + .ignore_stack_for_abs_path(&abs_path, metadata.is_dir, self.fs.as_ref()) + .await; let is_external = !canonical_path.starts_with(&root_canonical_path); + let entry_id = state.entry_id_for(self.next_entry_id.as_ref(), path, &metadata); let mut fs_entry = Entry::new( path.clone(), &metadata, - self.next_entry_id.as_ref(), + entry_id, state.snapshot.root_char_bag, if metadata.is_symlink { Some(canonical_path.as_path().to_path_buf().into()) @@ -4382,25 +4534,31 @@ impl BackgroundScanner { fs_entry.is_ignored = ignore_stack.is_abs_path_ignored(&abs_path, is_dir); fs_entry.is_external = is_external; fs_entry.is_private = self.is_path_private(path); - fs_entry.is_always_included = self.settings.is_path_always_included(path); + fs_entry.is_always_included = + self.settings.is_path_always_included(path, is_dir); + fs_entry.is_hidden = self.settings.is_path_hidden(path); if let (Some(scan_queue_tx), true) = (&scan_queue_tx, is_dir) { if state.should_scan_directory(&fs_entry) || (fs_entry.path.is_empty() && abs_path.file_name() == Some(OsStr::new(DOT_GIT))) { - state.enqueue_scan_dir( - abs_path, - &fs_entry, - scan_queue_tx, - self.fs.as_ref(), - ); + state + .enqueue_scan_dir( + abs_path, + &fs_entry, + scan_queue_tx, + self.fs.as_ref(), + ) + .await; } else { fs_entry.kind = EntryKind::UnloadedDir; } } - state.insert_entry(fs_entry.clone(), self.fs.as_ref(), self.watcher.as_ref()); + state + .insert_entry(fs_entry.clone(), self.fs.as_ref(), self.watcher.as_ref()) + .await; if path.is_empty() && let Some((ignores, repo)) = new_ancestor_repo.take() @@ -4415,6 +4573,7 @@ impl BackgroundScanner { self.fs.as_ref(), self.watcher.as_ref(), ) + .await .log_err(); } } @@ -4453,11 +4612,11 @@ impl BackgroundScanner { &self, scan_job_tx: Sender, prev_snapshot: LocalSnapshot, - mut ignores_to_update: impl Iterator, IgnoreStack)>, + ignores_to_update: Vec<(Arc, IgnoreStack)>, ) { let (ignore_queue_tx, ignore_queue_rx) = channel::unbounded(); { - while let Some((parent_abs_path, ignore_stack)) = ignores_to_update.next() { + for (parent_abs_path, ignore_stack) in ignores_to_update { ignore_queue_tx .send_blocking(UpdateIgnoreStatusJob { abs_path: parent_abs_path, @@ -4498,11 +4657,11 @@ impl BackgroundScanner { .await; } - fn ignores_needing_update(&self) -> Vec> { + async fn ignores_needing_update(&self) -> Vec> { let mut ignores_to_update = Vec::new(); { - let snapshot = &mut self.state.lock().snapshot; + let snapshot = &mut self.state.lock().await.snapshot; let abs_path = snapshot.abs_path.clone(); snapshot .ignores_by_parent_abs_path @@ -4530,26 +4689,27 @@ impl BackgroundScanner { ignores_to_update } - fn order_ignores( - &self, - mut ignores: Vec>, - ) -> impl use<> + Iterator, IgnoreStack)> { + async fn order_ignores(&self, mut ignores: Vec>) -> Vec<(Arc, IgnoreStack)> { let fs = self.fs.clone(); - let snapshot = self.state.lock().snapshot.clone(); + let snapshot = self.state.lock().await.snapshot.clone(); ignores.sort_unstable(); let mut ignores_to_update = ignores.into_iter().peekable(); - std::iter::from_fn(move || { - let parent_abs_path = ignores_to_update.next()?; + + let mut result = vec![]; + while let Some(parent_abs_path) = ignores_to_update.next() { while ignores_to_update .peek() .map_or(false, |p| p.starts_with(&parent_abs_path)) { ignores_to_update.next().unwrap(); } - let ignore_stack = - snapshot.ignore_stack_for_abs_path(&parent_abs_path, true, fs.as_ref()); - Some((parent_abs_path, ignore_stack)) - }) + let ignore_stack = snapshot + .ignore_stack_for_abs_path(&parent_abs_path, true, fs.as_ref()) + .await; + result.push((parent_abs_path, ignore_stack)); + } + + result } async fn update_ignore_status(&self, job: UpdateIgnoreStatusJob, snapshot: &LocalSnapshot) { @@ -4581,7 +4741,7 @@ impl BackgroundScanner { return; }; - if let Ok(Some(metadata)) = smol::block_on(self.fs.metadata(&job.abs_path.join(DOT_GIT))) + if let Ok(Some(metadata)) = self.fs.metadata(&job.abs_path.join(DOT_GIT)).await && metadata.is_dir { ignore_stack.repo_root = Some(job.abs_path.clone()); @@ -4601,14 +4761,16 @@ impl BackgroundScanner { // Scan any directories that were previously ignored and weren't previously scanned. if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() { - let state = self.state.lock(); + let state = self.state.lock().await; if state.should_scan_directory(&entry) { - state.enqueue_scan_dir( - abs_path.clone(), - &entry, - &job.scan_queue, - self.fs.as_ref(), - ); + state + .enqueue_scan_dir( + abs_path.clone(), + &entry, + &job.scan_queue, + self.fs.as_ref(), + ) + .await; } } @@ -4632,7 +4794,7 @@ impl BackgroundScanner { } } - let state = &mut self.state.lock(); + let state = &mut self.state.lock().await; for edit in &entries_by_path_edits { if let Edit::Insert(entry) = edit && let Err(ix) = state.changed_paths.binary_search(&entry.path) @@ -4648,9 +4810,9 @@ impl BackgroundScanner { state.snapshot.entries_by_id.edit(entries_by_id_edits, ()); } - fn update_git_repositories(&self, dot_git_paths: Vec) -> Vec> { + async fn update_git_repositories(&self, dot_git_paths: Vec) -> Vec> { log::trace!("reloading repositories: {dot_git_paths:?}"); - let mut state = self.state.lock(); + let mut state = self.state.lock().await; let scan_id = state.snapshot.scan_id; let mut affected_repo_roots = Vec::new(); for dot_git_dir in dot_git_paths { @@ -4680,13 +4842,15 @@ impl BackgroundScanner { return Vec::new(); }; affected_repo_roots.push(dot_git_dir.parent().unwrap().into()); - state.insert_git_repository( - RelPath::new(relative, PathStyle::local()) - .unwrap() - .into_arc(), - self.fs.as_ref(), - self.watcher.as_ref(), - ); + state + .insert_git_repository( + RelPath::new(relative, PathStyle::local()) + .unwrap() + .into_arc(), + self.fs.as_ref(), + self.watcher.as_ref(), + ) + .await; } Some(local_repository) => { state.snapshot.git_repositories.update( @@ -4714,7 +4878,7 @@ impl BackgroundScanner { if exists_in_snapshot || matches!( - smol::block_on(self.fs.metadata(&entry.common_dir_abs_path)), + self.fs.metadata(&entry.common_dir_abs_path).await, Ok(Some(_)) ) { @@ -5374,6 +5538,7 @@ impl<'a> From<&'a Entry> for proto::Entry { inode: entry.inode, mtime: entry.mtime.map(|time| time.into()), is_ignored: entry.is_ignored, + is_hidden: entry.is_hidden, is_external: entry.is_external, is_fifo: entry.is_fifo, size: Some(entry.size), @@ -5400,7 +5565,7 @@ impl TryFrom<(&CharBag, &PathMatcher, proto::Entry)> for Entry { let path = RelPath::from_proto(&entry.path).context("invalid relative path in proto message")?; let char_bag = char_bag_for_path(*root_char_bag, &path); - let is_always_included = always_included.is_match(path.as_std_path()); + let is_always_included = always_included.is_match(&path); Ok(Entry { id: ProjectEntryId::from_proto(entry.id), kind, @@ -5412,6 +5577,7 @@ impl TryFrom<(&CharBag, &PathMatcher, proto::Entry)> for Entry { .canonical_path .map(|path_string| Arc::from(PathBuf::from(path_string))), is_ignored: entry.is_ignored, + is_hidden: entry.is_hidden, is_always_included, is_external: entry.is_external, is_private: false, @@ -5466,11 +5632,13 @@ fn parse_gitfile(content: &str) -> anyhow::Result<&Path> { Ok(Path::new(path.trim())) } -fn discover_git_paths(dot_git_abs_path: &Arc, fs: &dyn Fs) -> (Arc, Arc) { +async fn discover_git_paths(dot_git_abs_path: &Arc, fs: &dyn Fs) -> (Arc, Arc) { let mut repository_dir_abs_path = dot_git_abs_path.clone(); let mut common_dir_abs_path = dot_git_abs_path.clone(); - if let Some(path) = smol::block_on(fs.load(dot_git_abs_path)) + if let Some(path) = fs + .load(dot_git_abs_path) + .await .ok() .as_ref() .and_then(|contents| parse_gitfile(contents).log_err()) @@ -5479,17 +5647,31 @@ fn discover_git_paths(dot_git_abs_path: &Arc, fs: &dyn Fs) -> (Arc, .parent() .unwrap_or(Path::new("")) .join(path); - if let Some(path) = smol::block_on(fs.canonicalize(&path)).log_err() { + if let Some(path) = fs.canonicalize(&path).await.log_err() { repository_dir_abs_path = Path::new(&path).into(); common_dir_abs_path = repository_dir_abs_path.clone(); - if let Some(commondir_contents) = smol::block_on(fs.load(&path.join("commondir"))).ok() - && let Some(commondir_path) = - smol::block_on(fs.canonicalize(&path.join(commondir_contents.trim()))).log_err() + + if let Some(commondir_contents) = fs.load(&path.join("commondir")).await.ok() + && let Some(commondir_path) = fs + .canonicalize(&path.join(commondir_contents.trim())) + .await + .log_err() { common_dir_abs_path = commondir_path.as_path().into(); } } }; - (repository_dir_abs_path, common_dir_abs_path) } + +struct NullWatcher; + +impl fs::Watcher for NullWatcher { + fn add(&self, _path: &Path) -> Result<()> { + Ok(()) + } + + fn remove(&self, _path: &Path) -> Result<()> { + Ok(()) + } +} diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index 3e8fc6114a..a86720184e 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -1,93 +1,89 @@ use std::path::Path; use anyhow::Context as _; -use gpui::App; -use settings::{Settings, SettingsContent}; +use settings::{RegisterSetting, Settings}; use util::{ ResultExt, paths::{PathMatcher, PathStyle}, rel_path::RelPath, }; -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, RegisterSetting)] pub struct WorktreeSettings { pub project_name: Option, - pub file_scan_inclusions: PathMatcher, + /// Whether to prevent this project from being shared in public channels. + pub prevent_sharing_in_public_channels: bool, pub file_scan_exclusions: PathMatcher, + pub file_scan_inclusions: PathMatcher, + /// This field contains all ancestors of the `file_scan_inclusions`. It's used to + /// determine whether to terminate worktree scanning for a given dir. + pub parent_dir_scan_inclusions: PathMatcher, pub private_files: PathMatcher, + pub hidden_files: PathMatcher, } impl WorktreeSettings { pub fn is_path_private(&self, path: &RelPath) -> bool { path.ancestors() - .any(|ancestor| self.private_files.is_match(ancestor.as_std_path())) + .any(|ancestor| self.private_files.is_match(ancestor)) } pub fn is_path_excluded(&self, path: &RelPath) -> bool { path.ancestors() - .any(|ancestor| self.file_scan_exclusions.is_match(ancestor.as_std_path())) + .any(|ancestor| self.file_scan_exclusions.is_match(ancestor)) } - pub fn is_path_always_included(&self, path: &RelPath) -> bool { + pub fn is_path_always_included(&self, path: &RelPath, is_dir: bool) -> bool { + if is_dir { + self.parent_dir_scan_inclusions.is_match(path) + } else { + self.file_scan_inclusions.is_match(path) + } + } + + pub fn is_path_hidden(&self, path: &RelPath) -> bool { path.ancestors() - .any(|ancestor| self.file_scan_inclusions.is_match(ancestor.as_std_path())) + .any(|ancestor| self.hidden_files.is_match(ancestor)) } } impl Settings for WorktreeSettings { - fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { let worktree = content.project.worktree.clone(); let file_scan_exclusions = worktree.file_scan_exclusions.unwrap(); let file_scan_inclusions = worktree.file_scan_inclusions.unwrap(); let private_files = worktree.private_files.unwrap().0; + let hidden_files = worktree.hidden_files.unwrap(); let parsed_file_scan_inclusions: Vec = file_scan_inclusions .iter() .flat_map(|glob| { Path::new(glob) .ancestors() + .skip(1) .map(|a| a.to_string_lossy().into()) }) .filter(|p: &String| !p.is_empty()) .collect(); Self { - project_name: worktree.project_name.filter(|p| !p.is_empty()), + project_name: worktree.project_name, + prevent_sharing_in_public_channels: worktree.prevent_sharing_in_public_channels, file_scan_exclusions: path_matchers(file_scan_exclusions, "file_scan_exclusions") .log_err() .unwrap_or_default(), - file_scan_inclusions: path_matchers( + parent_dir_scan_inclusions: path_matchers( parsed_file_scan_inclusions, "file_scan_inclusions", ) .unwrap(), + file_scan_inclusions: path_matchers(file_scan_inclusions, "file_scan_inclusions") + .unwrap(), private_files: path_matchers(private_files, "private_files") .log_err() .unwrap_or_default(), - } - } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut SettingsContent) { - if let Some(inclusions) = vscode - .read_value("files.watcherInclude") - .and_then(|v| v.as_array()) - .and_then(|v| v.iter().map(|n| n.as_str().map(str::to_owned)).collect()) - { - if let Some(old) = current.project.worktree.file_scan_inclusions.as_mut() { - old.extend(inclusions) - } else { - current.project.worktree.file_scan_inclusions = Some(inclusions) - } - } - if let Some(exclusions) = vscode - .read_value("files.watcherExclude") - .and_then(|v| v.as_array()) - .and_then(|v| v.iter().map(|n| n.as_str().map(str::to_owned)).collect()) - { - if let Some(old) = current.project.worktree.file_scan_exclusions.as_mut() { - old.extend(exclusions) - } else { - current.project.worktree.file_scan_exclusions = Some(exclusions) - } + hidden_files: path_matchers(hidden_files, "hidden_files") + .log_err() + .unwrap_or_default(), } } } diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 3c39d5c3ad..e58e99ea68 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -1,7 +1,4 @@ -use crate::{ - Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle, - worktree_settings::WorktreeSettings, -}; +use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle}; use anyhow::Result; use fs::{FakeFs, Fs, RealFs, RemoveOptions}; use git::GITIGNORE; @@ -12,7 +9,7 @@ use pretty_assertions::assert_eq; use rand::prelude::*; use serde_json::json; -use settings::{Settings, SettingsStore}; +use settings::SettingsStore; use std::{ env, fmt::Write, @@ -47,6 +44,7 @@ async fn test_traversal(cx: &mut TestAppContext) { true, fs, Default::default(), + true, &mut cx.to_async(), ) .await @@ -111,6 +109,7 @@ async fn test_circular_symlinks(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -210,6 +209,7 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -360,6 +360,7 @@ async fn test_renaming_case_only(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -382,6 +383,7 @@ async fn test_renaming_case_only(cx: &mut TestAppContext) { fs::RenameOptions { overwrite: true, ignore_if_exists: true, + create_parents: false, }, ) .await @@ -436,6 +438,7 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -600,6 +603,7 @@ async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -700,6 +704,7 @@ async fn test_write_file(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -734,7 +739,6 @@ async fn test_write_file(cx: &mut TestAppContext) { }) .await .unwrap(); - worktree.read_with(cx, |tree, _| { let tracked = tree .entry_for_path(rel_path("tracked-dir/file.txt")) @@ -761,6 +765,7 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { "prettier": { "package.json": "{}", }, + "package.json": "//package.json" }, "src": { ".DS_Store": "", @@ -793,6 +798,7 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -858,6 +864,7 @@ async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -916,6 +923,7 @@ async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppC true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1001,6 +1009,7 @@ async fn test_file_scan_exclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1057,6 +1066,93 @@ async fn test_file_scan_exclusions(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_hidden_files(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + let dir = TempTree::new(json!({ + ".gitignore": "**/target\n", + ".hidden_file": "content", + ".hidden_dir": { + "nested.rs": "code", + }, + "src": { + "visible.rs": "code", + }, + "logs": { + "app.log": "logs", + "debug.log": "logs", + }, + "visible.txt": "content", + })); + + let tree = Worktree::local( + dir.path(), + true, + Arc::new(RealFs::new(None, cx.executor())), + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_hidden)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path(".gitignore"), true), + (rel_path(".hidden_dir"), true), + (rel_path(".hidden_dir/nested.rs"), true), + (rel_path(".hidden_file"), true), + (rel_path("logs"), false), + (rel_path("logs/app.log"), false), + (rel_path("logs/debug.log"), false), + (rel_path("src"), false), + (rel_path("src/visible.rs"), false), + (rel_path("visible.txt"), false), + ] + ); + }); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.hidden_files = Some(vec!["**/*.log".to_string()]); + }); + }); + }); + tree.flush_fs_events(cx).await; + cx.executor().run_until_parked(); + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_hidden)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path(".gitignore"), false), + (rel_path(".hidden_dir"), false), + (rel_path(".hidden_dir/nested.rs"), false), + (rel_path(".hidden_file"), false), + (rel_path("logs"), false), + (rel_path("logs/app.log"), true), + (rel_path("logs/debug.log"), true), + (rel_path("src"), false), + (rel_path("src/visible.rs"), false), + (rel_path("visible.txt"), false), + ] + ); + }); +} + #[gpui::test] async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { init_test(cx); @@ -1106,6 +1202,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1217,6 +1314,7 @@ async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1255,6 +1353,7 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { true, fs, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1323,6 +1422,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { true, fs_fake, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1364,6 +1464,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { true, fs_real, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1449,6 +1550,177 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_create_file_in_expanded_gitignored_dir(cx: &mut TestAppContext) { + // Tests the behavior of our worktree refresh when a file in a gitignored directory + // is created. + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "ignored_dir\n", + "ignored_dir": { + "existing_file.txt": "existing content", + "another_file.txt": "another content", + }, + }), + ) + .await; + + let tree = Worktree::local( + Path::new("/root"), + true, + fs.clone(), + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert!(ignored_dir.is_ignored); + assert_eq!(ignored_dir.kind, EntryKind::UnloadedDir); + }); + + tree.update(cx, |tree, cx| { + tree.load_file(rel_path("ignored_dir/existing_file.txt"), cx) + }) + .await + .unwrap(); + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert!(ignored_dir.is_ignored); + assert_eq!(ignored_dir.kind, EntryKind::Dir); + + assert!( + tree.entry_for_path(rel_path("ignored_dir/existing_file.txt")) + .is_some() + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/another_file.txt")) + .is_some() + ); + }); + + let entry = tree + .update(cx, |tree, cx| { + tree.create_entry(rel_path("ignored_dir/new_file.txt").into(), false, None, cx) + }) + .await + .unwrap(); + assert!(entry.into_included().is_some()); + + cx.executor().run_until_parked(); + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert!(ignored_dir.is_ignored); + assert_eq!( + ignored_dir.kind, + EntryKind::Dir, + "ignored_dir should still be loaded, not UnloadedDir" + ); + + assert!( + tree.entry_for_path(rel_path("ignored_dir/existing_file.txt")) + .is_some(), + "existing_file.txt should still be visible" + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/another_file.txt")) + .is_some(), + "another_file.txt should still be visible" + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/new_file.txt")) + .is_some(), + "new_file.txt should be visible" + ); + }); +} + +#[gpui::test] +async fn test_fs_event_for_gitignored_dir_does_not_lose_contents(cx: &mut TestAppContext) { + // Tests the behavior of our worktree refresh when a directory modification for a gitignored directory + // is triggered. + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "ignored_dir\n", + "ignored_dir": { + "file1.txt": "content1", + "file2.txt": "content2", + }, + }), + ) + .await; + + let tree = Worktree::local( + Path::new("/root"), + true, + fs.clone(), + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + // Load a file to expand the ignored directory + tree.update(cx, |tree, cx| { + tree.load_file(rel_path("ignored_dir/file1.txt"), cx) + }) + .await + .unwrap(); + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert_eq!(ignored_dir.kind, EntryKind::Dir); + assert!( + tree.entry_for_path(rel_path("ignored_dir/file1.txt")) + .is_some() + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/file2.txt")) + .is_some() + ); + }); + + fs.emit_fs_event("/root/ignored_dir", Some(fs::PathEventKind::Changed)); + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert_eq!( + ignored_dir.kind, + EntryKind::Dir, + "ignored_dir should still be loaded (Dir), not UnloadedDir" + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/file1.txt")) + .is_some(), + "file1.txt should still be visible after directory fs event" + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/file2.txt")) + .is_some(), + "file2.txt should still be visible after directory fs event" + ); + }); +} + #[gpui::test(iterations = 100)] async fn test_random_worktree_operations_during_initial_scan( cx: &mut TestAppContext, @@ -1475,6 +1747,7 @@ async fn test_random_worktree_operations_during_initial_scan( true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1537,7 +1810,7 @@ async fn test_random_worktree_operations_during_initial_scan( assert_eq!( updated_snapshot.entries(true, 0).collect::>(), final_snapshot.entries(true, 0).collect::>(), - "wrong updates after snapshot {i}: {snapshot:#?} {updates:#?}", + "wrong updates after snapshot {i}: {updates:#?}", ); } } @@ -1565,6 +1838,7 @@ async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1637,6 +1911,7 @@ async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1903,6 +2178,7 @@ async fn randomly_mutate_fs( fs::RenameOptions { overwrite: true, ignore_if_exists: true, + create_parents: false, }, ) .await @@ -1949,6 +2225,7 @@ async fn test_private_single_file_worktree(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1981,6 +2258,7 @@ async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestA true, fs.clone(), Arc::default(), + true, &mut cx.to_async(), ) .await @@ -2058,6 +2336,7 @@ async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppCon true, fs.clone(), Arc::default(), + true, &mut cx.to_async(), ) .await @@ -2183,6 +2462,5 @@ fn init_test(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - WorktreeSettings::register(cx); }); } diff --git a/crates/worktree_benchmarks/Cargo.toml b/crates/worktree_benchmarks/Cargo.toml new file mode 100644 index 0000000000..29681573ad --- /dev/null +++ b/crates/worktree_benchmarks/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "worktree_benchmarks" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[dependencies] +fs.workspace = true +gpui = { workspace = true, features = ["windows-manifest"] } +settings.workspace = true +worktree.workspace = true + +[lints] +workspace = true diff --git a/crates/zeta_cli/LICENSE-GPL b/crates/worktree_benchmarks/LICENSE-GPL similarity index 100% rename from crates/zeta_cli/LICENSE-GPL rename to crates/worktree_benchmarks/LICENSE-GPL diff --git a/crates/worktree_benchmarks/src/main.rs b/crates/worktree_benchmarks/src/main.rs new file mode 100644 index 0000000000..00f268b75f --- /dev/null +++ b/crates/worktree_benchmarks/src/main.rs @@ -0,0 +1,53 @@ +use std::{ + path::Path, + sync::{Arc, atomic::AtomicUsize}, +}; + +use fs::RealFs; +use gpui::Application; +use settings::Settings; +use worktree::{Worktree, WorktreeSettings}; + +fn main() { + let Some(worktree_root_path) = std::env::args().nth(1) else { + println!( + "Missing path to worktree root\nUsage: bench_background_scan PATH_TO_WORKTREE_ROOT" + ); + return; + }; + let app = Application::headless(); + + app.run(|cx| { + settings::init(cx); + let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + + cx.spawn(async move |cx| { + let worktree = Worktree::local( + Path::new(&worktree_root_path), + true, + fs, + Arc::new(AtomicUsize::new(0)), + cx, + ) + .await + .expect("Worktree initialization to succeed"); + let did_finish_scan = worktree + .update(cx, |this, _| this.as_local().unwrap().scan_complete()) + .unwrap(); + let start = std::time::Instant::now(); + did_finish_scan.await; + let elapsed = start.elapsed(); + let (files, directories) = worktree + .read_with(cx, |this, _| (this.file_count(), this.dir_count())) + .unwrap(); + println!( + "{:?} for {directories} directories and {files} files", + elapsed + ); + cx.update(|cx| { + cx.quit(); + }) + }) + .detach(); + }) +} diff --git a/crates/x_ai/Cargo.toml b/crates/x_ai/Cargo.toml index 7ca0ca0939..8ff020df8c 100644 --- a/crates/x_ai/Cargo.toml +++ b/crates/x_ai/Cargo.toml @@ -20,4 +20,3 @@ anyhow.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index aac231b511..072a893a6a 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -30,6 +30,17 @@ pub enum Model { alias = "grok-4-fast-non-reasoning-latest" )] Grok4FastNonReasoning, + #[serde( + rename = "grok-4-1-fast-non-reasoning", + alias = "grok-4-1-fast-non-reasoning-latest" + )] + Grok41FastNonReasoning, + #[serde( + rename = "grok-4-1-fast-reasoning", + alias = "grok-4-1-fast-reasoning-latest", + alias = "grok-4-1-fast" + )] + Grok41FastReasoning, #[serde(rename = "grok-code-fast-1", alias = "grok-code-fast-1-0825")] GrokCodeFast1, #[serde(rename = "custom")] @@ -56,6 +67,9 @@ impl Model { "grok-4" => Ok(Self::Grok4), "grok-4-fast-reasoning" => Ok(Self::Grok4FastReasoning), "grok-4-fast-non-reasoning" => Ok(Self::Grok4FastNonReasoning), + "grok-4-1-fast-non-reasoning" => Ok(Self::Grok41FastNonReasoning), + "grok-4-1-fast-reasoning" => Ok(Self::Grok41FastReasoning), + "grok-4-1-fast" => Ok(Self::Grok41FastReasoning), "grok-2-vision" => Ok(Self::Grok2Vision), "grok-3" => Ok(Self::Grok3), "grok-3-mini" => Ok(Self::Grok3Mini), @@ -76,6 +90,8 @@ impl Model { Self::Grok4 => "grok-4", Self::Grok4FastReasoning => "grok-4-fast-reasoning", Self::Grok4FastNonReasoning => "grok-4-fast-non-reasoning", + Self::Grok41FastNonReasoning => "grok-4-1-fast-non-reasoning", + Self::Grok41FastReasoning => "grok-4-1-fast-reasoning", Self::GrokCodeFast1 => "grok-code-fast-1", Self::Custom { name, .. } => name, } @@ -91,6 +107,8 @@ impl Model { Self::Grok4 => "Grok 4", Self::Grok4FastReasoning => "Grok 4 Fast", Self::Grok4FastNonReasoning => "Grok 4 Fast (Non-Reasoning)", + Self::Grok41FastNonReasoning => "Grok 4.1 Fast (Non-Reasoning)", + Self::Grok41FastReasoning => "Grok 4.1 Fast", Self::GrokCodeFast1 => "Grok Code Fast 1", Self::Custom { name, display_name, .. @@ -102,7 +120,10 @@ impl Model { match self { Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => 131_072, Self::Grok4 | Self::GrokCodeFast1 => 256_000, - Self::Grok4FastReasoning | Self::Grok4FastNonReasoning => 128_000, + Self::Grok4FastReasoning + | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning => 2_000_000, Self::Grok2Vision => 8_192, Self::Custom { max_tokens, .. } => *max_tokens, } @@ -114,6 +135,8 @@ impl Model { Self::Grok4 | Self::Grok4FastReasoning | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning | Self::GrokCodeFast1 => Some(64_000), Self::Grok2Vision => Some(4_096), Self::Custom { @@ -131,7 +154,9 @@ impl Model { | Self::Grok3MiniFast | Self::Grok4 | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning => true, + | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning => true, Self::Custom { parallel_tool_calls: Some(support), .. @@ -154,6 +179,8 @@ impl Model { | Self::Grok4 | Self::Grok4FastReasoning | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning | Self::GrokCodeFast1 => true, Self::Custom { supports_tools: Some(support), @@ -165,7 +192,12 @@ impl Model { pub fn supports_images(&self) -> bool { match self { - Self::Grok2Vision => true, + Self::Grok2Vision + | Self::Grok4 + | Self::Grok4FastReasoning + | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning => true, Self::Custom { supports_images: Some(support), .. diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index b66cc60d5c..141de1139f 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.208.0" +version = "0.218.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -10,6 +10,9 @@ authors = ["Zed Team "] [lints] workspace = true +[features] +tracy = ["ztracing/tracy"] + [[bin]] name = "zed" path = "src/zed-main.rs" @@ -21,17 +24,15 @@ path = "src/main.rs" [dependencies] acp_tools.workspace = true activity_indicator.workspace = true -agent.workspace = true agent_settings.workspace = true agent_ui.workspace = true +agent_ui_v2.workspace = true anyhow.workspace = true askpass.workspace = true assets.workspace = true -assistant_tools.workspace = true audio.workspace = true auto_update.workspace = true auto_update_ui.workspace = true -backtrace = "0.3" bincode.workspace = true breadcrumbs.workspace = true call.workspace = true @@ -39,6 +40,7 @@ channel.workspace = true clap.workspace = true cli.workspace = true client.workspace = true +codestral.workspace = true collab_ui.workspace = true collections.workspace = true command_palette.workspace = true @@ -52,7 +54,6 @@ debugger_tools.workspace = true debugger_ui.workspace = true diagnostics.workspace = true editor.workspace = true -zeta2_tools.workspace = true env_logger.workspace = true extension.workspace = true extension_host.workspace = true @@ -74,8 +75,10 @@ gpui = { workspace = true, features = [ "windows-manifest", ] } gpui_tokio.workspace = true +rayon.workspace = true -edit_prediction_button.workspace = true +edit_prediction.workspace = true +edit_prediction_ui.workspace = true http_client.workspace = true image_viewer.workspace = true inspector_ui.workspace = true @@ -97,9 +100,9 @@ markdown.workspace = true markdown_preview.workspace = true menu.workspace = true migrator.workspace = true +miniprofiler_ui.workspace = true mimalloc = { version = "0.1", optional = true } nc.workspace = true -nix = { workspace = true, features = ["pthread", "signal"] } node_runtime.workspace = true notifications.workspace = true onboarding.workspace = true @@ -139,13 +142,14 @@ tab_switcher.workspace = true task.workspace = true tasks_ui.workspace = true telemetry.workspace = true -telemetry_events.workspace = true terminal_view.workspace = true theme.workspace = true theme_extension.workspace = true theme_selector.workspace = true time.workspace = true title_bar.workspace = true +ztracing.workspace = true +tracing.workspace = true toolchain_selector.workspace = true ui.workspace = true ui_input.workspace = true @@ -159,17 +163,16 @@ vim_mode_setting.workspace = true watch.workspace = true web_search.workspace = true web_search_providers.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true zed_env_vars.workspace = true -zeta.workspace = true -zeta2.workspace = true zlog.workspace = true zlog_settings.workspace = true +chrono.workspace = true [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true +chrono.workspace = true [target.'cfg(target_os = "windows")'.build-dependencies] winresource = "0.1" @@ -187,6 +190,7 @@ itertools.workspace = true language = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } +semver.workspace = true terminal_view = { workspace = true, features = ["test-support"] } tree-sitter-md.workspace = true tree-sitter-rust.workspace = true @@ -225,4 +229,4 @@ osx_info_plist_exts = ["resources/info/*"] osx_url_schemes = ["zed"] [package.metadata.cargo-machete] -ignored = ["profiling", "zstd"] +ignored = ["profiling", "zstd", "tracing"] diff --git a/crates/zed/build.rs b/crates/zed/build.rs index be420defa3..dd26a1152e 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -32,12 +32,16 @@ fn main() { println!("cargo:rustc-env=ZED_COMMIT_SHA={git_sha}"); + if let Some(build_identifier) = option_env!("GITHUB_RUN_NUMBER") { + println!("cargo:rustc-env=ZED_BUILD_ID={build_identifier}"); + } + if let Ok(build_profile) = std::env::var("PROFILE") && build_profile == "release" { // This is currently the best way to make `cargo build ...`'s build script // to print something to stdout without extra verbosity. - println!("cargo:warning=Info: using '{git_sha}' hash for ZED_COMMIT_SHA env var"); + println!("cargo::warning=Info: using '{git_sha}' hash for ZED_COMMIT_SHA env var"); } } @@ -49,6 +53,25 @@ fn main() { println!("cargo:rustc-link-arg=/stack:{}", 8 * 1024 * 1024); } + if cfg!(target_arch = "x86_64") { + println!("cargo::rerun-if-changed=resources\\windows\\bin\\x64\\conpty.dll"); + println!("cargo::rerun-if-changed=resources\\windows\\bin\\x64\\OpenConsole.exe"); + let conpty_target = std::env::var("OUT_DIR").unwrap() + "\\..\\..\\..\\conpty.dll"; + match std::fs::copy("resources/windows/bin/x64/conpty.dll", &conpty_target) { + Ok(_) => println!("Copied conpty.dll to {conpty_target}"), + Err(e) => println!("cargo::warning=Failed to copy conpty.dll: {}", e), + } + let open_console_target = + std::env::var("OUT_DIR").unwrap() + "\\..\\..\\..\\OpenConsole.exe"; + match std::fs::copy( + "resources/windows/bin/x64/OpenConsole.exe", + &open_console_target, + ) { + Ok(_) => println!("Copied OpenConsole.exe to {open_console_target}"), + Err(e) => println!("cargo::warning=Failed to copy OpenConsole.exe: {}", e), + } + } + let release_channel = option_env!("RELEASE_CHANNEL").unwrap_or("dev"); let icon = match release_channel { "stable" => "resources/windows/app-icon.ico", diff --git a/crates/zed/resources/windows/bin/x64/OpenConsole.exe b/crates/zed/resources/windows/bin/x64/OpenConsole.exe new file mode 100644 index 0000000000..8bb6ab2188 Binary files /dev/null and b/crates/zed/resources/windows/bin/x64/OpenConsole.exe differ diff --git a/crates/zed/resources/windows/bin/x64/conpty.dll b/crates/zed/resources/windows/bin/x64/conpty.dll new file mode 100644 index 0000000000..555d6bf655 Binary files /dev/null and b/crates/zed/resources/windows/bin/x64/conpty.dll differ diff --git a/crates/zed/resources/windows/zed.iss b/crates/zed/resources/windows/zed.iss index b726bb1c21..9df6d3b228 100644 --- a/crates/zed/resources/windows/zed.iss +++ b/crates/zed/resources/windows/zed.iss @@ -31,7 +31,10 @@ WizardStyle=modern CloseApplications=force +#if GetEnv("CI") != "" SignTool=Defaultsign +#endif + DefaultDirName={autopf}\{#AppName} PrivilegesRequired=lowest @@ -46,6 +49,10 @@ Name: "simplifiedChinese"; MessagesFile: "{#ResourcesDir}\messages\Default.zh-cn ; Delete logs Type: filesandordirs; Name: "{app}\tools" Type: filesandordirs; Name: "{app}\updates" +; Delete newer files which may not have been added by the initial installation +Type: filesandordirs; Name: "{app}\x64" +Type: filesandordirs; Name: "{app}\arm64" + [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked @@ -62,8 +69,15 @@ Source: "{#ResourcesDir}\Zed.exe"; DestDir: "{code:GetInstallDir}"; Flags: ignor Source: "{#ResourcesDir}\bin\*"; DestDir: "{code:GetInstallDir}\bin"; Flags: ignoreversion Source: "{#ResourcesDir}\tools\*"; DestDir: "{app}\tools"; Flags: ignoreversion Source: "{#ResourcesDir}\appx\*"; DestDir: "{app}\appx"; BeforeInstall: RemoveAppxPackage; AfterInstall: AddAppxPackage; Flags: ignoreversion; Check: IsWindows11OrLater +#ifexist ResourcesDir + "\amd_ags_x64.dll" Source: "{#ResourcesDir}\amd_ags_x64.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "{#ResourcesDir}\OpenConsole.exe"; DestDir: "{code:GetInstallDir}"; Flags: ignoreversion +#endif +#ifexist ResourcesDir + "\x64\OpenConsole.exe" +Source: "{#ResourcesDir}\x64\OpenConsole.exe"; DestDir: "{code:GetInstallDir}\x64"; Flags: ignoreversion +#endif +#ifexist ResourcesDir + "\arm64\OpenConsole.exe" +Source: "{#ResourcesDir}\arm64\OpenConsole.exe"; DestDir: "{code:GetInstallDir}\arm64"; Flags: ignoreversion +#endif Source: "{#ResourcesDir}\conpty.dll"; DestDir: "{code:GetInstallDir}"; Flags: ignoreversion [Icons] diff --git a/crates/zed/resources/zed.entitlements b/crates/zed/resources/zed.entitlements index cb4cd3dc69..2a16afe755 100644 --- a/crates/zed/resources/zed.entitlements +++ b/crates/zed/resources/zed.entitlements @@ -22,5 +22,9 @@ com.apple.security.personal-information.photos-library + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index db8d8736bd..6d94a15a66 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -3,7 +3,7 @@ mod zed; use agent_ui::AgentPanel; use anyhow::{Context as _, Error, Result}; -use clap::{Parser, command}; +use clap::Parser; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; use client::{Client, ProxySettings, UserStore, parse_zed_link}; use collab_ui::channel_view::ChannelView; @@ -12,11 +12,10 @@ use crashes::InitCrashHandler; use db::kvp::{GLOBAL_KEY_VALUE_STORE, KEY_VALUE_STORE}; use editor::Editor; use extension::ExtensionHostProxy; -use extension_host::ExtensionStore; use fs::{Fs, RealFs}; use futures::{StreamExt, channel::oneshot, future}; use git::GitHostingProviderRegistry; -use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, UpdateGlobal as _}; +use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _}; use gpui_tokio::Tokio; use language::LanguageRegistry; @@ -38,12 +37,10 @@ use std::{ io::{self, IsTerminal}, path::{Path, PathBuf}, process, - sync::Arc, -}; -use theme::{ - ActiveTheme, IconThemeNotFoundError, SystemAppearance, ThemeNotFoundError, ThemeRegistry, - ThemeSettings, + sync::{Arc, OnceLock}, + time::Instant, }; +use theme::{ActiveTheme, GlobalTheme, ThemeRegistry}; use util::{ResultExt, TryFutureExt, maybe}; use uuid::Uuid; use workspace::{ @@ -53,11 +50,11 @@ use workspace::{ use zed::{ OpenListener, OpenRequest, RawOpenRequest, app_menus, build_window_options, derive_paths_with_position, edit_prediction_registry, handle_cli_connection, - handle_keymap_file_changes, handle_settings_changed, handle_settings_file_changes, - initialize_workspace, open_paths_with_positions, + handle_keymap_file_changes, handle_settings_file_changes, initialize_workspace, + open_paths_with_positions, }; -use crate::zed::OpenRequestKind; +use crate::zed::{OpenRequestKind, eager_load_active_theme_and_icon_theme}; #[cfg(feature = "mimalloc")] #[global_allocator] @@ -91,31 +88,33 @@ fn files_not_created_on_launch(errors: HashMap>) { .collect::>().join("\n\n"); eprintln!("{message}: {error_details}"); - Application::new().run(move |cx| { - if let Ok(window) = cx.open_window(gpui::WindowOptions::default(), |_, cx| { - cx.new(|_| gpui::Empty) - }) { - window - .update(cx, |_, window, cx| { - let response = window.prompt( - gpui::PromptLevel::Critical, - message, - Some(&error_details), - &["Exit"], - cx, - ); + Application::new() + .with_quit_mode(QuitMode::Explicit) + .run(move |cx| { + if let Ok(window) = cx.open_window(gpui::WindowOptions::default(), |_, cx| { + cx.new(|_| gpui::Empty) + }) { + window + .update(cx, |_, window, cx| { + let response = window.prompt( + gpui::PromptLevel::Critical, + message, + Some(&error_details), + &["Exit"], + cx, + ); - cx.spawn_in(window, async move |_, cx| { - response.await?; - cx.update(|_, cx| cx.quit()) + cx.spawn_in(window, async move |_, cx| { + response.await?; + cx.update(|_, cx| cx.quit()) + }) + .detach_and_log_err(cx); }) - .detach_and_log_err(cx); - }) - .log_err(); - } else { - fail_to_open_window(anyhow::anyhow!("{message}: {error_details}"), cx) - } - }) + .log_err(); + } else { + fail_to_open_window(anyhow::anyhow!("{message}: {error_details}"), cx) + } + }) } fn fail_to_open_window_async(e: anyhow::Error, cx: &mut AsyncApp) { @@ -131,6 +130,7 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) { process::exit(1); } + // Maybe unify this with gpui::platform::linux::platform::ResultExt::notify_err(..)? #[cfg(any(target_os = "linux", target_os = "freebsd"))] { use ashpd::desktop::notification::{Notification, NotificationProxy, Priority}; @@ -163,8 +163,11 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) { .detach(); } } +pub static STARTUP_TIME: OnceLock = OnceLock::new(); pub fn main() { + STARTUP_TIME.get_or_init(|| Instant::now()); + #[cfg(unix)] util::prevent_root_execution(); @@ -194,6 +197,15 @@ pub fn main() { } } + #[cfg(all(not(debug_assertions), target_os = "windows"))] + unsafe { + use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole}; + + if args.foreground { + let _ = AttachConsole(ATTACH_PARENT_PROCESS); + } + } + // `zed --printenv` Outputs environment variables as JSON to stdout if args.printenv { util::shell_env::print_env(); @@ -210,21 +222,14 @@ pub fn main() { paths::set_custom_data_dir(dir); } - #[cfg(all(not(debug_assertions), target_os = "windows"))] - unsafe { - use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole}; - - if args.foreground { - let _ = AttachConsole(ATTACH_PARENT_PROCESS); - } - } - #[cfg(target_os = "windows")] match util::get_zed_cli_path() { Ok(path) => askpass::set_askpass_program(path), Err(err) => { eprintln!("Error: {}", err); - process::exit(1); + if std::option_env!("ZED_BUNDLE").is_some() { + process::exit(1); + } } } @@ -235,6 +240,7 @@ pub fn main() { } zlog::init(); + if stdout_is_a_pty() { zlog::init_output_stdout(); } else { @@ -244,10 +250,12 @@ pub fn main() { zlog::init_output_stdout(); }; } + ztracing::init(); - let app_version = AppVersion::load(env!("CARGO_PKG_VERSION")); + let version = option_env!("ZED_BUILD_ID"); let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|commit_sha| AppCommitSha::new(commit_sha.to_string())); + let app_version = AppVersion::load(env!("CARGO_PKG_VERSION"), version, app_commit_sha.clone()); if args.system_specs { let system_specs = system_specs::SystemSpecs::new_stateless( @@ -259,6 +267,13 @@ pub fn main() { return; } + rayon::ThreadPoolBuilder::new() + .num_threads(std::thread::available_parallelism().map_or(1, |n| n.get().div_ceil(2))) + .stack_size(10 * 1024 * 1024) + .thread_name(|ix| format!("RayonWorker{}", ix)) + .build_global() + .unwrap(); + log::info!( "========== starting zed version {}, sha {} ==========", app_version, @@ -274,14 +289,16 @@ pub fn main() { let app = Application::new().with_assets(Assets); - let system_id = app.background_executor().block(system_id()).ok(); - let installation_id = app.background_executor().block(installation_id()).ok(); + let system_id = app.background_executor().spawn(system_id()); + let installation_id = app.background_executor().spawn(installation_id()); let session_id = Uuid::new_v4().to_string(); - let session = app.background_executor().block(Session::new()); + let session = app + .background_executor() + .spawn(Session::new(session_id.clone())); app.background_executor() .spawn(crashes::init(InitCrashHandler { - session_id: session_id.clone(), + session_id, zed_version: app_version.to_string(), binary: "zed".to_string(), release_channel: release_channel::RELEASE_CHANNEL_NAME.clone(), @@ -329,7 +346,9 @@ pub fn main() { } else { None }; - log::info!("Using git binary path: {:?}", git_binary_path); + if let Some(git_binary_path) = &git_binary_path { + log::info!("Using git binary path: {:?}", git_binary_path); + } let fs = Arc::new(RealFs::new(git_binary_path, app.background_executor())); let user_settings_file_rx = watch_config_file( @@ -397,14 +416,9 @@ pub fn main() { } settings::init(cx); zlog_settings::init(cx); - handle_settings_file_changes( - user_settings_file_rx, - global_settings_file_rx, - cx, - handle_settings_changed, - ); + handle_settings_file_changes(user_settings_file_rx, global_settings_file_rx, cx); handle_keymap_file_changes(user_keymap_file_rx, cx); - client::init_settings(cx); + let user_agent = format!( "Zed/{} ({}; {})", AppVersion::global(cx), @@ -463,7 +477,6 @@ pub fn main() { let node_runtime = NodeRuntime::new(client.http_client(), Some(shell_env_loaded_rx), rx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); - language::init(cx); languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); @@ -496,11 +509,16 @@ pub fn main() { debugger_ui::init(cx); debugger_tools::init(cx); client::init(&client, cx); + + let system_id = cx.background_executor().block(system_id).ok(); + let installation_id = cx.background_executor().block(installation_id).ok(); + let session = cx.background_executor().block(session); + let telemetry = client.telemetry(); telemetry.start( system_id.as_ref().map(|id| id.to_string()), installation_id.as_ref().map(|id| id.to_string()), - session_id.clone(), + session.id().to_owned(), cx, ); @@ -533,19 +551,22 @@ pub fn main() { }); AppState::set_global(Arc::downgrade(&app_state), cx); - auto_update::init(client.http_client(), cx); + auto_update::init(client.clone(), cx); dap_adapters::init(cx); auto_update_ui::init(cx); - reliability::init( - client.http_client(), - system_id.as_ref().map(|id| id.to_string()), + reliability::init(client.clone(), cx); + extension_host::init( + extension_host_proxy.clone(), + app_state.fs.clone(), + app_state.client.clone(), + app_state.node_runtime.clone(), cx, ); - SystemAppearance::init(cx); theme::init(theme::LoadThemes::All(Box::new(Assets)), cx); + eager_load_active_theme_and_icon_theme(fs.clone(), cx); theme_extension::init( - extension_host_proxy.clone(), + extension_host_proxy, ThemeRegistry::global(cx), cx.background_executor().clone(), ); @@ -561,9 +582,8 @@ pub fn main() { supermaven::init(app_state.client.clone(), cx); language_model::init(app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); - agent_settings::init(cx); acp_tools::init(cx); - zeta2_tools::init(cx); + edit_prediction_ui::init(cx); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); snippet_provider::init(cx); @@ -577,20 +597,12 @@ pub fn main() { false, cx, ); - assistant_tools::init(app_state.client.http_client(), cx); + agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); - extension_host::init( - extension_host_proxy, - app_state.fs.clone(), - app_state.client.clone(), - app_state.node_runtime.clone(), - cx, - ); recent_projects::init(cx); load_embedded_fonts(cx); - app_state.languages.set_theme(cx.theme().clone()); editor::init(cx); image_viewer::init(cx); repl::notebook::init(cx); @@ -631,13 +643,12 @@ pub fn main() { settings_ui::init(cx); keymap_editor::init(cx); extensions_ui::init(cx); - zeta::init(cx); + edit_prediction::init(cx); inspector_ui::init(app_state.clone(), cx); json_schema_store::init(cx); + miniprofiler_ui::init(*STARTUP_TIME.get().unwrap(), cx); cx.observe_global::({ - let fs = fs.clone(); - let languages = app_state.languages.clone(); let http = app_state.client.http_client(); let client = app_state.client.clone(); move |cx| { @@ -650,9 +661,6 @@ pub fn main() { .ok(); } - eager_load_active_theme_and_icon_theme(fs.clone(), cx); - - languages.set_theme(cx.theme().clone()); let new_host = &client::ClientSettings::get_global(cx).server_url; if &http.base_url() != new_host { http.set_base_url(new_host); @@ -663,6 +671,14 @@ pub fn main() { } }) .detach(); + app_state.languages.set_theme(cx.theme().clone()); + cx.observe_global::({ + let languages = app_state.languages.clone(); + move |cx| { + languages.set_theme(cx.theme().clone()); + } + }) + .detach(); telemetry::event!( "Settings Changed", setting = "theme", @@ -848,6 +864,25 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut .detach(); }); } + OpenRequestKind::Setting { setting_path } => { + // zed://settings/languages/$(language)/tab_size - DONT SUPPORT + // zed://settings/languages/Rust/tab_size - SUPPORT + // languages.$(language).tab_size + // [ languages $(language) tab_size] + cx.spawn(async move |cx| { + let workspace = + workspace::get_any_active_workspace(app_state, cx.clone()).await?; + + workspace.update(cx, |_, window, cx| match setting_path { + None => window.dispatch_action(Box::new(zed_actions::OpenSettings), cx), + Some(setting_path) => window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { path: setting_path }), + cx, + ), + }) + }) + .detach_and_log_err(cx); + } } return; @@ -1201,6 +1236,7 @@ fn init_paths() -> HashMap> { paths::database_dir(), paths::logs_dir(), paths::temp_dir(), + paths::hang_traces_dir(), ] .into_iter() .fold(HashMap::default(), |mut errors, path| { @@ -1216,7 +1252,7 @@ pub fn stdout_is_a_pty() -> bool { } #[derive(Parser, Debug)] -#[command(name = "zed", disable_version_flag = true)] +#[command(name = "zed", disable_version_flag = true, max_term_width = 100)] struct Args { /// A sequence of space-separated paths or urls that you want to open. /// @@ -1231,11 +1267,12 @@ struct Args { diff: Vec, /// Sets a custom directory for all user data (e.g., database, extensions, logs). + /// /// This overrides the default platform-specific data directory location. /// On macOS, the default is `~/Library/Application Support/Zed`. /// On Linux/FreeBSD, the default is `$XDG_DATA_HOME/zed`. /// On Windows, the default is `%LOCALAPPDATA%\Zed`. - #[arg(long, value_name = "DIR")] + #[arg(long, value_name = "DIR", verbatim_doc_comment)] user_data_dir: Option, /// The username and WSL distribution to use when opening paths. If not specified, @@ -1255,8 +1292,11 @@ struct Args { #[arg(long)] dev_server_token: Option, - /// Prints system specs. Useful for submitting issues on GitHub when encountering a bug - /// that prevents Zed from starting, so you can't run `zed: copy system specs to clipboard` + /// Prints system specs. + /// + /// Useful for submitting issues on GitHub when encountering a bug that + /// prevents Zed from starting, so you can't run `zed: copy system specs to + /// clipboard` #[arg(long)] system_specs: bool, @@ -1352,63 +1392,6 @@ fn load_embedded_fonts(cx: &App) { .unwrap(); } -/// Eagerly loads the active theme and icon theme based on the selections in the -/// theme settings. -/// -/// This fast path exists to load these themes as soon as possible so the user -/// doesn't see the default themes while waiting on extensions to load. -fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &App) { - let extension_store = ExtensionStore::global(cx); - let theme_registry = ThemeRegistry::global(cx); - let theme_settings = ThemeSettings::get_global(cx); - let appearance = SystemAppearance::global(cx).0; - - if let Some(theme_selection) = theme_settings.theme_selection.as_ref() { - let theme_name = theme_selection.theme(appearance); - if matches!(theme_registry.get(theme_name), Err(ThemeNotFoundError(_))) - && let Some(theme_path) = extension_store.read(cx).path_to_extension_theme(theme_name) - { - cx.spawn({ - let theme_registry = theme_registry.clone(); - let fs = fs.clone(); - async move |cx| { - theme_registry.load_user_theme(&theme_path, fs).await?; - - cx.update(|cx| { - ThemeSettings::reload_current_theme(cx); - }) - } - }) - .detach_and_log_err(cx); - } - } - - if let Some(icon_theme_selection) = theme_settings.icon_theme_selection.as_ref() { - let icon_theme_name = icon_theme_selection.icon_theme(appearance); - if matches!( - theme_registry.get_icon_theme(icon_theme_name), - Err(IconThemeNotFoundError(_)) - ) && let Some((icon_theme_path, icons_root_path)) = extension_store - .read(cx) - .path_to_extension_icon_theme(icon_theme_name) - { - cx.spawn({ - let fs = fs.clone(); - async move |cx| { - theme_registry - .load_icon_theme(&icon_theme_path, &icons_root_path, fs) - .await?; - - cx.update(|cx| { - ThemeSettings::reload_current_icon_theme(cx); - }) - } - }) - .detach_and_log_err(cx); - } - } -} - /// Spawns a background task to load the user themes from the themes directory. fn load_user_themes_in_background(fs: Arc, cx: &mut App) { cx.spawn({ @@ -1433,7 +1416,7 @@ fn load_user_themes_in_background(fs: Arc, cx: &mut App) { } } theme_registry.load_user_themes(themes_dir, fs).await?; - cx.update(ThemeSettings::reload_current_theme)?; + cx.update(GlobalTheme::reload_theme)?; } anyhow::Ok(()) } @@ -1459,7 +1442,7 @@ fn watch_themes(fs: Arc, cx: &mut App) { .await .log_err() { - cx.update(ThemeSettings::reload_current_theme).log_err(); + cx.update(GlobalTheme::reload_theme).log_err(); } } } diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index dcabe93aab..da8dffa85d 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -1,41 +1,42 @@ use anyhow::{Context as _, Result}; -use client::{TelemetrySettings, telemetry::MINIDUMP_ENDPOINT}; -use futures::AsyncReadExt; -use gpui::{App, AppContext as _}; -use http_client::{self, HttpClient, HttpClientWithUrl}; +use client::{Client, telemetry::MINIDUMP_ENDPOINT}; +use futures::{AsyncReadExt, TryStreamExt}; +use gpui::{App, AppContext as _, SerializedThreadTaskTimings}; +use http_client::{self, AsyncBody, HttpClient, Request}; +use log::info; use project::Project; use proto::{CrashReport, GetCrashFilesResponse}; use reqwest::multipart::{Form, Part}; -use settings::Settings; use smol::stream::StreamExt; -use std::{ffi::OsStr, fs, sync::Arc}; +use std::{ffi::OsStr, fs, sync::Arc, thread::ThreadId, time::Duration}; use util::ResultExt; -pub fn init(http_client: Arc, installation_id: Option, cx: &mut App) { - #[cfg(target_os = "macos")] - monitor_main_thread_hangs(http_client.clone(), installation_id.clone(), cx); +use crate::STARTUP_TIME; - if client::TelemetrySettings::get_global(cx).diagnostics { - let client = http_client.clone(); - let id = installation_id.clone(); +pub fn init(client: Arc, cx: &mut App) { + monitor_hangs(cx); + + if client.telemetry().diagnostics_enabled() { + let client = client.clone(); cx.background_spawn(async move { - upload_previous_minidumps(client, id).await.warn_on_err(); + upload_previous_minidumps(client).await.warn_on_err(); }) .detach() } cx.observe_new(move |project: &mut Project, _, cx| { - let http_client = http_client.clone(); - let installation_id = installation_id.clone(); + let client = client.clone(); let Some(remote_client) = project.remote_client() else { return; }; - remote_client.update(cx, |client, cx| { - if !TelemetrySettings::get_global(cx).diagnostics { + remote_client.update(cx, |remote_client, cx| { + if !client.telemetry().diagnostics_enabled() { return; } - let request = client.proto_client().request(proto::GetCrashFiles {}); + let request = remote_client + .proto_client() + .request(proto::GetCrashFiles {}); cx.background_spawn(async move { let GetCrashFilesResponse { crashes } = request.await?; @@ -48,15 +49,9 @@ pub fn init(http_client: Arc, installation_id: Option } in crashes { if let Some(metadata) = serde_json::from_str(&metadata).log_err() { - upload_minidump( - http_client.clone(), - endpoint, - minidump_contents, - &metadata, - installation_id.clone(), - ) - .await - .log_err(); + upload_minidump(client.clone(), endpoint, minidump_contents, &metadata) + .await + .log_err(); } } @@ -68,91 +63,13 @@ pub fn init(http_client: Arc, installation_id: Option .detach(); } -#[cfg(target_os = "macos")] -pub fn monitor_main_thread_hangs( - http_client: Arc, - installation_id: Option, - cx: &App, -) { - // This is too noisy to ship to stable for now. - if !matches!( - ReleaseChannel::global(cx), - ReleaseChannel::Dev | ReleaseChannel::Nightly | ReleaseChannel::Preview - ) { - return; - } - - use nix::sys::signal::{ - SaFlags, SigAction, SigHandler, SigSet, - Signal::{self, SIGUSR2}, - sigaction, - }; - - use parking_lot::Mutex; - - use http_client::Method; - use release_channel::ReleaseChannel; - use std::{ - ffi::c_int, - sync::{OnceLock, mpsc}, - time::Duration, - }; - use telemetry_events::{BacktraceFrame, HangReport}; - - use nix::sys::pthread; +fn monitor_hangs(cx: &App) { + let main_thread_id = std::thread::current().id(); let foreground_executor = cx.foreground_executor(); let background_executor = cx.background_executor(); - let telemetry_settings = *client::TelemetrySettings::get_global(cx); - - // Initialize SIGUSR2 handler to send a backtrace to a channel. - let (backtrace_tx, backtrace_rx) = mpsc::channel(); - static BACKTRACE: Mutex> = Mutex::new(Vec::new()); - static BACKTRACE_SENDER: OnceLock> = OnceLock::new(); - BACKTRACE_SENDER.get_or_init(|| backtrace_tx); - BACKTRACE.lock().reserve(100); - - fn handle_backtrace_signal() { - unsafe { - extern "C" fn handle_sigusr2(_i: c_int) { - unsafe { - // ASYNC SIGNAL SAFETY: This lock is only accessed one other time, - // which can only be triggered by This signal handler. In addition, - // this signal handler is immediately removed by SA_RESETHAND, and this - // signal handler cannot be re-entrant due to the SIGUSR2 mask defined - // below - let mut bt = BACKTRACE.lock(); - bt.clear(); - backtrace::trace_unsynchronized(|frame| { - if bt.len() < bt.capacity() { - bt.push(frame.clone()); - true - } else { - false - } - }); - } - - BACKTRACE_SENDER.get().unwrap().send(()).ok(); - } - - let mut mask = SigSet::empty(); - mask.add(SIGUSR2); - sigaction( - Signal::SIGUSR2, - &SigAction::new( - SigHandler::Handler(handle_sigusr2), - SaFlags::SA_RESTART | SaFlags::SA_RESETHAND, - mask, - ), - ) - .log_err(); - } - } - - handle_backtrace_signal(); - let main_thread = pthread::pthread_self(); + // 3 seconds hang let (mut tx, mut rx) = futures::channel::mpsc::channel(3); foreground_executor .spawn(async move { while (rx.next().await).is_some() {} }) @@ -162,120 +79,79 @@ pub fn monitor_main_thread_hangs( .spawn({ let background_executor = background_executor.clone(); async move { + let mut hang_time = None; + + let mut hanging = false; loop { background_executor.timer(Duration::from_secs(1)).await; match tx.try_send(()) { - Ok(_) => continue, + Ok(_) => { + hang_time = None; + hanging = false; + continue; + } Err(e) => { - if e.into_send_error().is_full() { - pthread::pthread_kill(main_thread, SIGUSR2).log_err(); + let is_full = e.into_send_error().is_full(); + if is_full && !hanging { + hanging = true; + hang_time = Some(chrono::Local::now()); + } + + if is_full { + save_hang_trace( + main_thread_id, + &background_executor, + hang_time.unwrap(), + ); } - // Only detect the first hang - break; } } } } }) .detach(); - - let app_version = release_channel::AppVersion::global(cx); - let os_name = client::telemetry::os_name(); - - background_executor - .clone() - .spawn(async move { - let os_version = client::telemetry::os_version(); - - loop { - while backtrace_rx.recv().is_ok() { - if !telemetry_settings.diagnostics { - return; - } - - // ASYNC SIGNAL SAFETY: This lock is only accessed _after_ - // the backtrace transmitter has fired, which itself is only done - // by the signal handler. And due to SA_RESETHAND the signal handler - // will not run again until `handle_backtrace_signal` is called. - let raw_backtrace = BACKTRACE.lock().drain(..).collect::>(); - let backtrace: Vec<_> = raw_backtrace - .into_iter() - .map(|frame| { - let mut btf = BacktraceFrame { - ip: frame.ip() as usize, - symbol_addr: frame.symbol_address() as usize, - base: frame.module_base_address().map(|addr| addr as usize), - symbols: vec![], - }; - - backtrace::resolve_frame(&frame, |symbol| { - if let Some(name) = symbol.name() { - btf.symbols.push(name.to_string()); - } - }); - - btf - }) - .collect(); - - // IMPORTANT: Don't move this to before `BACKTRACE.lock()` - handle_backtrace_signal(); - - log::error!( - "Suspected hang on main thread:\n{}", - backtrace - .iter() - .flat_map(|bt| bt.symbols.first().as_ref().map(|s| s.as_str())) - .collect::>() - .join("\n") - ); - - let report = HangReport { - backtrace, - app_version: Some(app_version), - os_name: os_name.clone(), - os_version: Some(os_version.clone()), - architecture: std::env::consts::ARCH.into(), - installation_id: installation_id.clone(), - }; - - let Some(json_bytes) = serde_json::to_vec(&report).log_err() else { - continue; - }; - - let Some(checksum) = client::telemetry::calculate_json_checksum(&json_bytes) - else { - continue; - }; - - let Ok(url) = http_client.build_zed_api_url("/telemetry/hangs", &[]) else { - continue; - }; - - let Ok(request) = http_client::Request::builder() - .method(Method::POST) - .uri(url.as_ref()) - .header("x-zed-checksum", checksum) - .body(json_bytes.into()) - else { - continue; - }; - - if let Some(response) = http_client.send(request).await.log_err() - && response.status() != 200 - { - log::error!("Failed to send hang report: HTTP {:?}", response.status()); - } - } - } - }) - .detach() } -pub async fn upload_previous_minidumps( - http: Arc, - installation_id: Option, -) -> anyhow::Result<()> { +fn save_hang_trace( + main_thread_id: ThreadId, + background_executor: &gpui::BackgroundExecutor, + hang_time: chrono::DateTime, +) { + let thread_timings = background_executor.dispatcher.get_all_timings(); + let thread_timings = thread_timings + .into_iter() + .map(|mut timings| { + if timings.thread_id == main_thread_id { + timings.thread_name = Some("main".to_string()); + } + + SerializedThreadTaskTimings::convert(*STARTUP_TIME.get().unwrap(), timings) + }) + .collect::>(); + + let trace_path = paths::hang_traces_dir().join(&format!( + "hang-{}.miniprof", + hang_time.format("%Y-%m-%d_%H-%M-%S") + )); + + let Some(timings) = serde_json::to_string(&thread_timings) + .context("hang timings serialization") + .log_err() + else { + return; + }; + + std::fs::write(&trace_path, timings) + .context("hang trace file writing") + .log_err(); + + info!( + "hang detected, trace file saved at: {}", + trace_path.display() + ); +} + +pub async fn upload_previous_minidumps(client: Arc) -> anyhow::Result<()> { let Some(minidump_endpoint) = MINIDUMP_ENDPOINT.as_ref() else { log::warn!("Minidump endpoint not set"); return Ok(()); @@ -292,13 +168,12 @@ pub async fn upload_previous_minidumps( json_path.set_extension("json"); if let Ok(metadata) = serde_json::from_slice(&smol::fs::read(&json_path).await?) && upload_minidump( - http.clone(), + client.clone(), minidump_endpoint, smol::fs::read(&child_path) .await .context("Failed to read minidump")?, &metadata, - installation_id.clone(), ) .await .log_err() @@ -312,11 +187,10 @@ pub async fn upload_previous_minidumps( } async fn upload_minidump( - http: Arc, + client: Arc, endpoint: &str, minidump: Vec, metadata: &crashes::CrashInfo, - installation_id: Option, ) -> Result<()> { let mut form = Form::new() .part( @@ -343,8 +217,19 @@ async fn upload_minidump( if let Some(minidump_error) = metadata.minidump_error.clone() { form = form.text("minidump_error", minidump_error); } - if let Some(id) = installation_id.clone() { - form = form.text("sentry[user][id]", id) + + if let Some(id) = client.telemetry().metrics_id() { + form = form.text("sentry[user][id]", id.to_string()); + form = form.text( + "sentry[user][is_staff]", + if client.telemetry().is_staff().unwrap_or_default() { + "true" + } else { + "false" + }, + ); + } else if let Some(id) = client.telemetry().installation_id() { + form = form.text("sentry[user][id]", format!("installation-{}", id)) } ::telemetry::event!( @@ -411,8 +296,14 @@ async fn upload_minidump( // TODO: feature-flag-context, and more of device-context like screen resolution, available ram, device model, etc + let stream = form + .into_stream() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) + .into_async_read(); + let body = AsyncBody::from_reader(stream); + let req = Request::builder().uri(endpoint).body(body)?; let mut response_text = String::new(); - let mut response = http.send_multipart_form(endpoint, form).await?; + let mut response = client.http_client().send(req).await?; response .body_mut() .read_to_string(&mut response_text) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index cc2d0086c2..a51e38bfe4 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -10,6 +10,7 @@ mod quick_action_bar; pub(crate) mod windows_only_instance; use agent_ui::{AgentDiffToolbar, AgentPanelDelegate}; +use agent_ui_v2::agents_panel::AgentsPanel; use anyhow::Context as _; pub use app_menus::*; use assets::Assets; @@ -18,18 +19,21 @@ use breadcrumbs::Breadcrumbs; use client::zed_urls; use collections::VecDeque; use debugger_ui::debugger_panel::DebugPanel; -use editor::ProposedChangesEditorToolbar; use editor::{Editor, MultiBuffer}; +use extension_host::ExtensionStore; use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag}; +use fs::Fs; +use futures::FutureExt as _; use futures::future::Either; use futures::{StreamExt, channel::mpsc, select_biased}; +use git_ui::commit_view::CommitViewToolbar; use git_ui::git_panel::GitPanel; use git_ui::project_diff::ProjectDiffToolbar; use gpui::{ - Action, App, AppContext as _, Context, DismissEvent, Element, Entity, Focusable, KeyBinding, - ParentElement, PathPromptOptions, PromptLevel, ReadGlobal, SharedString, Styled, Task, - TitlebarOptions, UpdateGlobal, Window, WindowKind, WindowOptions, actions, image_cache, point, - px, retain_all, + Action, App, AppContext as _, AsyncWindowContext, Context, DismissEvent, Element, Entity, + Focusable, KeyBinding, ParentElement, PathPromptOptions, PromptLevel, ReadGlobal, SharedString, + Task, TitlebarOptions, UpdateGlobal, WeakEntity, Window, WindowKind, WindowOptions, actions, + image_cache, point, px, retain_all, }; use image_viewer::ImageInfo; use language::Capability; @@ -37,7 +41,7 @@ use language_onboarding::BasedPyrightBanner; use language_tools::lsp_button::{self, LspButton}; use language_tools::lsp_log_view::LspLogToolbarItemView; use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType}; -use migrator::{migrate_keymap, migrate_settings}; +use migrator::migrate_keymap; use onboarding::DOCS_URL; use onboarding::multibuffer_hint::MultibufferHint; pub use open_listener::*; @@ -51,12 +55,12 @@ use project_panel::ProjectPanel; use prompt_store::PromptBuilder; use quick_action_bar::QuickActionBar; use recent_projects::open_remote_project; -use release_channel::{AppCommitSha, ReleaseChannel}; +use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; use rope::Rope; use search::project_search::ProjectSearchBar; use settings::{ BaseKeymap, DEFAULT_KEYMAP_PATH, InvalidSettingsError, KeybindSource, KeymapFile, - KeymapFileLoadResult, Settings, SettingsStore, VIM_KEYMAP_PATH, + KeymapFileLoadResult, MigrationStatus, Settings, SettingsStore, VIM_KEYMAP_PATH, initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, update_settings_file, }; @@ -68,7 +72,7 @@ use std::{ sync::atomic::{self, AtomicBool}, }; use terminal_view::terminal_panel::{self, TerminalPanel}; -use theme::{ActiveTheme, ThemeSettings}; +use theme::{ActiveTheme, GlobalTheme, SystemAppearance, ThemeRegistry, ThemeSettings}; use ui::{PopoverMenuHandle, prelude::*}; use util::markdown::MarkdownString; use util::rel_path::RelPath; @@ -78,8 +82,9 @@ use vim_mode_setting::VimModeSetting; use workspace::notifications::{ NotificationId, SuppressEvent, dismiss_app_notification, show_app_notification, }; +use workspace::utility_pane::utility_slot_for_dock_position; use workspace::{ - AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings, + AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings, create_and_open_local_file, notifications::simple_message_notification::MessageNotification, open_new, }; @@ -88,7 +93,8 @@ use workspace::{ }; use workspace::{Pane, notifications::DetachAndPromptErr}; use zed_actions::{ - OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettings, OpenZedUrl, Quit, + OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettingsFile, OpenZedUrl, + Quit, }; actions!( @@ -104,8 +110,8 @@ actions!( Minimize, /// Opens the default settings file. OpenDefaultSettings, - /// Opens project-specific settings. - OpenProjectSettings, + /// Opens project-specific settings file. + OpenProjectSettingsFile, /// Opens the project tasks configuration. OpenProjectTasks, /// Opens the tasks panel. @@ -155,15 +161,15 @@ pub fn init(cx: &mut App) { || flag.await { cx.update(|cx| { - cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")); - cx.on_action(|_: &TestCrash, _| { - unsafe extern "C" { - fn puts(s: *const i8); - } - unsafe { - puts(0xabad1d3a as *const i8); - } - }); + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) + .on_action(|_: &TestCrash, _| { + unsafe extern "C" { + fn puts(s: *const i8); + } + unsafe { + puts(0xabad1d3a as *const i8); + } + }); }) .ok(); }; @@ -173,8 +179,11 @@ pub fn init(cx: &mut App) { with_active_or_new_workspace(cx, |workspace, window, cx| { open_log_file(workspace, window, cx); }); - }); - cx.on_action(|_: &zed_actions::OpenLicenses, cx| { + }) + .on_action(|_: &workspace::RevealLogInFileManager, cx| { + cx.reveal_path(paths::log_file().as_path()); + }) + .on_action(|_: &zed_actions::OpenLicenses, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -185,13 +194,13 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::OpenTelemetryLog, cx| { + }) + .on_action(|_: &zed_actions::OpenTelemetryLog, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_telemetry_log_file(workspace, window, cx); }); - }); - cx.on_action(|&zed_actions::OpenKeymap, cx| { + }) + .on_action(|&zed_actions::OpenKeymapFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::keymap_file(), @@ -200,8 +209,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenSettings, cx| { + }) + .on_action(|_: &OpenSettingsFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::settings_file(), @@ -210,13 +219,13 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenAccountSettings, cx| { + }) + .on_action(|_: &OpenAccountSettings, cx| { with_active_or_new_workspace(cx, |_, _, cx| { cx.open_url(&zed_urls::account_url(cx)); }); - }); - cx.on_action(|_: &OpenTasks, cx| { + }) + .on_action(|_: &OpenTasks, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::tasks_file(), @@ -225,8 +234,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenDebugTasks, cx| { + }) + .on_action(|_: &OpenDebugTasks, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::debug_scenarios_file(), @@ -235,8 +244,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenDefaultSettings, cx| { + }) + .on_action(|_: &OpenDefaultSettings, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -247,8 +256,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::OpenDefaultKeymap, cx| { + }) + .on_action(|_: &zed_actions::OpenDefaultKeymap, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -259,20 +268,36 @@ pub fn init(cx: &mut App) { cx, ); }); + }) + .on_action(|_: &zed_actions::About, cx| { + with_active_or_new_workspace(cx, |workspace, window, cx| { + about(workspace, window, cx); + }); }); } fn bind_on_window_closed(cx: &mut App) -> Option { - WorkspaceSettings::get_global(cx) - .on_last_window_closed - .is_quit_app() - .then(|| { - cx.on_window_closed(|cx| { - if cx.windows().is_empty() { - cx.quit(); - } + #[cfg(target_os = "macos")] + { + WorkspaceSettings::get_global(cx) + .on_last_window_closed + .is_quit_app() + .then(|| { + cx.on_window_closed(|cx| { + if cx.windows().is_empty() { + cx.quit(); + } + }) }) - }) + } + #[cfg(not(target_os = "macos"))] + { + Some(cx.on_window_closed(|cx| { + if cx.windows().is_empty() { + cx.quit(); + } + })) + } } pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowOptions { @@ -285,7 +310,10 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") { Ok(val) if val == "server" => gpui::WindowDecorations::Server, Ok(val) if val == "client" => gpui::WindowDecorations::Client, - _ => gpui::WindowDecorations::Client, + _ => match WorkspaceSettings::get_global(cx).window_decorations { + settings::WindowDecorations::Server => gpui::WindowDecorations::Server, + settings::WindowDecorations::Client => gpui::WindowDecorations::Client, + }, }; let use_system_window_tabs = WorkspaceSettings::get_global(cx).use_system_window_tabs; @@ -371,17 +399,21 @@ pub fn initialize_workspace( } } + #[cfg(target_os = "windows")] + unstable_version_notification(cx); + let edit_prediction_menu_handle = PopoverMenuHandle::default(); - let edit_prediction_button = cx.new(|cx| { - edit_prediction_button::EditPredictionButton::new( + let edit_prediction_ui = cx.new(|cx| { + edit_prediction_ui::EditPredictionButton::new( app_state.fs.clone(), app_state.user_store.clone(), edit_prediction_menu_handle.clone(), + app_state.client.clone(), cx, ) }); workspace.register_action({ - move |_, _: &edit_prediction_button::ToggleMenu, window, cx| { + move |_, _: &edit_prediction_ui::ToggleMenu, window, cx| { edit_prediction_menu_handle.toggle(window, cx); } }); @@ -413,14 +445,17 @@ pub fn initialize_workspace( let cursor_position = cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace)); + let line_ending_indicator = + cx.new(|_| line_ending_selector::LineEndingIndicator::default()); workspace.status_bar().update(cx, |status_bar, cx| { status_bar.add_left_item(search_button, window, cx); status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); status_bar.add_left_item(activity_indicator, window, cx); - status_bar.add_right_item(edit_prediction_button, window, cx); + status_bar.add_right_item(edit_prediction_ui, window, cx); status_bar.add_right_item(active_buffer_language, window, cx); status_bar.add_right_item(active_toolchain_language, window, cx); + status_bar.add_right_item(line_ending_indicator, window, cx); status_bar.add_right_item(vim_mode_indicator, window, cx); status_bar.add_right_item(cursor_position, window, cx); status_bar.add_right_item(image_info, window, cx); @@ -445,6 +480,53 @@ pub fn initialize_workspace( .detach(); } +#[cfg(target_os = "windows")] +fn unstable_version_notification(cx: &mut App) { + if !matches!( + ReleaseChannel::try_global(cx), + Some(ReleaseChannel::Nightly) + ) { + return; + } + let db_key = "zed_windows_nightly_notif_shown_at".to_owned(); + let time = chrono::Utc::now(); + if let Some(last_shown) = db::kvp::KEY_VALUE_STORE + .read_kvp(&db_key) + .log_err() + .flatten() + .and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(×tamp).ok()) + { + if time.fixed_offset() - last_shown < chrono::Duration::days(7) { + return; + } + } + cx.spawn(async move |_| { + db::kvp::KEY_VALUE_STORE + .write_kvp(db_key, time.to_rfc3339()) + .await + }) + .detach_and_log_err(cx); + struct WindowsNightly; + show_app_notification(NotificationId::unique::(), cx, |cx| { + cx.new(|cx| { + MessageNotification::new("You're using an unstable version of Zed (Nightly)", cx) + .primary_message("Download Stable") + .primary_icon_color(Color::Accent) + .primary_icon(IconName::Download) + .primary_on_click(|window, cx| { + window.dispatch_action( + zed_actions::OpenBrowser { + url: "https://zed.dev/download".to_string(), + } + .boxed_clone(), + cx, + ); + cx.emit(DismissEvent); + }) + }) + }); +} + #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn initialize_file_watcher(window: &mut Window, cx: &mut Context) { if let Err(e) = fs::fs_watcher::global(|_| {}) { @@ -576,107 +658,146 @@ fn initialize_panels( ); let debug_panel = DebugPanel::load(workspace_handle.clone(), cx); - let ( - project_panel, - outline_panel, - terminal_panel, - git_panel, - channels_panel, - notification_panel, - debug_panel, - ) = futures::try_join!( - project_panel, - outline_panel, - git_panel, - terminal_panel, - channels_panel, - notification_panel, - debug_panel, - )?; - - workspace_handle.update_in(cx, |workspace, window, cx| { - workspace.add_panel(project_panel, window, cx); - workspace.add_panel(outline_panel, window, cx); - workspace.add_panel(terminal_panel, window, cx); - workspace.add_panel(git_panel, window, cx); - workspace.add_panel(channels_panel, window, cx); - workspace.add_panel(notification_panel, window, cx); - workspace.add_panel(debug_panel, window, cx); - })?; - - fn setup_or_teardown_agent_panel( - workspace: &mut Workspace, - prompt_builder: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let disable_ai = SettingsStore::global(cx) - .get::(None) - .disable_ai - || cfg!(test); - let existing_panel = workspace.panel::(cx); - match (disable_ai, existing_panel) { - (false, None) => cx.spawn_in(window, async move |workspace, cx| { - let panel = - agent_ui::AgentPanel::load(workspace.clone(), prompt_builder, cx.clone()) - .await?; - workspace.update_in(cx, |workspace, window, cx| { - let disable_ai = SettingsStore::global(cx) - .get::(None) - .disable_ai; - let have_panel = workspace.panel::(cx).is_some(); - if !disable_ai && !have_panel { - workspace.add_panel(panel, window, cx); - } + async fn add_panel_when_ready( + panel_task: impl Future>> + 'static, + workspace_handle: WeakEntity, + mut cx: gpui::AsyncWindowContext, + ) { + if let Some(panel) = panel_task.await.context("failed to load panel").log_err() + { + workspace_handle + .update_in(&mut cx, |workspace, window, cx| { + workspace.add_panel(panel, window, cx); }) - }), - (true, Some(existing_panel)) => { - workspace.remove_panel::(&existing_panel, window, cx); - Task::ready(Ok(())) - } - _ => Task::ready(Ok(())), + .log_err(); } } - workspace_handle - .update_in(cx, |workspace, window, cx| { - setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx) - })? - .await?; - - workspace_handle.update_in(cx, |workspace, window, cx| { - cx.observe_global_in::(window, { - let prompt_builder = prompt_builder.clone(); - move |workspace, window, cx| { - setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx) - .detach_and_log_err(cx); - } - }) - .detach(); - - // Register the actions that are shared between `assistant` and `assistant2`. - // - // We need to do this here instead of within the individual `init` - // functions so that we only register the actions once. - // - // Once we ship `assistant2` we can push this back down into `agent::agent_panel::init`. - if !cfg!(test) { - ::set_global( - Arc::new(agent_ui::ConcreteAssistantPanelDelegate), - cx, - ); - - workspace - .register_action(agent_ui::AgentPanel::toggle_focus) - .register_action(agent_ui::InlineAssistant::inline_assist); - } - })?; + futures::join!( + add_panel_when_ready(project_panel, workspace_handle.clone(), cx.clone()), + add_panel_when_ready(outline_panel, workspace_handle.clone(), cx.clone()), + add_panel_when_ready(terminal_panel, workspace_handle.clone(), cx.clone()), + add_panel_when_ready(git_panel, workspace_handle.clone(), cx.clone()), + add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()), + add_panel_when_ready(notification_panel, workspace_handle.clone(), cx.clone()), + add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()), + initialize_agent_panel(workspace_handle.clone(), prompt_builder, cx.clone()).map(|r| r.log_err()), + initialize_agents_panel(workspace_handle, cx.clone()).map(|r| r.log_err()) + ); anyhow::Ok(()) }) .detach(); } +fn setup_or_teardown_ai_panel( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + load_panel: impl FnOnce( + WeakEntity, + AsyncWindowContext, + ) -> Task>> + + 'static, +) -> Task> { + let disable_ai = SettingsStore::global(cx) + .get::(None) + .disable_ai + || cfg!(test); + let existing_panel = workspace.panel::

(cx); + + match (disable_ai, existing_panel) { + (false, None) => cx.spawn_in(window, async move |workspace, cx| { + let panel = load_panel(workspace.clone(), cx.clone()).await?; + workspace.update_in(cx, |workspace, window, cx| { + let disable_ai = SettingsStore::global(cx) + .get::(None) + .disable_ai; + let have_panel = workspace.panel::

(cx).is_some(); + if !disable_ai && !have_panel { + workspace.add_panel(panel, window, cx); + } + }) + }), + (true, Some(existing_panel)) => { + workspace.remove_panel::

(&existing_panel, window, cx); + Task::ready(Ok(())) + } + _ => Task::ready(Ok(())), + } +} + +async fn initialize_agent_panel( + workspace_handle: WeakEntity, + prompt_builder: Arc, + mut cx: AsyncWindowContext, +) -> anyhow::Result<()> { + workspace_handle + .update_in(&mut cx, |workspace, window, cx| { + let prompt_builder = prompt_builder.clone(); + setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, prompt_builder, cx) + }) + })? + .await?; + + workspace_handle.update_in(&mut cx, |workspace, window, cx| { + let prompt_builder = prompt_builder.clone(); + cx.observe_global_in::(window, move |workspace, window, cx| { + let prompt_builder = prompt_builder.clone(); + setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, prompt_builder, cx) + }) + .detach_and_log_err(cx); + }) + .detach(); + + // Register the actions that are shared between `assistant` and `assistant2`. + // + // We need to do this here instead of within the individual `init` + // functions so that we only register the actions once. + // + // Once we ship `assistant2` we can push this back down into `agent::agent_panel::init`. + if !cfg!(test) { + ::set_global( + Arc::new(agent_ui::ConcreteAssistantPanelDelegate), + cx, + ); + + workspace + .register_action(agent_ui::AgentPanel::toggle_focus) + .register_action(agent_ui::InlineAssistant::inline_assist); + } + })?; + + anyhow::Ok(()) +} + +async fn initialize_agents_panel( + workspace_handle: WeakEntity, + mut cx: AsyncWindowContext, +) -> anyhow::Result<()> { + workspace_handle + .update_in(&mut cx, |workspace, window, cx| { + setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { + AgentsPanel::load(workspace, cx) + }) + })? + .await?; + + workspace_handle.update_in(&mut cx, |_workspace, window, cx| { + cx.observe_global_in::(window, move |workspace, window, cx| { + setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { + AgentsPanel::load(workspace, cx) + }) + .detach_and_log_err(cx); + }) + .detach(); + })?; + + anyhow::Ok(()) +} + fn register_actions( app_state: Arc, workspace: &mut Workspace, @@ -684,7 +805,6 @@ fn register_actions( cx: &mut Context, ) { workspace - .register_action(about) .register_action(|_, _: &OpenDocs, _, cx| cx.open_url(DOCS_URL)) .register_action(|_, _: &Minimize, window, _| { window.minimize_window(); @@ -701,7 +821,24 @@ fn register_actions( ..Default::default() }) }) - .register_action(|_, action: &OpenBrowser, _window, cx| cx.open_url(&action.url)) + .register_action(|workspace, action: &OpenBrowser, _window, cx| { + // Parse and validate the URL to ensure it's properly formatted + match url::Url::parse(&action.url) { + Ok(parsed_url) => { + // Use the parsed URL's string representation which is properly escaped + cx.open_url(parsed_url.as_str()); + } + Err(e) => { + workspace.show_error( + &anyhow::anyhow!( + "Opening this URL in a browser failed because the URL is invalid: {}\n\nError was: {e}", + action.url + ), + cx, + ); + } + } + }) .register_action(|workspace, _: &workspace::Open, window, cx| { telemetry::event!("Project Opened"); let paths = workspace.prompt_for_open_path( @@ -861,6 +998,24 @@ fn register_actions( } } }) + .register_action({ + let fs = app_state.fs.clone(); + move |_, action: &zed_actions::ResetAllZoom, _window, cx| { + if action.persist { + update_settings_file(fs.clone(), cx, move |settings, _| { + settings.theme.ui_font_size = None; + settings.theme.buffer_font_size = None; + settings.theme.agent_ui_font_size = None; + settings.theme.agent_buffer_font_size = None; + }); + } else { + theme::reset_ui_font_size(cx); + theme::reset_buffer_font_size(cx); + theme::reset_agent_ui_font_size(cx); + theme::reset_agent_buffer_font_size(cx); + } + } + }) .register_action(|_, _: &install_cli::RegisterZedScheme, window, cx| { cx.spawn_in(window, async move |workspace, cx| { install_cli::register_zed_scheme(cx).await?; @@ -892,7 +1047,7 @@ fn register_actions( .register_action(open_project_debug_tasks_file) .register_action( |workspace: &mut Workspace, - _: &project_panel::ToggleFocus, + _: &zed_actions::project_panel::ToggleFocus, window: &mut Window, cx: &mut Context| { workspace.toggle_panel_focus::(window, cx); @@ -932,6 +1087,18 @@ fn register_actions( workspace.toggle_panel_focus::(window, cx); }, ) + .register_action( + |workspace: &mut Workspace, + _: &zed_actions::agent::ToggleAgentPane, + window: &mut Window, + cx: &mut Context| { + if let Some(panel) = workspace.panel::(cx) { + let position = panel.read(cx).position(window, cx); + let slot = utility_slot_for_dock_position(position); + workspace.toggle_utility_pane(slot, window, cx); + } + }, + ) .register_action({ let app_state = Arc::downgrade(&app_state); move |_, _: &NewWindow, _, cx| { @@ -1025,8 +1192,6 @@ fn initialize_pane( ) }); toolbar.add_item(buffer_search_bar.clone(), window, cx); - let proposed_change_bar = cx.new(|_| ProposedChangesEditorToolbar::new()); - toolbar.add_item(proposed_change_bar, window, cx); let quick_action_bar = cx.new(|cx| QuickActionBar::new(buffer_search_bar, workspace, cx)); toolbar.add_item(quick_action_bar, window, cx); @@ -1038,12 +1203,16 @@ fn initialize_pane( toolbar.add_item(lsp_log_item, window, cx); let dap_log_item = cx.new(|_| debugger_tools::DapLogToolbarItemView::new()); toolbar.add_item(dap_log_item, window, cx); + let acp_tools_item = cx.new(|_| acp_tools::AcpToolsToolbarItemView::new()); + toolbar.add_item(acp_tools_item, window, cx); let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new()); toolbar.add_item(syntax_tree_item, window, cx); let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx)); toolbar.add_item(migration_banner, window, cx); let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx)); toolbar.add_item(project_diff_toolbar, window, cx); + let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new()); + toolbar.add_item(commit_view_toolbar, window, cx); let agent_diff_toolbar = cx.new(AgentDiffToolbar::new); toolbar.add_item(agent_diff_toolbar, window, cx); let basedpyright_banner = cx.new(|cx| BasedPyrightBanner::new(workspace, cx)); @@ -1052,13 +1221,10 @@ fn initialize_pane( }); } -fn about( - _: &mut Workspace, - _: &zed_actions::About, - window: &mut Window, - cx: &mut Context, -) { +fn about(_: &mut Workspace, window: &mut Window, cx: &mut Context) { + use std::fmt::Write; let release_channel = ReleaseChannel::global(cx).display_name(); + let full_version = AppVersion::global(cx); let version = env!("CARGO_PKG_VERSION"); let debug = if cfg!(debug_assertions) { "(debug)" @@ -1066,7 +1232,16 @@ fn about( "" }; let message = format!("{release_channel} {version} {debug}"); - let detail = AppCommitSha::try_global(cx).map(|sha| sha.full()); + + let mut detail = AppCommitSha::try_global(cx) + .map(|sha| sha.full()) + .unwrap_or_default(); + if !detail.is_empty() { + detail.push('\n'); + } + _ = write!(&mut detail, "\n{full_version}"); + + let detail = Some(detail); let prompt = window.prompt( PromptLevel::Info, @@ -1256,67 +1431,85 @@ fn open_log_file(workspace: &mut Workspace, window: &mut Window, cx: &mut Contex .detach(); } +fn notify_settings_errors(result: settings::SettingsParseResult, is_user: bool, cx: &mut App) { + if let settings::ParseStatus::Failed { error: err } = &result.parse_status { + let settings_type = if is_user { "user" } else { "global" }; + log::error!("Failed to load {} settings: {err}", settings_type); + } + + let error = match result.parse_status { + settings::ParseStatus::Failed { error } => Some(anyhow::format_err!(error)), + settings::ParseStatus::Success => None, + }; + let id = NotificationId::Named(format!("failed-to-parse-settings-{is_user}").into()); + + let showed_parse_error = match error { + Some(error) => { + if let Some(InvalidSettingsError::LocalSettings { .. }) = + error.downcast_ref::() + { + false + // Local settings errors are displayed by the projects + } else { + show_app_notification(id, cx, move |cx| { + cx.new(|cx| { + MessageNotification::new(format!("Invalid user settings file\n{error}"), cx) + .primary_message("Open Settings File") + .primary_icon(IconName::Settings) + .primary_on_click(|window, cx| { + window.dispatch_action( + zed_actions::OpenSettingsFile.boxed_clone(), + cx, + ); + cx.emit(DismissEvent); + }) + }) + }); + true + } + } + None => { + dismiss_app_notification(&id, cx); + false + } + }; + let id = NotificationId::Named(format!("failed-to-migrate-settings-{is_user}").into()); + + match result.migration_status { + settings::MigrationStatus::Succeeded | settings::MigrationStatus::NotNeeded => { + dismiss_app_notification(&id, cx); + } + settings::MigrationStatus::Failed { error: err } => { + if !showed_parse_error { + show_app_notification(id, cx, move |cx| { + cx.new(|cx| { + MessageNotification::new( + format!( + "Failed to migrate settings\n\ + {err}" + ), + cx, + ) + .primary_message("Open Settings File") + .primary_icon(IconName::Settings) + .primary_on_click(|window, cx| { + window.dispatch_action(zed_actions::OpenSettingsFile.boxed_clone(), cx); + cx.emit(DismissEvent); + }) + }) + }); + } + } + }; +} + pub fn handle_settings_file_changes( mut user_settings_file_rx: mpsc::UnboundedReceiver, mut global_settings_file_rx: mpsc::UnboundedReceiver, cx: &mut App, - settings_changed: impl Fn(Option, &mut App) + 'static, ) { MigrationNotification::set_global(cx.new(|_| MigrationNotification), cx); - // Helper function to process settings content - let process_settings = - move |content: String, is_user: bool, store: &mut SettingsStore, cx: &mut App| -> bool { - let id = NotificationId::Named("failed-to-migrate-settings".into()); - // Apply migrations to both user and global settings - let (processed_content, content_migrated) = match migrate_settings(&content) { - Ok(result) => { - dismiss_app_notification(&id, cx); - if let Some(migrated_content) = result { - (migrated_content, true) - } else { - (content, false) - } - } - Err(err) => { - show_app_notification(id, cx, move |cx| { - cx.new(|cx| { - MessageNotification::new( - format!( - "Failed to migrate settings\n\ - {err}" - ), - cx, - ) - .primary_message("Open Settings File") - .primary_icon(IconName::Settings) - .primary_on_click(|window, cx| { - window.dispatch_action(zed_actions::OpenSettings.boxed_clone(), cx); - cx.emit(DismissEvent); - }) - }) - }); - // notify user here - (content, false) - } - }; - - let result = if is_user { - store.set_user_settings(&processed_content, cx) - } else { - store.set_global_settings(&processed_content, cx) - }; - - if let Err(err) = &result { - let settings_type = if is_user { "user" } else { "global" }; - log::error!("Failed to load {} settings: {err}", settings_type); - } - - settings_changed(result.err(), cx); - - content_migrated - }; - // Initial load of both settings files let global_content = cx .background_executor() @@ -1328,8 +1521,8 @@ pub fn handle_settings_file_changes( .unwrap(); SettingsStore::update_global(cx, |store, cx| { - process_settings(global_content, false, store, cx); - process_settings(user_content, true, store, cx); + notify_settings_errors(store.set_user_settings(&user_content, cx), true, cx); + notify_settings_errors(store.set_global_settings(&global_content, cx), false, cx); }); // Watch for changes in both files @@ -1346,7 +1539,14 @@ pub fn handle_settings_file_changes( }; let result = cx.update_global(|store: &mut SettingsStore, cx| { - let migrating_in_memory = process_settings(content, is_user, store, cx); + let result = if is_user { + store.set_user_settings(&content, cx) + } else { + store.set_global_settings(&content, cx) + }; + let migrating_in_memory = + matches!(&result.migration_status, MigrationStatus::Succeeded); + notify_settings_errors(result, is_user, cx); if let Some(notifier) = MigrationNotification::try_global(cx) { notifier.update(cx, |_, cx| { cx.emit(MigrationEvent::ContentChanged { @@ -1370,9 +1570,6 @@ pub fn handle_keymap_file_changes( mut user_keymap_file_rx: mpsc::UnboundedReceiver, cx: &mut App, ) { - BaseKeymap::register(cx); - vim_mode_setting::init(cx); - let (base_keymap_tx, mut base_keymap_rx) = mpsc::unbounded(); let (keyboard_layout_tx, mut keyboard_layout_rx) = mpsc::unbounded(); let mut old_base_keymap = *BaseKeymap::get_global(cx); @@ -1493,8 +1690,9 @@ fn show_keymap_file_json_error( cx.new(|cx| { MessageNotification::new(message.clone(), cx) .primary_message("Open Keymap File") + .primary_icon(IconName::Settings) .primary_on_click(|window, cx| { - window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); + window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx); cx.emit(DismissEvent); }) }) @@ -1511,7 +1709,7 @@ fn show_keymap_file_load_error( error_message, "Open Keymap File".into(), |window, cx| { - window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); + window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx); cx.emit(DismissEvent); }, cx, @@ -1551,16 +1749,18 @@ fn show_markdown_app_notification( cx.new(move |cx| { MessageNotification::new_from_builder(cx, move |window, cx| { image_cache(retain_all("notification-cache")) - .text_xs() - .child(markdown_preview::markdown_renderer::render_parsed_markdown( - &parsed_markdown.clone(), - Some(workspace_handle.clone()), - window, - cx, + .child(div().text_ui(cx).child( + markdown_preview::markdown_renderer::render_parsed_markdown( + &parsed_markdown.clone(), + Some(workspace_handle.clone()), + window, + cx, + ), )) .into_any() }) .primary_message(primary_button_message) + .primary_icon(IconName::Settings) .primary_on_click_arc(primary_button_on_click) }) }) @@ -1612,36 +1812,6 @@ pub fn load_default_keymap(cx: &mut App) { } } -pub fn handle_settings_changed(error: Option, cx: &mut App) { - struct SettingsParseErrorNotification; - let id = NotificationId::unique::(); - - match error { - Some(error) => { - if let Some(InvalidSettingsError::LocalSettings { .. }) = - error.downcast_ref::() - { - // Local settings errors are displayed by the projects - return; - } - show_app_notification(id, cx, move |cx| { - cx.new(|cx| { - MessageNotification::new(format!("Invalid user settings file\n{error}"), cx) - .primary_message("Open Settings File") - .primary_icon(IconName::Settings) - .primary_on_click(|window, cx| { - window.dispatch_action(zed_actions::OpenSettings.boxed_clone(), cx); - cx.emit(DismissEvent); - }) - }) - }); - } - None => { - dismiss_app_notification(&id, cx); - } - } -} - pub fn open_new_ssh_project_from_project( workspace: &mut Workspace, paths: Vec, @@ -1670,7 +1840,7 @@ pub fn open_new_ssh_project_from_project( fn open_project_settings_file( workspace: &mut Workspace, - _: &OpenProjectSettings, + _: &OpenProjectSettingsFile, window: &mut Window, cx: &mut Context, ) { @@ -1805,53 +1975,67 @@ fn open_telemetry_log_file( window: &mut Window, cx: &mut Context, ) { - workspace.with_local_workspace(window, cx, move |workspace, window, cx| { - let app_state = workspace.app_state().clone(); - cx.spawn_in(window, async move |workspace, cx| { - async fn fetch_log_string(app_state: &Arc) -> Option { - let path = client::telemetry::Telemetry::log_file_path(); - app_state.fs.load(&path).await.log_err() - } + const HEADER: &str = concat!( + "// Zed collects anonymous usage data to help us understand how people are using the app.\n", + "// Telemetry can be disabled via the `settings.json` file.\n", + "// Here is the data that has been reported for the current session:\n", + ); + workspace + .with_local_workspace(window, cx, move |workspace, window, cx| { + let app_state = workspace.app_state().clone(); + cx.spawn_in(window, async move |workspace, cx| { + async fn fetch_log_string(app_state: &Arc) -> Option { + let path = client::telemetry::Telemetry::log_file_path(); + app_state.fs.load(&path).await.log_err() + } - let log = fetch_log_string(&app_state).await.unwrap_or_else(|| "// No data has been collected yet".to_string()); + let log = fetch_log_string(&app_state) + .await + .unwrap_or_else(|| "// No data has been collected yet".to_string()); - const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024; - let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN); - if let Some(newline_offset) = log[start_offset..].find('\n') { - start_offset += newline_offset + 1; - } - let log_suffix = &log[start_offset..]; - let header = concat!( - "// Zed collects anonymous usage data to help us understand how people are using the app.\n", - "// Telemetry can be disabled via the `settings.json` file.\n", - "// Here is the data that has been reported for the current session:\n", - ); - let content = format!("{}\n{}", header, log_suffix); - let json = app_state.languages.language_for_name("JSON").await.log_err(); + const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024; + let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN); + if let Some(newline_offset) = log[start_offset..].find('\n') { + start_offset += newline_offset + 1; + } + let log_suffix = &log[start_offset..]; + let content = format!("{}\n{}", HEADER, log_suffix); + let json = app_state + .languages + .language_for_name("JSON") + .await + .log_err(); - workspace.update_in( cx, |workspace, window, cx| { - let project = workspace.project().clone(); - let buffer = project.update(cx, |project, cx| project.create_local_buffer(&content, json,false, cx)); - let buffer = cx.new(|cx| { - MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into()) - }); - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - let mut editor = Editor::for_multibuffer(buffer, Some(project), window, cx); - editor.set_read_only(true); - editor.set_breadcrumb_header("Telemetry Log".into()); - editor - })), - None, - true, - window, cx, - ); - }).log_err()?; + workspace + .update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer(&content, json, false, cx) + }); + let buffer = cx.new(|cx| { + MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into()) + }); + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project), window, cx); + editor.set_read_only(true); + editor.set_breadcrumb_header("Telemetry Log".into()); + editor + })), + None, + true, + window, + cx, + ); + }) + .log_err()?; - Some(()) + Some(()) + }) + .detach(); }) .detach(); - }).detach(); } fn open_bundled_file( @@ -1884,6 +2068,7 @@ fn open_bundled_file( let mut editor = Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); editor.set_read_only(true); + editor.set_should_serialize(false, cx); editor.set_breadcrumb_header(title.into()); editor })), @@ -2012,27 +2197,130 @@ fn capture_recent_audio(workspace: &mut Workspace, _: &mut Window, cx: &mut Cont ); } +/// Eagerly loads the active theme and icon theme based on the selections in the +/// theme settings. +/// +/// This fast path exists to load these themes as soon as possible so the user +/// doesn't see the default themes while waiting on extensions to load. +pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &mut App) { + let extension_store = ExtensionStore::global(cx); + let theme_registry = ThemeRegistry::global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let appearance = SystemAppearance::global(cx).0; + + enum LoadTarget { + Theme(PathBuf), + IconTheme((PathBuf, PathBuf)), + } + + let theme_name = theme_settings.theme.name(appearance); + let icon_theme_name = theme_settings.icon_theme.name(appearance); + let themes_to_load = [ + theme_registry + .get(&theme_name.0) + .is_err() + .then(|| { + extension_store + .read(cx) + .path_to_extension_theme(&theme_name.0) + }) + .flatten() + .map(LoadTarget::Theme), + theme_registry + .get_icon_theme(&icon_theme_name.0) + .is_err() + .then(|| { + extension_store + .read(cx) + .path_to_extension_icon_theme(&icon_theme_name.0) + }) + .flatten() + .map(LoadTarget::IconTheme), + ]; + + enum ReloadTarget { + Theme, + IconTheme, + } + + let executor = cx.background_executor(); + let reload_tasks = parking_lot::Mutex::new(Vec::with_capacity(themes_to_load.len())); + + let mut themes_to_load = themes_to_load.into_iter().flatten().peekable(); + + if themes_to_load.peek().is_none() { + return; + } + + executor.block(executor.scoped(|scope| { + for load_target in themes_to_load { + let theme_registry = &theme_registry; + let reload_tasks = &reload_tasks; + let fs = fs.clone(); + + scope.spawn(async { + match load_target { + LoadTarget::Theme(theme_path) => { + if theme_registry + .load_user_theme(&theme_path, fs) + .await + .log_err() + .is_some() + { + reload_tasks.lock().push(ReloadTarget::Theme); + } + } + LoadTarget::IconTheme((icon_theme_path, icons_root_path)) => { + if theme_registry + .load_icon_theme(&icon_theme_path, &icons_root_path, fs) + .await + .log_err() + .is_some() + { + reload_tasks.lock().push(ReloadTarget::IconTheme); + } + } + } + }); + } + })); + + for reload_target in reload_tasks.into_inner() { + match reload_target { + ReloadTarget::Theme => GlobalTheme::reload_theme(cx), + ReloadTarget::IconTheme => GlobalTheme::reload_icon_theme(cx), + }; + } +} + #[cfg(test)] mod tests { use super::*; use assets::Assets; use collections::HashSet; - use editor::{DisplayPoint, Editor, SelectionEffects, display_map::DisplayRow}; - use gpui::{ - Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, SemanticVersion, - TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle, actions, + use editor::{ + DisplayPoint, Editor, MultiBufferOffset, SelectionEffects, display_map::DisplayRow, }; - use language::{LanguageMatcher, LanguageRegistry}; + use gpui::{ + Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, TestAppContext, UpdateGlobal, + VisualTestContext, WindowHandle, actions, + }; + use language::LanguageRegistry; + use languages::{markdown_lang, rust_lang}; use pretty_assertions::{assert_eq, assert_ne}; use project::{Project, ProjectPath}; + use semver::Version; use serde_json::json; use settings::{SettingsStore, watch_config_file}; use std::{ path::{Path, PathBuf}, time::Duration, }; - use theme::{ThemeRegistry, ThemeSettings}; - use util::{path, rel_path::rel_path}; + use theme::ThemeRegistry; + use util::{ + path, + rel_path::{RelPath, rel_path}, + }; use workspace::{ NewFile, OpenOptions, OpenVisible, SERIALIZATION_THROTTLE_TIME, SaveIntent, SplitDirection, WorkspaceHandle, @@ -2658,9 +2946,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let workspace = window.root(cx).unwrap(); @@ -2725,7 +3011,13 @@ mod tests { // Split the pane with the first entry, then open the second entry again. window .update(cx, |w, window, cx| { - w.split_and_clone(w.active_pane().clone(), SplitDirection::Right, window, cx); + w.split_and_clone(w.active_pane().clone(), SplitDirection::Right, window, cx) + }) + .unwrap() + .await + .unwrap(); + window + .update(cx, |w, window, cx| { w.open_path(file2.clone(), None, true, window, cx) }) .unwrap() @@ -3084,9 +3376,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let workspace = window.root(cx).unwrap(); @@ -3178,9 +3468,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let workspace = window.root(cx).unwrap(); @@ -3251,7 +3539,7 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _| { - project.languages().add(markdown_language()); + project.languages().add(markdown_lang()); project.languages().add(rust_lang()); }); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); @@ -3275,7 +3563,11 @@ mod tests { assert!(!editor.is_dirty(cx)); assert_eq!(editor.title(cx), "untitled"); assert!(Arc::ptr_eq( - &editor.buffer().read(cx).language_at(0, cx).unwrap(), + &editor + .buffer() + .read(cx) + .language_at(MultiBufferOffset(0), cx) + .unwrap(), &languages::PLAIN_TEXT )); editor.handle_input("hi", window, cx); @@ -3309,7 +3601,12 @@ mod tests { assert!(!editor.is_dirty(cx)); assert_eq!(editor.title(cx), "the-new-name.rs"); assert_eq!( - editor.buffer().read(cx).language_at(0, cx).unwrap().name(), + editor + .buffer() + .read(cx) + .language_at(MultiBufferOffset(0), cx) + .unwrap() + .name(), "Rust".into() ); }); @@ -3353,7 +3650,13 @@ mod tests { SplitDirection::Right, window, cx, - ); + ) + }) + .unwrap() + .await + .unwrap(); + window + .update(cx, |workspace, window, cx| { workspace.open_path( (worktree.read(cx).id(), rel_path("the-new-name.rs")), None, @@ -3389,8 +3692,8 @@ mod tests { let project = Project::test(app_state.fs.clone(), [], cx).await; project.update(cx, |project, _| { - project.languages().add(rust_lang()); - project.languages().add(markdown_language()); + project.languages().add(language::rust_lang()); + project.languages().add(language::markdown_lang()); }); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); @@ -3409,7 +3712,11 @@ mod tests { .update(cx, |_, window, cx| { editor.update(cx, |editor, cx| { assert!(Arc::ptr_eq( - &editor.buffer().read(cx).language_at(0, cx).unwrap(), + &editor + .buffer() + .read(cx) + .language_at(MultiBufferOffset(0), cx) + .unwrap(), &languages::PLAIN_TEXT )); editor.handle_input("hi", window, cx); @@ -3433,7 +3740,12 @@ mod tests { editor.update(cx, |editor, cx| { assert!(!editor.is_dirty(cx)); assert_eq!( - editor.buffer().read(cx).language_at(0, cx).unwrap().name(), + editor + .buffer() + .read(cx) + .language_at(MultiBufferOffset(0), cx) + .unwrap() + .name(), "Rust".into() ) }); @@ -3460,9 +3772,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let workspace = window.root(cx).unwrap(); @@ -3564,9 +3874,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let pane = workspace @@ -3922,7 +4230,9 @@ mod tests { let editor = item.downcast::().unwrap(); let (selections, scroll_position) = editor.update(cx, |editor, cx| { ( - editor.selections.display_ranges(cx), + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), editor.scroll_position(cx), ) }); @@ -3956,9 +4266,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let pane = workspace .read_with(cx, |workspace, _| workspace.active_pane().clone()) @@ -4179,10 +4487,8 @@ mod tests { theme::init(theme::LoadThemes::JustBase, cx); client::init(&app_state.client, cx); - language::init(cx); workspace::init(app_state.clone(), cx); onboarding::init(cx); - Project::init_settings(cx); app_state }) } @@ -4238,7 +4544,7 @@ mod tests { app_state.fs.clone(), PathBuf::from("/global_settings.json"), ); - handle_settings_file_changes(settings_rx, global_settings_rx, cx, |_, _| {}); + handle_settings_file_changes(settings_rx, global_settings_rx, cx); handle_keymap_file_changes(keymap_rx, cx); }); workspace @@ -4356,7 +4662,7 @@ mod tests { app_state.fs.clone(), PathBuf::from("/global_settings.json"), ); - handle_settings_file_changes(settings_rx, global_settings_rx, cx, |_, _| {}); + handle_settings_file_changes(settings_rx, global_settings_rx, cx); handle_keymap_file_changes(keymap_rx, cx); }); @@ -4418,73 +4724,6 @@ mod tests { }); } - /// Actions that don't build from empty input won't work from command palette invocation. - #[gpui::test] - async fn test_actions_build_with_empty_input(cx: &mut gpui::TestAppContext) { - init_keymap_test(cx); - cx.update(|cx| { - let all_actions = cx.all_action_names(); - let mut failing_names = Vec::new(); - let mut errors = Vec::new(); - for action in all_actions { - match action.to_string().as_str() { - "vim::FindCommand" - | "vim::Literal" - | "vim::ResizePane" - | "vim::PushObject" - | "vim::PushFindForward" - | "vim::PushFindBackward" - | "vim::PushSneak" - | "vim::PushSneakBackward" - | "vim::PushChangeSurrounds" - | "vim::PushJump" - | "vim::PushDigraph" - | "vim::PushLiteral" - | "vim::PushHelixNext" - | "vim::PushHelixPrevious" - | "vim::Number" - | "vim::SelectRegister" - | "git::StageAndNext" - | "git::UnstageAndNext" - | "terminal::SendText" - | "terminal::SendKeystroke" - | "app_menu::OpenApplicationMenu" - | "picker::ConfirmInput" - | "editor::HandleInput" - | "editor::FoldAtLevel" - | "pane::ActivateItem" - | "workspace::ActivatePane" - | "workspace::MoveItemToPane" - | "workspace::MoveItemToPaneInDirection" - | "workspace::OpenTerminal" - | "workspace::SendKeystrokes" - | "agent::NewNativeAgentThreadFromSummary" - | "action::Sequence" - | "zed::OpenBrowser" - | "zed::OpenZedUrl" - | "settings_editor::FocusFile" => {} - _ => { - let result = cx.build_action(action, None); - match &result { - Ok(_) => {} - Err(err) => { - failing_names.push(action); - errors.push(format!("{action} failed to build: {err:?}")); - } - } - } - } - } - if !errors.is_empty() { - panic!( - "Failed to build actions using {{}} as input: {:?}. Errors:\n{}", - failing_names, - errors.join("\n") - ); - } - }); - } - /// Checks that action namespaces are the expected set. The purpose of this is to prevent typos /// and let you know when introducing a new namespace. #[gpui::test] @@ -4525,11 +4764,14 @@ mod tests { "action", "activity_indicator", "agent", + "agents", #[cfg(not(target_os = "macos"))] "app_menu", "assistant", "assistant2", "auto_update", + "branch_picker", + "bedrock", "branches", "buffer_search", "channel_modal", @@ -4554,11 +4796,12 @@ mod tests { "git_panel", "go_to_line", "icon_theme_selector", + "inline_assistant", "journal", "keymap_editor", "keystroke_input", "language_selector", - "line_ending", + "line_ending_selector", "lsp_tool", "markdown", "menu", @@ -4596,6 +4839,7 @@ mod tests { "window", "workspace", "zed", + "zed_actions", "zed_predict_onboarding", "zeta", ]; @@ -4632,7 +4876,7 @@ mod tests { for theme_name in themes.list().into_iter().map(|meta| meta.name) { let theme = themes.get(&theme_name).unwrap(); assert_eq!(theme.name, theme_name); - if theme.name == ThemeSettings::get(None, cx).active_theme.name { + if theme.name.as_ref() == "One Dark" { has_default_theme = true; } } @@ -4711,21 +4955,17 @@ mod tests { let state = Arc::get_mut(&mut app_state).unwrap(); state.build_window_options = build_window_options; - - app_state.languages.add(markdown_language()); + app_state.languages.add(markdown_lang()); gpui_tokio::init(cx); - vim_mode_setting::init(cx); theme::init(theme::LoadThemes::JustBase, cx); audio::init(cx); channel::init(&app_state.client, app_state.user_store.clone(), cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); workspace::init(app_state.clone(), cx); - Project::init_settings(cx); - release_channel::init(SemanticVersion::default(), cx); + release_channel::init(Version::new(0, 0, 0), cx); command_palette::init(cx); - language::init(cx); editor::init(cx); collab_ui::init(&app_state, cx); git_ui::init(cx); @@ -4752,6 +4992,7 @@ mod tests { false, cx, ); + agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); repl::notebook::init(cx); tasks_ui::init(cx); @@ -4766,34 +5007,6 @@ mod tests { }) } - fn rust_lang() -> Arc { - Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) - } - - fn markdown_language() -> Arc { - Arc::new(language::Language::new( - language::LanguageConfig { - name: "Markdown".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["md".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_md::LANGUAGE.into()), - )) - } - #[track_caller] fn assert_key_bindings_for( window: AnyWindowHandle, @@ -4912,7 +5125,7 @@ mod tests { .update(cx, |workspace, window, cx| { // Call the exact function that contains the bug eprintln!("About to call open_project_settings_file"); - open_project_settings_file(workspace, &OpenProjectSettings, window, cx); + open_project_settings_file(workspace, &OpenProjectSettingsFile, window, cx); }) .unwrap(); @@ -4940,4 +5153,63 @@ mod tests { "BUG FOUND: Project settings were overwritten when opening via command - original custom content was lost" ); } + + #[gpui::test] + async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + let paths = [PathBuf::from(path!("/dir/document.txt"))]; + + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "document.txt": "Some of the documentation's content." + }), + ) + .await; + + let project_a = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window_a = + cx.add_window(|window, cx| Workspace::test_new(project_a.clone(), window, cx)); + + let project_b = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window_b = + cx.add_window(|window, cx| Workspace::test_new(project_b.clone(), window, cx)); + + let project_c = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window_c = + cx.add_window(|window, cx| Workspace::test_new(project_c.clone(), window, cx)); + + for window in [window_a, window_b, window_c] { + let _ = cx.update_window(*window, |_, window, _| { + window.activate_window(); + }); + + cx.update(|cx| { + let open_options = OpenOptions { + prefer_focused_window: true, + ..Default::default() + }; + + workspace::open_paths(&paths, app_state.clone(), open_options, cx) + }) + .await + .unwrap(); + + cx.update_window(*window, |_, window, _| assert!(window.is_window_active())) + .unwrap(); + + let _ = window.read_with(cx, |workspace, cx| { + let pane = workspace.active_pane().read(cx); + let project_path = pane.active_item().unwrap().project_path(cx).unwrap(); + + assert_eq!( + project_path.path.as_ref().as_std_path().to_str().unwrap(), + path!("document.txt") + ) + }); + } + } } diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 3087de999c..a7961ac6d4 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -20,11 +20,15 @@ pub fn app_menus(cx: &mut App) -> Vec

{ "Reset Zoom", zed_actions::ResetBufferFontSize { persist: false }, ), + MenuItem::action( + "Reset All Zoom", + zed_actions::ResetAllZoom { persist: false }, + ), MenuItem::separator(), MenuItem::action("Toggle Left Dock", workspace::ToggleLeftDock), MenuItem::action("Toggle Right Dock", workspace::ToggleRightDock), MenuItem::action("Toggle Bottom Dock", workspace::ToggleBottomDock), - MenuItem::action("Close All Docks", workspace::CloseAllDocks), + MenuItem::action("Toggle All Docks", workspace::ToggleAllDocks), MenuItem::submenu(Menu { name: "Editor Layout".into(), items: vec![ @@ -35,7 +39,7 @@ pub fn app_menus(cx: &mut App) -> Vec { ], }), MenuItem::separator(), - MenuItem::action("Project Panel", project_panel::ToggleFocus), + MenuItem::action("Project Panel", zed_actions::project_panel::ToggleFocus), MenuItem::action("Outline Panel", outline_panel::ToggleFocus), MenuItem::action("Collab Panel", collab_panel::ToggleFocus), MenuItem::action("Terminal Panel", terminal_panel::ToggleFocus), @@ -63,22 +67,30 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::submenu(Menu { name: "Settings".into(), items: vec![ - MenuItem::action("Open Settings", super::OpenSettings), - MenuItem::action("Open Key Bindings", zed_actions::OpenKeymapEditor), + MenuItem::action("Open Settings", zed_actions::OpenSettings), + MenuItem::action("Open Settings File", super::OpenSettingsFile), + MenuItem::action("Open Project Settings", zed_actions::OpenProjectSettings), + MenuItem::action( + "Open Project Settings File", + super::OpenProjectSettingsFile, + ), MenuItem::action("Open Default Settings", super::OpenDefaultSettings), + MenuItem::separator(), + MenuItem::action("Open Keymap", zed_actions::OpenKeymap), + MenuItem::action("Open Keymap File", zed_actions::OpenKeymapFile), MenuItem::action( "Open Default Key Bindings", zed_actions::OpenDefaultKeymap, ), - MenuItem::action("Open Project Settings", super::OpenProjectSettings), - MenuItem::action( - "Select Settings Profile...", - zed_actions::settings_profile_selector::Toggle, - ), + MenuItem::separator(), MenuItem::action( "Select Theme...", zed_actions::theme_selector::Toggle::default(), ), + MenuItem::action( + "Select Icon Theme...", + zed_actions::icon_theme_selector::Toggle::default(), + ), ], }), MenuItem::separator(), @@ -157,7 +169,7 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::os_action("Paste", editor::actions::Paste, OsAction::Paste), MenuItem::separator(), MenuItem::action("Find", search::buffer_search::Deploy::find()), - MenuItem::action("Find In Project", workspace::DeploySearch::find()), + MenuItem::action("Find in Project", workspace::DeploySearch::find()), MenuItem::separator(), MenuItem::action( "Toggle Line Comment", @@ -181,8 +193,18 @@ pub fn app_menus(cx: &mut App) -> Vec { editor::actions::SelectPreviousSyntaxNode, ), MenuItem::separator(), - MenuItem::action("Add Cursor Above", editor::actions::AddSelectionAbove), - MenuItem::action("Add Cursor Below", editor::actions::AddSelectionBelow), + MenuItem::action( + "Add Cursor Above", + editor::actions::AddSelectionAbove { + skip_soft_wrap: true, + }, + ), + MenuItem::action( + "Add Cursor Below", + editor::actions::AddSelectionBelow { + skip_soft_wrap: true, + }, + ), MenuItem::action( "Select Next Occurrence", editor::actions::SelectNext { @@ -225,7 +247,10 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::action("Go to Definition", editor::actions::GoToDefinition), MenuItem::action("Go to Declaration", editor::actions::GoToDeclaration), MenuItem::action("Go to Type Definition", editor::actions::GoToTypeDefinition), - MenuItem::action("Find All References", editor::actions::FindAllReferences), + MenuItem::action( + "Find All References", + editor::actions::FindAllReferences::default(), + ), MenuItem::separator(), MenuItem::action("Next Problem", editor::actions::GoToDiagnostic::default()), MenuItem::action( @@ -255,7 +280,7 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::separator(), MenuItem::action("Toggle Breakpoint", editor::actions::ToggleBreakpoint), MenuItem::action("Edit Breakpoint", editor::actions::EditLogBreakpoint), - MenuItem::action("Clear all Breakpoints", debugger_ui::ClearAllBreakpoints), + MenuItem::action("Clear All Breakpoints", debugger_ui::ClearAllBreakpoints), ], }, Menu { @@ -276,7 +301,10 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::action("View Telemetry", zed_actions::OpenTelemetryLog), MenuItem::action("View Dependency Licenses", zed_actions::OpenLicenses), MenuItem::action("Show Welcome", onboarding::ShowWelcome), - MenuItem::action("Give Feedback...", zed_actions::feedback::GiveFeedback), + MenuItem::separator(), + MenuItem::action("File Bug Report...", zed_actions::feedback::FileBugReport), + MenuItem::action("Request Feature...", zed_actions::feedback::RequestFeature), + MenuItem::action("Email Us...", zed_actions::feedback::EmailZed), MenuItem::separator(), MenuItem::action( "Documentation", @@ -284,6 +312,7 @@ pub fn app_menus(cx: &mut App) -> Vec { url: "https://zed.dev/docs".into(), }, ), + MenuItem::action("Zed Repository", feedback::OpenZedRepo), MenuItem::action( "Zed Twitter", super::OpenBrowser { diff --git a/crates/zed/src/zed/component_preview.rs b/crates/zed/src/zed/component_preview.rs index 7a287cf3d8..14a46d8882 100644 --- a/crates/zed/src/zed/component_preview.rs +++ b/crates/zed/src/zed/component_preview.rs @@ -17,7 +17,7 @@ use persistence::COMPONENT_PREVIEW_DB; use project::Project; use std::{iter::Iterator, ops::Range, sync::Arc}; use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*}; -use ui_input::SingleLineInput; +use ui_input::InputField; use workspace::{ AppState, Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent, @@ -99,7 +99,7 @@ struct ComponentPreview { component_map: HashMap, components: Vec, cursor_index: usize, - filter_editor: Entity, + filter_editor: Entity, filter_text: String, focus_handle: FocusHandle, language_registry: Arc, @@ -126,8 +126,7 @@ impl ComponentPreview { let sorted_components = component_registry.sorted_components(); let selected_index = selected_index.into().unwrap_or(0); let active_page = active_page.unwrap_or(PreviewPage::AllComponents); - let filter_editor = - cx.new(|cx| SingleLineInput::new(window, cx, "Find components or usages…")); + let filter_editor = cx.new(|cx| InputField::new(window, cx, "Find components or usages…")); let component_list = ListState::new( sorted_components.len(), @@ -628,7 +627,7 @@ impl Render for ComponentPreview { .collect() }), ) - .track_scroll(self.nav_scroll_handle.clone()) + .track_scroll(&self.nav_scroll_handle) .p_2p5() .w(px(231.)) // Matches perfectly with the size of the "Component Preview" tab, if that's the first one in the pane .h_full() @@ -654,10 +653,8 @@ impl Render for ComponentPreview { ) .child( v_flex() - .id("content-area") .flex_1() .size_full() - .overflow_y_scroll() .child( div() .p_2() @@ -666,14 +663,18 @@ impl Render for ComponentPreview { .border_color(cx.theme().colors().border) .child(self.filter_editor.clone()), ) - .child(match active_page { - PreviewPage::AllComponents => { - self.render_all_components(cx).into_any_element() - } - PreviewPage::Component(id) => self - .render_component_page(&id, window, cx) - .into_any_element(), - }), + .child( + div().id("content-area").flex_1().overflow_y_scroll().child( + match active_page { + PreviewPage::AllComponents => { + self.render_all_components(cx).into_any_element() + } + PreviewPage::Component(id) => self + .render_component_page(&id, window, cx) + .into_any_element(), + }, + ), + ), ) } } @@ -716,12 +717,16 @@ impl Item for ComponentPreview { false } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { @@ -743,13 +748,13 @@ impl Item for ComponentPreview { cx, ); - match self_result { + Task::ready(match self_result { Ok(preview) => Some(cx.new(|_cx| preview)), Err(e) => { log::error!("Failed to clone component preview: {}", e); None } - } + }) } fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { @@ -931,15 +936,16 @@ impl ComponentPreviewPage { fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement { v_flex() - .py_12() - .px_16() + .min_w_0() + .w_full() + .p_12() .gap_6() .bg(cx.theme().colors().surface_background) .border_b_1() .border_color(cx.theme().colors().border) .child( v_flex() - .gap_0p5() + .gap_1() .child( Label::new(self.component.scope().to_string()) .size(LabelSize::Small) @@ -956,7 +962,7 @@ impl ComponentPreviewPage { ), ) .when_some(self.component.description(), |this, description| { - this.child(div().text_sm().child(description)) + this.child(Label::new(description).size(LabelSize::Small)) }) } diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index a1ae52fc06..77a1f71596 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -1,14 +1,21 @@ use client::{Client, UserStore}; +use codestral::CodestralEditPredictionDelegate; use collections::HashMap; -use copilot::{Copilot, CopilotCompletionProvider}; +use copilot::{Copilot, CopilotEditPredictionDelegate}; +use edit_prediction::{SweepFeatureFlag, ZedEditPredictionDelegate, Zeta2FeatureFlag}; use editor::Editor; +use feature_flags::FeatureFlagAppExt; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity}; use language::language_settings::{EditPredictionProvider, all_language_settings}; -use settings::SettingsStore; +use language_models::MistralLanguageModelProvider; +use settings::{ + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, SettingsStore, +}; use std::{cell::RefCell, rc::Rc, sync::Arc}; -use supermaven::{Supermaven, SupermavenCompletionProvider}; +use supermaven::{Supermaven, SupermavenEditPredictionDelegate}; use ui::Window; -use zeta::ZetaEditPredictionProvider; pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let editors: Rc, AnyWindowHandle>>> = Rc::default(); @@ -53,7 +60,7 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { }) .detach(); - cx.on_action(clear_zeta_edit_history); + cx.on_action(clear_edit_prediction_store_edit_history); let mut provider = all_language_settings(None, cx).edit_predictions.provider; cx.subscribe(&user_store, { @@ -94,11 +101,9 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { .detach(); } -fn clear_zeta_edit_history(_: &zeta::ClearHistory, cx: &mut App) { - if let Some(zeta) = zeta::Zeta::global(cx) { - zeta.update(cx, |zeta, _| zeta.clear_history()); - } else if let Some(zeta) = zeta2::Zeta::try_global(cx) { - zeta.update(cx, |zeta, _| zeta.clear_history()); +fn clear_edit_prediction_store_edit_history(_: &edit_prediction::ClearHistory, cx: &mut App) { + if let Some(ep_store) = edit_prediction::EditPredictionStore::try_global(cx) { + ep_store.update(cx, |ep_store, _| ep_store.clear_history()); } } @@ -109,6 +114,10 @@ fn assign_edit_prediction_providers( user_store: Entity, cx: &mut App, ) { + if provider == EditPredictionProvider::Codestral { + let mistral = MistralLanguageModelProvider::global(client.http_client(), cx); + mistral.load_codestral_api_key(cx).detach(); + } for (editor, window) in editors.borrow().iter() { _ = window.update(cx, |_window, window, cx| { _ = editor.update(cx, |editor, cx| { @@ -168,7 +177,7 @@ fn assign_edit_prediction_provider( match provider { EditPredictionProvider::None => { - editor.set_edit_prediction_provider::(None, window, cx); + editor.set_edit_prediction_provider::(None, window, cx); } EditPredictionProvider::Copilot => { if let Some(copilot) = Copilot::global(cx) { @@ -179,74 +188,67 @@ fn assign_edit_prediction_provider( copilot.register_buffer(&buffer, cx); }); } - let provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor.set_edit_prediction_provider(Some(provider), window, cx); } } EditPredictionProvider::Supermaven => { if let Some(supermaven) = Supermaven::global(cx) { - let provider = cx.new(|_| SupermavenCompletionProvider::new(supermaven)); + let provider = cx.new(|_| SupermavenEditPredictionDelegate::new(supermaven)); editor.set_edit_prediction_provider(Some(provider), window, cx); } } - EditPredictionProvider::Zed => { - if user_store.read(cx).current_user().is_some() { - let mut worktree = None; + EditPredictionProvider::Codestral => { + let http_client = client.http_client(); + let provider = cx.new(|_| CodestralEditPredictionDelegate::new(http_client)); + editor.set_edit_prediction_provider(Some(provider), window, cx); + } + value @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => { + let ep_store = edit_prediction::EditPredictionStore::global(client, &user_store, cx); - if let Some(buffer) = &singleton_buffer - && let Some(file) = buffer.read(cx).file() - { - let id = file.worktree_id(cx); - if let Some(inner_worktree) = editor - .project() - .and_then(|project| project.read(cx).worktree_for_id(id, cx)) - { - worktree = Some(inner_worktree); - } - } - - if let Some(project) = editor.project() { - if std::env::var("ZED_ZETA2").is_ok() { - let zeta = zeta2::Zeta::global(client, &user_store, cx); - let provider = cx.new(|cx| { - zeta2::ZetaEditPredictionProvider::new( - project.clone(), - &client, - &user_store, - cx, - ) - }); - - // TODO [zeta2] handle multibuffers - if let Some(buffer) = &singleton_buffer - && buffer.read(cx).file().is_some() + if let Some(project) = editor.project() + && let Some(buffer) = &singleton_buffer + && buffer.read(cx).file().is_some() + { + let has_model = ep_store.update(cx, |ep_store, cx| { + let model = if let EditPredictionProvider::Experimental(name) = value { + if name == EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME + && cx.has_flag::() { - zeta.update(cx, |zeta, cx| { - zeta.register_buffer(buffer, project, cx); - }); + edit_prediction::EditPredictionModel::Sweep + } else if name == EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME + && cx.has_flag::() + { + edit_prediction::EditPredictionModel::Zeta2 + } else if name == EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME + && cx.has_flag::() + { + edit_prediction::EditPredictionModel::Mercury + } else { + return false; } - - editor.set_edit_prediction_provider(Some(provider), window, cx); + } else if user_store.read(cx).current_user().is_some() { + edit_prediction::EditPredictionModel::Zeta1 } else { - let zeta = zeta::Zeta::register(worktree, client.clone(), user_store, cx); + return false; + }; - if let Some(buffer) = &singleton_buffer - && buffer.read(cx).file().is_some() - { - zeta.update(cx, |zeta, cx| { - zeta.register_buffer(buffer, project, cx); - }); - } + ep_store.set_edit_prediction_model(model); + ep_store.register_buffer(buffer, project, cx); + true + }); - let provider = cx.new(|_| { - zeta::ZetaEditPredictionProvider::new( - zeta, - project.clone(), - singleton_buffer, - ) - }); - editor.set_edit_prediction_provider(Some(provider), window, cx); - } + if has_model { + let provider = cx.new(|cx| { + ZedEditPredictionDelegate::new( + project.clone(), + singleton_buffer, + &client, + &user_store, + cx, + ) + }); + editor.set_edit_prediction_provider(Some(provider), window, cx); } } } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index a8a998b658..6352c20e5c 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -3,13 +3,14 @@ use crate::restorable_workspace_locations; use anyhow::{Context as _, Result, anyhow}; use cli::{CliRequest, CliResponse, ipc::IpcSender}; use cli::{IpcHandshake, ipc}; -use client::parse_zed_link; +use client::{ZedLink, parse_zed_link}; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use fs::Fs; use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::channel::{mpsc, oneshot}; +use futures::future; use futures::future::join_all; use futures::{FutureExt, SinkExt, StreamExt}; use git_ui::file_diff_view::FileDiffView; @@ -43,10 +44,20 @@ pub struct OpenRequest { #[derive(Debug)] pub enum OpenRequestKind { CliConnection((mpsc::Receiver, IpcSender)), - Extension { extension_id: String }, + Extension { + extension_id: String, + }, AgentPanel, - DockMenuAction { index: usize }, - BuiltinJsonSchema { schema_path: String }, + DockMenuAction { + index: usize, + }, + BuiltinJsonSchema { + schema_path: String, + }, + Setting { + /// `None` opens settings without navigating to a specific path. + setting_path: Option, + }, } impl OpenRequest { @@ -93,10 +104,26 @@ impl OpenRequest { this.kind = Some(OpenRequestKind::BuiltinJsonSchema { schema_path: schema_path.to_string(), }); + } else if url == "zed://settings" || url == "zed://settings/" { + this.kind = Some(OpenRequestKind::Setting { setting_path: None }); + } else if let Some(setting_path) = url.strip_prefix("zed://settings/") { + this.kind = Some(OpenRequestKind::Setting { + setting_path: Some(setting_path.to_string()), + }); } else if url.starts_with("ssh://") { this.parse_ssh_file_path(&url, cx)? - } else if let Some(request_path) = parse_zed_link(&url, cx) { - this.parse_request_path(request_path).log_err(); + } else if let Some(zed_link) = parse_zed_link(&url, cx) { + match zed_link { + ZedLink::Channel { channel_id } => { + this.join_channel = Some(channel_id); + } + ZedLink::ChannelNotes { + channel_id, + heading, + } => { + this.open_channel_notes.push((channel_id, heading)); + } + } } else { log::error!("unhandled url: {}", url); } @@ -140,31 +167,6 @@ impl OpenRequest { self.parse_file_path(url.path()); Ok(()) } - - fn parse_request_path(&mut self, request_path: &str) -> Result<()> { - let mut parts = request_path.split('/'); - if parts.next() == Some("channel") - && let Some(slug) = parts.next() - && let Some(id_str) = slug.split('-').next_back() - && let Ok(channel_id) = id_str.parse::() - { - let Some(next) = parts.next() else { - self.join_channel = Some(channel_id); - return Ok(()); - }; - - if let Some(heading) = next.strip_prefix("notes#") { - self.open_channel_notes - .push((channel_id, Some(heading.to_string()))); - return Ok(()); - } - if next == "notes" { - self.open_channel_notes.push((channel_id, None)); - return Ok(()); - } - } - anyhow::bail!("invalid zed url: {request_path}") - } } #[derive(Clone)] @@ -328,6 +330,7 @@ pub async fn handle_cli_connection( wait, wsl, open_new_workspace, + reuse, env, user_data_dir: _, } => { @@ -363,6 +366,7 @@ pub async fn handle_cli_connection( paths, diff_paths, open_new_workspace, + reuse, &responses, wait, app_state.clone(), @@ -382,6 +386,7 @@ async fn open_workspaces( paths: Vec, diff_paths: Vec<[String; 2]>, open_new_workspace: Option, + reuse: bool, responses: &IpcSender, wait: bool, app_state: Arc, @@ -441,6 +446,7 @@ async fn open_workspaces( workspace_paths, diff_paths.clone(), open_new_workspace, + reuse, wait, responses, env.as_ref(), @@ -487,22 +493,36 @@ async fn open_local_workspace( workspace_paths: Vec, diff_paths: Vec<[String; 2]>, open_new_workspace: Option, + reuse: bool, wait: bool, responses: &IpcSender, env: Option<&HashMap>, app_state: &Arc, cx: &mut AsyncApp, ) -> bool { - let mut errored = false; - let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), workspace_paths).await; - match open_paths_with_positions( + + // If reuse flag is passed, open a new workspace in an existing window. + let (open_new_workspace, replace_window) = if reuse { + ( + Some(true), + cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next()) + .ok() + .flatten(), + ) + } else { + (open_new_workspace, None) + }; + + let (workspace, items) = match open_paths_with_positions( &paths_with_position, &diff_paths, app_state.clone(), workspace::OpenOptions { open_new_workspace, + replace_window, + prefer_focused_window: wait, env: env.cloned(), ..Default::default() }, @@ -510,80 +530,95 @@ async fn open_local_workspace( ) .await { - Ok((workspace, items)) => { - let mut item_release_futures = Vec::new(); - - for item in items { - match item { - Some(Ok(item)) => { - cx.update(|cx| { - let released = oneshot::channel(); - item.on_release( - cx, - Box::new(move |_| { - let _ = released.0.send(()); - }), - ) - .detach(); - item_release_futures.push(released.1); - }) - .log_err(); - } - Some(Err(err)) => { - responses - .send(CliResponse::Stderr { - message: err.to_string(), - }) - .log_err(); - errored = true; - } - None => {} - } - } - - if wait { - let background = cx.background_executor().clone(); - let wait = async move { - if paths_with_position.is_empty() && diff_paths.is_empty() { - let (done_tx, done_rx) = oneshot::channel(); - let _subscription = workspace.update(cx, |_, _, cx| { - cx.on_release(move |_, _| { - let _ = done_tx.send(()); - }) - }); - let _ = done_rx.await; - } else { - let _ = futures::future::try_join_all(item_release_futures).await; - }; - } - .fuse(); - - futures::pin_mut!(wait); - - loop { - // Repeatedly check if CLI is still open to avoid wasting resources - // waiting for files or workspaces to close. - let mut timer = background.timer(Duration::from_secs(1)).fuse(); - futures::select_biased! { - _ = wait => break, - _ = timer => { - if responses.send(CliResponse::Ping).is_err() { - break; - } - } - } - } - } - } + Ok(result) => result, Err(error) => { - errored = true; responses .send(CliResponse::Stderr { message: format!("error opening {paths_with_position:?}: {error}"), }) .log_err(); + return true; + } + }; + + let mut errored = false; + let mut item_release_futures = Vec::new(); + let mut subscriptions = Vec::new(); + + // If --wait flag is used with no paths, or a directory, then wait until + // the entire workspace is closed. + if wait { + let mut wait_for_window_close = paths_with_position.is_empty() && diff_paths.is_empty(); + for path_with_position in &paths_with_position { + if app_state.fs.is_dir(&path_with_position.path).await { + wait_for_window_close = true; + break; + } + } + + if wait_for_window_close { + let (release_tx, release_rx) = oneshot::channel(); + item_release_futures.push(release_rx); + subscriptions.push(workspace.update(cx, |_, _, cx| { + cx.on_release(move |_, _| { + let _ = release_tx.send(()); + }) + })); } } + + for item in items { + match item { + Some(Ok(item)) => { + if wait { + let (release_tx, release_rx) = oneshot::channel(); + item_release_futures.push(release_rx); + subscriptions.push(cx.update(|cx| { + item.on_release( + cx, + Box::new(move |_| { + release_tx.send(()).ok(); + }), + ) + })); + } + } + Some(Err(err)) => { + responses + .send(CliResponse::Stderr { + message: err.to_string(), + }) + .log_err(); + errored = true; + } + None => {} + } + } + + if wait { + let wait = async move { + let _subscriptions = subscriptions; + let _ = future::try_join_all(item_release_futures).await; + } + .fuse(); + futures::pin_mut!(wait); + + let background = cx.background_executor().clone(); + loop { + // Repeatedly check if CLI is still open to avoid wasting resources + // waiting for files or workspaces to close. + let mut timer = background.timer(Duration::from_secs(1)).fuse(); + futures::select_biased! { + _ = wait => break, + _ = timer => { + if responses.send(CliResponse::Ping).is_err() { + break; + } + } + } + } + } + errored } @@ -613,19 +648,19 @@ mod tests { ipc::{self}, }; use editor::Editor; - use gpui::TestAppContext; + use futures::poll; + use gpui::{AppContext as _, TestAppContext}; + use language::LineEnding; use remote::SshConnectionOptions; + use rope::Rope; use serde_json::json; - use std::sync::Arc; + use std::{sync::Arc, task::Poll}; use util::path; use workspace::{AppState, Workspace}; #[gpui::test] fn test_parse_ssh_url(cx: &mut TestAppContext) { let _app_state = init_test(cx); - cx.update(|cx| { - SshSettings::register(cx); - }); let request = cx.update(|cx| { OpenRequest::parse( RawOpenRequest { @@ -647,6 +682,7 @@ mod tests { port_forwards: None, nickname: None, upload_binary_over_ssh: false, + connection_timeout: None, }) ); assert_eq!(request.open_paths, vec!["/"]); @@ -714,6 +750,60 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_wait_with_directory_waits_for_window_close(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "dir1": { + "file1.txt": "content1", + }, + }), + ) + .await; + + let (response_tx, _) = ipc::channel::().unwrap(); + let workspace_paths = vec![path!("/root/dir1").to_owned()]; + + let (done_tx, mut done_rx) = futures::channel::oneshot::channel(); + cx.spawn({ + let app_state = app_state.clone(); + move |mut cx| async move { + let errored = open_local_workspace( + workspace_paths, + vec![], + None, + false, + true, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await; + let _ = done_tx.send(errored); + } + }) + .detach(); + + cx.background_executor.run_until_parked(); + assert_eq!(cx.windows().len(), 1); + assert!(matches!(poll!(&mut done_rx), Poll::Pending)); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, _| window.remove_window()) + .unwrap(); + cx.background_executor.run_until_parked(); + + let errored = done_rx.await.unwrap(); + assert!(!errored); + } + #[gpui::test] async fn test_open_workspace_with_nonexistent_files(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -780,6 +870,7 @@ mod tests { vec![], open_new_workspace, false, + false, &response_tx, None, &app_state, @@ -791,4 +882,102 @@ mod tests { assert!(!errored); } + + #[gpui::test] + async fn test_reuse_flag_functionality(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + let root_dir = if cfg!(windows) { "C:\\root" } else { "/root" }; + let file1_path = if cfg!(windows) { + "C:\\root\\file1.txt" + } else { + "/root/file1.txt" + }; + let file2_path = if cfg!(windows) { + "C:\\root\\file2.txt" + } else { + "/root/file2.txt" + }; + + app_state.fs.create_dir(Path::new(root_dir)).await.unwrap(); + app_state + .fs + .create_file(Path::new(file1_path), Default::default()) + .await + .unwrap(); + app_state + .fs + .save( + Path::new(file1_path), + &Rope::from("content1"), + LineEnding::Unix, + ) + .await + .unwrap(); + app_state + .fs + .create_file(Path::new(file2_path), Default::default()) + .await + .unwrap(); + app_state + .fs + .save( + Path::new(file2_path), + &Rope::from("content2"), + LineEnding::Unix, + ) + .await + .unwrap(); + + // First, open a workspace normally + let (response_tx, _response_rx) = ipc::channel::().unwrap(); + let workspace_paths = vec![file1_path.to_string()]; + + let _errored = cx + .spawn({ + let app_state = app_state.clone(); + let response_tx = response_tx.clone(); + |mut cx| async move { + open_local_workspace( + workspace_paths, + vec![], + None, + false, + false, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + // Now test the reuse functionality - should replace the existing workspace + let workspace_paths_reuse = vec![file1_path.to_string()]; + + let errored_reuse = cx + .spawn({ + let app_state = app_state.clone(); + let response_tx = response_tx.clone(); + |mut cx| async move { + open_local_workspace( + workspace_paths_reuse, + vec![], + None, // open_new_workspace will be overridden by reuse logic + true, // reuse = true + false, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + assert!(!errored_reuse); + } } diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index c721e1e8b6..2a52cc6972 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -15,19 +15,19 @@ use gpui::{ FocusHandle, Focusable, InteractiveElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, anchored, deferred, point, }; -use project::project_settings::DiagnosticSeverity; +use project::{DisableAiSettings, project_settings::DiagnosticSeverity}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, SettingsStore}; use ui::{ ButtonStyle, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton, IconName, IconSize, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*, }; -use vim_mode_setting::VimModeSetting; +use vim_mode_setting::{HelixModeSetting, VimModeSetting}; use workspace::item::ItemBufferKind; use workspace::{ ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, item::ItemHandle, }; -use zed_actions::{assistant::InlineAssist, outline::ToggleOutline}; +use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist, outline::ToggleOutline}; const MAX_CODE_ACTION_MENU_LINES: u32 = 16; @@ -174,17 +174,13 @@ impl Render for QuickActionBar { .as_ref() .is_some_and(|menu| matches!(menu.origin(), ContextMenuOrigin::QuickActionBar)) }; - let code_action_element = if is_deployed { - editor.update(cx, |editor, cx| { - if let Some(style) = editor.style() { - editor.render_context_menu(style, MAX_CODE_ACTION_MENU_LINES, window, cx) - } else { - None - } + let code_action_element = is_deployed + .then(|| { + editor.update(cx, |editor, cx| { + editor.render_context_menu(MAX_CODE_ACTION_MENU_LINES, window, cx) + }) }) - } else { - None - }; + .flatten(); v_flex() .child( IconButton::new("toggle_code_actions_icon", IconName::BoltOutlined) @@ -241,8 +237,14 @@ impl Render for QuickActionBar { .read(cx) .snapshot(cx) .has_diff_hunks(); + let has_selection = editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }); + let focus = editor.focus_handle(cx); + let disable_ai = DisableAiSettings::get_global(cx).disable_ai; + PopoverMenu::new("editor-selections-dropdown") .trigger_with_tooltip( IconButton::new("toggle_editor_selections_icon", IconName::CursorIBeam) @@ -266,8 +268,25 @@ impl Render for QuickActionBar { ) .action("Expand Selection", Box::new(SelectLargerSyntaxNode)) .action("Shrink Selection", Box::new(SelectSmallerSyntaxNode)) - .action("Add Cursor Above", Box::new(AddSelectionAbove)) - .action("Add Cursor Below", Box::new(AddSelectionBelow)) + .action( + "Add Cursor Above", + Box::new(AddSelectionAbove { + skip_soft_wrap: true, + }), + ) + .action( + "Add Cursor Below", + Box::new(AddSelectionBelow { + skip_soft_wrap: true, + }), + ) + .when(!disable_ai, |this| { + this.separator().action_disabled_when( + !has_selection, + "Add to Agent Thread", + Box::new(AddSelectionToThread), + ) + }) .separator() .action("Go to Symbol", Box::new(ToggleOutline)) .action("Go to Line/Column", Box::new(ToggleGoToLine)) @@ -297,6 +316,7 @@ impl Render for QuickActionBar { let editor = editor.downgrade(); let editor_settings_dropdown = { let vim_mode_enabled = VimModeSetting::get_global(cx).0; + let helix_mode_enabled = HelixModeSetting::get_global(cx).0; PopoverMenu::new("editor-settings") .trigger_with_tooltip( @@ -573,10 +593,25 @@ impl Render for QuickActionBar { move |window, cx| { let new_value = !vim_mode_enabled; VimModeSetting::override_global(VimModeSetting(new_value), cx); + HelixModeSetting::override_global(HelixModeSetting(false), cx); window.refresh(); } }, ); + menu = menu.toggleable_entry( + "Helix Mode", + helix_mode_enabled, + IconPosition::Start, + None, + { + move |window, cx| { + let new_value = !helix_mode_enabled; + HelixModeSetting::override_global(HelixModeSetting(new_value), cx); + VimModeSetting::override_global(VimModeSetting(false), cx); + window.refresh(); + } + } + ); menu } @@ -645,8 +680,8 @@ impl RenderOnce for QuickActionBarButton { .icon_size(IconSize::Small) .style(ButtonStyle::Subtle) .toggle_state(self.toggled) - .tooltip(move |window, cx| { - Tooltip::for_action_in(tooltip.clone(), &*action, &self.focus_handle, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action_in(tooltip.clone(), &*action, &self.focus_handle, cx) }) .on_click(move |event, window, cx| (self.on_click)(event, window, cx)) } diff --git a/crates/zed/src/zed/quick_action_bar/preview.rs b/crates/zed/src/zed/quick_action_bar/preview.rs index fb5a75f78d..5d43e79542 100644 --- a/crates/zed/src/zed/quick_action_bar/preview.rs +++ b/crates/zed/src/zed/quick_action_bar/preview.rs @@ -32,7 +32,7 @@ impl QuickActionBar { .is_some() { preview_type = Some(PreviewType::Markdown); - } else if SvgPreviewView::resolve_active_item_as_svg_editor(workspace, cx).is_some() + } else if SvgPreviewView::resolve_active_item_as_svg_buffer(workspace, cx).is_some() { preview_type = Some(PreviewType::Svg); } @@ -68,7 +68,7 @@ impl QuickActionBar { let button = IconButton::new(button_id, IconName::Eye) .icon_size(IconSize::Small) .style(ButtonStyle::Subtle) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( tooltip_text, Some(open_action_for_tooltip), @@ -76,7 +76,6 @@ impl QuickActionBar { "{} to open in a split", text_for_keystroke(&alt_click.modifiers, &alt_click.key, cx) ), - window, cx, ) }) diff --git a/crates/zed/src/zed/quick_action_bar/repl_menu.rs b/crates/zed/src/zed/quick_action_bar/repl_menu.rs index 82eb82de1e..1ebdf35bb9 100644 --- a/crates/zed/src/zed/quick_action_bar/repl_menu.rs +++ b/crates/zed/src/zed/quick_action_bar/repl_menu.rs @@ -54,7 +54,8 @@ impl QuickActionBar { .count() .ne(&0) .then(|| { - let latest = this.selections.newest_display(cx); + let snapshot = this.display_snapshot(cx); + let latest = this.selections.newest_display(&snapshot); !latest.is_empty() }) .unwrap_or_default() @@ -387,16 +388,55 @@ fn session_state(session: Entity, cx: &mut App) -> ReplMenuState { } }; - match &session.kernel { - Kernel::Restarting => ReplMenuState { - tooltip: format!("Restarting {}", kernel_name).into(), - icon_is_animating: true, - popover_disabled: true, + let transitional = + |tooltip: SharedString, animating: bool, popover_disabled: bool| ReplMenuState { + tooltip, + icon_is_animating: animating, + popover_disabled, icon_color: Color::Muted, indicator: Some(Indicator::dot().color(Color::Muted)), status: session.kernel.status(), ..fill_fields() - }, + }; + + let starting = || transitional(format!("{} is starting", kernel_name).into(), true, true); + let restarting = || transitional(format!("Restarting {}", kernel_name).into(), true, true); + let shutting_down = || { + transitional( + format!("{} is shutting down", kernel_name).into(), + false, + true, + ) + }; + let auto_restarting = || { + transitional( + format!("Auto-restarting {}", kernel_name).into(), + true, + true, + ) + }; + let unknown = || transitional(format!("{} state unknown", kernel_name).into(), false, true); + let other = |state: &str| { + transitional( + format!("{} state: {}", kernel_name, state).into(), + false, + true, + ) + }; + + let shutdown = || ReplMenuState { + tooltip: "Nothing running".into(), + icon: IconName::ReplNeutral, + icon_color: Color::Default, + icon_is_animating: false, + popover_disabled: false, + indicator: None, + status: KernelStatus::Shutdown, + ..fill_fields() + }; + + match &session.kernel { + Kernel::Restarting => restarting(), Kernel::RunningKernel(kernel) => match &kernel.execution_state() { ExecutionState::Idle => ReplMenuState { tooltip: format!("Run code on {} ({})", kernel_name, kernel_language).into(), @@ -412,16 +452,15 @@ fn session_state(session: Entity, cx: &mut App) -> ReplMenuState { status: session.kernel.status(), ..fill_fields() }, + ExecutionState::Unknown => unknown(), + ExecutionState::Starting => starting(), + ExecutionState::Restarting => restarting(), + ExecutionState::Terminating => shutting_down(), + ExecutionState::AutoRestarting => auto_restarting(), + ExecutionState::Dead => shutdown(), + ExecutionState::Other(state) => other(state), }, - Kernel::StartingKernel(_) => ReplMenuState { - tooltip: format!("{} is starting", kernel_name).into(), - icon_is_animating: true, - popover_disabled: true, - icon_color: Color::Muted, - indicator: Some(Indicator::dot().color(Color::Muted)), - status: session.kernel.status(), - ..fill_fields() - }, + Kernel::StartingKernel(_) => starting(), Kernel::ErroredLaunch(e) => ReplMenuState { tooltip: format!("Error with kernel {}: {}", kernel_name, e).into(), popover_disabled: false, @@ -429,23 +468,7 @@ fn session_state(session: Entity, cx: &mut App) -> ReplMenuState { status: session.kernel.status(), ..fill_fields() }, - Kernel::ShuttingDown => ReplMenuState { - tooltip: format!("{} is shutting down", kernel_name).into(), - popover_disabled: true, - icon_color: Color::Muted, - indicator: Some(Indicator::dot().color(Color::Muted)), - status: session.kernel.status(), - ..fill_fields() - }, - Kernel::Shutdown => ReplMenuState { - tooltip: "Nothing running".into(), - icon: IconName::ReplNeutral, - icon_color: Color::Default, - icon_is_animating: false, - popover_disabled: false, - indicator: None, - status: KernelStatus::Shutdown, - ..fill_fields() - }, + Kernel::ShuttingDown => shutting_down(), + Kernel::Shutdown => shutdown(), } } diff --git a/crates/zed/src/zed/windows_only_instance.rs b/crates/zed/src/zed/windows_only_instance.rs index 45f3cd158b..f3eab15441 100644 --- a/crates/zed/src/zed/windows_only_instance.rs +++ b/crates/zed/src/zed/windows_only_instance.rs @@ -158,6 +158,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> { wait: false, wsl: args.wsl.clone(), open_new_workspace: None, + reuse: false, env: None, user_data_dir: args.user_data_dir.clone(), } diff --git a/crates/zed_actions/Cargo.toml b/crates/zed_actions/Cargo.toml index 3778d19621..1a140c483f 100644 --- a/crates/zed_actions/Cargo.toml +++ b/crates/zed_actions/Cargo.toml @@ -12,5 +12,4 @@ workspace = true gpui.workspace = true schemars.workspace = true serde.workspace = true -workspace-hack.workspace = true uuid.workspace = true diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 753918841f..f69baa03b0 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -27,25 +27,39 @@ pub struct OpenZedUrl { pub url: String, } +/// Opens the keymap to either add a keybinding or change an existing one +#[derive(PartialEq, Clone, Default, Action, JsonSchema, Serialize, Deserialize)] +#[action(namespace = zed, no_json, no_register)] +pub struct ChangeKeybinding { + pub action: String, +} + actions!( zed, [ - /// Opens the settings JSON file. - OpenSettings, /// Opens the settings editor. - OpenSettingsEditor, + #[action(deprecated_aliases = ["zed_actions::OpenSettingsEditor"])] + OpenSettings, + /// Opens the settings JSON file. + #[action(deprecated_aliases = ["zed_actions::OpenSettings"])] + OpenSettingsFile, + /// Opens project-specific settings. + #[action(deprecated_aliases = ["zed_actions::OpenProjectSettings"])] + OpenProjectSettings, /// Opens the default keymap file. OpenDefaultKeymap, + /// Opens the user keymap file. + #[action(deprecated_aliases = ["zed_actions::OpenKeymap"])] + OpenKeymapFile, + /// Opens the keymap editor. + #[action(deprecated_aliases = ["zed_actions::OpenKeymapEditor"])] + OpenKeymap, /// Opens account settings. OpenAccountSettings, - /// Opens the keymap editor. - OpenKeymapEditor, /// Opens server settings. OpenServerSettings, /// Quits the application. Quit, - /// Opens the user keymap file. - OpenKeymap, /// Shows information about Zed. About, /// Opens the documentation website. @@ -54,6 +68,8 @@ actions!( OpenLicenses, /// Opens the telemetry log. OpenTelemetryLog, + /// Opens the performance profiler. + OpenPerformanceProfiler, ] ); @@ -66,6 +82,7 @@ pub enum ExtensionCategoryFilter { Grammars, LanguageServers, ContextServers, + AgentServers, SlashCommands, IndexedDocsProviders, Snippets, @@ -103,6 +120,15 @@ pub struct IncreaseBufferFontSize { pub persist: bool, } +/// Increases the font size in the editor buffer. +#[derive(PartialEq, Clone, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = zed)] +#[serde(deny_unknown_fields)] +pub struct OpenSettingsAt { + /// A path to a specific setting (e.g. `theme.mode`) + pub path: String, +} + /// Resets the buffer font size to the default value. #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] #[action(namespace = zed)] @@ -139,6 +165,15 @@ pub struct ResetUiFontSize { pub persist: bool, } +/// Resets all zoom levels (UI and buffer font sizes, including in the agent panel) to their default values. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = zed)] +#[serde(deny_unknown_fields)] +pub struct ResetAllZoom { + #[serde(default)] + pub persist: bool, +} + pub mod dev { use gpui::actions; @@ -180,11 +215,17 @@ pub mod git { Switch, /// Selects a different repository. SelectRepo, + /// Filter remotes. + FilterRemotes, + /// Create a git remote. + CreateRemote, /// Opens the git branch selector. #[action(deprecated_aliases = ["branches::OpenRecent"])] Branch, /// Opens the git stash selector. - ViewStash + ViewStash, + /// Opens the git worktree selector. + Worktree ] ); } @@ -208,21 +249,34 @@ pub mod command_palette { command_palette, [ /// Toggles the command palette. - Toggle + Toggle, ] ); } +pub mod project_panel { + use gpui::actions; + + actions!( + project_panel, + [ + /// Toggles focus on the project panel. + ToggleFocus + ] + ); +} pub mod feedback { use gpui::actions; actions!( feedback, [ + /// Opens email client to send feedback to Zed support. + EmailZed, /// Opens the bug report form. FileBugReport, - /// Opens the feedback form. - GiveFeedback + /// Opens the feature request form. + RequestFeature ] ); } @@ -290,7 +344,14 @@ pub mod agent { #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])] ToggleModelSelector, /// Triggers re-authentication on Gemini - ReauthenticateAgent + ReauthenticateAgent, + /// Add the current selection as context for threads in the agent panel. + #[action(deprecated_aliases = ["assistant::QuoteSelection", "agent::QuoteSelection"])] + AddSelectionToThread, + /// Resets the agent panel zoom levels (agent UI and buffer font sizes). + ResetAgentZoom, + /// Toggles the utility/agent pane open/closed state. + ToggleAgentPane, ] ); } @@ -369,6 +430,12 @@ pub struct OpenRemote { pub create_new_window: bool, } +/// Opens the dev container connection modal. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = projects)] +#[serde(deny_unknown_fields)] +pub struct OpenDevContainer; + /// Where to spawn the task in the UI. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -488,6 +555,24 @@ actions!( ] ); +pub mod vim { + use gpui::actions; + + actions!( + vim, + [ + /// Opens the default keymap file. + OpenDefaultKeymap + ] + ); +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WslConnectionOptions { + pub distro_name: String, + pub user: Option, +} + #[cfg(target_os = "windows")] pub mod wsl_actions { use gpui::Action; diff --git a/crates/zed_env_vars/Cargo.toml b/crates/zed_env_vars/Cargo.toml index f56e3dd529..1cf32174c3 100644 --- a/crates/zed_env_vars/Cargo.toml +++ b/crates/zed_env_vars/Cargo.toml @@ -15,5 +15,4 @@ path = "src/zed_env_vars.rs" default = [] [dependencies] -workspace-hack.workspace = true gpui.workspace = true diff --git a/crates/zed_env_vars/src/zed_env_vars.rs b/crates/zed_env_vars/src/zed_env_vars.rs index 53b9c22bb2..e601cc9536 100644 --- a/crates/zed_env_vars/src/zed_env_vars.rs +++ b/crates/zed_env_vars/src/zed_env_vars.rs @@ -5,6 +5,7 @@ use std::sync::LazyLock; /// When true, Zed will use in-memory databases instead of persistent storage. pub static ZED_STATELESS: LazyLock = bool_env_var!("ZED_STATELESS"); +#[derive(Clone)] pub struct EnvVar { pub name: SharedString, /// Value of the environment variable. Also `None` when set to an empty string. @@ -30,7 +31,7 @@ impl EnvVar { #[macro_export] macro_rules! env_var { ($name:expr) => { - LazyLock::new(|| $crate::EnvVar::new(($name).into())) + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into())) }; } @@ -39,6 +40,6 @@ macro_rules! env_var { #[macro_export] macro_rules! bool_env_var { ($name:expr) => { - LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) }; } diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml deleted file mode 100644 index 09bcfa7f54..0000000000 --- a/crates/zeta/Cargo.toml +++ /dev/null @@ -1,84 +0,0 @@ -[package] -name = "zeta" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" -exclude = ["fixtures"] - -[lints] -workspace = true - -[lib] -path = "src/zeta.rs" -doctest = false - -[features] -test-support = [] - -[dependencies] -ai_onboarding.workspace = true -anyhow.workspace = true -arrayvec.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -collections.workspace = true -command_palette_hooks.workspace = true -copilot.workspace = true -db.workspace = true -edit_prediction.workspace = true -editor.workspace = true -feature_flags.workspace = true -fs.workspace = true -futures.workspace = true -gpui.workspace = true -http_client.workspace = true -indoc.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true -log.workspace = true -menu.workspace = true -postage.workspace = true -project.workspace = true -rand.workspace = true -regex.workspace = true -release_channel.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -strum.workspace = true -telemetry.workspace = true -telemetry_events.workspace = true -theme.workspace = true -thiserror.workspace = true -ui.workspace = true -util.workspace = true -uuid.workspace = true -workspace-hack.workspace = true -workspace.workspace = true -worktree.workspace = true -zed_actions.workspace = true - -[dev-dependencies] -call = { workspace = true, features = ["test-support"] } -client = { workspace = true, features = ["test-support"] } -clock = { workspace = true, features = ["test-support"] } -cloud_api_types.workspace = true -collections = { workspace = true, features = ["test-support"] } -ctor.workspace = true -editor = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } -indoc.workspace = true -language = { workspace = true, features = ["test-support"] } -parking_lot.workspace = true -reqwest_client = { workspace = true, features = ["test-support"] } -rpc = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } -theme = { workspace = true, features = ["test-support"] } -tree-sitter-go.workspace = true -tree-sitter-rust.workspace = true -workspace = { workspace = true, features = ["test-support"] } -worktree = { workspace = true, features = ["test-support"] } -zlog.workspace = true diff --git a/crates/zeta/src/completion_diff_element.rs b/crates/zeta/src/completion_diff_element.rs deleted file mode 100644 index 73c3cb20cd..0000000000 --- a/crates/zeta/src/completion_diff_element.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::cmp; - -use crate::EditPrediction; -use gpui::{ - AnyElement, App, BorderStyle, Bounds, Corners, Edges, HighlightStyle, Hsla, StyledText, - TextLayout, TextStyle, point, prelude::*, quad, size, -}; -use language::OffsetRangeExt; -use settings::Settings; -use theme::ThemeSettings; -use ui::prelude::*; - -pub struct CompletionDiffElement { - element: AnyElement, - text_layout: TextLayout, - cursor_offset: usize, -} - -impl CompletionDiffElement { - pub fn new(completion: &EditPrediction, cx: &App) -> Self { - let mut diff = completion - .snapshot - .text_for_range(completion.excerpt_range.clone()) - .collect::(); - - let mut cursor_offset_in_diff = None; - let mut delta = 0; - let mut diff_highlights = Vec::new(); - for (old_range, new_text) in completion.edits.iter() { - let old_range = old_range.to_offset(&completion.snapshot); - - if cursor_offset_in_diff.is_none() && completion.cursor_offset <= old_range.end { - cursor_offset_in_diff = - Some(completion.cursor_offset - completion.excerpt_range.start + delta); - } - - let old_start_in_diff = old_range.start - completion.excerpt_range.start + delta; - let old_end_in_diff = old_range.end - completion.excerpt_range.start + delta; - if old_start_in_diff < old_end_in_diff { - diff_highlights.push(( - old_start_in_diff..old_end_in_diff, - HighlightStyle { - background_color: Some(cx.theme().status().deleted_background), - strikethrough: Some(gpui::StrikethroughStyle { - thickness: px(1.), - color: Some(cx.theme().colors().text_muted), - }), - ..Default::default() - }, - )); - } - - if !new_text.is_empty() { - diff.insert_str(old_end_in_diff, new_text); - diff_highlights.push(( - old_end_in_diff..old_end_in_diff + new_text.len(), - HighlightStyle { - background_color: Some(cx.theme().status().created_background), - ..Default::default() - }, - )); - delta += new_text.len(); - } - } - - let cursor_offset_in_diff = cursor_offset_in_diff - .unwrap_or_else(|| completion.cursor_offset - completion.excerpt_range.start + delta); - - let settings = ThemeSettings::get_global(cx).clone(); - let text_style = TextStyle { - color: cx.theme().colors().editor_foreground, - font_size: settings.buffer_font_size(cx).into(), - font_family: settings.buffer_font.family, - font_features: settings.buffer_font.features, - font_fallbacks: settings.buffer_font.fallbacks, - line_height: relative(settings.buffer_line_height.value()), - font_weight: settings.buffer_font.weight, - font_style: settings.buffer_font.style, - ..Default::default() - }; - let element = StyledText::new(diff).with_default_highlights(&text_style, diff_highlights); - let text_layout = element.layout().clone(); - - CompletionDiffElement { - element: element.into_any_element(), - text_layout, - cursor_offset: cursor_offset_in_diff, - } - } -} - -impl IntoElement for CompletionDiffElement { - type Element = Self; - - fn into_element(self) -> Self { - self - } -} - -impl Element for CompletionDiffElement { - type RequestLayoutState = (); - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { - (self.element.request_layout(window, cx), ()) - } - - fn prepaint( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - _bounds: gpui::Bounds, - _request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - self.element.prepaint(window, cx); - } - - fn paint( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - _bounds: gpui::Bounds, - _request_layout: &mut Self::RequestLayoutState, - _prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - if let Some(position) = self.text_layout.position_for_index(self.cursor_offset) { - let bounds = self.text_layout.bounds(); - let line_height = self.text_layout.line_height(); - let line_width = self - .text_layout - .line_layout_for_index(self.cursor_offset) - .map_or(bounds.size.width, |layout| layout.width()); - window.paint_quad(quad( - Bounds::new( - point(bounds.origin.x, position.y), - size(cmp::max(bounds.size.width, line_width), line_height), - ), - Corners::default(), - cx.theme().colors().editor_active_line_background, - Edges::default(), - Hsla::transparent_black(), - BorderStyle::default(), - )); - self.element.paint(window, cx); - window.paint_quad(quad( - Bounds::new(position, size(px(2.), line_height)), - Corners::default(), - cx.theme().players().local().cursor, - Edges::default(), - Hsla::transparent_black(), - BorderStyle::default(), - )); - } - } -} diff --git a/crates/zeta/src/init.rs b/crates/zeta/src/init.rs deleted file mode 100644 index 0167d878fa..0000000000 --- a/crates/zeta/src/init.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::any::{Any, TypeId}; - -use command_palette_hooks::CommandPaletteFilter; -use feature_flags::{FeatureFlagAppExt as _, PredictEditsRateCompletionsFeatureFlag}; -use gpui::actions; -use language::language_settings::EditPredictionProvider; -use project::DisableAiSettings; -use settings::{Settings, SettingsStore, update_settings_file}; -use ui::App; -use workspace::Workspace; - -use crate::{RateCompletionModal, onboarding_modal::ZedPredictModal}; - -actions!( - edit_prediction, - [ - /// Resets the edit prediction onboarding state. - ResetOnboarding, - /// Opens the rate completions modal. - RateCompletions - ] -); - -pub fn init(cx: &mut App) { - feature_gate_predict_edits_actions(cx); - - cx.observe_new(move |workspace: &mut Workspace, _, _cx| { - workspace.register_action(|workspace, _: &RateCompletions, window, cx| { - if cx.has_flag::() { - RateCompletionModal::toggle(workspace, window, cx); - } - }); - - workspace.register_action( - move |workspace, _: &zed_actions::OpenZedPredictOnboarding, window, cx| { - ZedPredictModal::toggle( - workspace, - workspace.user_store().clone(), - workspace.client().clone(), - window, - cx, - ) - }, - ); - - workspace.register_action(|workspace, _: &ResetOnboarding, _window, cx| { - update_settings_file(workspace.app_state().fs.clone(), cx, move |settings, _| { - settings - .project - .all_languages - .features - .get_or_insert_default() - .edit_prediction_provider = Some(EditPredictionProvider::None) - }); - }); - }) - .detach(); -} - -fn feature_gate_predict_edits_actions(cx: &mut App) { - let rate_completion_action_types = [TypeId::of::()]; - let reset_onboarding_action_types = [TypeId::of::()]; - let zeta_all_action_types = [ - TypeId::of::(), - TypeId::of::(), - zed_actions::OpenZedPredictOnboarding.type_id(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - ]; - - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.hide_action_types(&rate_completion_action_types); - filter.hide_action_types(&reset_onboarding_action_types); - filter.hide_action_types(&[zed_actions::OpenZedPredictOnboarding.type_id()]); - }); - - cx.observe_global::(move |cx| { - let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; - let has_feature_flag = cx.has_flag::(); - - CommandPaletteFilter::update_global(cx, |filter, _cx| { - if is_ai_disabled { - filter.hide_action_types(&zeta_all_action_types); - } else if has_feature_flag { - filter.show_action_types(&rate_completion_action_types); - } else { - filter.hide_action_types(&rate_completion_action_types); - } - }); - }) - .detach(); - - cx.observe_flag::(move |is_enabled, cx| { - if !DisableAiSettings::get_global(cx).disable_ai { - if is_enabled { - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.show_action_types(&rate_completion_action_types); - }); - } else { - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.hide_action_types(&rate_completion_action_types); - }); - } - } - }) - .detach(); -} diff --git a/crates/zeta/src/input_excerpt.rs b/crates/zeta/src/input_excerpt.rs deleted file mode 100644 index 06bff5b1be..0000000000 --- a/crates/zeta/src/input_excerpt.rs +++ /dev/null @@ -1,229 +0,0 @@ -use crate::{ - CURSOR_MARKER, EDITABLE_REGION_END_MARKER, EDITABLE_REGION_START_MARKER, START_OF_FILE_MARKER, - guess_token_count, -}; -use language::{BufferSnapshot, Point}; -use std::{fmt::Write, ops::Range}; - -#[derive(Debug)] -pub struct InputExcerpt { - pub editable_range: Range, - pub prompt: String, -} - -pub fn excerpt_for_cursor_position( - position: Point, - path: &str, - snapshot: &BufferSnapshot, - editable_region_token_limit: usize, - context_token_limit: usize, -) -> InputExcerpt { - let mut scope_range = position..position; - let mut remaining_edit_tokens = editable_region_token_limit; - - while let Some(parent) = snapshot.syntax_ancestor(scope_range.clone()) { - let parent_tokens = guess_token_count(parent.byte_range().len()); - let parent_point_range = Point::new( - parent.start_position().row as u32, - parent.start_position().column as u32, - ) - ..Point::new( - parent.end_position().row as u32, - parent.end_position().column as u32, - ); - if parent_point_range == scope_range { - break; - } else if parent_tokens <= editable_region_token_limit { - scope_range = parent_point_range; - remaining_edit_tokens = editable_region_token_limit - parent_tokens; - } else { - break; - } - } - - let editable_range = expand_range(snapshot, scope_range, remaining_edit_tokens); - let context_range = expand_range(snapshot, editable_range.clone(), context_token_limit); - - let mut prompt = String::new(); - - writeln!(&mut prompt, "```{path}").unwrap(); - if context_range.start == Point::zero() { - writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap(); - } - - for chunk in snapshot.chunks(context_range.start..editable_range.start, false) { - prompt.push_str(chunk.text); - } - - push_editable_range(position, snapshot, editable_range.clone(), &mut prompt); - - for chunk in snapshot.chunks(editable_range.end..context_range.end, false) { - prompt.push_str(chunk.text); - } - write!(prompt, "\n```").unwrap(); - - InputExcerpt { - editable_range, - prompt, - } -} - -fn push_editable_range( - cursor_position: Point, - snapshot: &BufferSnapshot, - editable_range: Range, - prompt: &mut String, -) { - writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap(); - for chunk in snapshot.chunks(editable_range.start..cursor_position, false) { - prompt.push_str(chunk.text); - } - prompt.push_str(CURSOR_MARKER); - for chunk in snapshot.chunks(cursor_position..editable_range.end, false) { - prompt.push_str(chunk.text); - } - write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap(); -} - -fn expand_range( - snapshot: &BufferSnapshot, - range: Range, - mut remaining_tokens: usize, -) -> Range { - let mut expanded_range = range; - expanded_range.start.column = 0; - expanded_range.end.column = snapshot.line_len(expanded_range.end.row); - loop { - let mut expanded = false; - - if remaining_tokens > 0 && expanded_range.start.row > 0 { - expanded_range.start.row -= 1; - let line_tokens = - guess_token_count(snapshot.line_len(expanded_range.start.row) as usize); - remaining_tokens = remaining_tokens.saturating_sub(line_tokens); - expanded = true; - } - - if remaining_tokens > 0 && expanded_range.end.row < snapshot.max_point().row { - expanded_range.end.row += 1; - expanded_range.end.column = snapshot.line_len(expanded_range.end.row); - let line_tokens = guess_token_count(expanded_range.end.column as usize); - remaining_tokens = remaining_tokens.saturating_sub(line_tokens); - expanded = true; - } - - if !expanded { - break; - } - } - expanded_range -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::{App, AppContext}; - use indoc::indoc; - use language::{Buffer, Language, LanguageConfig, LanguageMatcher}; - use std::sync::Arc; - - #[gpui::test] - fn test_excerpt_for_cursor_position(cx: &mut App) { - let text = indoc! {r#" - fn foo() { - let x = 42; - println!("Hello, world!"); - } - - fn bar() { - let x = 42; - let mut sum = 0; - for i in 0..x { - sum += i; - } - println!("Sum: {}", sum); - return sum; - } - - fn generate_random_numbers() -> Vec { - let mut rng = rand::thread_rng(); - let mut numbers = Vec::new(); - for _ in 0..5 { - numbers.push(rng.random_range(1..101)); - } - numbers - } - "#}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let snapshot = buffer.read(cx).snapshot(); - - // Ensure we try to fit the largest possible syntax scope, resorting to line-based expansion - // when a larger scope doesn't fit the editable region. - let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32); - assert_eq!( - excerpt.prompt, - indoc! {r#" - ```main.rs - let x = 42; - println!("Hello, world!"); - <|editable_region_start|> - } - - fn bar() { - let x = 42; - let mut sum = 0; - for i in 0..x { - sum += i; - } - println!("Sum: {}", sum); - r<|user_cursor_is_here|>eturn sum; - } - - fn generate_random_numbers() -> Vec { - <|editable_region_end|> - let mut rng = rand::thread_rng(); - let mut numbers = Vec::new(); - ```"#} - ); - - // The `bar` function won't fit within the editable region, so we resort to line-based expansion. - let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32); - assert_eq!( - excerpt.prompt, - indoc! {r#" - ```main.rs - fn bar() { - let x = 42; - let mut sum = 0; - <|editable_region_start|> - for i in 0..x { - sum += i; - } - println!("Sum: {}", sum); - r<|user_cursor_is_here|>eturn sum; - } - - fn generate_random_numbers() -> Vec { - let mut rng = rand::thread_rng(); - <|editable_region_end|> - let mut numbers = Vec::new(); - for _ in 0..5 { - numbers.push(rng.random_range(1..101)); - ```"#} - ); - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - } -} diff --git a/crates/zeta/src/onboarding_telemetry.rs b/crates/zeta/src/onboarding_telemetry.rs deleted file mode 100644 index 3c7d5e1442..0000000000 --- a/crates/zeta/src/onboarding_telemetry.rs +++ /dev/null @@ -1,9 +0,0 @@ -#[macro_export] -macro_rules! onboarding_event { - ($name:expr) => { - telemetry::event!($name, source = "Edit Prediction Onboarding"); - }; - ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { - telemetry::event!($name, source = "Edit Prediction Onboarding", $($key $(= $value)?),+); - }; -} diff --git a/crates/zeta/src/rate_completion_modal.rs b/crates/zeta/src/rate_completion_modal.rs deleted file mode 100644 index 8028865b05..0000000000 --- a/crates/zeta/src/rate_completion_modal.rs +++ /dev/null @@ -1,691 +0,0 @@ -use crate::{CompletionDiffElement, EditPrediction, EditPredictionRating, Zeta}; -use editor::Editor; -use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, actions, prelude::*}; -use language::language_settings; -use std::time::Duration; -use ui::{KeyBinding, List, ListItem, ListItemSpacing, Tooltip, prelude::*}; -use workspace::{ModalView, Workspace}; - -actions!( - zeta, - [ - /// Rates the active completion with a thumbs up. - ThumbsUpActiveCompletion, - /// Rates the active completion with a thumbs down. - ThumbsDownActiveCompletion, - /// Navigates to the next edit in the completion history. - NextEdit, - /// Navigates to the previous edit in the completion history. - PreviousEdit, - /// Focuses on the completions list. - FocusCompletions, - /// Previews the selected completion. - PreviewCompletion, - ] -); - -pub struct RateCompletionModal { - zeta: Entity, - active_completion: Option, - selected_index: usize, - focus_handle: FocusHandle, - _subscription: gpui::Subscription, - current_view: RateCompletionView, -} - -struct ActiveCompletion { - completion: EditPrediction, - feedback_editor: Entity, -} - -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -enum RateCompletionView { - SuggestedEdits, - RawInput, -} - -impl RateCompletionView { - pub fn name(&self) -> &'static str { - match self { - Self::SuggestedEdits => "Suggested Edits", - Self::RawInput => "Recorded Events & Input", - } - } -} - -impl RateCompletionModal { - pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { - if let Some(zeta) = Zeta::global(cx) { - workspace.toggle_modal(window, cx, |_window, cx| RateCompletionModal::new(zeta, cx)); - - telemetry::event!("Rate Completion Modal Open", source = "Edit Prediction"); - } - } - - pub fn new(zeta: Entity, cx: &mut Context) -> Self { - let subscription = cx.observe(&zeta, |_, _, cx| cx.notify()); - - Self { - zeta, - selected_index: 0, - focus_handle: cx.focus_handle(), - active_completion: None, - _subscription: subscription, - current_view: RateCompletionView::SuggestedEdits, - } - } - - fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context) { - self.selected_index += 1; - self.selected_index = usize::min( - self.selected_index, - self.zeta.read(cx).shown_completions().count(), - ); - cx.notify(); - } - - fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _: &mut Window, - cx: &mut Context, - ) { - self.selected_index = self.selected_index.saturating_sub(1); - cx.notify(); - } - - fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) { - let next_index = self - .zeta - .read(cx) - .shown_completions() - .skip(self.selected_index) - .enumerate() - .skip(1) // Skip straight to the next item - .find(|(_, completion)| !completion.edits.is_empty()) - .map(|(ix, _)| ix + self.selected_index); - - if let Some(next_index) = next_index { - self.selected_index = next_index; - cx.notify(); - } - } - - fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context) { - let zeta = self.zeta.read(cx); - let completions_len = zeta.shown_completions_len(); - - let prev_index = self - .zeta - .read(cx) - .shown_completions() - .rev() - .skip((completions_len - 1) - self.selected_index) - .enumerate() - .skip(1) // Skip straight to the previous item - .find(|(_, completion)| !completion.edits.is_empty()) - .map(|(ix, _)| self.selected_index - ix); - - if let Some(prev_index) = prev_index { - self.selected_index = prev_index; - cx.notify(); - } - cx.notify(); - } - - fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context) { - self.selected_index = 0; - cx.notify(); - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.selected_index = self.zeta.read(cx).shown_completions_len() - 1; - cx.notify(); - } - - pub fn thumbs_up_active( - &mut self, - _: &ThumbsUpActiveCompletion, - window: &mut Window, - cx: &mut Context, - ) { - self.zeta.update(cx, |zeta, cx| { - if let Some(active) = &self.active_completion { - zeta.rate_completion( - &active.completion, - EditPredictionRating::Positive, - active.feedback_editor.read(cx).text(cx), - cx, - ); - } - }); - - let current_completion = self - .active_completion - .as_ref() - .map(|completion| completion.completion.clone()); - self.select_completion(current_completion, false, window, cx); - self.select_next_edit(&Default::default(), window, cx); - self.confirm(&Default::default(), window, cx); - - cx.notify(); - } - - pub fn thumbs_down_active( - &mut self, - _: &ThumbsDownActiveCompletion, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active) = &self.active_completion { - if active.feedback_editor.read(cx).text(cx).is_empty() { - return; - } - - self.zeta.update(cx, |zeta, cx| { - zeta.rate_completion( - &active.completion, - EditPredictionRating::Negative, - active.feedback_editor.read(cx).text(cx), - cx, - ); - }); - } - - let current_completion = self - .active_completion - .as_ref() - .map(|completion| completion.completion.clone()); - self.select_completion(current_completion, false, window, cx); - self.select_next_edit(&Default::default(), window, cx); - self.confirm(&Default::default(), window, cx); - - cx.notify(); - } - - fn focus_completions( - &mut self, - _: &FocusCompletions, - window: &mut Window, - cx: &mut Context, - ) { - cx.focus_self(window); - cx.notify(); - } - - fn preview_completion( - &mut self, - _: &PreviewCompletion, - window: &mut Window, - cx: &mut Context, - ) { - let completion = self - .zeta - .read(cx) - .shown_completions() - .skip(self.selected_index) - .take(1) - .next() - .cloned(); - - self.select_completion(completion, false, window, cx); - } - - fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let completion = self - .zeta - .read(cx) - .shown_completions() - .skip(self.selected_index) - .take(1) - .next() - .cloned(); - - self.select_completion(completion, true, window, cx); - } - - pub fn select_completion( - &mut self, - completion: Option, - focus: bool, - window: &mut Window, - cx: &mut Context, - ) { - // Avoid resetting completion rating if it's already selected. - if let Some(completion) = completion.as_ref() { - self.selected_index = self - .zeta - .read(cx) - .shown_completions() - .enumerate() - .find(|(_, completion_b)| completion.id == completion_b.id) - .map(|(ix, _)| ix) - .unwrap_or(self.selected_index); - cx.notify(); - - if let Some(prev_completion) = self.active_completion.as_ref() - && completion.id == prev_completion.completion.id - { - if focus { - window.focus(&prev_completion.feedback_editor.focus_handle(cx)); - } - return; - } - } - - self.active_completion = completion.map(|completion| ActiveCompletion { - completion, - feedback_editor: cx.new(|cx| { - let mut editor = Editor::multi_line(window, cx); - editor.disable_scrollbars_and_minimap(window, cx); - editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); - editor.set_show_line_numbers(false, cx); - editor.set_show_git_diff_gutter(false, cx); - editor.set_show_code_actions(false, cx); - editor.set_show_runnables(false, cx); - editor.set_show_breakpoints(false, cx); - editor.set_show_wrap_guides(false, cx); - editor.set_show_indent_guides(false, cx); - editor.set_show_edit_predictions(Some(false), window, cx); - editor.set_placeholder_text("Add your feedback…", window, cx); - if focus { - cx.focus_self(window); - } - editor - }), - }); - cx.notify(); - } - - fn render_view_nav(&self, cx: &Context) -> impl IntoElement { - h_flex() - .h_8() - .px_1() - .border_b_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) - .gap_1() - .child( - Button::new( - ElementId::Name("suggested-edits".into()), - RateCompletionView::SuggestedEdits.name(), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener(move |this, _, _window, cx| { - this.current_view = RateCompletionView::SuggestedEdits; - cx.notify(); - })) - .toggle_state(self.current_view == RateCompletionView::SuggestedEdits), - ) - .child( - Button::new( - ElementId::Name("raw-input".into()), - RateCompletionView::RawInput.name(), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener(move |this, _, _window, cx| { - this.current_view = RateCompletionView::RawInput; - cx.notify(); - })) - .toggle_state(self.current_view == RateCompletionView::RawInput), - ) - } - - fn render_suggested_edits(&self, cx: &mut Context) -> Option> { - let active_completion = self.active_completion.as_ref()?; - let bg_color = cx.theme().colors().editor_background; - - Some( - div() - .id("diff") - .p_4() - .size_full() - .bg(bg_color) - .overflow_scroll() - .whitespace_nowrap() - .child(CompletionDiffElement::new( - &active_completion.completion, - cx, - )), - ) - } - - fn render_raw_input(&self, cx: &mut Context) -> Option> { - Some( - v_flex() - .size_full() - .overflow_hidden() - .relative() - .child( - div() - .id("raw-input") - .py_4() - .px_6() - .size_full() - .bg(cx.theme().colors().editor_background) - .overflow_scroll() - .child(if let Some(active_completion) = &self.active_completion { - format!( - "{}\n{}", - active_completion.completion.input_events, - active_completion.completion.input_excerpt - ) - } else { - "No active completion".to_string() - }), - ) - .id("raw-input-view"), - ) - } - - fn render_active_completion( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option { - let active_completion = self.active_completion.as_ref()?; - let completion_id = active_completion.completion.id; - let focus_handle = &self.focus_handle(cx); - - let border_color = cx.theme().colors().border; - let bg_color = cx.theme().colors().editor_background; - - let rated = self.zeta.read(cx).is_completion_rated(completion_id); - let feedback_empty = active_completion - .feedback_editor - .read(cx) - .text(cx) - .is_empty(); - - let label_container = h_flex().pl_1().gap_1p5(); - - Some( - v_flex() - .size_full() - .overflow_hidden() - .relative() - .child( - v_flex() - .size_full() - .overflow_hidden() - .relative() - .child(self.render_view_nav(cx)) - .when_some(match self.current_view { - RateCompletionView::SuggestedEdits => self.render_suggested_edits(cx), - RateCompletionView::RawInput => self.render_raw_input(cx), - }, |this, element| this.child(element)) - ) - .when(!rated, |this| { - this.child( - h_flex() - .p_2() - .gap_2() - .border_y_1() - .border_color(border_color) - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted) - ) - .child( - div() - .w_full() - .pr_2() - .flex_wrap() - .child( - Label::new("Explain why this completion is good or bad. If it's negative, describe what you expected instead.") - .size(LabelSize::Small) - .color(Color::Muted) - ) - ) - ) - }) - .when(!rated, |this| { - this.child( - div() - .h_40() - .pt_1() - .bg(bg_color) - .child(active_completion.feedback_editor.clone()) - ) - }) - .child( - h_flex() - .p_1() - .h_8() - .max_h_8() - .border_t_1() - .border_color(border_color) - .max_w_full() - .justify_between() - .children(if rated { - Some( - label_container - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Rated completion.").color(Color::Muted)), - ) - } else if active_completion.completion.edits.is_empty() { - Some( - label_container - .child( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .child(Label::new("No edits produced.").color(Color::Muted)), - ) - } else { - Some(label_container) - }) - .child( - h_flex() - .gap_1() - .child( - Button::new("bad", "Bad Completion") - .icon(IconName::ThumbsDown) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(rated || feedback_empty) - .when(feedback_empty, |this| { - this.tooltip(Tooltip::text("Explain what's bad about it before reporting it")) - }) - .key_binding(KeyBinding::for_action_in( - &ThumbsDownActiveCompletion, - focus_handle, - window, - cx - )) - .on_click(cx.listener(move |this, _, window, cx| { - if this.active_completion.is_some() { - this.thumbs_down_active( - &ThumbsDownActiveCompletion, - window, cx, - ); - } - })), - ) - .child( - Button::new("good", "Good Completion") - .icon(IconName::ThumbsUp) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(rated) - .key_binding(KeyBinding::for_action_in( - &ThumbsUpActiveCompletion, - focus_handle, - window, - cx - )) - .on_click(cx.listener(move |this, _, window, cx| { - if this.active_completion.is_some() { - this.thumbs_up_active(&ThumbsUpActiveCompletion, window, cx); - } - })), - ), - ), - ), - ) - } -} - -impl Render for RateCompletionModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let border_color = cx.theme().colors().border; - - h_flex() - .key_context("RateCompletionModal") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::dismiss)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_prev_edit)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_next_edit)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::thumbs_up_active)) - .on_action(cx.listener(Self::thumbs_down_active)) - .on_action(cx.listener(Self::focus_completions)) - .on_action(cx.listener(Self::preview_completion)) - .bg(cx.theme().colors().elevated_surface_background) - .border_1() - .border_color(border_color) - .w(window.viewport_size().width - px(320.)) - .h(window.viewport_size().height - px(300.)) - .rounded_lg() - .shadow_lg() - .child( - v_flex() - .w_72() - .h_full() - .border_r_1() - .border_color(border_color) - .flex_shrink_0() - .overflow_hidden() - .child( - h_flex() - .h_8() - .px_2() - .justify_between() - .border_b_1() - .border_color(border_color) - .child( - Icon::new(IconName::ZedPredict) - .size(IconSize::Small) - ) - .child( - Label::new("From most recent to oldest") - .color(Color::Muted) - .size(LabelSize::Small), - ) - ) - .child( - div() - .id("completion_list") - .p_0p5() - .h_full() - .overflow_y_scroll() - .child( - List::new() - .empty_message( - div() - .p_2() - .child( - Label::new("No completions yet. Use the editor to generate some, and make sure to rate them!") - .color(Color::Muted), - ) - .into_any_element(), - ) - .children(self.zeta.read(cx).shown_completions().cloned().enumerate().map( - |(index, completion)| { - let selected = - self.active_completion.as_ref().is_some_and(|selected| { - selected.completion.id == completion.id - }); - let rated = - self.zeta.read(cx).is_completion_rated(completion.id); - - let (icon_name, icon_color, tooltip_text) = match (rated, completion.edits.is_empty()) { - (true, _) => (IconName::Check, Color::Success, "Rated Completion"), - (false, true) => (IconName::File, Color::Muted, "No Edits Produced"), - (false, false) => (IconName::FileDiff, Color::Accent, "Edits Available"), - }; - - let file_name = completion.path.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or("untitled".to_string()); - let file_path = completion.path.parent().map(|p| p.to_string_lossy().into_owned()); - - ListItem::new(completion.id) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .focused(index == self.selected_index) - .toggle_state(selected) - .child( - h_flex() - .id("completion-content") - .gap_3() - .child( - Icon::new(icon_name) - .color(icon_color) - .size(IconSize::Small) - ) - .child( - v_flex() - .child( - h_flex().gap_1() - .child(Label::new(file_name).size(LabelSize::Small)) - .when_some(file_path, |this, p| this.child(Label::new(p).size(LabelSize::Small).color(Color::Muted))) - ) - .child(Label::new(format!("{} ago, {:.2?}", format_time_ago(completion.response_received_at.elapsed()), completion.latency())) - .color(Color::Muted) - .size(LabelSize::XSmall) - ) - ) - ) - .tooltip(Tooltip::text(tooltip_text)) - .on_click(cx.listener(move |this, _, window, cx| { - this.select_completion(Some(completion.clone()), true, window, cx); - })) - }, - )), - ) - ), - ) - .children(self.render_active_completion(window, cx)) - .on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))) - } -} - -impl EventEmitter for RateCompletionModal {} - -impl Focusable for RateCompletionModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl ModalView for RateCompletionModal {} - -fn format_time_ago(elapsed: Duration) -> String { - let seconds = elapsed.as_secs(); - if seconds < 120 { - "1 minute".to_string() - } else if seconds < 3600 { - format!("{} minutes", seconds / 60) - } else if seconds < 7200 { - "1 hour".to_string() - } else if seconds < 86400 { - format!("{} hours", seconds / 3600) - } else if seconds < 172800 { - "1 day".to_string() - } else { - format!("{} days", seconds / 86400) - } -} diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs deleted file mode 100644 index 3a156f351d..0000000000 --- a/crates/zeta/src/zeta.rs +++ /dev/null @@ -1,2296 +0,0 @@ -mod completion_diff_element; -mod init; -mod input_excerpt; -mod license_detection; -mod onboarding_modal; -mod onboarding_telemetry; -mod rate_completion_modal; - -pub(crate) use completion_diff_element::*; -use db::kvp::{Dismissable, KEY_VALUE_STORE}; -use edit_prediction::DataCollectionState; -pub use init::*; -use license_detection::LicenseDetectionWatcher; -pub use rate_completion_modal::*; - -use anyhow::{Context as _, Result, anyhow}; -use arrayvec::ArrayVec; -use client::{Client, EditPredictionUsage, UserStore}; -use cloud_llm_client::{ - AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, MINIMUM_REQUIRED_VERSION_HEADER_NAME, - PredictEditsBody, PredictEditsGitInfo, PredictEditsResponse, ZED_VERSION_HEADER_NAME, -}; -use collections::{HashMap, HashSet, VecDeque}; -use futures::AsyncReadExt; -use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EntityId, Global, SemanticVersion, - SharedString, Subscription, Task, actions, -}; -use http_client::{AsyncBody, HttpClient, Method, Request, Response}; -use input_excerpt::excerpt_for_cursor_position; -use language::{ - Anchor, Buffer, BufferSnapshot, EditPreview, File, OffsetRangeExt, ToOffset, ToPoint, text_diff, -}; -use language_model::{LlmApiToken, RefreshLlmTokenListener}; -use project::{Project, ProjectPath}; -use release_channel::AppVersion; -use settings::WorktreeId; -use std::collections::hash_map; -use std::mem; -use std::str::FromStr; -use std::{ - cmp, - fmt::Write, - future::Future, - ops::Range, - path::Path, - rc::Rc, - sync::Arc, - time::{Duration, Instant}, -}; -use telemetry_events::EditPredictionRating; -use thiserror::Error; -use util::ResultExt; -use util::rel_path::RelPath; -use uuid::Uuid; -use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; -use worktree::Worktree; - -const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; -const START_OF_FILE_MARKER: &str = "<|start_of_file|>"; -const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>"; -const EDITABLE_REGION_END_MARKER: &str = "<|editable_region_end|>"; -const BUFFER_CHANGE_GROUPING_INTERVAL: Duration = Duration::from_secs(1); -const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; - -const MAX_CONTEXT_TOKENS: usize = 150; -const MAX_REWRITE_TOKENS: usize = 350; -const MAX_EVENT_TOKENS: usize = 500; - -/// Maximum number of events to track. -const MAX_EVENT_COUNT: usize = 16; - -actions!( - edit_prediction, - [ - /// Clears the edit prediction history. - ClearHistory - ] -); - -#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] -pub struct EditPredictionId(Uuid); - -impl From for gpui::ElementId { - fn from(value: EditPredictionId) -> Self { - gpui::ElementId::Uuid(value.0) - } -} - -impl std::fmt::Display for EditPredictionId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -struct ZedPredictUpsell; - -impl Dismissable for ZedPredictUpsell { - const KEY: &'static str = "dismissed-edit-predict-upsell"; - - fn dismissed() -> bool { - // To make this backwards compatible with older versions of Zed, we - // check if the user has seen the previous Edit Prediction Onboarding - // before, by checking the data collection choice which was written to - // the database once the user clicked on "Accept and Enable" - if KEY_VALUE_STORE - .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) - .log_err() - .is_some_and(|s| s.is_some()) - { - return true; - } - - KEY_VALUE_STORE - .read_kvp(Self::KEY) - .log_err() - .is_some_and(|s| s.is_some()) - } -} - -pub fn should_show_upsell_modal() -> bool { - !ZedPredictUpsell::dismissed() -} - -#[derive(Clone)] -struct ZetaGlobal(Entity); - -impl Global for ZetaGlobal {} - -#[derive(Clone)] -pub struct EditPrediction { - id: EditPredictionId, - path: Arc, - excerpt_range: Range, - cursor_offset: usize, - edits: Arc<[(Range, String)]>, - snapshot: BufferSnapshot, - edit_preview: EditPreview, - input_outline: Arc, - input_events: Arc, - input_excerpt: Arc, - output_excerpt: Arc, - buffer_snapshotted_at: Instant, - response_received_at: Instant, -} - -impl EditPrediction { - fn latency(&self) -> Duration { - self.response_received_at - .duration_since(self.buffer_snapshotted_at) - } - - fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option, String)>> { - interpolate(&self.snapshot, new_snapshot, self.edits.clone()) - } -} - -fn interpolate( - old_snapshot: &BufferSnapshot, - new_snapshot: &BufferSnapshot, - current_edits: Arc<[(Range, String)]>, -) -> Option, String)>> { - let mut edits = Vec::new(); - - let mut model_edits = current_edits.iter().peekable(); - for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { - while let Some((model_old_range, _)) = model_edits.peek() { - let model_old_range = model_old_range.to_offset(old_snapshot); - if model_old_range.end < user_edit.old.start { - let (model_old_range, model_new_text) = model_edits.next().unwrap(); - edits.push((model_old_range.clone(), model_new_text.clone())); - } else { - break; - } - } - - if let Some((model_old_range, model_new_text)) = model_edits.peek() { - let model_old_offset_range = model_old_range.to_offset(old_snapshot); - if user_edit.old == model_old_offset_range { - let user_new_text = new_snapshot - .text_for_range(user_edit.new.clone()) - .collect::(); - - if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { - if !model_suffix.is_empty() { - let anchor = old_snapshot.anchor_after(user_edit.old.end); - edits.push((anchor..anchor, model_suffix.to_string())); - } - - model_edits.next(); - continue; - } - } - } - - return None; - } - - edits.extend(model_edits.cloned()); - - if edits.is_empty() { None } else { Some(edits) } -} - -impl std::fmt::Debug for EditPrediction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EditPrediction") - .field("id", &self.id) - .field("path", &self.path) - .field("edits", &self.edits) - .finish_non_exhaustive() - } -} - -pub struct Zeta { - projects: HashMap, - client: Arc, - shown_completions: VecDeque, - rated_completions: HashSet, - data_collection_choice: DataCollectionChoice, - llm_token: LlmApiToken, - _llm_token_subscription: Subscription, - /// Whether an update to a newer version of Zed is required to continue using Zeta. - update_required: bool, - user_store: Entity, - license_detection_watchers: HashMap>, -} - -struct ZetaProject { - events: VecDeque, - registered_buffers: HashMap, -} - -impl Zeta { - pub fn global(cx: &mut App) -> Option> { - cx.try_global::().map(|global| global.0.clone()) - } - - pub fn register( - worktree: Option>, - client: Arc, - user_store: Entity, - cx: &mut App, - ) -> Entity { - let this = Self::global(cx).unwrap_or_else(|| { - let entity = cx.new(|cx| Self::new(client, user_store, cx)); - cx.set_global(ZetaGlobal(entity.clone())); - entity - }); - - this.update(cx, move |this, cx| { - if let Some(worktree) = worktree { - let worktree_id = worktree.read(cx).id(); - this.license_detection_watchers - .entry(worktree_id) - .or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(&worktree, cx))); - } - }); - - this - } - - pub fn clear_history(&mut self) { - for zeta_project in self.projects.values_mut() { - zeta_project.events.clear(); - } - } - - pub fn usage(&self, cx: &App) -> Option { - self.user_store.read(cx).edit_prediction_usage() - } - - fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); - let data_collection_choice = Self::load_data_collection_choice(); - Self { - projects: HashMap::default(), - client, - shown_completions: VecDeque::new(), - rated_completions: HashSet::default(), - data_collection_choice, - llm_token: LlmApiToken::default(), - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _listener, _event, cx| { - let client = this.client.clone(); - let llm_token = this.llm_token.clone(); - cx.spawn(async move |_this, _cx| { - llm_token.refresh(&client).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), - update_required: false, - license_detection_watchers: HashMap::default(), - user_store, - } - } - - fn get_or_init_zeta_project( - &mut self, - project: &Entity, - cx: &mut Context, - ) -> &mut ZetaProject { - let project_id = project.entity_id(); - match self.projects.entry(project_id) { - hash_map::Entry::Occupied(entry) => entry.into_mut(), - hash_map::Entry::Vacant(entry) => { - cx.observe_release(project, move |this, _, _cx| { - this.projects.remove(&project_id); - }) - .detach(); - entry.insert(ZetaProject { - events: VecDeque::with_capacity(MAX_EVENT_COUNT), - registered_buffers: HashMap::default(), - }) - } - } - } - - fn push_event(zeta_project: &mut ZetaProject, event: Event) { - let events = &mut zeta_project.events; - - if let Some(Event::BufferChange { - new_snapshot: last_new_snapshot, - timestamp: last_timestamp, - .. - }) = events.back_mut() - { - // Coalesce edits for the same buffer when they happen one after the other. - let Event::BufferChange { - old_snapshot, - new_snapshot, - timestamp, - } = &event; - - if timestamp.duration_since(*last_timestamp) <= BUFFER_CHANGE_GROUPING_INTERVAL - && old_snapshot.remote_id() == last_new_snapshot.remote_id() - && old_snapshot.version == last_new_snapshot.version - { - *last_new_snapshot = new_snapshot.clone(); - *last_timestamp = *timestamp; - return; - } - } - - if events.len() >= MAX_EVENT_COUNT { - // These are halved instead of popping to improve prompt caching. - events.drain(..MAX_EVENT_COUNT / 2); - } - - events.push_back(event); - } - - pub fn register_buffer( - &mut self, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) { - let zeta_project = self.get_or_init_zeta_project(project, cx); - Self::register_buffer_impl(zeta_project, buffer, project, cx); - } - - fn register_buffer_impl<'a>( - zeta_project: &'a mut ZetaProject, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) -> &'a mut RegisteredBuffer { - let buffer_id = buffer.entity_id(); - match zeta_project.registered_buffers.entry(buffer_id) { - hash_map::Entry::Occupied(entry) => entry.into_mut(), - hash_map::Entry::Vacant(entry) => { - let snapshot = buffer.read(cx).snapshot(); - let project_entity_id = project.entity_id(); - entry.insert(RegisteredBuffer { - snapshot, - _subscriptions: [ - cx.subscribe(buffer, { - let project = project.downgrade(); - move |this, buffer, event, cx| { - if let language::BufferEvent::Edited = event - && let Some(project) = project.upgrade() - { - this.report_changes_for_buffer(&buffer, &project, cx); - } - } - }), - cx.observe_release(buffer, move |this, _buffer, _cx| { - let Some(zeta_project) = this.projects.get_mut(&project_entity_id) - else { - return; - }; - zeta_project.registered_buffers.remove(&buffer_id); - }), - ], - }) - } - } - } - - fn request_completion_impl( - &mut self, - project: &Entity, - buffer: &Entity, - cursor: language::Anchor, - cx: &mut Context, - perform_predict_edits: F, - ) -> Task>> - where - F: FnOnce(PerformPredictEditsParams) -> R + 'static, - R: Future)>> - + Send - + 'static, - { - let buffer = buffer.clone(); - let buffer_snapshotted_at = Instant::now(); - let snapshot = self.report_changes_for_buffer(&buffer, project, cx); - let zeta = cx.entity(); - let client = self.client.clone(); - let llm_token = self.llm_token.clone(); - let app_version = AppVersion::global(cx); - - let zeta_project = self.get_or_init_zeta_project(project, cx); - let mut events = Vec::with_capacity(zeta_project.events.len()); - events.extend(zeta_project.events.iter().cloned()); - let events = Arc::new(events); - - let (git_info, can_collect_file) = if let Some(file) = snapshot.file() { - let can_collect_file = self.can_collect_file(file, cx); - let git_info = if can_collect_file { - git_info_for_file(project, &ProjectPath::from_file(file.as_ref(), cx), cx) - } else { - None - }; - (git_info, can_collect_file) - } else { - (None, false) - }; - - let full_path: Arc = snapshot - .file() - .map(|f| Arc::from(f.full_path(cx).as_path())) - .unwrap_or_else(|| Arc::from(Path::new("untitled"))); - let full_path_str = full_path.to_string_lossy().into_owned(); - let cursor_point = cursor.to_point(&snapshot); - let cursor_offset = cursor_point.to_offset(&snapshot); - let prompt_for_events = { - let events = events.clone(); - move || prompt_for_events_impl(&events, MAX_EVENT_TOKENS) - }; - let gather_task = gather_context( - full_path_str, - &snapshot, - cursor_point, - prompt_for_events, - cx, - ); - - cx.spawn(async move |this, cx| { - let GatherContextOutput { - mut body, - editable_range, - included_events_count, - } = gather_task.await?; - let done_gathering_context_at = Instant::now(); - - let included_events = &events[events.len() - included_events_count..events.len()]; - body.can_collect_data = can_collect_file - && this - .read_with(cx, |this, cx| this.can_collect_events(included_events, cx)) - .unwrap_or(false); - if body.can_collect_data { - body.git_info = git_info; - } - - log::debug!( - "Events:\n{}\nExcerpt:\n{:?}", - body.input_events, - body.input_excerpt - ); - - let input_outline = body.outline.clone().unwrap_or_default(); - let input_events = body.input_events.clone(); - let input_excerpt = body.input_excerpt.clone(); - - let response = perform_predict_edits(PerformPredictEditsParams { - client, - llm_token, - app_version, - body, - }) - .await; - let (response, usage) = match response { - Ok(response) => response, - Err(err) => { - if err.is::() { - cx.update(|cx| { - zeta.update(cx, |zeta, _cx| { - zeta.update_required = true; - }); - - let error_message: SharedString = err.to_string().into(); - show_app_notification( - NotificationId::unique::(), - cx, - move |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new(error_message.clone(), cx) - .with_link_button( - "Update Zed", - "https://zed.dev/releases", - ) - }) - }, - ); - }) - .ok(); - } - - return Err(err); - } - }; - - let received_response_at = Instant::now(); - log::debug!("completion response: {}", &response.output_excerpt); - - if let Some(usage) = usage { - this.update(cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.update_edit_prediction_usage(usage, cx); - }); - }) - .ok(); - } - - let edit_prediction = Self::process_completion_response( - response, - buffer, - &snapshot, - editable_range, - cursor_offset, - full_path, - input_outline, - input_events, - input_excerpt, - buffer_snapshotted_at, - cx, - ) - .await; - - let finished_at = Instant::now(); - - // record latency for ~1% of requests - if rand::random::() <= 2 { - telemetry::event!( - "Edit Prediction Request", - context_latency = done_gathering_context_at - .duration_since(buffer_snapshotted_at) - .as_millis(), - request_latency = received_response_at - .duration_since(done_gathering_context_at) - .as_millis(), - process_latency = finished_at.duration_since(received_response_at).as_millis() - ); - } - - edit_prediction - }) - } - - #[cfg(any(test, feature = "test-support"))] - pub fn fake_completion( - &mut self, - project: &Entity, - buffer: &Entity, - position: language::Anchor, - response: PredictEditsResponse, - cx: &mut Context, - ) -> Task>> { - self.request_completion_impl(project, buffer, position, cx, |_params| { - std::future::ready(Ok((response, None))) - }) - } - - pub fn request_completion( - &mut self, - project: &Entity, - buffer: &Entity, - position: language::Anchor, - cx: &mut Context, - ) -> Task>> { - self.request_completion_impl(project, buffer, position, cx, Self::perform_predict_edits) - } - - pub fn perform_predict_edits( - params: PerformPredictEditsParams, - ) -> impl Future)>> { - async move { - let PerformPredictEditsParams { - client, - llm_token, - app_version, - body, - .. - } = params; - - let http_client = client.http_client(); - let mut token = llm_token.acquire(&client).await?; - let mut did_retry = false; - - loop { - let request_builder = http_client::Request::builder().method(Method::POST); - let request_builder = - if let Ok(predict_edits_url) = std::env::var("ZED_PREDICT_EDITS_URL") { - request_builder.uri(predict_edits_url) - } else { - request_builder.uri( - http_client - .build_zed_llm_url("/predict_edits/v2", &[])? - .as_ref(), - ) - }; - let request = request_builder - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", token)) - .header(ZED_VERSION_HEADER_NAME, app_version.to_string()) - .body(serde_json::to_string(&body)?.into())?; - - let mut response = http_client.send(request).await?; - - if let Some(minimum_required_version) = response - .headers() - .get(MINIMUM_REQUIRED_VERSION_HEADER_NAME) - .and_then(|version| SemanticVersion::from_str(version.to_str().ok()?).ok()) - { - anyhow::ensure!( - app_version >= minimum_required_version, - ZedUpdateRequiredError { - minimum_version: minimum_required_version - } - ); - } - - if response.status().is_success() { - let usage = EditPredictionUsage::from_headers(response.headers()).ok(); - - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - return Ok((serde_json::from_str(&body)?, usage)); - } else if !did_retry - && response - .headers() - .get(EXPIRED_LLM_TOKEN_HEADER_NAME) - .is_some() - { - did_retry = true; - token = llm_token.refresh(&client).await?; - } else { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - anyhow::bail!( - "error predicting edits.\nStatus: {:?}\nBody: {}", - response.status(), - body - ); - } - } - } - } - - fn accept_edit_prediction( - &mut self, - request_id: EditPredictionId, - cx: &mut Context, - ) -> Task> { - let client = self.client.clone(); - let llm_token = self.llm_token.clone(); - let app_version = AppVersion::global(cx); - cx.spawn(async move |this, cx| { - let http_client = client.http_client(); - let mut response = llm_token_retry(&llm_token, &client, |token| { - let request_builder = http_client::Request::builder().method(Method::POST); - let request_builder = - if let Ok(accept_prediction_url) = std::env::var("ZED_ACCEPT_PREDICTION_URL") { - request_builder.uri(accept_prediction_url) - } else { - request_builder.uri( - http_client - .build_zed_llm_url("/predict_edits/accept", &[])? - .as_ref(), - ) - }; - Ok(request_builder - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", token)) - .header(ZED_VERSION_HEADER_NAME, app_version.to_string()) - .body( - serde_json::to_string(&AcceptEditPredictionBody { - request_id: request_id.0, - })? - .into(), - )?) - }) - .await?; - - if let Some(minimum_required_version) = response - .headers() - .get(MINIMUM_REQUIRED_VERSION_HEADER_NAME) - .and_then(|version| SemanticVersion::from_str(version.to_str().ok()?).ok()) - && app_version < minimum_required_version - { - return Err(anyhow!(ZedUpdateRequiredError { - minimum_version: minimum_required_version - })); - } - - if response.status().is_success() { - if let Some(usage) = EditPredictionUsage::from_headers(response.headers()).ok() { - this.update(cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.update_edit_prediction_usage(usage, cx); - }); - })?; - } - - Ok(()) - } else { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - Err(anyhow!( - "error accepting edit prediction.\nStatus: {:?}\nBody: {}", - response.status(), - body - )) - } - }) - } - - fn process_completion_response( - prediction_response: PredictEditsResponse, - buffer: Entity, - snapshot: &BufferSnapshot, - editable_range: Range, - cursor_offset: usize, - path: Arc, - input_outline: String, - input_events: String, - input_excerpt: String, - buffer_snapshotted_at: Instant, - cx: &AsyncApp, - ) -> Task>> { - let snapshot = snapshot.clone(); - let request_id = prediction_response.request_id; - let output_excerpt = prediction_response.output_excerpt; - cx.spawn(async move |cx| { - let output_excerpt: Arc = output_excerpt.into(); - - let edits: Arc<[(Range, String)]> = cx - .background_spawn({ - let output_excerpt = output_excerpt.clone(); - let editable_range = editable_range.clone(); - let snapshot = snapshot.clone(); - async move { Self::parse_edits(output_excerpt, editable_range, &snapshot) } - }) - .await? - .into(); - - let Some((edits, snapshot, edit_preview)) = buffer.read_with(cx, { - let edits = edits.clone(); - |buffer, cx| { - let new_snapshot = buffer.snapshot(); - let edits: Arc<[(Range, String)]> = - interpolate(&snapshot, &new_snapshot, edits)?.into(); - Some((edits.clone(), new_snapshot, buffer.preview_edits(edits, cx))) - } - })? - else { - return anyhow::Ok(None); - }; - - let edit_preview = edit_preview.await; - - Ok(Some(EditPrediction { - id: EditPredictionId(request_id), - path, - excerpt_range: editable_range, - cursor_offset, - edits, - edit_preview, - snapshot, - input_outline: input_outline.into(), - input_events: input_events.into(), - input_excerpt: input_excerpt.into(), - output_excerpt, - buffer_snapshotted_at, - response_received_at: Instant::now(), - })) - }) - } - - fn parse_edits( - output_excerpt: Arc, - editable_range: Range, - snapshot: &BufferSnapshot, - ) -> Result, String)>> { - let content = output_excerpt.replace(CURSOR_MARKER, ""); - - let start_markers = content - .match_indices(EDITABLE_REGION_START_MARKER) - .collect::>(); - anyhow::ensure!( - start_markers.len() == 1, - "expected exactly one start marker, found {}", - start_markers.len() - ); - - let end_markers = content - .match_indices(EDITABLE_REGION_END_MARKER) - .collect::>(); - anyhow::ensure!( - end_markers.len() == 1, - "expected exactly one end marker, found {}", - end_markers.len() - ); - - let sof_markers = content - .match_indices(START_OF_FILE_MARKER) - .collect::>(); - anyhow::ensure!( - sof_markers.len() <= 1, - "expected at most one start-of-file marker, found {}", - sof_markers.len() - ); - - let codefence_start = start_markers[0].0; - let content = &content[codefence_start..]; - - let newline_ix = content.find('\n').context("could not find newline")?; - let content = &content[newline_ix + 1..]; - - let codefence_end = content - .rfind(&format!("\n{EDITABLE_REGION_END_MARKER}")) - .context("could not find end marker")?; - let new_text = &content[..codefence_end]; - - let old_text = snapshot - .text_for_range(editable_range.clone()) - .collect::(); - - Ok(Self::compute_edits( - old_text, - new_text, - editable_range.start, - snapshot, - )) - } - - pub fn compute_edits( - old_text: String, - new_text: &str, - offset: usize, - snapshot: &BufferSnapshot, - ) -> Vec<(Range, String)> { - text_diff(&old_text, new_text) - .into_iter() - .map(|(mut old_range, new_text)| { - old_range.start += offset; - old_range.end += offset; - - let prefix_len = common_prefix( - snapshot.chars_for_range(old_range.clone()), - new_text.chars(), - ); - old_range.start += prefix_len; - - let suffix_len = common_prefix( - snapshot.reversed_chars_for_range(old_range.clone()), - new_text[prefix_len..].chars().rev(), - ); - old_range.end = old_range.end.saturating_sub(suffix_len); - - let new_text = new_text[prefix_len..new_text.len() - suffix_len].to_string(); - let range = if old_range.is_empty() { - let anchor = snapshot.anchor_after(old_range.start); - anchor..anchor - } else { - snapshot.anchor_after(old_range.start)..snapshot.anchor_before(old_range.end) - }; - (range, new_text) - }) - .collect() - } - - pub fn is_completion_rated(&self, completion_id: EditPredictionId) -> bool { - self.rated_completions.contains(&completion_id) - } - - pub fn completion_shown(&mut self, completion: &EditPrediction, cx: &mut Context) { - self.shown_completions.push_front(completion.clone()); - if self.shown_completions.len() > 50 { - let completion = self.shown_completions.pop_back().unwrap(); - self.rated_completions.remove(&completion.id); - } - cx.notify(); - } - - pub fn rate_completion( - &mut self, - completion: &EditPrediction, - rating: EditPredictionRating, - feedback: String, - cx: &mut Context, - ) { - self.rated_completions.insert(completion.id); - telemetry::event!( - "Edit Prediction Rated", - rating, - input_events = completion.input_events, - input_excerpt = completion.input_excerpt, - input_outline = completion.input_outline, - output_excerpt = completion.output_excerpt, - feedback - ); - self.client.telemetry().flush_events().detach(); - cx.notify(); - } - - pub fn shown_completions(&self) -> impl DoubleEndedIterator { - self.shown_completions.iter() - } - - pub fn shown_completions_len(&self) -> usize { - self.shown_completions.len() - } - - fn report_changes_for_buffer( - &mut self, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) -> BufferSnapshot { - let zeta_project = self.get_or_init_zeta_project(project, cx); - let registered_buffer = Self::register_buffer_impl(zeta_project, buffer, project, cx); - - let new_snapshot = buffer.read(cx).snapshot(); - if new_snapshot.version != registered_buffer.snapshot.version { - let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); - Self::push_event( - zeta_project, - Event::BufferChange { - old_snapshot, - new_snapshot: new_snapshot.clone(), - timestamp: Instant::now(), - }, - ); - } - - new_snapshot - } - - fn can_collect_file(&self, file: &Arc, cx: &App) -> bool { - self.data_collection_choice.is_enabled() && self.is_file_open_source(file, cx) - } - - fn can_collect_events(&self, events: &[Event], cx: &App) -> bool { - if !self.data_collection_choice.is_enabled() { - return false; - } - let mut last_checked_file = None; - for event in events { - match event { - Event::BufferChange { - old_snapshot, - new_snapshot, - .. - } => { - if let Some(old_file) = old_snapshot.file() - && let Some(new_file) = new_snapshot.file() - { - if let Some(last_checked_file) = last_checked_file - && Arc::ptr_eq(last_checked_file, old_file) - && Arc::ptr_eq(last_checked_file, new_file) - { - continue; - } - if !self.can_collect_file(old_file, cx) { - return false; - } - if !Arc::ptr_eq(old_file, new_file) && !self.can_collect_file(new_file, cx) - { - return false; - } - last_checked_file = Some(new_file); - } else { - return false; - } - } - } - } - true - } - - fn is_file_open_source(&self, file: &Arc, cx: &App) -> bool { - if !file.is_local() || file.is_private() { - return false; - } - self.license_detection_watchers - .get(&file.worktree_id(cx)) - .is_some_and(|watcher| watcher.is_project_open_source()) - } - - fn load_data_collection_choice() -> DataCollectionChoice { - let choice = KEY_VALUE_STORE - .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) - .log_err() - .flatten(); - - match choice.as_deref() { - Some("true") => DataCollectionChoice::Enabled, - Some("false") => DataCollectionChoice::Disabled, - Some(_) => { - log::error!("unknown value in '{ZED_PREDICT_DATA_COLLECTION_CHOICE}'"); - DataCollectionChoice::NotAnswered - } - None => DataCollectionChoice::NotAnswered, - } - } - - fn toggle_data_collection_choice(&mut self, cx: &mut Context) { - self.data_collection_choice = self.data_collection_choice.toggle(); - let new_choice = self.data_collection_choice; - db::write_and_log(cx, move || { - KEY_VALUE_STORE.write_kvp( - ZED_PREDICT_DATA_COLLECTION_CHOICE.into(), - new_choice.is_enabled().to_string(), - ) - }); - } -} - -pub struct PerformPredictEditsParams { - pub client: Arc, - pub llm_token: LlmApiToken, - pub app_version: SemanticVersion, - pub body: PredictEditsBody, -} - -#[derive(Error, Debug)] -#[error( - "You must update to Zed version {minimum_version} or higher to continue using edit predictions." -)] -pub struct ZedUpdateRequiredError { - minimum_version: SemanticVersion, -} - -fn common_prefix, T2: Iterator>(a: T1, b: T2) -> usize { - a.zip(b) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a.len_utf8()) - .sum() -} - -fn git_info_for_file( - project: &Entity, - project_path: &ProjectPath, - cx: &App, -) -> Option { - let git_store = project.read(cx).git_store().read(cx); - if let Some((repository, _repo_path)) = - git_store.repository_and_path_for_project_path(project_path, cx) - { - let repository = repository.read(cx); - let head_sha = repository - .head_commit - .as_ref() - .map(|head_commit| head_commit.sha.to_string()); - let remote_origin_url = repository.remote_origin_url.clone(); - let remote_upstream_url = repository.remote_upstream_url.clone(); - if head_sha.is_none() && remote_origin_url.is_none() && remote_upstream_url.is_none() { - return None; - } - Some(PredictEditsGitInfo { - head_sha, - remote_origin_url, - remote_upstream_url, - }) - } else { - None - } -} - -pub struct GatherContextOutput { - pub body: PredictEditsBody, - pub editable_range: Range, - pub included_events_count: usize, -} - -pub fn gather_context( - full_path_str: String, - snapshot: &BufferSnapshot, - cursor_point: language::Point, - prompt_for_events: impl FnOnce() -> (String, usize) + Send + 'static, - cx: &App, -) -> Task> { - cx.background_spawn({ - let snapshot = snapshot.clone(); - async move { - let input_excerpt = excerpt_for_cursor_position( - cursor_point, - &full_path_str, - &snapshot, - MAX_REWRITE_TOKENS, - MAX_CONTEXT_TOKENS, - ); - let (input_events, included_events_count) = prompt_for_events(); - let editable_range = input_excerpt.editable_range.to_offset(&snapshot); - - let body = PredictEditsBody { - input_events, - input_excerpt: input_excerpt.prompt, - can_collect_data: false, - diagnostic_groups: None, - git_info: None, - outline: None, - speculated_output: None, - }; - - Ok(GatherContextOutput { - body, - editable_range, - included_events_count, - }) - } - }) -} - -fn prompt_for_events_impl(events: &[Event], mut remaining_tokens: usize) -> (String, usize) { - let mut result = String::new(); - for (ix, event) in events.iter().rev().enumerate() { - let event_string = event.to_prompt(); - let event_tokens = guess_token_count(event_string.len()); - if event_tokens > remaining_tokens { - return (result, ix); - } - - if !result.is_empty() { - result.insert_str(0, "\n\n"); - } - result.insert_str(0, &event_string); - remaining_tokens -= event_tokens; - } - return (result, events.len()); -} - -struct RegisteredBuffer { - snapshot: BufferSnapshot, - _subscriptions: [gpui::Subscription; 2], -} - -#[derive(Clone)] -pub enum Event { - BufferChange { - old_snapshot: BufferSnapshot, - new_snapshot: BufferSnapshot, - timestamp: Instant, - }, -} - -impl Event { - fn to_prompt(&self) -> String { - match self { - Event::BufferChange { - old_snapshot, - new_snapshot, - .. - } => { - let mut prompt = String::new(); - - let old_path = old_snapshot - .file() - .map(|f| f.path().as_ref()) - .unwrap_or(RelPath::unix("untitled").unwrap()); - let new_path = new_snapshot - .file() - .map(|f| f.path().as_ref()) - .unwrap_or(RelPath::unix("untitled").unwrap()); - if old_path != new_path { - writeln!(prompt, "User renamed {:?} to {:?}\n", old_path, new_path).unwrap(); - } - - let diff = language::unified_diff(&old_snapshot.text(), &new_snapshot.text()); - if !diff.is_empty() { - write!( - prompt, - "User edited {:?}:\n```diff\n{}\n```", - new_path, diff - ) - .unwrap(); - } - - prompt - } - } - } -} - -#[derive(Debug, Clone)] -struct CurrentEditPrediction { - buffer_id: EntityId, - completion: EditPrediction, -} - -impl CurrentEditPrediction { - fn should_replace_completion(&self, old_completion: &Self, snapshot: &BufferSnapshot) -> bool { - if self.buffer_id != old_completion.buffer_id { - return true; - } - - let Some(old_edits) = old_completion.completion.interpolate(snapshot) else { - return true; - }; - let Some(new_edits) = self.completion.interpolate(snapshot) else { - return false; - }; - - if old_edits.len() == 1 && new_edits.len() == 1 { - let (old_range, old_text) = &old_edits[0]; - let (new_range, new_text) = &new_edits[0]; - new_range == old_range && new_text.starts_with(old_text) - } else { - true - } - } -} - -struct PendingCompletion { - id: usize, - _task: Task<()>, -} - -#[derive(Debug, Clone, Copy)] -pub enum DataCollectionChoice { - NotAnswered, - Enabled, - Disabled, -} - -impl DataCollectionChoice { - pub fn is_enabled(self) -> bool { - match self { - Self::Enabled => true, - Self::NotAnswered | Self::Disabled => false, - } - } - - pub fn is_answered(self) -> bool { - match self { - Self::Enabled | Self::Disabled => true, - Self::NotAnswered => false, - } - } - - #[must_use] - pub fn toggle(&self) -> DataCollectionChoice { - match self { - Self::Enabled => Self::Disabled, - Self::Disabled => Self::Enabled, - Self::NotAnswered => Self::Enabled, - } - } -} - -impl From for DataCollectionChoice { - fn from(value: bool) -> Self { - match value { - true => DataCollectionChoice::Enabled, - false => DataCollectionChoice::Disabled, - } - } -} - -async fn llm_token_retry( - llm_token: &LlmApiToken, - client: &Arc, - build_request: impl Fn(String) -> Result>, -) -> Result> { - let mut did_retry = false; - let http_client = client.http_client(); - let mut token = llm_token.acquire(client).await?; - loop { - let request = build_request(token.clone())?; - let response = http_client.send(request).await?; - - if !did_retry - && !response.status().is_success() - && response - .headers() - .get(EXPIRED_LLM_TOKEN_HEADER_NAME) - .is_some() - { - did_retry = true; - token = llm_token.refresh(client).await?; - continue; - } - - return Ok(response); - } -} - -pub struct ZetaEditPredictionProvider { - zeta: Entity, - singleton_buffer: Option>, - pending_completions: ArrayVec, - next_pending_completion_id: usize, - current_completion: Option, - last_request_timestamp: Instant, - project: Entity, -} - -impl ZetaEditPredictionProvider { - pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); - - pub fn new( - zeta: Entity, - project: Entity, - singleton_buffer: Option>, - ) -> Self { - Self { - zeta, - singleton_buffer, - pending_completions: ArrayVec::new(), - next_pending_completion_id: 0, - current_completion: None, - last_request_timestamp: Instant::now(), - project, - } - } -} - -impl edit_prediction::EditPredictionProvider for ZetaEditPredictionProvider { - fn name() -> &'static str { - "zed-predict" - } - - fn display_name() -> &'static str { - "Zed's Edit Predictions" - } - - fn show_completions_in_menu() -> bool { - true - } - - fn show_tab_accept_marker() -> bool { - true - } - - fn data_collection_state(&self, cx: &App) -> DataCollectionState { - if let Some(buffer) = &self.singleton_buffer - && let Some(file) = buffer.read(cx).file() - { - let is_project_open_source = self.zeta.read(cx).is_file_open_source(file, cx); - if self.zeta.read(cx).data_collection_choice.is_enabled() { - DataCollectionState::Enabled { - is_project_open_source, - } - } else { - DataCollectionState::Disabled { - is_project_open_source, - } - } - } else { - return DataCollectionState::Disabled { - is_project_open_source: false, - }; - } - } - - fn toggle_data_collection(&mut self, cx: &mut App) { - self.zeta - .update(cx, |zeta, cx| zeta.toggle_data_collection_choice(cx)); - } - - fn usage(&self, cx: &App) -> Option { - self.zeta.read(cx).usage(cx) - } - - fn is_enabled( - &self, - _buffer: &Entity, - _cursor_position: language::Anchor, - _cx: &App, - ) -> bool { - true - } - fn is_refreshing(&self) -> bool { - !self.pending_completions.is_empty() - } - - fn refresh( - &mut self, - buffer: Entity, - position: language::Anchor, - _debounce: bool, - cx: &mut Context, - ) { - if self.zeta.read(cx).update_required { - return; - } - - if self - .zeta - .read(cx) - .user_store - .read_with(cx, |user_store, _cx| { - user_store.account_too_young() || user_store.has_overdue_invoices() - }) - { - return; - } - - if let Some(current_completion) = self.current_completion.as_ref() { - let snapshot = buffer.read(cx).snapshot(); - if current_completion - .completion - .interpolate(&snapshot) - .is_some() - { - return; - } - } - - let pending_completion_id = self.next_pending_completion_id; - self.next_pending_completion_id += 1; - let last_request_timestamp = self.last_request_timestamp; - - let project = self.project.clone(); - let task = cx.spawn(async move |this, cx| { - if let Some(timeout) = (last_request_timestamp + Self::THROTTLE_TIMEOUT) - .checked_duration_since(Instant::now()) - { - cx.background_executor().timer(timeout).await; - } - - let completion_request = this.update(cx, |this, cx| { - this.last_request_timestamp = Instant::now(); - this.zeta.update(cx, |zeta, cx| { - zeta.request_completion(&project, &buffer, position, cx) - }) - }); - - let completion = match completion_request { - Ok(completion_request) => { - let completion_request = completion_request.await; - completion_request.map(|c| { - c.map(|completion| CurrentEditPrediction { - buffer_id: buffer.entity_id(), - completion, - }) - }) - } - Err(error) => Err(error), - }; - let Some(new_completion) = completion - .context("edit prediction failed") - .log_err() - .flatten() - else { - this.update(cx, |this, cx| { - if this.pending_completions[0].id == pending_completion_id { - this.pending_completions.remove(0); - } else { - this.pending_completions.clear(); - } - - cx.notify(); - }) - .ok(); - return; - }; - - this.update(cx, |this, cx| { - if this.pending_completions[0].id == pending_completion_id { - this.pending_completions.remove(0); - } else { - this.pending_completions.clear(); - } - - if let Some(old_completion) = this.current_completion.as_ref() { - let snapshot = buffer.read(cx).snapshot(); - if new_completion.should_replace_completion(old_completion, &snapshot) { - this.zeta.update(cx, |zeta, cx| { - zeta.completion_shown(&new_completion.completion, cx); - }); - this.current_completion = Some(new_completion); - } - } else { - this.zeta.update(cx, |zeta, cx| { - zeta.completion_shown(&new_completion.completion, cx); - }); - this.current_completion = Some(new_completion); - } - - cx.notify(); - }) - .ok(); - }); - - // We always maintain at most two pending completions. When we already - // have two, we replace the newest one. - if self.pending_completions.len() <= 1 { - self.pending_completions.push(PendingCompletion { - id: pending_completion_id, - _task: task, - }); - } else if self.pending_completions.len() == 2 { - self.pending_completions.pop(); - self.pending_completions.push(PendingCompletion { - id: pending_completion_id, - _task: task, - }); - } - } - - fn cycle( - &mut self, - _buffer: Entity, - _cursor_position: language::Anchor, - _direction: edit_prediction::Direction, - _cx: &mut Context, - ) { - // Right now we don't support cycling. - } - - fn accept(&mut self, cx: &mut Context) { - let completion_id = self - .current_completion - .as_ref() - .map(|completion| completion.completion.id); - if let Some(completion_id) = completion_id { - self.zeta - .update(cx, |zeta, cx| { - zeta.accept_edit_prediction(completion_id, cx) - }) - .detach(); - } - self.pending_completions.clear(); - } - - fn discard(&mut self, _cx: &mut Context) { - self.pending_completions.clear(); - self.current_completion.take(); - } - - fn suggest( - &mut self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut Context, - ) -> Option { - let CurrentEditPrediction { - buffer_id, - completion, - .. - } = self.current_completion.as_mut()?; - - // Invalidate previous completion if it was generated for a different buffer. - if *buffer_id != buffer.entity_id() { - self.current_completion.take(); - return None; - } - - let buffer = buffer.read(cx); - let Some(edits) = completion.interpolate(&buffer.snapshot()) else { - self.current_completion.take(); - return None; - }; - - let cursor_row = cursor_position.to_point(buffer).row; - let (closest_edit_ix, (closest_edit_range, _)) = - edits.iter().enumerate().min_by_key(|(_, (range, _))| { - let distance_from_start = cursor_row.abs_diff(range.start.to_point(buffer).row); - let distance_from_end = cursor_row.abs_diff(range.end.to_point(buffer).row); - cmp::min(distance_from_start, distance_from_end) - })?; - - let mut edit_start_ix = closest_edit_ix; - for (range, _) in edits[..edit_start_ix].iter().rev() { - let distance_from_closest_edit = - closest_edit_range.start.to_point(buffer).row - range.end.to_point(buffer).row; - if distance_from_closest_edit <= 1 { - edit_start_ix -= 1; - } else { - break; - } - } - - let mut edit_end_ix = closest_edit_ix + 1; - for (range, _) in &edits[edit_end_ix..] { - let distance_from_closest_edit = - range.start.to_point(buffer).row - closest_edit_range.end.to_point(buffer).row; - if distance_from_closest_edit <= 1 { - edit_end_ix += 1; - } else { - break; - } - } - - Some(edit_prediction::EditPrediction::Local { - id: Some(completion.id.to_string().into()), - edits: edits[edit_start_ix..edit_end_ix].to_vec(), - edit_preview: Some(completion.edit_preview.clone()), - }) - } -} - -/// Typical number of string bytes per token for the purposes of limiting model input. This is -/// intentionally low to err on the side of underestimating limits. -const BYTES_PER_TOKEN_GUESS: usize = 3; - -fn guess_token_count(bytes: usize) -> usize { - bytes / BYTES_PER_TOKEN_GUESS -} - -#[cfg(test)] -mod tests { - use client::test::FakeServer; - use clock::FakeSystemClock; - use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; - use gpui::TestAppContext; - use http_client::FakeHttpClient; - use indoc::indoc; - use language::Point; - use parking_lot::Mutex; - use serde_json::json; - use settings::SettingsStore; - use util::{path, rel_path::rel_path}; - - use super::*; - - const BSD_0_TXT: &str = include_str!("../license_examples/0bsd.txt"); - - #[gpui::test] - async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { - let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); - let edits: Arc<[(Range, String)]> = cx.update(|cx| { - to_completion_edits( - [(2..5, "REM".to_string()), (9..11, "".to_string())], - &buffer, - cx, - ) - .into() - }); - - let edit_preview = cx - .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) - .await; - - let completion = EditPrediction { - edits, - edit_preview, - path: Path::new("").into(), - snapshot: cx.read(|cx| buffer.read(cx).snapshot()), - id: EditPredictionId(Uuid::new_v4()), - excerpt_range: 0..0, - cursor_offset: 0, - input_outline: "".into(), - input_events: "".into(), - input_excerpt: "".into(), - output_excerpt: "".into(), - buffer_snapshotted_at: Instant::now(), - response_received_at: Instant::now(), - }; - - cx.update(|cx| { - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..5, "REM".to_string()), (9..11, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..2, "REM".to_string()), (6..8, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.undo(cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..5, "REM".to_string()), (9..11, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(3..3, "EM".to_string()), (7..9, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".to_string()), (8..10, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(9..11, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".to_string()), (8..10, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); - assert_eq!(completion.interpolate(&buffer.read(cx).snapshot()), None); - }) - } - - #[gpui::test] - async fn test_clean_up_diff(cx: &mut TestAppContext) { - init_test(cx); - - assert_eq!( - apply_edit_prediction( - indoc! {" - fn main() { - let word_1 = \"lorem\"; - let range = word.len()..word.len(); - } - "}, - indoc! {" - <|editable_region_start|> - fn main() { - let word_1 = \"lorem\"; - let range = word_1.len()..word_1.len(); - } - - <|editable_region_end|> - "}, - cx, - ) - .await, - indoc! {" - fn main() { - let word_1 = \"lorem\"; - let range = word_1.len()..word_1.len(); - } - "}, - ); - - assert_eq!( - apply_edit_prediction( - indoc! {" - fn main() { - let story = \"the quick\" - } - "}, - indoc! {" - <|editable_region_start|> - fn main() { - let story = \"the quick brown fox jumps over the lazy dog\"; - } - - <|editable_region_end|> - "}, - cx, - ) - .await, - indoc! {" - fn main() { - let story = \"the quick brown fox jumps over the lazy dog\"; - } - "}, - ); - } - - #[gpui::test] - async fn test_edit_prediction_end_of_buffer(cx: &mut TestAppContext) { - init_test(cx); - - let buffer_content = "lorem\n"; - let completion_response = indoc! {" - ```animals.js - <|start_of_file|> - <|editable_region_start|> - lorem - ipsum - <|editable_region_end|> - ```"}; - - assert_eq!( - apply_edit_prediction(buffer_content, completion_response, cx).await, - "lorem\nipsum" - ); - } - - #[gpui::test] - async fn test_can_collect_data(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree(path!("/project"), json!({ "LICENSE": BSD_0_TXT })) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/project/src/main.rs"), cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Disabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - } - - #[gpui::test] - async fn test_no_data_collection_for_remote_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [], cx).await; - - let buffer = cx.new(|_cx| { - Buffer::remote( - language::BufferId::new(1).unwrap(), - 1, - language::Capability::ReadWrite, - "fn main() {\n println!(\"Hello\");\n}", - ) - }); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - } - - #[gpui::test] - async fn test_no_data_collection_for_private_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "LICENSE": BSD_0_TXT, - ".env": "SECRET_KEY=secret" - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/.env", cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - } - - #[gpui::test] - async fn test_no_data_collection_for_untitled_buffer(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [], cx).await; - let buffer = cx.new(|cx| Buffer::local("", cx)); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - } - - #[gpui::test] - async fn test_no_data_collection_when_closed_source(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree(path!("/project"), json!({ "main.rs": "fn main() {}" })) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/main.rs", cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - } - - #[gpui::test] - async fn test_data_collection_status_changes_on_move(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/open_source_worktree"), - json!({ "LICENSE": BSD_0_TXT, "main.rs": "" }), - ) - .await; - fs.insert_tree(path!("/closed_source_worktree"), json!({ "main.rs": "" })) - .await; - - let project = Project::test( - fs.clone(), - [ - path!("/open_source_worktree").as_ref(), - path!("/closed_source_worktree").as_ref(), - ], - cx, - ) - .await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/open_source_worktree/main.rs"), cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - let closed_source_file = project - .update(cx, |project, cx| { - let worktree2 = project - .worktree_for_root_name("closed_source_worktree", cx) - .unwrap(); - worktree2.update(cx, |worktree2, cx| { - worktree2.load_file(rel_path("main.rs"), cx) - }) - }) - .await - .unwrap() - .file; - - buffer.update(cx, |buffer, cx| { - buffer.file_updated(closed_source_file, cx); - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - } - - #[gpui::test] - async fn test_no_data_collection_for_events_in_uncollectable_buffers(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/worktree1"), - json!({ "LICENSE": BSD_0_TXT, "main.rs": "", "other.rs": "" }), - ) - .await; - fs.insert_tree(path!("/worktree2"), json!({ "private.rs": "" })) - .await; - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/worktree1/main.rs"), cx) - }) - .await - .unwrap(); - let private_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/worktree2/file.rs"), cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - // this has a side effect of registering the buffer to watch for edits - run_edit_prediction(&private_buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - - private_buffer.update(cx, |private_buffer, cx| { - private_buffer.edit([(0..0, "An edit for the history!")], None, cx); - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - - // make an edit that uses too many bytes, causing private_buffer edit to not be able to be - // included - buffer.update(cx, |buffer, cx| { - buffer.edit( - [(0..0, " ".repeat(MAX_EVENT_TOKENS * BYTES_PER_TOKEN_GUESS))], - None, - cx, - ); - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - client::init_settings(cx); - Project::init_settings(cx); - }); - } - - async fn apply_edit_prediction( - buffer_content: &str, - completion_response: &str, - cx: &mut TestAppContext, - ) -> String { - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); - let (zeta, _, response) = make_test_zeta(&project, cx).await; - *response.lock() = completion_response.to_string(); - let edit_prediction = run_edit_prediction(&buffer, &project, &zeta, cx).await; - buffer.update(cx, |buffer, cx| { - buffer.edit(edit_prediction.edits.iter().cloned(), None, cx) - }); - buffer.read_with(cx, |buffer, _| buffer.text()) - } - - async fn run_edit_prediction( - buffer: &Entity, - project: &Entity, - zeta: &Entity, - cx: &mut TestAppContext, - ) -> EditPrediction { - let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); - zeta.update(cx, |zeta, cx| zeta.register_buffer(buffer, &project, cx)); - cx.background_executor.run_until_parked(); - let completion_task = zeta.update(cx, |zeta, cx| { - zeta.request_completion(&project, buffer, cursor, cx) - }); - completion_task.await.unwrap().unwrap() - } - - async fn make_test_zeta( - project: &Entity, - cx: &mut TestAppContext, - ) -> ( - Entity, - Arc>>, - Arc>, - ) { - let default_response = indoc! {" - ```main.rs - <|start_of_file|> - <|editable_region_start|> - hello world - <|editable_region_end|> - ```" - }; - let captured_request: Arc>> = Arc::new(Mutex::new(None)); - let completion_response: Arc> = - Arc::new(Mutex::new(default_response.to_string())); - let http_client = FakeHttpClient::create({ - let captured_request = captured_request.clone(); - let completion_response = completion_response.clone(); - move |req| { - let captured_request = captured_request.clone(); - let completion_response = completion_response.clone(); - async move { - match (req.method(), req.uri().path()) { - (&Method::POST, "/client/llm_tokens") => { - Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&CreateLlmTokenResponse { - token: LlmToken("the-llm-token".to_string()), - }) - .unwrap() - .into(), - ) - .unwrap()) - } - (&Method::POST, "/predict_edits/v2") => { - let mut request_body = String::new(); - req.into_body().read_to_string(&mut request_body).await?; - *captured_request.lock() = - Some(serde_json::from_str(&request_body).unwrap()); - Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&PredictEditsResponse { - request_id: Uuid::new_v4(), - output_excerpt: completion_response.lock().clone(), - }) - .unwrap() - .into(), - ) - .unwrap()) - } - _ => Ok(http_client::Response::builder() - .status(404) - .body("Not Found".into()) - .unwrap()), - } - } - } - }); - - let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); - cx.update(|cx| { - RefreshLlmTokenListener::register(client.clone(), cx); - }); - let _server = FakeServer::for_client(42, &client, cx).await; - - let zeta = cx.new(|cx| { - let mut zeta = Zeta::new(client, project.read(cx).user_store(), cx); - - let worktrees = project.read(cx).worktrees(cx).collect::>(); - for worktree in worktrees { - let worktree_id = worktree.read(cx).id(); - zeta.license_detection_watchers - .entry(worktree_id) - .or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(&worktree, cx))); - } - - zeta - }); - - (zeta, captured_request, completion_response) - } - - fn to_completion_edits( - iterator: impl IntoIterator, String)>, - buffer: &Entity, - cx: &App, - ) -> Vec<(Range, String)> { - let buffer = buffer.read(cx); - iterator - .into_iter() - .map(|(range, text)| { - ( - buffer.anchor_after(range.start)..buffer.anchor_before(range.end), - text, - ) - }) - .collect() - } - - fn from_completion_edits( - editor_edits: &[(Range, String)], - buffer: &Entity, - cx: &App, - ) -> Vec<(Range, String)> { - let buffer = buffer.read(cx); - editor_edits - .iter() - .map(|(range, text)| { - ( - range.start.to_offset(buffer)..range.end.to_offset(buffer), - text.clone(), - ) - }) - .collect() - } - - #[ctor::ctor] - fn init_logger() { - zlog::init_test(); - } -} diff --git a/crates/zeta2/Cargo.toml b/crates/zeta2/Cargo.toml deleted file mode 100644 index bce7e5987c..0000000000 --- a/crates/zeta2/Cargo.toml +++ /dev/null @@ -1,48 +0,0 @@ -[package] -name = "zeta2" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/zeta2.rs" - -[dependencies] -anyhow.workspace = true -arrayvec.workspace = true -chrono.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -cloud_zeta2_prompt.workspace = true -edit_prediction.workspace = true -edit_prediction_context.workspace = true -futures.workspace = true -gpui.workspace = true -indoc.workspace = true -language.workspace = true -language_model.workspace = true -log.workspace = true -project.workspace = true -release_channel.workspace = true -serde_json.workspace = true -thiserror.workspace = true -util.workspace = true -uuid.workspace = true -workspace-hack.workspace = true -workspace.workspace = true -worktree.workspace = true - -[dev-dependencies] -clock = { workspace = true, features = ["test-support"] } -cloud_llm_client = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -lsp.workspace = true -indoc.workspace = true -language_model = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } diff --git a/crates/zeta2/src/prediction.rs b/crates/zeta2/src/prediction.rs deleted file mode 100644 index d4832993b9..0000000000 --- a/crates/zeta2/src/prediction.rs +++ /dev/null @@ -1,442 +0,0 @@ -use std::{borrow::Cow, ops::Range, path::Path, sync::Arc}; - -use anyhow::Context as _; -use cloud_llm_client::predict_edits_v3; -use gpui::{App, AsyncApp, Entity}; -use language::{ - Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, TextBufferSnapshot, text_diff, -}; -use project::Project; -use util::ResultExt; -use uuid::Uuid; - -#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] -pub struct EditPredictionId(Uuid); - -impl From for gpui::ElementId { - fn from(value: EditPredictionId) -> Self { - gpui::ElementId::Uuid(value.0) - } -} - -impl std::fmt::Display for EditPredictionId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Clone)] -pub struct EditPrediction { - pub id: EditPredictionId, - pub path: Arc, - pub edits: Arc<[(Range, String)]>, - pub snapshot: BufferSnapshot, - pub edit_preview: EditPreview, - // We keep a reference to the buffer so that we do not need to reload it from disk when applying the prediction. - _buffer: Entity, -} - -impl EditPrediction { - pub async fn from_response( - response: predict_edits_v3::PredictEditsResponse, - active_buffer_old_snapshot: &TextBufferSnapshot, - active_buffer: &Entity, - project: &Entity, - cx: &mut AsyncApp, - ) -> Option { - // TODO only allow cloud to return one path - let Some(path) = response.edits.first().map(|e| e.path.clone()) else { - return None; - }; - - let is_same_path = active_buffer - .read_with(cx, |buffer, cx| buffer_path_eq(buffer, &path, cx)) - .ok()?; - - let (buffer, edits, snapshot, edit_preview_task) = if is_same_path { - active_buffer - .read_with(cx, |buffer, cx| { - let new_snapshot = buffer.snapshot(); - let edits = edits_from_response(&response.edits, &active_buffer_old_snapshot); - let edits: Arc<[_]> = - interpolate_edits(active_buffer_old_snapshot, &new_snapshot, edits)?.into(); - - Some(( - active_buffer.clone(), - edits.clone(), - new_snapshot, - buffer.preview_edits(edits, cx), - )) - }) - .ok()?? - } else { - let buffer_handle = project - .update(cx, |project, cx| { - let project_path = project - .find_project_path(&path, cx) - .context("Failed to find project path for zeta edit")?; - anyhow::Ok(project.open_buffer(project_path, cx)) - }) - .ok()? - .log_err()? - .await - .context("Failed to open buffer for zeta edit") - .log_err()?; - - buffer_handle - .read_with(cx, |buffer, cx| { - let snapshot = buffer.snapshot(); - let edits = edits_from_response(&response.edits, &snapshot); - if edits.is_empty() { - return None; - } - Some(( - buffer_handle.clone(), - edits.clone(), - snapshot, - buffer.preview_edits(edits, cx), - )) - }) - .ok()?? - }; - - let edit_preview = edit_preview_task.await; - - Some(EditPrediction { - id: EditPredictionId(response.request_id), - path, - edits, - snapshot, - edit_preview, - _buffer: buffer, - }) - } - - pub fn interpolate( - &self, - new_snapshot: &TextBufferSnapshot, - ) -> Option, String)>> { - interpolate_edits(&self.snapshot, new_snapshot, self.edits.clone()) - } - - pub fn targets_buffer(&self, buffer: &Buffer, cx: &App) -> bool { - buffer_path_eq(buffer, &self.path, cx) - } -} - -impl std::fmt::Debug for EditPrediction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EditPrediction") - .field("id", &self.id) - .field("path", &self.path) - .field("edits", &self.edits) - .finish() - } -} - -pub fn buffer_path_eq(buffer: &Buffer, path: &Path, cx: &App) -> bool { - buffer.file().map(|p| p.full_path(cx)).as_deref() == Some(path) -} - -pub fn interpolate_edits( - old_snapshot: &TextBufferSnapshot, - new_snapshot: &TextBufferSnapshot, - current_edits: Arc<[(Range, String)]>, -) -> Option, String)>> { - let mut edits = Vec::new(); - - let mut model_edits = current_edits.iter().peekable(); - for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { - while let Some((model_old_range, _)) = model_edits.peek() { - let model_old_range = model_old_range.to_offset(old_snapshot); - if model_old_range.end < user_edit.old.start { - let (model_old_range, model_new_text) = model_edits.next().unwrap(); - edits.push((model_old_range.clone(), model_new_text.clone())); - } else { - break; - } - } - - if let Some((model_old_range, model_new_text)) = model_edits.peek() { - let model_old_offset_range = model_old_range.to_offset(old_snapshot); - if user_edit.old == model_old_offset_range { - let user_new_text = new_snapshot - .text_for_range(user_edit.new.clone()) - .collect::(); - - if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { - if !model_suffix.is_empty() { - let anchor = old_snapshot.anchor_after(user_edit.old.end); - edits.push((anchor..anchor, model_suffix.to_string())); - } - - model_edits.next(); - continue; - } - } - } - - return None; - } - - edits.extend(model_edits.cloned()); - - if edits.is_empty() { None } else { Some(edits) } -} - -fn edits_from_response( - edits: &[predict_edits_v3::Edit], - snapshot: &TextBufferSnapshot, -) -> Arc<[(Range, String)]> { - edits - .iter() - .flat_map(|edit| { - let old_text = snapshot.text_for_range(edit.range.clone()); - - excerpt_edits_from_response( - old_text.collect::>(), - &edit.content, - edit.range.start, - &snapshot, - ) - }) - .collect::>() - .into() -} - -fn excerpt_edits_from_response( - old_text: Cow, - new_text: &str, - offset: usize, - snapshot: &TextBufferSnapshot, -) -> impl Iterator, String)> { - text_diff(&old_text, new_text) - .into_iter() - .map(move |(mut old_range, new_text)| { - old_range.start += offset; - old_range.end += offset; - - let prefix_len = common_prefix( - snapshot.chars_for_range(old_range.clone()), - new_text.chars(), - ); - old_range.start += prefix_len; - - let suffix_len = common_prefix( - snapshot.reversed_chars_for_range(old_range.clone()), - new_text[prefix_len..].chars().rev(), - ); - old_range.end = old_range.end.saturating_sub(suffix_len); - - let new_text = new_text[prefix_len..new_text.len() - suffix_len].to_string(); - let range = if old_range.is_empty() { - let anchor = snapshot.anchor_after(old_range.start); - anchor..anchor - } else { - snapshot.anchor_after(old_range.start)..snapshot.anchor_before(old_range.end) - }; - (range, new_text) - }) -} - -fn common_prefix, T2: Iterator>(a: T1, b: T2) -> usize { - a.zip(b) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a.len_utf8()) - .sum() -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - use cloud_llm_client::predict_edits_v3; - use gpui::{App, Entity, TestAppContext, prelude::*}; - use indoc::indoc; - use language::{Buffer, ToOffset as _}; - - #[gpui::test] - async fn test_compute_edits(cx: &mut TestAppContext) { - let old = indoc! {r#" - fn main() { - let args = - println!("{}", args[1]) - } - "#}; - - let new = indoc! {r#" - fn main() { - let args = std::env::args(); - println!("{}", args[1]); - } - "#}; - - let buffer = cx.new(|cx| Buffer::local(old, cx)); - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - - // TODO cover more cases when multi-file is supported - let big_edits = vec![predict_edits_v3::Edit { - path: PathBuf::from("test.txt").into(), - range: 0..old.len(), - content: new.into(), - }]; - - let edits = edits_from_response(&big_edits, &snapshot); - assert_eq!(edits.len(), 2); - assert_eq!( - edits[0].0.to_point(&snapshot).start, - language::Point::new(1, 14) - ); - assert_eq!(edits[0].1, " std::env::args();"); - assert_eq!( - edits[1].0.to_point(&snapshot).start, - language::Point::new(2, 27) - ); - assert_eq!(edits[1].1, ";"); - } - - #[gpui::test] - async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { - let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); - let edits: Arc<[(Range, String)]> = cx.update(|cx| { - to_prediction_edits( - [(2..5, "REM".to_string()), (9..11, "".to_string())], - &buffer, - cx, - ) - .into() - }); - - let edit_preview = cx - .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) - .await; - - let prediction = EditPrediction { - id: EditPredictionId(Uuid::new_v4()), - edits, - snapshot: cx.read(|cx| buffer.read(cx).snapshot()), - path: Path::new("test.txt").into(), - _buffer: buffer.clone(), - edit_preview, - }; - - cx.update(|cx| { - assert_eq!( - from_prediction_edits( - &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..5, "REM".to_string()), (9..11, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); - assert_eq!( - from_prediction_edits( - &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..2, "REM".to_string()), (6..8, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.undo(cx)); - assert_eq!( - from_prediction_edits( - &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..5, "REM".to_string()), (9..11, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); - assert_eq!( - from_prediction_edits( - &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(3..3, "EM".to_string()), (7..9, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); - assert_eq!( - from_prediction_edits( - &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".to_string()), (8..10, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); - assert_eq!( - from_prediction_edits( - &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(9..11, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); - assert_eq!( - from_prediction_edits( - &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".to_string()), (8..10, "".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); - assert_eq!( - from_prediction_edits( - &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".to_string())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); - assert_eq!(prediction.interpolate(&buffer.read(cx).snapshot()), None); - }) - } - - fn to_prediction_edits( - iterator: impl IntoIterator, String)>, - buffer: &Entity, - cx: &App, - ) -> Vec<(Range, String)> { - let buffer = buffer.read(cx); - iterator - .into_iter() - .map(|(range, text)| { - ( - buffer.anchor_after(range.start)..buffer.anchor_before(range.end), - text, - ) - }) - .collect() - } - - fn from_prediction_edits( - editor_edits: &[(Range, String)], - buffer: &Entity, - cx: &App, - ) -> Vec<(Range, String)> { - let buffer = buffer.read(cx); - editor_edits - .iter() - .map(|(range, text)| { - ( - range.start.to_offset(buffer)..range.end.to_offset(buffer), - text.clone(), - ) - }) - .collect() - } -} diff --git a/crates/zeta2/src/provider.rs b/crates/zeta2/src/provider.rs deleted file mode 100644 index db637208aa..0000000000 --- a/crates/zeta2/src/provider.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::{ - cmp, - sync::Arc, - time::{Duration, Instant}, -}; - -use arrayvec::ArrayVec; -use client::{Client, UserStore}; -use edit_prediction::{DataCollectionState, Direction, EditPredictionProvider}; -use gpui::{App, Entity, Task, prelude::*}; -use language::ToPoint as _; -use project::Project; -use util::ResultExt as _; - -use crate::{BufferEditPrediction, Zeta}; - -pub struct ZetaEditPredictionProvider { - zeta: Entity, - next_pending_prediction_id: usize, - pending_predictions: ArrayVec, - last_request_timestamp: Instant, - project: Entity, -} - -impl ZetaEditPredictionProvider { - pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); - - pub fn new( - project: Entity, - client: &Arc, - user_store: &Entity, - cx: &mut App, - ) -> Self { - let zeta = Zeta::global(client, user_store, cx); - zeta.update(cx, |zeta, cx| { - zeta.register_project(&project, cx); - }); - - Self { - zeta, - next_pending_prediction_id: 0, - pending_predictions: ArrayVec::new(), - last_request_timestamp: Instant::now(), - project: project, - } - } -} - -struct PendingPrediction { - id: usize, - _task: Task<()>, -} - -impl EditPredictionProvider for ZetaEditPredictionProvider { - fn name() -> &'static str { - "zed-predict2" - } - - fn display_name() -> &'static str { - "Zed's Edit Predictions 2" - } - - fn show_completions_in_menu() -> bool { - true - } - - fn show_tab_accept_marker() -> bool { - true - } - - fn data_collection_state(&self, _cx: &App) -> DataCollectionState { - // TODO [zeta2] - DataCollectionState::Unsupported - } - - fn toggle_data_collection(&mut self, _cx: &mut App) { - // TODO [zeta2] - } - - fn usage(&self, cx: &App) -> Option { - self.zeta.read(cx).usage(cx) - } - - fn is_enabled( - &self, - _buffer: &Entity, - _cursor_position: language::Anchor, - _cx: &App, - ) -> bool { - true - } - - fn is_refreshing(&self) -> bool { - !self.pending_predictions.is_empty() - } - - fn refresh( - &mut self, - buffer: Entity, - cursor_position: language::Anchor, - _debounce: bool, - cx: &mut Context, - ) { - let zeta = self.zeta.read(cx); - - if zeta.user_store.read_with(cx, |user_store, _cx| { - user_store.account_too_young() || user_store.has_overdue_invoices() - }) { - return; - } - - if let Some(current) = zeta.current_prediction_for_buffer(&buffer, &self.project, cx) - && let BufferEditPrediction::Local { prediction } = current - && prediction.interpolate(buffer.read(cx)).is_some() - { - return; - } - - let pending_prediction_id = self.next_pending_prediction_id; - self.next_pending_prediction_id += 1; - let last_request_timestamp = self.last_request_timestamp; - - let project = self.project.clone(); - let task = cx.spawn(async move |this, cx| { - if let Some(timeout) = (last_request_timestamp + Self::THROTTLE_TIMEOUT) - .checked_duration_since(Instant::now()) - { - cx.background_executor().timer(timeout).await; - } - - let refresh_task = this.update(cx, |this, cx| { - this.last_request_timestamp = Instant::now(); - this.zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction(&project, &buffer, cursor_position, cx) - }) - }); - - if let Some(refresh_task) = refresh_task.ok() { - refresh_task.await.log_err(); - } - - this.update(cx, |this, cx| { - if this.pending_predictions[0].id == pending_prediction_id { - this.pending_predictions.remove(0); - } else { - this.pending_predictions.clear(); - } - - cx.notify(); - }) - .ok(); - }); - - // We always maintain at most two pending predictions. When we already - // have two, we replace the newest one. - if self.pending_predictions.len() <= 1 { - self.pending_predictions.push(PendingPrediction { - id: pending_prediction_id, - _task: task, - }); - } else if self.pending_predictions.len() == 2 { - self.pending_predictions.pop(); - self.pending_predictions.push(PendingPrediction { - id: pending_prediction_id, - _task: task, - }); - } - - cx.notify(); - } - - fn cycle( - &mut self, - _buffer: Entity, - _cursor_position: language::Anchor, - _direction: Direction, - _cx: &mut Context, - ) { - } - - fn accept(&mut self, cx: &mut Context) { - self.zeta.update(cx, |zeta, _cx| { - zeta.accept_current_prediction(&self.project); - }); - self.pending_predictions.clear(); - } - - fn discard(&mut self, cx: &mut Context) { - self.zeta.update(cx, |zeta, _cx| { - zeta.discard_current_prediction(&self.project); - }); - self.pending_predictions.clear(); - } - - fn suggest( - &mut self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut Context, - ) -> Option { - let prediction = - self.zeta - .read(cx) - .current_prediction_for_buffer(buffer, &self.project, cx)?; - - let prediction = match prediction { - BufferEditPrediction::Local { prediction } => prediction, - BufferEditPrediction::Jump { prediction } => { - return Some(edit_prediction::EditPrediction::Jump { - id: Some(prediction.id.to_string().into()), - snapshot: prediction.snapshot.clone(), - target: prediction.edits.first().unwrap().0.start, - }); - } - }; - - let buffer = buffer.read(cx); - let snapshot = buffer.snapshot(); - - let Some(edits) = prediction.interpolate(&snapshot) else { - self.zeta.update(cx, |zeta, _cx| { - zeta.discard_current_prediction(&self.project); - }); - return None; - }; - - let cursor_row = cursor_position.to_point(&snapshot).row; - let (closest_edit_ix, (closest_edit_range, _)) = - edits.iter().enumerate().min_by_key(|(_, (range, _))| { - let distance_from_start = cursor_row.abs_diff(range.start.to_point(&snapshot).row); - let distance_from_end = cursor_row.abs_diff(range.end.to_point(&snapshot).row); - cmp::min(distance_from_start, distance_from_end) - })?; - - let mut edit_start_ix = closest_edit_ix; - for (range, _) in edits[..edit_start_ix].iter().rev() { - let distance_from_closest_edit = closest_edit_range.start.to_point(&snapshot).row - - range.end.to_point(&snapshot).row; - if distance_from_closest_edit <= 1 { - edit_start_ix -= 1; - } else { - break; - } - } - - let mut edit_end_ix = closest_edit_ix + 1; - for (range, _) in &edits[edit_end_ix..] { - let distance_from_closest_edit = - range.start.to_point(buffer).row - closest_edit_range.end.to_point(&snapshot).row; - if distance_from_closest_edit <= 1 { - edit_end_ix += 1; - } else { - break; - } - } - - Some(edit_prediction::EditPrediction::Local { - id: Some(prediction.id.to_string().into()), - edits: edits[edit_start_ix..edit_end_ix].to_vec(), - edit_preview: Some(prediction.edit_preview.clone()), - }) - } -} diff --git a/crates/zeta2/src/zeta2.rs b/crates/zeta2/src/zeta2.rs deleted file mode 100644 index d32b258913..0000000000 --- a/crates/zeta2/src/zeta2.rs +++ /dev/null @@ -1,1376 +0,0 @@ -use anyhow::{Context as _, Result, anyhow}; -use chrono::TimeDelta; -use client::{Client, EditPredictionUsage, UserStore}; -use cloud_llm_client::predict_edits_v3::{self, PromptFormat, Signature}; -use cloud_llm_client::{ - EXPIRED_LLM_TOKEN_HEADER_NAME, MINIMUM_REQUIRED_VERSION_HEADER_NAME, ZED_VERSION_HEADER_NAME, -}; -use cloud_zeta2_prompt::DEFAULT_MAX_PROMPT_BYTES; -use edit_prediction_context::{ - DeclarationId, EditPredictionContext, EditPredictionExcerptOptions, SyntaxIndex, - SyntaxIndexState, -}; -use futures::AsyncReadExt as _; -use futures::channel::mpsc; -use gpui::http_client::Method; -use gpui::{ - App, Entity, EntityId, Global, SemanticVersion, SharedString, Subscription, Task, WeakEntity, - http_client, prelude::*, -}; -use language::{Buffer, DiagnosticSet, LanguageServerId, ToOffset as _, ToPoint}; -use language::{BufferSnapshot, TextBufferSnapshot}; -use language_model::{LlmApiToken, RefreshLlmTokenListener}; -use project::Project; -use release_channel::AppVersion; -use std::collections::{HashMap, VecDeque, hash_map}; -use std::path::Path; -use std::str::FromStr as _; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use thiserror::Error; -use util::rel_path::RelPathBuf; -use util::some_or_debug_panic; -use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; - -mod prediction; -mod provider; - -use crate::prediction::EditPrediction; -pub use provider::ZetaEditPredictionProvider; - -const BUFFER_CHANGE_GROUPING_INTERVAL: Duration = Duration::from_secs(1); - -/// Maximum number of events to track. -const MAX_EVENT_COUNT: usize = 16; - -pub const DEFAULT_EXCERPT_OPTIONS: EditPredictionExcerptOptions = EditPredictionExcerptOptions { - max_bytes: 512, - min_bytes: 128, - target_before_cursor_over_total_bytes: 0.5, -}; - -pub const DEFAULT_OPTIONS: ZetaOptions = ZetaOptions { - excerpt: DEFAULT_EXCERPT_OPTIONS, - max_prompt_bytes: DEFAULT_MAX_PROMPT_BYTES, - max_diagnostic_bytes: 2048, - prompt_format: PromptFormat::DEFAULT, - file_indexing_parallelism: 1, -}; - -#[derive(Clone)] -struct ZetaGlobal(Entity); - -impl Global for ZetaGlobal {} - -pub struct Zeta { - client: Arc, - user_store: Entity, - llm_token: LlmApiToken, - _llm_token_subscription: Subscription, - projects: HashMap, - options: ZetaOptions, - update_required: bool, - debug_tx: Option>>, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ZetaOptions { - pub excerpt: EditPredictionExcerptOptions, - pub max_prompt_bytes: usize, - pub max_diagnostic_bytes: usize, - pub prompt_format: predict_edits_v3::PromptFormat, - pub file_indexing_parallelism: usize, -} - -pub struct PredictionDebugInfo { - pub context: EditPredictionContext, - pub retrieval_time: TimeDelta, - pub request: RequestDebugInfo, - pub buffer: WeakEntity, - pub position: language::Anchor, -} - -pub type RequestDebugInfo = predict_edits_v3::DebugInfo; - -struct ZetaProject { - syntax_index: Entity, - events: VecDeque, - registered_buffers: HashMap, - current_prediction: Option, -} - -#[derive(Clone)] -struct CurrentEditPrediction { - pub requested_by_buffer_id: EntityId, - pub prediction: EditPrediction, -} - -impl CurrentEditPrediction { - fn should_replace_prediction( - &self, - old_prediction: &Self, - snapshot: &TextBufferSnapshot, - ) -> bool { - if self.requested_by_buffer_id != old_prediction.requested_by_buffer_id { - return true; - } - - let Some(old_edits) = old_prediction.prediction.interpolate(snapshot) else { - return true; - }; - - let Some(new_edits) = self.prediction.interpolate(snapshot) else { - return false; - }; - if old_edits.len() == 1 && new_edits.len() == 1 { - let (old_range, old_text) = &old_edits[0]; - let (new_range, new_text) = &new_edits[0]; - new_range == old_range && new_text.starts_with(old_text) - } else { - true - } - } -} - -/// A prediction from the perspective of a buffer. -#[derive(Debug)] -enum BufferEditPrediction<'a> { - Local { prediction: &'a EditPrediction }, - Jump { prediction: &'a EditPrediction }, -} - -struct RegisteredBuffer { - snapshot: BufferSnapshot, - _subscriptions: [gpui::Subscription; 2], -} - -#[derive(Clone)] -pub enum Event { - BufferChange { - old_snapshot: BufferSnapshot, - new_snapshot: BufferSnapshot, - timestamp: Instant, - }, -} - -impl Zeta { - pub fn try_global(cx: &App) -> Option> { - cx.try_global::().map(|global| global.0.clone()) - } - - pub fn global( - client: &Arc, - user_store: &Entity, - cx: &mut App, - ) -> Entity { - cx.try_global::() - .map(|global| global.0.clone()) - .unwrap_or_else(|| { - let zeta = cx.new(|cx| Self::new(client.clone(), user_store.clone(), cx)); - cx.set_global(ZetaGlobal(zeta.clone())); - zeta - }) - } - - pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); - - Self { - projects: HashMap::new(), - client, - user_store, - options: DEFAULT_OPTIONS, - llm_token: LlmApiToken::default(), - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _listener, _event, cx| { - let client = this.client.clone(); - let llm_token = this.llm_token.clone(); - cx.spawn(async move |_this, _cx| { - llm_token.refresh(&client).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), - update_required: false, - debug_tx: None, - } - } - - pub fn debug_info(&mut self) -> mpsc::UnboundedReceiver> { - let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); - self.debug_tx = Some(debug_watch_tx); - debug_watch_rx - } - - pub fn options(&self) -> &ZetaOptions { - &self.options - } - - pub fn set_options(&mut self, options: ZetaOptions) { - self.options = options; - } - - pub fn clear_history(&mut self) { - for zeta_project in self.projects.values_mut() { - zeta_project.events.clear(); - } - } - - pub fn usage(&self, cx: &App) -> Option { - self.user_store.read(cx).edit_prediction_usage() - } - - pub fn register_project(&mut self, project: &Entity, cx: &mut App) { - self.get_or_init_zeta_project(project, cx); - } - - pub fn register_buffer( - &mut self, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) { - let zeta_project = self.get_or_init_zeta_project(project, cx); - Self::register_buffer_impl(zeta_project, buffer, project, cx); - } - - fn get_or_init_zeta_project( - &mut self, - project: &Entity, - cx: &mut App, - ) -> &mut ZetaProject { - self.projects - .entry(project.entity_id()) - .or_insert_with(|| ZetaProject { - syntax_index: cx.new(|cx| { - SyntaxIndex::new(project, self.options.file_indexing_parallelism, cx) - }), - events: VecDeque::new(), - registered_buffers: HashMap::new(), - current_prediction: None, - }) - } - - fn register_buffer_impl<'a>( - zeta_project: &'a mut ZetaProject, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) -> &'a mut RegisteredBuffer { - let buffer_id = buffer.entity_id(); - match zeta_project.registered_buffers.entry(buffer_id) { - hash_map::Entry::Occupied(entry) => entry.into_mut(), - hash_map::Entry::Vacant(entry) => { - let snapshot = buffer.read(cx).snapshot(); - let project_entity_id = project.entity_id(); - entry.insert(RegisteredBuffer { - snapshot, - _subscriptions: [ - cx.subscribe(buffer, { - let project = project.downgrade(); - move |this, buffer, event, cx| { - if let language::BufferEvent::Edited = event - && let Some(project) = project.upgrade() - { - this.report_changes_for_buffer(&buffer, &project, cx); - } - } - }), - cx.observe_release(buffer, move |this, _buffer, _cx| { - let Some(zeta_project) = this.projects.get_mut(&project_entity_id) - else { - return; - }; - zeta_project.registered_buffers.remove(&buffer_id); - }), - ], - }) - } - } - } - - fn report_changes_for_buffer( - &mut self, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) -> BufferSnapshot { - let zeta_project = self.get_or_init_zeta_project(project, cx); - let registered_buffer = Self::register_buffer_impl(zeta_project, buffer, project, cx); - - let new_snapshot = buffer.read(cx).snapshot(); - if new_snapshot.version != registered_buffer.snapshot.version { - let old_snapshot = - std::mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); - Self::push_event( - zeta_project, - Event::BufferChange { - old_snapshot, - new_snapshot: new_snapshot.clone(), - timestamp: Instant::now(), - }, - ); - } - - new_snapshot - } - - fn push_event(zeta_project: &mut ZetaProject, event: Event) { - let events = &mut zeta_project.events; - - if let Some(Event::BufferChange { - new_snapshot: last_new_snapshot, - timestamp: last_timestamp, - .. - }) = events.back_mut() - { - // Coalesce edits for the same buffer when they happen one after the other. - let Event::BufferChange { - old_snapshot, - new_snapshot, - timestamp, - } = &event; - - if timestamp.duration_since(*last_timestamp) <= BUFFER_CHANGE_GROUPING_INTERVAL - && old_snapshot.remote_id() == last_new_snapshot.remote_id() - && old_snapshot.version == last_new_snapshot.version - { - *last_new_snapshot = new_snapshot.clone(); - *last_timestamp = *timestamp; - return; - } - } - - if events.len() >= MAX_EVENT_COUNT { - // These are halved instead of popping to improve prompt caching. - events.drain(..MAX_EVENT_COUNT / 2); - } - - events.push_back(event); - } - - fn current_prediction_for_buffer( - &self, - buffer: &Entity, - project: &Entity, - cx: &App, - ) -> Option> { - let project_state = self.projects.get(&project.entity_id())?; - - let CurrentEditPrediction { - requested_by_buffer_id, - prediction, - } = project_state.current_prediction.as_ref()?; - - if prediction.targets_buffer(buffer.read(cx), cx) { - Some(BufferEditPrediction::Local { prediction }) - } else if *requested_by_buffer_id == buffer.entity_id() { - Some(BufferEditPrediction::Jump { prediction }) - } else { - None - } - } - - fn accept_current_prediction(&mut self, project: &Entity) { - if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { - project_state.current_prediction.take(); - }; - // TODO report accepted - } - - fn discard_current_prediction(&mut self, project: &Entity) { - if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { - project_state.current_prediction.take(); - }; - } - - pub fn refresh_prediction( - &mut self, - project: &Entity, - buffer: &Entity, - position: language::Anchor, - cx: &mut Context, - ) -> Task> { - let request_task = self.request_prediction(project, buffer, position, cx); - let buffer = buffer.clone(); - let project = project.clone(); - - cx.spawn(async move |this, cx| { - if let Some(prediction) = request_task.await? { - this.update(cx, |this, cx| { - let project_state = this - .projects - .get_mut(&project.entity_id()) - .context("Project not found")?; - - let new_prediction = CurrentEditPrediction { - requested_by_buffer_id: buffer.entity_id(), - prediction: prediction, - }; - - if project_state - .current_prediction - .as_ref() - .is_none_or(|old_prediction| { - new_prediction - .should_replace_prediction(&old_prediction, buffer.read(cx)) - }) - { - project_state.current_prediction = Some(new_prediction); - } - anyhow::Ok(()) - })??; - } - Ok(()) - }) - } - - fn request_prediction( - &mut self, - project: &Entity, - buffer: &Entity, - position: language::Anchor, - cx: &mut Context, - ) -> Task>> { - let project_state = self.projects.get(&project.entity_id()); - - let index_state = project_state.map(|state| { - state - .syntax_index - .read_with(cx, |index, _cx| index.state().clone()) - }); - let options = self.options.clone(); - let snapshot = buffer.read(cx).snapshot(); - let Some(excerpt_path) = snapshot.file().map(|path| path.full_path(cx).into()) else { - return Task::ready(Err(anyhow!("No file path for excerpt"))); - }; - let client = self.client.clone(); - let llm_token = self.llm_token.clone(); - let app_version = AppVersion::global(cx); - let worktree_snapshots = project - .read(cx) - .worktrees(cx) - .map(|worktree| worktree.read(cx).snapshot()) - .collect::>(); - let debug_tx = self.debug_tx.clone(); - - let events = project_state - .map(|state| { - state - .events - .iter() - .filter_map(|event| match event { - Event::BufferChange { - old_snapshot, - new_snapshot, - .. - } => { - let path = new_snapshot.file().map(|f| f.full_path(cx)); - - let old_path = old_snapshot.file().and_then(|f| { - let old_path = f.full_path(cx); - if Some(&old_path) != path.as_ref() { - Some(old_path) - } else { - None - } - }); - - // TODO [zeta2] move to bg? - let diff = - language::unified_diff(&old_snapshot.text(), &new_snapshot.text()); - - if path == old_path && diff.is_empty() { - None - } else { - Some(predict_edits_v3::Event::BufferChange { - old_path, - path, - diff, - //todo: Actually detect if this edit was predicted or not - predicted: false, - }) - } - } - }) - .collect::>() - }) - .unwrap_or_default(); - - let diagnostics = snapshot.diagnostic_sets().clone(); - - let request_task = cx.background_spawn({ - let snapshot = snapshot.clone(); - let buffer = buffer.clone(); - async move { - let index_state = if let Some(index_state) = index_state { - Some(index_state.lock_owned().await) - } else { - None - }; - - let cursor_offset = position.to_offset(&snapshot); - let cursor_point = cursor_offset.to_point(&snapshot); - - let before_retrieval = chrono::Utc::now(); - - let Some(context) = EditPredictionContext::gather_context( - cursor_point, - &snapshot, - &options.excerpt, - index_state.as_deref(), - ) else { - return Ok(None); - }; - - let debug_context = if let Some(debug_tx) = debug_tx { - Some((debug_tx, context.clone())) - } else { - None - }; - - let (diagnostic_groups, diagnostic_groups_truncated) = - Self::gather_nearby_diagnostics( - cursor_offset, - &diagnostics, - &snapshot, - options.max_diagnostic_bytes, - ); - - let request = make_cloud_request( - excerpt_path, - context, - events, - // TODO data collection - false, - diagnostic_groups, - diagnostic_groups_truncated, - None, - debug_context.is_some(), - &worktree_snapshots, - index_state.as_deref(), - Some(options.max_prompt_bytes), - options.prompt_format, - ); - - let retrieval_time = chrono::Utc::now() - before_retrieval; - let response = Self::perform_request(client, llm_token, app_version, request).await; - - if let Some((debug_tx, context)) = debug_context { - debug_tx - .unbounded_send(response.as_ref().map_err(|err| err.to_string()).and_then( - |response| { - let Some(request) = - some_or_debug_panic(response.0.debug_info.clone()) - else { - return Err("Missing debug info".to_string()); - }; - Ok(PredictionDebugInfo { - context, - request, - retrieval_time, - buffer: buffer.downgrade(), - position, - }) - }, - )) - .ok(); - } - - anyhow::Ok(Some(response?)) - } - }); - - let buffer = buffer.clone(); - - cx.spawn({ - let project = project.clone(); - async move |this, cx| { - match request_task.await { - Ok(Some((response, usage))) => { - if let Some(usage) = usage { - this.update(cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.update_edit_prediction_usage(usage, cx); - }); - }) - .ok(); - } - - let prediction = EditPrediction::from_response( - response, &snapshot, &buffer, &project, cx, - ) - .await; - - // TODO telemetry: duration, etc - Ok(prediction) - } - Ok(None) => Ok(None), - Err(err) => { - if err.is::() { - cx.update(|cx| { - this.update(cx, |this, _cx| { - this.update_required = true; - }) - .ok(); - - let error_message: SharedString = err.to_string().into(); - show_app_notification( - NotificationId::unique::(), - cx, - move |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new(error_message.clone(), cx) - .with_link_button( - "Update Zed", - "https://zed.dev/releases", - ) - }) - }, - ); - }) - .ok(); - } - - Err(err) - } - } - } - }) - } - - async fn perform_request( - client: Arc, - llm_token: LlmApiToken, - app_version: SemanticVersion, - request: predict_edits_v3::PredictEditsRequest, - ) -> Result<( - predict_edits_v3::PredictEditsResponse, - Option, - )> { - let http_client = client.http_client(); - let mut token = llm_token.acquire(&client).await?; - let mut did_retry = false; - - loop { - let request_builder = http_client::Request::builder().method(Method::POST); - let request_builder = - if let Ok(predict_edits_url) = std::env::var("ZED_PREDICT_EDITS_URL") { - request_builder.uri(predict_edits_url) - } else { - request_builder.uri( - http_client - .build_zed_llm_url("/predict_edits/v3", &[])? - .as_ref(), - ) - }; - let request = request_builder - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", token)) - .header(ZED_VERSION_HEADER_NAME, app_version.to_string()) - .body(serde_json::to_string(&request)?.into())?; - - let mut response = http_client.send(request).await?; - - if let Some(minimum_required_version) = response - .headers() - .get(MINIMUM_REQUIRED_VERSION_HEADER_NAME) - .and_then(|version| SemanticVersion::from_str(version.to_str().ok()?).ok()) - { - anyhow::ensure!( - app_version >= minimum_required_version, - ZedUpdateRequiredError { - minimum_version: minimum_required_version - } - ); - } - - if response.status().is_success() { - let usage = EditPredictionUsage::from_headers(response.headers()).ok(); - - let mut body = Vec::new(); - response.body_mut().read_to_end(&mut body).await?; - return Ok((serde_json::from_slice(&body)?, usage)); - } else if !did_retry - && response - .headers() - .get(EXPIRED_LLM_TOKEN_HEADER_NAME) - .is_some() - { - did_retry = true; - token = llm_token.refresh(&client).await?; - } else { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - anyhow::bail!( - "error predicting edits.\nStatus: {:?}\nBody: {}", - response.status(), - body - ); - } - } - } - - fn gather_nearby_diagnostics( - cursor_offset: usize, - diagnostic_sets: &[(LanguageServerId, DiagnosticSet)], - snapshot: &BufferSnapshot, - max_diagnostics_bytes: usize, - ) -> (Vec, bool) { - // TODO: Could make this more efficient - let mut diagnostic_groups = Vec::new(); - for (language_server_id, diagnostics) in diagnostic_sets { - let mut groups = Vec::new(); - diagnostics.groups(*language_server_id, &mut groups, &snapshot); - diagnostic_groups.extend( - groups - .into_iter() - .map(|(_, group)| group.resolve::(&snapshot)), - ); - } - - // sort by proximity to cursor - diagnostic_groups.sort_by_key(|group| { - let range = &group.entries[group.primary_ix].range; - if range.start >= cursor_offset { - range.start - cursor_offset - } else if cursor_offset >= range.end { - cursor_offset - range.end - } else { - (cursor_offset - range.start).min(range.end - cursor_offset) - } - }); - - let mut results = Vec::new(); - let mut diagnostic_groups_truncated = false; - let mut diagnostics_byte_count = 0; - for group in diagnostic_groups { - let raw_value = serde_json::value::to_raw_value(&group).unwrap(); - diagnostics_byte_count += raw_value.get().len(); - if diagnostics_byte_count > max_diagnostics_bytes { - diagnostic_groups_truncated = true; - break; - } - results.push(predict_edits_v3::DiagnosticGroup(raw_value)); - } - - (results, diagnostic_groups_truncated) - } - - // TODO: Dedupe with similar code in request_prediction? - pub fn cloud_request_for_zeta_cli( - &mut self, - project: &Entity, - buffer: &Entity, - position: language::Anchor, - cx: &mut Context, - ) -> Task> { - let project_state = self.projects.get(&project.entity_id()); - - let index_state = project_state.map(|state| { - state - .syntax_index - .read_with(cx, |index, _cx| index.state().clone()) - }); - let options = self.options.clone(); - let snapshot = buffer.read(cx).snapshot(); - let Some(excerpt_path) = snapshot.file().map(|path| path.full_path(cx)) else { - return Task::ready(Err(anyhow!("No file path for excerpt"))); - }; - let worktree_snapshots = project - .read(cx) - .worktrees(cx) - .map(|worktree| worktree.read(cx).snapshot()) - .collect::>(); - - cx.background_spawn(async move { - let index_state = if let Some(index_state) = index_state { - Some(index_state.lock_owned().await) - } else { - None - }; - - let cursor_point = position.to_point(&snapshot); - - let debug_info = true; - EditPredictionContext::gather_context( - cursor_point, - &snapshot, - &options.excerpt, - index_state.as_deref(), - ) - .context("Failed to select excerpt") - .map(|context| { - make_cloud_request( - excerpt_path.into(), - context, - // TODO pass everything - Vec::new(), - false, - Vec::new(), - false, - None, - debug_info, - &worktree_snapshots, - index_state.as_deref(), - Some(options.max_prompt_bytes), - options.prompt_format, - ) - }) - }) - } - - pub fn wait_for_initial_indexing( - &mut self, - project: &Entity, - cx: &mut App, - ) -> Task> { - let zeta_project = self.get_or_init_zeta_project(project, cx); - zeta_project - .syntax_index - .read(cx) - .wait_for_initial_file_indexing(cx) - } -} - -#[derive(Error, Debug)] -#[error( - "You must update to Zed version {minimum_version} or higher to continue using edit predictions." -)] -pub struct ZedUpdateRequiredError { - minimum_version: SemanticVersion, -} - -fn make_cloud_request( - excerpt_path: Arc, - context: EditPredictionContext, - events: Vec, - can_collect_data: bool, - diagnostic_groups: Vec, - diagnostic_groups_truncated: bool, - git_info: Option, - debug_info: bool, - worktrees: &Vec, - index_state: Option<&SyntaxIndexState>, - prompt_max_bytes: Option, - prompt_format: PromptFormat, -) -> predict_edits_v3::PredictEditsRequest { - let mut signatures = Vec::new(); - let mut declaration_to_signature_index = HashMap::default(); - let mut referenced_declarations = Vec::new(); - - for snippet in context.declarations { - let project_entry_id = snippet.declaration.project_entry_id(); - let Some(path) = worktrees.iter().find_map(|worktree| { - worktree.entry_for_id(project_entry_id).map(|entry| { - let mut full_path = RelPathBuf::new(); - full_path.push(worktree.root_name()); - full_path.push(&entry.path); - full_path - }) - }) else { - continue; - }; - - let parent_index = index_state.and_then(|index_state| { - snippet.declaration.parent().and_then(|parent| { - add_signature( - parent, - &mut declaration_to_signature_index, - &mut signatures, - index_state, - ) - }) - }); - - let (text, text_is_truncated) = snippet.declaration.item_text(); - referenced_declarations.push(predict_edits_v3::ReferencedDeclaration { - path: path.as_std_path().into(), - text: text.into(), - range: snippet.declaration.item_range(), - text_is_truncated, - signature_range: snippet.declaration.signature_range_in_item_text(), - parent_index, - score_components: snippet.score_components, - signature_score: snippet.scores.signature, - declaration_score: snippet.scores.declaration, - }); - } - - let excerpt_parent = index_state.and_then(|index_state| { - context - .excerpt - .parent_declarations - .last() - .and_then(|(parent, _)| { - add_signature( - *parent, - &mut declaration_to_signature_index, - &mut signatures, - index_state, - ) - }) - }); - - predict_edits_v3::PredictEditsRequest { - excerpt_path, - excerpt: context.excerpt_text.body, - excerpt_range: context.excerpt.range, - cursor_offset: context.cursor_offset_in_excerpt, - referenced_declarations, - signatures, - excerpt_parent, - events, - can_collect_data, - diagnostic_groups, - diagnostic_groups_truncated, - git_info, - debug_info, - prompt_max_bytes, - prompt_format, - } -} - -fn add_signature( - declaration_id: DeclarationId, - declaration_to_signature_index: &mut HashMap, - signatures: &mut Vec, - index: &SyntaxIndexState, -) -> Option { - if let Some(signature_index) = declaration_to_signature_index.get(&declaration_id) { - return Some(*signature_index); - } - let Some(parent_declaration) = index.declaration(declaration_id) else { - log::error!("bug: missing parent declaration"); - return None; - }; - let parent_index = parent_declaration.parent().and_then(|parent| { - add_signature(parent, declaration_to_signature_index, signatures, index) - }); - let (text, text_is_truncated) = parent_declaration.signature_text(); - let signature_index = signatures.len(); - signatures.push(Signature { - text: text.into(), - text_is_truncated, - parent_index, - range: parent_declaration.signature_range(), - }); - declaration_to_signature_index.insert(declaration_id, signature_index); - Some(signature_index) -} - -#[cfg(test)] -mod tests { - use std::{ - path::{Path, PathBuf}, - sync::Arc, - }; - - use client::UserStore; - use clock::FakeSystemClock; - use cloud_llm_client::predict_edits_v3; - use futures::{ - AsyncReadExt, StreamExt, - channel::{mpsc, oneshot}, - }; - use gpui::{ - Entity, TestAppContext, - http_client::{FakeHttpClient, Response}, - prelude::*, - }; - use indoc::indoc; - use language::{LanguageServerId, OffsetRangeExt as _}; - use pretty_assertions::{assert_eq, assert_matches}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - use uuid::Uuid; - - use crate::{BufferEditPrediction, Zeta}; - - #[gpui::test] - async fn test_current_state(cx: &mut TestAppContext) { - let (zeta, mut req_rx) = init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "1.txt": "Hello!\nHow\nBye", - "2.txt": "Hola!\nComo\nAdios" - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - zeta.update(cx, |zeta, cx| { - zeta.register_project(&project, cx); - }); - - let buffer1 = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/1.txt"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); - let position = snapshot1.anchor_before(language::Point::new(1, 3)); - - // Prediction for current file - - let prediction_task = zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction(&project, &buffer1, position, cx) - }); - let (_request, respond_tx) = req_rx.next().await.unwrap(); - respond_tx - .send(predict_edits_v3::PredictEditsResponse { - request_id: Uuid::new_v4(), - edits: vec![predict_edits_v3::Edit { - path: Path::new(path!("root/1.txt")).into(), - range: 0..snapshot1.len(), - content: "Hello!\nHow are you?\nBye".into(), - }], - debug_info: None, - }) - .unwrap(); - prediction_task.await.unwrap(); - - zeta.read_with(cx, |zeta, cx| { - let prediction = zeta - .current_prediction_for_buffer(&buffer1, &project, cx) - .unwrap(); - assert_matches!(prediction, BufferEditPrediction::Local { .. }); - }); - - // Prediction for another file - - let prediction_task = zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction(&project, &buffer1, position, cx) - }); - let (_request, respond_tx) = req_rx.next().await.unwrap(); - respond_tx - .send(predict_edits_v3::PredictEditsResponse { - request_id: Uuid::new_v4(), - edits: vec![predict_edits_v3::Edit { - path: Path::new(path!("root/2.txt")).into(), - range: 0..snapshot1.len(), - content: "Hola!\nComo estas?\nAdios".into(), - }], - debug_info: None, - }) - .unwrap(); - prediction_task.await.unwrap(); - - zeta.read_with(cx, |zeta, cx| { - let prediction = zeta - .current_prediction_for_buffer(&buffer1, &project, cx) - .unwrap(); - assert_matches!( - prediction, - BufferEditPrediction::Jump { prediction } if prediction.path.as_ref() == Path::new(path!("root/2.txt")) - ); - }); - - let buffer2 = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/2.txt"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - - zeta.read_with(cx, |zeta, cx| { - let prediction = zeta - .current_prediction_for_buffer(&buffer2, &project, cx) - .unwrap(); - assert_matches!(prediction, BufferEditPrediction::Local { .. }); - }); - } - - #[gpui::test] - async fn test_simple_request(cx: &mut TestAppContext) { - let (zeta, mut req_rx) = init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "foo.md": "Hello!\nHow\nBye" - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - let buffer = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let position = snapshot.anchor_before(language::Point::new(1, 3)); - - let prediction_task = zeta.update(cx, |zeta, cx| { - zeta.request_prediction(&project, &buffer, position, cx) - }); - - let (request, respond_tx) = req_rx.next().await.unwrap(); - assert_eq!( - request.excerpt_path.as_ref(), - Path::new(path!("root/foo.md")) - ); - assert_eq!(request.cursor_offset, 10); - - respond_tx - .send(predict_edits_v3::PredictEditsResponse { - request_id: Uuid::new_v4(), - edits: vec![predict_edits_v3::Edit { - path: Path::new(path!("root/foo.md")).into(), - range: 0..snapshot.len(), - content: "Hello!\nHow are you?\nBye".into(), - }], - debug_info: None, - }) - .unwrap(); - - let prediction = prediction_task.await.unwrap().unwrap(); - - assert_eq!(prediction.edits.len(), 1); - assert_eq!( - prediction.edits[0].0.to_point(&snapshot).start, - language::Point::new(1, 3) - ); - assert_eq!(prediction.edits[0].1, " are you?"); - } - - #[gpui::test] - async fn test_request_events(cx: &mut TestAppContext) { - let (zeta, mut req_rx) = init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "foo.md": "Hello!\n\nBye" - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - let buffer = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - - zeta.update(cx, |zeta, cx| { - zeta.register_buffer(&buffer, &project, cx); - }); - - buffer.update(cx, |buffer, cx| { - buffer.edit(vec![(7..7, "How")], None, cx); - }); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let position = snapshot.anchor_before(language::Point::new(1, 3)); - - let prediction_task = zeta.update(cx, |zeta, cx| { - zeta.request_prediction(&project, &buffer, position, cx) - }); - - let (request, respond_tx) = req_rx.next().await.unwrap(); - - assert_eq!(request.events.len(), 1); - assert_eq!( - request.events[0], - predict_edits_v3::Event::BufferChange { - path: Some(PathBuf::from(path!("root/foo.md"))), - old_path: None, - diff: indoc! {" - @@ -1,3 +1,3 @@ - Hello! - - - +How - Bye - "} - .to_string(), - predicted: false - } - ); - - respond_tx - .send(predict_edits_v3::PredictEditsResponse { - request_id: Uuid::new_v4(), - edits: vec![predict_edits_v3::Edit { - path: Path::new(path!("root/foo.md")).into(), - range: 0..snapshot.len(), - content: "Hello!\nHow are you?\nBye".into(), - }], - debug_info: None, - }) - .unwrap(); - - let prediction = prediction_task.await.unwrap().unwrap(); - - assert_eq!(prediction.edits.len(), 1); - assert_eq!( - prediction.edits[0].0.to_point(&snapshot).start, - language::Point::new(1, 3) - ); - assert_eq!(prediction.edits[0].1, " are you?"); - } - - #[gpui::test] - async fn test_request_diagnostics(cx: &mut TestAppContext) { - let (zeta, mut req_rx) = init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "foo.md": "Hello!\nBye" - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - let path_to_buffer_uri = lsp::Uri::from_file_path(path!("/root/foo.md")).unwrap(); - let diagnostic = lsp::Diagnostic { - range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), - severity: Some(lsp::DiagnosticSeverity::ERROR), - message: "\"Hello\" deprecated. Use \"Hi\" instead".to_string(), - ..Default::default() - }; - - project.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - // Create some diagnostics - lsp_store - .update_diagnostics( - LanguageServerId(0), - lsp::PublishDiagnosticsParams { - uri: path_to_buffer_uri.clone(), - diagnostics: vec![diagnostic], - version: None, - }, - None, - language::DiagnosticSourceKind::Pushed, - &[], - cx, - ) - .unwrap(); - }); - }); - - let buffer = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let position = snapshot.anchor_before(language::Point::new(0, 0)); - - let _prediction_task = zeta.update(cx, |zeta, cx| { - zeta.request_prediction(&project, &buffer, position, cx) - }); - - let (request, _respond_tx) = req_rx.next().await.unwrap(); - - assert_eq!(request.diagnostic_groups.len(), 1); - let value = serde_json::from_str::(request.diagnostic_groups[0].0.get()) - .unwrap(); - // We probably don't need all of this. TODO define a specific diagnostic type in predict_edits_v3 - assert_eq!( - value, - json!({ - "entries": [{ - "range": { - "start": 8, - "end": 10 - }, - "diagnostic": { - "source": null, - "code": null, - "code_description": null, - "severity": 1, - "message": "\"Hello\" deprecated. Use \"Hi\" instead", - "markdown": null, - "group_id": 0, - "is_primary": true, - "is_disk_based": false, - "is_unnecessary": false, - "source_kind": "Pushed", - "data": null, - "underline": true - } - }], - "primary_ix": 0 - }) - ); - } - - fn init_test( - cx: &mut TestAppContext, - ) -> ( - Entity, - mpsc::UnboundedReceiver<( - predict_edits_v3::PredictEditsRequest, - oneshot::Sender, - )>, - ) { - cx.update(move |cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - - let (req_tx, req_rx) = mpsc::unbounded(); - - let http_client = FakeHttpClient::create({ - move |req| { - let uri = req.uri().path().to_string(); - let mut body = req.into_body(); - let req_tx = req_tx.clone(); - async move { - let resp = match uri.as_str() { - "/client/llm_tokens" => serde_json::to_string(&json!({ - "token": "test" - })) - .unwrap(), - "/predict_edits/v3" => { - let mut buf = Vec::new(); - body.read_to_end(&mut buf).await.ok(); - let req = serde_json::from_slice(&buf).unwrap(); - - let (res_tx, res_rx) = oneshot::channel(); - req_tx.unbounded_send((req, res_tx)).unwrap(); - serde_json::to_string(&res_rx.await.unwrap()).unwrap() - } - _ => { - panic!("Unexpected path: {}", uri) - } - }; - - Ok(Response::builder().body(resp.into()).unwrap()) - } - } - }); - - let client = client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); - client.cloud_client().set_credentials(1, "test".into()); - - language_model::init(client.clone(), cx); - - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let zeta = Zeta::global(&client, &user_store, cx); - - (zeta, req_rx) - }) - } -} diff --git a/crates/zeta2_tools/Cargo.toml b/crates/zeta2_tools/Cargo.toml deleted file mode 100644 index e2dd18e46e..0000000000 --- a/crates/zeta2_tools/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "zeta2_tools" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/zeta2_tools.rs" - -[dependencies] -chrono.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -collections.workspace = true -edit_prediction_context.workspace = true -editor.workspace = true -futures.workspace = true -gpui.workspace = true -language.workspace = true -log.workspace = true -project.workspace = true -serde.workspace = true -text.workspace = true -ui.workspace = true -ui_input.workspace = true -util.workspace = true -workspace-hack.workspace = true -workspace.workspace = true -zeta2.workspace = true - -[dev-dependencies] -clap.workspace = true -gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true -language = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } -serde_json.workspace = true -settings = { workspace = true, features = ["test-support"] } -text = { workspace = true, features = ["test-support"] } -util = { workspace = true, features = ["test-support"] } -zlog.workspace = true diff --git a/crates/zeta2_tools/src/zeta2_tools.rs b/crates/zeta2_tools/src/zeta2_tools.rs deleted file mode 100644 index a299726c64..0000000000 --- a/crates/zeta2_tools/src/zeta2_tools.rs +++ /dev/null @@ -1,735 +0,0 @@ -use std::{collections::hash_map::Entry, path::PathBuf, str::FromStr, sync::Arc, time::Duration}; - -use chrono::TimeDelta; -use client::{Client, UserStore}; -use cloud_llm_client::predict_edits_v3::PromptFormat; -use collections::HashMap; -use editor::{Editor, EditorEvent, EditorMode, ExcerptRange, MultiBuffer}; -use futures::StreamExt as _; -use gpui::{ - Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, actions, - prelude::*, -}; -use language::{Buffer, DiskState}; -use project::{Project, WorktreeId}; -use ui::{ContextMenu, ContextMenuEntry, DropdownMenu, prelude::*}; -use ui_input::SingleLineInput; -use util::{ResultExt, paths::PathStyle, rel_path::RelPath}; -use workspace::{Item, SplitDirection, Workspace}; -use zeta2::{Zeta, ZetaOptions}; - -use edit_prediction_context::{DeclarationStyle, EditPredictionExcerptOptions}; - -actions!( - dev, - [ - /// Opens the language server protocol logs viewer. - OpenZeta2Inspector - ] -); - -pub fn init(cx: &mut App) { - cx.observe_new(move |workspace: &mut Workspace, _, _cx| { - workspace.register_action(move |workspace, _: &OpenZeta2Inspector, window, cx| { - let project = workspace.project(); - workspace.split_item( - SplitDirection::Right, - Box::new(cx.new(|cx| { - Zeta2Inspector::new( - &project, - workspace.client(), - workspace.user_store(), - window, - cx, - ) - })), - window, - cx, - ); - }); - }) - .detach(); -} - -// TODO show included diagnostics, and events - -pub struct Zeta2Inspector { - focus_handle: FocusHandle, - project: Entity, - last_prediction: Option, - max_excerpt_bytes_input: Entity, - min_excerpt_bytes_input: Entity, - cursor_context_ratio_input: Entity, - max_prompt_bytes_input: Entity, - active_view: ActiveView, - zeta: Entity, - _active_editor_subscription: Option, - _update_state_task: Task<()>, - _receive_task: Task<()>, -} - -#[derive(PartialEq)] -enum ActiveView { - Context, - Inference, -} - -enum LastPredictionState { - Failed(SharedString), - Success(LastPrediction), - Replaying { - prediction: LastPrediction, - _task: Task<()>, - }, -} - -struct LastPrediction { - context_editor: Entity, - retrieval_time: TimeDelta, - prompt_planning_time: TimeDelta, - inference_time: TimeDelta, - parsing_time: TimeDelta, - prompt_editor: Entity, - model_response_editor: Entity, - buffer: WeakEntity, - position: language::Anchor, -} - -impl Zeta2Inspector { - pub fn new( - project: &Entity, - client: &Arc, - user_store: &Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let zeta = Zeta::global(client, user_store, cx); - let mut request_rx = zeta.update(cx, |zeta, _cx| zeta.debug_info()); - - let receive_task = cx.spawn_in(window, async move |this, cx| { - while let Some(prediction_result) = request_rx.next().await { - this.update_in(cx, |this, window, cx| match prediction_result { - Ok(prediction) => { - this.update_last_prediction(prediction, window, cx); - } - Err(err) => { - this.last_prediction = Some(LastPredictionState::Failed(err.into())); - cx.notify(); - } - }) - .ok(); - } - }); - - let mut this = Self { - focus_handle: cx.focus_handle(), - project: project.clone(), - last_prediction: None, - active_view: ActiveView::Context, - max_excerpt_bytes_input: Self::number_input("Max Excerpt Bytes", window, cx), - min_excerpt_bytes_input: Self::number_input("Min Excerpt Bytes", window, cx), - cursor_context_ratio_input: Self::number_input("Cursor Context Ratio", window, cx), - max_prompt_bytes_input: Self::number_input("Max Prompt Bytes", window, cx), - zeta: zeta.clone(), - _active_editor_subscription: None, - _update_state_task: Task::ready(()), - _receive_task: receive_task, - }; - this.set_input_options(&zeta.read(cx).options().clone(), window, cx); - this - } - - fn set_input_options( - &mut self, - options: &ZetaOptions, - window: &mut Window, - cx: &mut Context, - ) { - self.max_excerpt_bytes_input.update(cx, |input, cx| { - input.set_text(options.excerpt.max_bytes.to_string(), window, cx); - }); - self.min_excerpt_bytes_input.update(cx, |input, cx| { - input.set_text(options.excerpt.min_bytes.to_string(), window, cx); - }); - self.cursor_context_ratio_input.update(cx, |input, cx| { - input.set_text( - format!( - "{:.2}", - options.excerpt.target_before_cursor_over_total_bytes - ), - window, - cx, - ); - }); - self.max_prompt_bytes_input.update(cx, |input, cx| { - input.set_text(options.max_prompt_bytes.to_string(), window, cx); - }); - cx.notify(); - } - - fn set_options(&mut self, options: ZetaOptions, cx: &mut Context) { - self.zeta.update(cx, |this, _cx| this.set_options(options)); - - const THROTTLE_TIME: Duration = Duration::from_millis(100); - - if let Some( - LastPredictionState::Success(prediction) - | LastPredictionState::Replaying { prediction, .. }, - ) = self.last_prediction.take() - { - if let Some(buffer) = prediction.buffer.upgrade() { - let position = prediction.position; - let zeta = self.zeta.clone(); - let project = self.project.clone(); - let task = cx.spawn(async move |_this, cx| { - cx.background_executor().timer(THROTTLE_TIME).await; - if let Some(task) = zeta - .update(cx, |zeta, cx| { - zeta.refresh_prediction(&project, &buffer, position, cx) - }) - .ok() - { - task.await.log_err(); - } - }); - self.last_prediction = Some(LastPredictionState::Replaying { - prediction, - _task: task, - }); - } else { - self.last_prediction = Some(LastPredictionState::Failed("Buffer dropped".into())); - } - } - - cx.notify(); - } - - fn number_input( - label: &'static str, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - let input = cx.new(|cx| { - SingleLineInput::new(window, cx, "") - .label(label) - .label_min_width(px(64.)) - }); - - cx.subscribe_in( - &input.read(cx).editor().clone(), - window, - |this, _, event, _window, cx| { - let EditorEvent::BufferEdited = event else { - return; - }; - - fn number_input_value( - input: &Entity, - cx: &App, - ) -> T { - input - .read(cx) - .editor() - .read(cx) - .text(cx) - .parse::() - .unwrap_or_default() - } - - let excerpt_options = EditPredictionExcerptOptions { - max_bytes: number_input_value(&this.max_excerpt_bytes_input, cx), - min_bytes: number_input_value(&this.min_excerpt_bytes_input, cx), - target_before_cursor_over_total_bytes: number_input_value( - &this.cursor_context_ratio_input, - cx, - ), - }; - - let zeta_options = this.zeta.read(cx).options(); - this.set_options( - ZetaOptions { - excerpt: excerpt_options, - max_prompt_bytes: number_input_value(&this.max_prompt_bytes_input, cx), - max_diagnostic_bytes: zeta_options.max_diagnostic_bytes, - prompt_format: zeta_options.prompt_format, - file_indexing_parallelism: zeta_options.file_indexing_parallelism, - }, - cx, - ); - }, - ) - .detach(); - input - } - - fn update_last_prediction( - &mut self, - prediction: zeta2::PredictionDebugInfo, - window: &mut Window, - cx: &mut Context, - ) { - let project = self.project.read(cx); - let path_style = project.path_style(cx); - let Some(worktree_id) = project - .worktrees(cx) - .next() - .map(|worktree| worktree.read(cx).id()) - else { - log::error!("Open a worktree to use edit prediction debug view"); - self.last_prediction.take(); - return; - }; - - self._update_state_task = cx.spawn_in(window, { - let language_registry = self.project.read(cx).languages().clone(); - async move |this, cx| { - let mut languages = HashMap::default(); - for lang_id in prediction - .context - .declarations - .iter() - .map(|snippet| snippet.declaration.identifier().language_id) - .chain(prediction.context.excerpt_text.language_id) - { - if let Entry::Vacant(entry) = languages.entry(lang_id) { - // Most snippets are gonna be the same language, - // so we think it's fine to do this sequentially for now - entry.insert(language_registry.language_for_id(lang_id).await.ok()); - } - } - - let markdown_language = language_registry - .language_for_name("Markdown") - .await - .log_err(); - - this.update_in(cx, |this, window, cx| { - let context_editor = cx.new(|cx| { - let multibuffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(language::Capability::ReadOnly); - let excerpt_file = Arc::new(ExcerptMetadataFile { - title: RelPath::unix("Cursor Excerpt").unwrap().into(), - path_style, - worktree_id, - }); - - let excerpt_buffer = cx.new(|cx| { - let mut buffer = - Buffer::local(prediction.context.excerpt_text.body, cx); - if let Some(language) = prediction - .context - .excerpt_text - .language_id - .as_ref() - .and_then(|id| languages.get(id)) - { - buffer.set_language(language.clone(), cx); - } - buffer.file_updated(excerpt_file, cx); - buffer - }); - - multibuffer.push_excerpts( - excerpt_buffer, - [ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], - cx, - ); - - for snippet in &prediction.context.declarations { - let path = this - .project - .read(cx) - .path_for_entry(snippet.declaration.project_entry_id(), cx); - - let snippet_file = Arc::new(ExcerptMetadataFile { - title: RelPath::unix(&format!( - "{} (Score density: {})", - path.map(|p| p.path.display(path_style).to_string()) - .unwrap_or_else(|| "".to_string()), - snippet.score_density(DeclarationStyle::Declaration) - )) - .unwrap() - .into(), - path_style, - worktree_id, - }); - - let excerpt_buffer = cx.new(|cx| { - let mut buffer = - Buffer::local(snippet.declaration.item_text().0, cx); - buffer.file_updated(snippet_file, cx); - if let Some(language) = - languages.get(&snippet.declaration.identifier().language_id) - { - buffer.set_language(language.clone(), cx); - } - buffer - }); - - multibuffer.push_excerpts( - excerpt_buffer, - [ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], - cx, - ); - } - - multibuffer - }); - - Editor::new(EditorMode::full(), multibuffer, None, window, cx) - }); - - let last_prediction = LastPrediction { - context_editor, - prompt_editor: cx.new(|cx| { - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(prediction.request.prompt, cx); - buffer.set_language(markdown_language.clone(), cx); - buffer - }); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - let mut editor = - Editor::new(EditorMode::full(), buffer, None, window, cx); - editor.set_read_only(true); - editor.set_show_line_numbers(false, cx); - editor.set_show_gutter(false, cx); - editor.set_show_scrollbars(false, cx); - editor - }), - model_response_editor: cx.new(|cx| { - let buffer = cx.new(|cx| { - let mut buffer = - Buffer::local(prediction.request.model_response, cx); - buffer.set_language(markdown_language, cx); - buffer - }); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - let mut editor = - Editor::new(EditorMode::full(), buffer, None, window, cx); - editor.set_read_only(true); - editor.set_show_line_numbers(false, cx); - editor.set_show_gutter(false, cx); - editor.set_show_scrollbars(false, cx); - editor - }), - retrieval_time: prediction.retrieval_time, - prompt_planning_time: prediction.request.prompt_planning_time, - inference_time: prediction.request.inference_time, - parsing_time: prediction.request.parsing_time, - buffer: prediction.buffer, - position: prediction.position, - }; - this.last_prediction = Some(LastPredictionState::Success(last_prediction)); - cx.notify(); - }) - .ok(); - } - }); - } - - fn render_options(&self, window: &mut Window, cx: &mut Context) -> Div { - v_flex() - .gap_2() - .child( - h_flex() - .child(Headline::new("Options").size(HeadlineSize::Small)) - .justify_between() - .child( - ui::Button::new("reset-options", "Reset") - .disabled(self.zeta.read(cx).options() == &zeta2::DEFAULT_OPTIONS) - .style(ButtonStyle::Outlined) - .size(ButtonSize::Large) - .on_click(cx.listener(|this, _, window, cx| { - this.set_input_options(&zeta2::DEFAULT_OPTIONS, window, cx); - })), - ), - ) - .child( - v_flex() - .gap_2() - .child( - h_flex() - .gap_2() - .items_end() - .child(self.max_excerpt_bytes_input.clone()) - .child(self.min_excerpt_bytes_input.clone()) - .child(self.cursor_context_ratio_input.clone()), - ) - .child( - h_flex() - .gap_2() - .items_end() - .child(self.max_prompt_bytes_input.clone()) - .child(self.render_prompt_format_dropdown(window, cx)), - ), - ) - } - - fn render_prompt_format_dropdown(&self, window: &mut Window, cx: &mut Context) -> Div { - let active_format = self.zeta.read(cx).options().prompt_format; - let this = cx.weak_entity(); - - v_flex() - .gap_1p5() - .child( - Label::new("Prompt Format") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - DropdownMenu::new( - "ep-prompt-format", - active_format.to_string(), - ContextMenu::build(window, cx, move |mut menu, _window, _cx| { - for prompt_format in PromptFormat::iter() { - menu = menu.item( - ContextMenuEntry::new(prompt_format.to_string()) - .toggleable(IconPosition::End, active_format == prompt_format) - .handler({ - let this = this.clone(); - move |_window, cx| { - this.update(cx, |this, cx| { - let current_options = - this.zeta.read(cx).options().clone(); - let options = ZetaOptions { - prompt_format, - ..current_options - }; - this.set_options(options, cx); - }) - .ok(); - } - }), - ) - } - menu - }), - ) - .style(ui::DropdownStyle::Outlined), - ) - } - - fn render_tabs(&self, cx: &mut Context) -> Option { - let Some(LastPredictionState::Success { .. } | LastPredictionState::Replaying { .. }) = - self.last_prediction.as_ref() - else { - return None; - }; - - Some( - ui::ToggleButtonGroup::single_row( - "prediction", - [ - ui::ToggleButtonSimple::new( - "Context", - cx.listener(|this, _, _, cx| { - this.active_view = ActiveView::Context; - cx.notify(); - }), - ), - ui::ToggleButtonSimple::new( - "Inference", - cx.listener(|this, _, _, cx| { - this.active_view = ActiveView::Inference; - cx.notify(); - }), - ), - ], - ) - .style(ui::ToggleButtonGroupStyle::Outlined) - .selected_index(if self.active_view == ActiveView::Context { - 0 - } else { - 1 - }) - .into_any_element(), - ) - } - - fn render_stats(&self) -> Option
{ - let Some( - LastPredictionState::Success(prediction) - | LastPredictionState::Replaying { prediction, .. }, - ) = self.last_prediction.as_ref() - else { - return None; - }; - - Some( - v_flex() - .p_4() - .gap_2() - .min_w(px(160.)) - .child(Headline::new("Stats").size(HeadlineSize::Small)) - .child(Self::render_duration( - "Context retrieval", - prediction.retrieval_time, - )) - .child(Self::render_duration( - "Prompt planning", - prediction.prompt_planning_time, - )) - .child(Self::render_duration( - "Inference", - prediction.inference_time, - )) - .child(Self::render_duration("Parsing", prediction.parsing_time)), - ) - } - - fn render_duration(name: &'static str, time: chrono::TimeDelta) -> Div { - h_flex() - .gap_1() - .child(Label::new(name).color(Color::Muted).size(LabelSize::Small)) - .child( - Label::new(if time.num_microseconds().unwrap_or(0) >= 1000 { - format!("{} ms", time.num_milliseconds()) - } else { - format!("{} µs", time.num_microseconds().unwrap_or(0)) - }) - .size(LabelSize::Small), - ) - } - - fn render_content(&self, cx: &mut Context) -> AnyElement { - match self.last_prediction.as_ref() { - None => v_flex() - .size_full() - .justify_center() - .items_center() - .child(Label::new("No prediction").size(LabelSize::Large)) - .into_any(), - Some(LastPredictionState::Success(prediction)) => { - self.render_last_prediction(prediction, cx).into_any() - } - Some(LastPredictionState::Replaying { prediction, _task }) => self - .render_last_prediction(prediction, cx) - .opacity(0.6) - .into_any(), - Some(LastPredictionState::Failed(err)) => v_flex() - .p_4() - .gap_2() - .child(Label::new(err.clone()).buffer_font(cx)) - .into_any(), - } - } - - fn render_last_prediction(&self, prediction: &LastPrediction, cx: &mut Context) -> Div { - match &self.active_view { - ActiveView::Context => div().size_full().child(prediction.context_editor.clone()), - ActiveView::Inference => h_flex() - .items_start() - .w_full() - .flex_1() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .child( - v_flex() - .flex_1() - .gap_2() - .p_4() - .h_full() - .child(ui::Headline::new("Prompt").size(ui::HeadlineSize::XSmall)) - .child(prediction.prompt_editor.clone()), - ) - .child(ui::vertical_divider()) - .child( - v_flex() - .flex_1() - .gap_2() - .h_full() - .p_4() - .child(ui::Headline::new("Model Response").size(ui::HeadlineSize::XSmall)) - .child(prediction.model_response_editor.clone()), - ), - } - } -} - -impl Focusable for Zeta2Inspector { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Item for Zeta2Inspector { - type Event = (); - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Zeta2 Inspector".into() - } -} - -impl EventEmitter<()> for Zeta2Inspector {} - -impl Render for Zeta2Inspector { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .bg(cx.theme().colors().editor_background) - .child( - h_flex() - .w_full() - .child( - v_flex() - .flex_1() - .p_4() - .h_full() - .justify_between() - .child(self.render_options(window, cx)) - .gap_4() - .children(self.render_tabs(cx)), - ) - .child(ui::vertical_divider()) - .children(self.render_stats()), - ) - .child(self.render_content(cx)) - } -} - -// Using same approach as commit view - -struct ExcerptMetadataFile { - title: Arc, - worktree_id: WorktreeId, - path_style: PathStyle, -} - -impl language::File for ExcerptMetadataFile { - fn as_local(&self) -> Option<&dyn language::LocalFile> { - None - } - - fn disk_state(&self) -> DiskState { - DiskState::New - } - - fn path(&self) -> &Arc { - &self.title - } - - fn full_path(&self, _: &App) -> PathBuf { - self.title.as_std_path().to_path_buf() - } - - fn file_name<'a>(&'a self, _: &'a App) -> &'a str { - self.title.file_name().unwrap() - } - - fn path_style(&self, _: &App) -> PathStyle { - self.path_style - } - - fn worktree_id(&self, _: &App) -> WorktreeId { - self.worktree_id - } - - fn to_proto(&self, _: &App) -> language::proto::File { - unimplemented!() - } - - fn is_private(&self) -> bool { - false - } -} diff --git a/crates/zeta_cli/src/main.rs b/crates/zeta_cli/src/main.rs deleted file mode 100644 index feaf3999dc..0000000000 --- a/crates/zeta_cli/src/main.rs +++ /dev/null @@ -1,960 +0,0 @@ -mod headless; - -use anyhow::{Result, anyhow}; -use clap::{Args, Parser, Subcommand}; -use cloud_llm_client::predict_edits_v3; -use edit_prediction_context::{ - Declaration, EditPredictionContext, EditPredictionExcerptOptions, Identifier, ReferenceRegion, - SyntaxIndex, references_in_range, -}; -use futures::channel::mpsc; -use futures::{FutureExt as _, StreamExt as _}; -use gpui::{AppContext, Application, AsyncApp}; -use gpui::{Entity, Task}; -use language::{Bias, LanguageServerId}; -use language::{Buffer, OffsetRangeExt}; -use language::{LanguageId, Point}; -use language_model::LlmApiToken; -use ordered_float::OrderedFloat; -use project::{Project, ProjectPath, Worktree}; -use release_channel::AppVersion; -use reqwest_client::ReqwestClient; -use serde_json::json; -use std::cmp::Reverse; -use std::collections::{HashMap, HashSet}; -use std::io::Write as _; -use std::ops::Range; -use std::path::{Path, PathBuf}; -use std::process::exit; -use std::str::FromStr; -use std::sync::Arc; -use std::time::Duration; -use util::paths::PathStyle; -use util::rel_path::RelPath; -use util::{RangeExt, ResultExt as _}; -use zeta::{PerformPredictEditsParams, Zeta}; - -use crate::headless::ZetaCliAppState; - -#[derive(Parser, Debug)] -#[command(name = "zeta")] -struct ZetaCliArgs { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -enum Commands { - Context(ContextArgs), - Zeta2Context { - #[clap(flatten)] - zeta2_args: Zeta2Args, - #[clap(flatten)] - context_args: ContextArgs, - }, - Predict { - #[arg(long)] - predict_edits_body: Option, - #[clap(flatten)] - context_args: Option, - }, - RetrievalStats { - #[arg(long)] - worktree: PathBuf, - #[arg(long, default_value_t = 42)] - file_indexing_parallelism: usize, - }, -} - -#[derive(Debug, Args)] -#[group(requires = "worktree")] -struct ContextArgs { - #[arg(long)] - worktree: PathBuf, - #[arg(long)] - cursor: CursorPosition, - #[arg(long)] - use_language_server: bool, - #[arg(long)] - events: Option, -} - -#[derive(Debug, Args)] -struct Zeta2Args { - #[arg(long, default_value_t = 8192)] - max_prompt_bytes: usize, - #[arg(long, default_value_t = 2048)] - max_excerpt_bytes: usize, - #[arg(long, default_value_t = 1024)] - min_excerpt_bytes: usize, - #[arg(long, default_value_t = 0.66)] - target_before_cursor_over_total_bytes: f32, - #[arg(long, default_value_t = 1024)] - max_diagnostic_bytes: usize, - #[arg(long, value_enum, default_value_t = PromptFormat::default())] - prompt_format: PromptFormat, - #[arg(long, value_enum, default_value_t = Default::default())] - output_format: OutputFormat, - #[arg(long, default_value_t = 42)] - file_indexing_parallelism: usize, -} - -#[derive(clap::ValueEnum, Default, Debug, Clone)] -enum PromptFormat { - #[default] - MarkedExcerpt, - LabeledSections, - OnlySnippets, -} - -impl Into for PromptFormat { - fn into(self) -> predict_edits_v3::PromptFormat { - match self { - Self::MarkedExcerpt => predict_edits_v3::PromptFormat::MarkedExcerpt, - Self::LabeledSections => predict_edits_v3::PromptFormat::LabeledSections, - Self::OnlySnippets => predict_edits_v3::PromptFormat::OnlySnippets, - } - } -} - -#[derive(clap::ValueEnum, Default, Debug, Clone)] -enum OutputFormat { - #[default] - Prompt, - Request, - Full, -} - -#[derive(Debug, Clone)] -enum FileOrStdin { - File(PathBuf), - Stdin, -} - -impl FileOrStdin { - async fn read_to_string(&self) -> Result { - match self { - FileOrStdin::File(path) => smol::fs::read_to_string(path).await, - FileOrStdin::Stdin => smol::unblock(|| std::io::read_to_string(std::io::stdin())).await, - } - } -} - -impl FromStr for FileOrStdin { - type Err = ::Err; - - fn from_str(s: &str) -> Result { - match s { - "-" => Ok(Self::Stdin), - _ => Ok(Self::File(PathBuf::from_str(s)?)), - } - } -} - -#[derive(Debug, Clone)] -struct CursorPosition { - path: Arc, - point: Point, -} - -impl FromStr for CursorPosition { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 3 { - return Err(anyhow!( - "Invalid cursor format. Expected 'file.rs:line:column', got '{}'", - s - )); - } - - let path = RelPath::new(Path::new(&parts[0]), PathStyle::local())?.into_arc(); - let line: u32 = parts[1] - .parse() - .map_err(|_| anyhow!("Invalid line number: '{}'", parts[1]))?; - let column: u32 = parts[2] - .parse() - .map_err(|_| anyhow!("Invalid column number: '{}'", parts[2]))?; - - // Convert from 1-based to 0-based indexing - let point = Point::new(line.saturating_sub(1), column.saturating_sub(1)); - - Ok(CursorPosition { path, point }) - } -} - -enum GetContextOutput { - Zeta1(zeta::GatherContextOutput), - Zeta2(String), -} - -async fn get_context( - zeta2_args: Option, - args: ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let ContextArgs { - worktree: worktree_path, - cursor, - use_language_server, - events, - } = args; - - let worktree_path = worktree_path.canonicalize()?; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - - let mut ready_languages = HashSet::default(); - let (_lsp_open_handle, buffer) = if use_language_server { - let (lsp_open_handle, _, buffer) = open_buffer_with_language_server( - &project, - &worktree, - &cursor.path, - &mut ready_languages, - cx, - ) - .await?; - (Some(lsp_open_handle), buffer) - } else { - let buffer = open_buffer(&project, &worktree, &cursor.path, cx).await?; - (None, buffer) - }; - - let full_path_str = worktree - .read_with(cx, |worktree, _| worktree.root_name().join(&cursor.path))? - .display(PathStyle::local()) - .to_string(); - - let snapshot = cx.update(|cx| buffer.read(cx).snapshot())?; - let clipped_cursor = snapshot.clip_point(cursor.point, Bias::Left); - if clipped_cursor != cursor.point { - let max_row = snapshot.max_point().row; - if cursor.point.row < max_row { - return Err(anyhow!( - "Cursor position {:?} is out of bounds (line length is {})", - cursor.point, - snapshot.line_len(cursor.point.row) - )); - } else { - return Err(anyhow!( - "Cursor position {:?} is out of bounds (max row is {})", - cursor.point, - max_row - )); - } - } - - let events = match events { - Some(events) => events.read_to_string().await?, - None => String::new(), - }; - - if let Some(zeta2_args) = zeta2_args { - // wait for worktree scan before starting zeta2 so that wait_for_initial_indexing waits for - // the whole worktree. - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - let output = cx - .update(|cx| { - let zeta = cx.new(|cx| { - zeta2::Zeta::new(app_state.client.clone(), app_state.user_store.clone(), cx) - }); - let indexing_done_task = zeta.update(cx, |zeta, cx| { - zeta.set_options(zeta2::ZetaOptions { - excerpt: EditPredictionExcerptOptions { - max_bytes: zeta2_args.max_excerpt_bytes, - min_bytes: zeta2_args.min_excerpt_bytes, - target_before_cursor_over_total_bytes: zeta2_args - .target_before_cursor_over_total_bytes, - }, - max_diagnostic_bytes: zeta2_args.max_diagnostic_bytes, - max_prompt_bytes: zeta2_args.max_prompt_bytes, - prompt_format: zeta2_args.prompt_format.into(), - file_indexing_parallelism: zeta2_args.file_indexing_parallelism, - }); - zeta.register_buffer(&buffer, &project, cx); - zeta.wait_for_initial_indexing(&project, cx) - }); - cx.spawn(async move |cx| { - indexing_done_task.await?; - let request = zeta - .update(cx, |zeta, cx| { - let cursor = buffer.read(cx).snapshot().anchor_before(clipped_cursor); - zeta.cloud_request_for_zeta_cli(&project, &buffer, cursor, cx) - })? - .await?; - - let planned_prompt = cloud_zeta2_prompt::PlannedPrompt::populate(&request)?; - let (prompt_string, section_labels) = planned_prompt.to_prompt_string()?; - - match zeta2_args.output_format { - OutputFormat::Prompt => anyhow::Ok(prompt_string), - OutputFormat::Request => { - anyhow::Ok(serde_json::to_string_pretty(&request)?) - } - OutputFormat::Full => anyhow::Ok(serde_json::to_string_pretty(&json!({ - "request": request, - "prompt": prompt_string, - "section_labels": section_labels, - }))?), - } - }) - })? - .await?; - Ok(GetContextOutput::Zeta2(output)) - } else { - let prompt_for_events = move || (events, 0); - Ok(GetContextOutput::Zeta1( - cx.update(|cx| { - zeta::gather_context( - full_path_str, - &snapshot, - clipped_cursor, - prompt_for_events, - cx, - ) - })? - .await?, - )) - } -} - -pub async fn retrieval_stats( - worktree: PathBuf, - file_indexing_parallelism: usize, - app_state: Arc, - cx: &mut AsyncApp, -) -> Result { - let worktree_path = worktree.canonicalize()?; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - let worktree_id = worktree.read_with(cx, |worktree, _cx| worktree.id())?; - - // wait for worktree scan so that wait_for_initial_file_indexing waits for the whole worktree. - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - - let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx))?; - index - .read_with(cx, |index, cx| index.wait_for_initial_file_indexing(cx))? - .await?; - let files = index - .read_with(cx, |index, cx| index.indexed_file_paths(cx))? - .await - .into_iter() - .filter(|project_path| { - project_path - .path - .extension() - .is_some_and(|extension| !["md", "json", "sh", "diff"].contains(&extension)) - }) - .collect::>(); - - let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store())?; - cx.subscribe(&lsp_store, { - move |_, event, _| { - if let project::LspStoreEvent::LanguageServerUpdate { - message: - client::proto::update_language_server::Variant::WorkProgress( - client::proto::LspWorkProgress { - message: Some(message), - .. - }, - ), - .. - } = event - { - println!("⟲ {message}") - } - } - })? - .detach(); - - let mut lsp_open_handles = Vec::new(); - let mut output = std::fs::File::create("retrieval-stats.txt")?; - let mut results = Vec::new(); - let mut ready_languages = HashSet::default(); - for (file_index, project_path) in files.iter().enumerate() { - let processing_file_message = format!( - "Processing file {} of {}: {}", - file_index + 1, - files.len(), - project_path.path.display(PathStyle::Posix) - ); - println!("{}", processing_file_message); - write!(output, "{processing_file_message}\n\n").ok(); - - let Some((lsp_open_handle, language_server_id, buffer)) = open_buffer_with_language_server( - &project, - &worktree, - &project_path.path, - &mut ready_languages, - cx, - ) - .await - .log_err() else { - continue; - }; - lsp_open_handles.push(lsp_open_handle); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let full_range = 0..snapshot.len(); - let references = references_in_range( - full_range, - &snapshot.text(), - ReferenceRegion::Nearby, - &snapshot, - ); - - loop { - let is_ready = lsp_store - .read_with(cx, |lsp_store, _cx| { - lsp_store - .language_server_statuses - .get(&language_server_id) - .is_some_and(|status| status.pending_work.is_empty()) - }) - .unwrap(); - if is_ready { - break; - } - cx.background_executor() - .timer(Duration::from_millis(10)) - .await; - } - - let index = index.read_with(cx, |index, _cx| index.state().clone())?; - let index = index.lock().await; - for reference in references { - let query_point = snapshot.offset_to_point(reference.range.start); - let mut single_reference_map = HashMap::default(); - single_reference_map.insert(reference.identifier.clone(), vec![reference.clone()]); - let edit_prediction_context = EditPredictionContext::gather_context_with_references_fn( - query_point, - &snapshot, - &zeta2::DEFAULT_EXCERPT_OPTIONS, - Some(&index), - |_, _, _| single_reference_map, - ); - - let Some(edit_prediction_context) = edit_prediction_context else { - let result = RetrievalStatsResult { - identifier: reference.identifier, - point: query_point, - outcome: RetrievalStatsOutcome::NoExcerpt, - }; - write!(output, "{:?}\n\n", result)?; - results.push(result); - continue; - }; - - let mut retrieved_definitions = Vec::new(); - for scored_declaration in edit_prediction_context.declarations { - match &scored_declaration.declaration { - Declaration::File { - project_entry_id, - declaration, - } => { - let Some(path) = worktree.read_with(cx, |worktree, _cx| { - worktree - .entry_for_id(*project_entry_id) - .map(|entry| entry.path.clone()) - })? - else { - log::error!("bug: file project entry not found"); - continue; - }; - let project_path = ProjectPath { - worktree_id, - path: path.clone(), - }; - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? - .await?; - let rope = buffer.read_with(cx, |buffer, _cx| buffer.as_rope().clone())?; - retrieved_definitions.push(( - path, - rope.offset_to_point(declaration.item_range.start) - ..rope.offset_to_point(declaration.item_range.end), - scored_declaration.scores.declaration, - scored_declaration.scores.retrieval, - )); - } - Declaration::Buffer { - project_entry_id, - rope, - declaration, - .. - } => { - let Some(path) = worktree.read_with(cx, |worktree, _cx| { - worktree - .entry_for_id(*project_entry_id) - .map(|entry| entry.path.clone()) - })? - else { - // This case happens when dependency buffers have been opened by - // go-to-definition, resulting in single-file worktrees. - continue; - }; - retrieved_definitions.push(( - path, - rope.offset_to_point(declaration.item_range.start) - ..rope.offset_to_point(declaration.item_range.end), - scored_declaration.scores.declaration, - scored_declaration.scores.retrieval, - )); - } - } - } - retrieved_definitions - .sort_by_key(|(_, _, _, retrieval_score)| Reverse(OrderedFloat(*retrieval_score))); - - // TODO: Consider still checking language server in this case, or having a mode for - // this. For now assuming that the purpose of this is to refine the ranking rather than - // refining whether the definition is present at all. - if retrieved_definitions.is_empty() { - continue; - } - - // TODO: Rename declaration to definition in edit_prediction_context? - let lsp_result = project - .update(cx, |project, cx| { - project.definitions(&buffer, reference.range.start, cx) - })? - .await; - match lsp_result { - Ok(lsp_definitions) => { - let lsp_definitions = lsp_definitions - .unwrap_or_default() - .into_iter() - .filter_map(|definition| { - definition - .target - .buffer - .read_with(cx, |buffer, _cx| { - let path = buffer.file()?.path(); - // filter out definitions from single-file worktrees - if path.is_empty() { - None - } else { - Some(( - path.clone(), - definition.target.range.to_point(&buffer), - )) - } - }) - .ok()? - }) - .collect::>(); - - let result = RetrievalStatsResult { - identifier: reference.identifier, - point: query_point, - outcome: RetrievalStatsOutcome::Success { - matches: lsp_definitions - .iter() - .map(|(path, range)| { - retrieved_definitions.iter().position( - |(retrieved_path, retrieved_range, _, _)| { - path == retrieved_path - && retrieved_range.contains_inclusive(&range) - }, - ) - }) - .collect(), - lsp_definitions, - retrieved_definitions, - }, - }; - write!(output, "{:?}\n\n", result)?; - results.push(result); - } - Err(err) => { - let result = RetrievalStatsResult { - identifier: reference.identifier, - point: query_point, - outcome: RetrievalStatsOutcome::LanguageServerError { - message: err.to_string(), - }, - }; - write!(output, "{:?}\n\n", result)?; - results.push(result); - } - } - } - } - - let mut no_excerpt_count = 0; - let mut error_count = 0; - let mut definitions_count = 0; - let mut top_match_count = 0; - let mut non_top_match_count = 0; - let mut ranking_involved_count = 0; - let mut ranking_involved_top_match_count = 0; - let mut ranking_involved_non_top_match_count = 0; - for result in &results { - match &result.outcome { - RetrievalStatsOutcome::NoExcerpt => no_excerpt_count += 1, - RetrievalStatsOutcome::LanguageServerError { .. } => error_count += 1, - RetrievalStatsOutcome::Success { - matches, - retrieved_definitions, - .. - } => { - definitions_count += 1; - let top_matches = matches.contains(&Some(0)); - if top_matches { - top_match_count += 1; - } - let non_top_matches = !top_matches && matches.iter().any(|index| *index != Some(0)); - if non_top_matches { - non_top_match_count += 1; - } - if retrieved_definitions.len() > 1 { - ranking_involved_count += 1; - if top_matches { - ranking_involved_top_match_count += 1; - } - if non_top_matches { - ranking_involved_non_top_match_count += 1; - } - } - } - } - } - - println!("\nStats:\n"); - println!("No Excerpt: {}", no_excerpt_count); - println!("Language Server Error: {}", error_count); - println!("Definitions: {}", definitions_count); - println!("Top Match: {}", top_match_count); - println!("Non-Top Match: {}", non_top_match_count); - println!("Ranking Involved: {}", ranking_involved_count); - println!( - "Ranking Involved Top Match: {}", - ranking_involved_top_match_count - ); - println!( - "Ranking Involved Non-Top Match: {}", - ranking_involved_non_top_match_count - ); - - Ok("".to_string()) -} - -#[derive(Debug)] -struct RetrievalStatsResult { - #[allow(dead_code)] - identifier: Identifier, - #[allow(dead_code)] - point: Point, - outcome: RetrievalStatsOutcome, -} - -#[derive(Debug)] -enum RetrievalStatsOutcome { - NoExcerpt, - LanguageServerError { - #[allow(dead_code)] - message: String, - }, - Success { - matches: Vec>, - #[allow(dead_code)] - lsp_definitions: Vec<(Arc, Range)>, - retrieved_definitions: Vec<(Arc, Range, f32, f32)>, - }, -} - -pub async fn open_buffer( - project: &Entity, - worktree: &Entity, - path: &RelPath, - cx: &mut AsyncApp, -) -> Result> { - let project_path = worktree.read_with(cx, |worktree, _cx| ProjectPath { - worktree_id: worktree.id(), - path: path.into(), - })?; - - project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? - .await -} - -pub async fn open_buffer_with_language_server( - project: &Entity, - worktree: &Entity, - path: &RelPath, - ready_languages: &mut HashSet, - cx: &mut AsyncApp, -) -> Result<(Entity>, LanguageServerId, Entity)> { - let buffer = open_buffer(project, worktree, path, cx).await?; - - let (lsp_open_handle, path_style) = project.update(cx, |project, cx| { - ( - project.register_buffer_with_language_servers(&buffer, cx), - project.path_style(cx), - ) - })?; - - let Some(language_id) = buffer.read_with(cx, |buffer, _cx| { - buffer.language().map(|language| language.id()) - })? - else { - return Err(anyhow!("No language for {}", path.display(path_style))); - }; - - let log_prefix = path.display(path_style); - if !ready_languages.contains(&language_id) { - wait_for_lang_server(&project, &buffer, log_prefix.into_owned(), cx).await?; - ready_languages.insert(language_id); - } - - let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store())?; - - // hacky wait for buffer to be registered with the language server - for _ in 0..100 { - let Some(language_server_id) = lsp_store.update(cx, |lsp_store, cx| { - buffer.update(cx, |buffer, cx| { - lsp_store - .language_servers_for_local_buffer(&buffer, cx) - .next() - .map(|(_, language_server)| language_server.server_id()) - }) - })? - else { - cx.background_executor() - .timer(Duration::from_millis(10)) - .await; - continue; - }; - - return Ok((lsp_open_handle, language_server_id, buffer)); - } - - return Err(anyhow!("No language server found for buffer")); -} - -// TODO: Dedupe with similar function in crates/eval/src/instance.rs -pub fn wait_for_lang_server( - project: &Entity, - buffer: &Entity, - log_prefix: String, - cx: &mut AsyncApp, -) -> Task> { - println!("{}⏵ Waiting for language server", log_prefix); - - let (mut tx, mut rx) = mpsc::channel(1); - - let lsp_store = project - .read_with(cx, |project, _| project.lsp_store()) - .unwrap(); - - let has_lang_server = buffer - .update(cx, |buffer, cx| { - lsp_store.update(cx, |lsp_store, cx| { - lsp_store - .language_servers_for_local_buffer(buffer, cx) - .next() - .is_some() - }) - }) - .unwrap_or(false); - - if has_lang_server { - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .unwrap() - .detach(); - } - let (mut added_tx, mut added_rx) = mpsc::channel(1); - - let subscriptions = [ - cx.subscribe(&lsp_store, { - let log_prefix = log_prefix.clone(); - move |_, event, _| { - if let project::LspStoreEvent::LanguageServerUpdate { - message: - client::proto::update_language_server::Variant::WorkProgress( - client::proto::LspWorkProgress { - message: Some(message), - .. - }, - ), - .. - } = event - { - println!("{}⟲ {message}", log_prefix) - } - } - }), - cx.subscribe(project, { - let buffer = buffer.clone(); - move |project, event, cx| match event { - project::Event::LanguageServerAdded(_, _, _) => { - let buffer = buffer.clone(); - project - .update(cx, |project, cx| project.save_buffer(buffer, cx)) - .detach(); - added_tx.try_send(()).ok(); - } - project::Event::DiskBasedDiagnosticsFinished { .. } => { - tx.try_send(()).ok(); - } - _ => {} - } - }), - ]; - - cx.spawn(async move |cx| { - if !has_lang_server { - // some buffers never have a language server, so this aborts quickly in that case. - let timeout = cx.background_executor().timer(Duration::from_secs(5)); - futures::select! { - _ = added_rx.next() => {}, - _ = timeout.fuse() => { - anyhow::bail!("Waiting for language server add timed out after 5 seconds"); - } - }; - } - let timeout = cx.background_executor().timer(Duration::from_secs(60 * 5)); - let result = futures::select! { - _ = rx.next() => { - println!("{}⚑ Language server idle", log_prefix); - anyhow::Ok(()) - }, - _ = timeout.fuse() => { - anyhow::bail!("LSP wait timed out after 5 minutes"); - } - }; - drop(subscriptions); - result - }) -} - -fn main() { - zlog::init(); - zlog::init_output_stderr(); - let args = ZetaCliArgs::parse(); - let http_client = Arc::new(ReqwestClient::new()); - let app = Application::headless().with_http_client(http_client); - - app.run(move |cx| { - let app_state = Arc::new(headless::init(cx)); - cx.spawn(async move |cx| { - let result = match args.command { - Commands::Zeta2Context { - zeta2_args, - context_args, - } => match get_context(Some(zeta2_args), context_args, &app_state, cx).await { - Ok(GetContextOutput::Zeta1 { .. }) => unreachable!(), - Ok(GetContextOutput::Zeta2(output)) => Ok(output), - Err(err) => Err(err), - }, - Commands::Context(context_args) => { - match get_context(None, context_args, &app_state, cx).await { - Ok(GetContextOutput::Zeta1(output)) => { - Ok(serde_json::to_string_pretty(&output.body).unwrap()) - } - Ok(GetContextOutput::Zeta2 { .. }) => unreachable!(), - Err(err) => Err(err), - } - } - Commands::Predict { - predict_edits_body, - context_args, - } => { - cx.spawn(async move |cx| { - let app_version = cx.update(|cx| AppVersion::global(cx))?; - app_state.client.sign_in(true, cx).await?; - let llm_token = LlmApiToken::default(); - llm_token.refresh(&app_state.client).await?; - - let predict_edits_body = - if let Some(predict_edits_body) = predict_edits_body { - serde_json::from_str(&predict_edits_body.read_to_string().await?)? - } else if let Some(context_args) = context_args { - match get_context(None, context_args, &app_state, cx).await? { - GetContextOutput::Zeta1(output) => output.body, - GetContextOutput::Zeta2 { .. } => unreachable!(), - } - } else { - return Err(anyhow!( - "Expected either --predict-edits-body-file \ - or the required args of the `context` command." - )); - }; - - let (response, _usage) = - Zeta::perform_predict_edits(PerformPredictEditsParams { - client: app_state.client.clone(), - llm_token, - app_version, - body: predict_edits_body, - }) - .await?; - - Ok(response.output_excerpt) - }) - .await - } - Commands::RetrievalStats { - worktree, - file_indexing_parallelism, - } => retrieval_stats(worktree, file_indexing_parallelism, app_state, cx).await, - }; - match result { - Ok(output) => { - println!("{}", output); - let _ = cx.update(|cx| cx.quit()); - } - Err(e) => { - eprintln!("Failed: {:?}", e); - exit(1); - } - } - }) - .detach(); - }); -} diff --git a/crates/zeta_prompt/Cargo.toml b/crates/zeta_prompt/Cargo.toml new file mode 100644 index 0000000000..c9b1e2d784 --- /dev/null +++ b/crates/zeta_prompt/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zeta_prompt" +version = "0.1.0" +publish.workspace = true +edition.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/zeta_prompt.rs" + +[dependencies] +serde.workspace = true \ No newline at end of file diff --git a/tooling/workspace-hack/LICENSE-GPL b/crates/zeta_prompt/LICENSE-GPL similarity index 100% rename from tooling/workspace-hack/LICENSE-GPL rename to crates/zeta_prompt/LICENSE-GPL diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs new file mode 100644 index 0000000000..21fbca1ae1 --- /dev/null +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::ops::Range; +use std::path::Path; +use std::sync::Arc; + +pub const CURSOR_MARKER: &str = "<|user_cursor|>"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ZetaPromptInput { + pub cursor_path: Arc, + pub cursor_excerpt: Arc, + pub editable_range_in_excerpt: Range, + pub cursor_offset_in_excerpt: usize, + pub events: Vec>, + pub related_files: Arc<[RelatedFile]>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum Event { + BufferChange { + path: Arc, + old_path: Arc, + diff: String, + predicted: bool, + in_open_source_repo: bool, + }, +} + +pub fn write_event(prompt: &mut String, event: &Event) { + fn write_path_as_unix_str(prompt: &mut String, path: &Path) { + for component in path.components() { + prompt.push('/'); + write!(prompt, "{}", component.as_os_str().display()).ok(); + } + } + match event { + Event::BufferChange { + path, + old_path, + diff, + predicted, + in_open_source_repo: _, + } => { + if *predicted { + prompt.push_str("// User accepted prediction:\n"); + } + prompt.push_str("--- a"); + write_path_as_unix_str(prompt, old_path.as_ref()); + prompt.push_str("\n+++ b"); + write_path_as_unix_str(prompt, path.as_ref()); + prompt.push('\n'); + prompt.push_str(diff); + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RelatedFile { + pub path: Arc, + pub max_row: u32, + pub excerpts: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RelatedExcerpt { + pub row_range: Range, + pub text: String, +} + +pub fn format_zeta_prompt(input: &ZetaPromptInput) -> String { + let mut prompt = String::new(); + write_related_files(&mut prompt, &input.related_files); + write_edit_history_section(&mut prompt, input); + write_cursor_excerpt_section(&mut prompt, input); + prompt +} + +pub fn write_related_files(prompt: &mut String, related_files: &[RelatedFile]) { + push_delimited(prompt, "related_files", &[], |prompt| { + for file in related_files { + let path_str = file.path.to_string_lossy(); + push_delimited(prompt, "related_file", &[("path", &path_str)], |prompt| { + for excerpt in &file.excerpts { + push_delimited( + prompt, + "related_excerpt", + &[( + "lines", + &format!( + "{}-{}", + excerpt.row_range.start + 1, + excerpt.row_range.end + 1 + ), + )], + |prompt| { + prompt.push_str(&excerpt.text); + prompt.push('\n'); + }, + ); + } + }); + } + }); +} + +fn write_edit_history_section(prompt: &mut String, input: &ZetaPromptInput) { + push_delimited(prompt, "edit_history", &[], |prompt| { + if input.events.is_empty() { + prompt.push_str("(No edit history)"); + } else { + for event in &input.events { + write_event(prompt, event); + } + } + }); +} + +fn write_cursor_excerpt_section(prompt: &mut String, input: &ZetaPromptInput) { + push_delimited(prompt, "cursor_excerpt", &[], |prompt| { + let path_str = input.cursor_path.to_string_lossy(); + push_delimited(prompt, "file", &[("path", &path_str)], |prompt| { + prompt.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + push_delimited(prompt, "editable_region", &[], |prompt| { + prompt.push_str( + &input.cursor_excerpt + [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], + ); + prompt.push_str(CURSOR_MARKER); + prompt.push_str( + &input.cursor_excerpt + [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], + ); + }); + prompt.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + }); + }); +} + +fn push_delimited( + prompt: &mut String, + tag: &'static str, + arguments: &[(&str, &str)], + cb: impl FnOnce(&mut String), +) { + if !prompt.ends_with("\n") { + prompt.push('\n'); + } + prompt.push('<'); + prompt.push_str(tag); + for (arg_name, arg_value) in arguments { + write!(prompt, " {}=\"{}\"", arg_name, arg_value).ok(); + } + prompt.push_str(">\n"); + + cb(prompt); + + if !prompt.ends_with('\n') { + prompt.push('\n'); + } + prompt.push_str("\n"); +} diff --git a/crates/zlog/Cargo.toml b/crates/zlog/Cargo.toml index 4b758437d5..2799592c8e 100644 --- a/crates/zlog/Cargo.toml +++ b/crates/zlog/Cargo.toml @@ -18,7 +18,6 @@ default = [] collections.workspace = true chrono.workspace = true log.workspace = true -workspace-hack.workspace = true anyhow.workspace = true [dev-dependencies] diff --git a/crates/zlog/README.md b/crates/zlog/README.md new file mode 100644 index 0000000000..6d0fef147c --- /dev/null +++ b/crates/zlog/README.md @@ -0,0 +1,15 @@ +# Zlog + +Use the `ZED_LOG` environment variable to control logging output for Zed +applications and libraries. The variable accepts a comma-separated list of +directives that specify logging levels for different modules (crates). The +general format is for instance: + +``` +ZED_LOG=info,project=debug,agent=off +``` + +- Levels can be one of: `off`/`none`, `error`, `warn`, `info`, `debug`, or + `trace`. +- You don't need to specify the global level, default is `trace` in the crate + and `info` set by `RUST_LOG` in Zed. diff --git a/crates/zlog/src/filter.rs b/crates/zlog/src/filter.rs index 9a2de13cb3..0be6f4ead5 100644 --- a/crates/zlog/src/filter.rs +++ b/crates/zlog/src/filter.rs @@ -5,12 +5,12 @@ use std::sync::{ atomic::{AtomicU8, Ordering}, }; -use crate::{SCOPE_DEPTH_MAX, SCOPE_STRING_SEP_STR, Scope, ScopeAlloc, env_config, private}; +use crate::{SCOPE_DEPTH_MAX, SCOPE_STRING_SEP_STR, ScopeAlloc, ScopeRef, env_config, private}; use log; static ENV_FILTER: OnceLock = OnceLock::new(); -static SCOPE_MAP: RwLock> = RwLock::new(None); +static SCOPE_MAP: RwLock = RwLock::new(ScopeMap::empty()); pub const LEVEL_ENABLED_MAX_DEFAULT: log::LevelFilter = log::LevelFilter::Info; /// The maximum log level of verbosity that is enabled by default. @@ -41,6 +41,9 @@ const DEFAULT_FILTERS: &[(&str, log::LevelFilter)] = &[ ("blade_graphics", log::LevelFilter::Warn), #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] ("naga::back::spv::writer", log::LevelFilter::Warn), + // usvg prints a lot of warnings on rendering an SVG with partial errors, which + // can happen a lot with the SVG preview + ("usvg::parser::style", log::LevelFilter::Error), ]; pub fn init_env_filter(filter: env_config::EnvFilter) { @@ -56,7 +59,11 @@ pub fn is_possibly_enabled_level(level: log::Level) -> bool { level as u8 <= LEVEL_ENABLED_MAX_CONFIG.load(Ordering::Acquire) } -pub fn is_scope_enabled(scope: &Scope, module_path: Option<&str>, level: log::Level) -> bool { +pub fn is_scope_enabled( + scope: &ScopeRef<'_>, + module_path: Option<&str>, + level: log::Level, +) -> bool { // TODO: is_always_allowed_level that checks against LEVEL_ENABLED_MIN_CONFIG if !is_possibly_enabled_level(level) { // [FAST PATH] @@ -71,16 +78,11 @@ pub fn is_scope_enabled(scope: &Scope, module_path: Option<&str>, level: log::Le err.into_inner() }); - let Some(map) = global_scope_map.as_ref() else { - // on failure, return false because it's not <= LEVEL_ENABLED_MAX_STATIC - return is_enabled_by_default; - }; - - if map.is_empty() { + if global_scope_map.is_empty() { // if no scopes are enabled, return false because it's not <= LEVEL_ENABLED_MAX_STATIC return is_enabled_by_default; } - let enabled_status = map.is_enabled(scope, module_path, level); + let enabled_status = global_scope_map.is_enabled(scope, module_path, level); match enabled_status { EnabledStatus::NotConfigured => is_enabled_by_default, EnabledStatus::Enabled => true, @@ -104,7 +106,7 @@ pub fn refresh_from_settings(settings: &HashMap) { SCOPE_MAP.clear_poison(); err.into_inner() }); - global_map.replace(map_new); + *global_map = map_new; } log::trace!("Log configuration updated"); } @@ -392,12 +394,21 @@ impl ScopeMap { } EnabledStatus::NotConfigured } + + const fn empty() -> ScopeMap { + ScopeMap { + entries: vec![], + modules: vec![], + root_count: 0, + } + } } #[cfg(test)] mod tests { use log::LevelFilter; + use crate::Scope; use crate::private::scope_new; use super::*; diff --git a/crates/zlog/src/sink.rs b/crates/zlog/src/sink.rs index afbdf37bf9..07e87be1b0 100644 --- a/crates/zlog/src/sink.rs +++ b/crates/zlog/src/sink.rs @@ -8,7 +8,7 @@ use std::{ }, }; -use crate::{SCOPE_STRING_SEP_CHAR, Scope}; +use crate::{SCOPE_STRING_SEP_CHAR, ScopeRef}; // ANSI color escape codes for log levels const ANSI_RESET: &str = "\x1b[0m"; @@ -35,10 +35,11 @@ static SINK_FILE_SIZE_BYTES: AtomicU64 = AtomicU64::new(0); const SINK_FILE_SIZE_BYTES_MAX: u64 = 1024 * 1024; // 1 MB pub struct Record<'a> { - pub scope: Scope, + pub scope: ScopeRef<'a>, pub level: log::Level, pub message: &'a std::fmt::Arguments<'a>, pub module_path: Option<&'a str>, + pub line: Option, } pub fn init_output_stdout() { @@ -105,7 +106,11 @@ static LEVEL_ANSI_COLORS: [&str; 6] = [ ]; // PERF: batching -pub fn submit(record: Record) { +pub fn submit(mut record: Record) { + if record.module_path.is_none_or(|p| !p.ends_with(".rs")) { + // Only render line numbers for actual rust files emitted by `log_err` and friends + record.line.take(); + } if ENABLED_SINKS_STDOUT.load(Ordering::Acquire) { let mut stdout = std::io::stdout().lock(); _ = writeln!( @@ -117,6 +122,7 @@ pub fn submit(record: Record) { SourceFmt { scope: record.scope, module_path: record.module_path, + line: record.line, ansi: true, }, record.message @@ -132,6 +138,7 @@ pub fn submit(record: Record) { SourceFmt { scope: record.scope, module_path: record.module_path, + line: record.line, ansi: true, }, record.message @@ -167,6 +174,7 @@ pub fn submit(record: Record) { SourceFmt { scope: record.scope, module_path: record.module_path, + line: record.line, ansi: false, }, record.message @@ -200,8 +208,9 @@ pub fn flush() { } struct SourceFmt<'a> { - scope: Scope, + scope: ScopeRef<'a>, module_path: Option<&'a str>, + line: Option, ansi: bool, } @@ -225,6 +234,10 @@ impl std::fmt::Display for SourceFmt<'_> { f.write_str(subscope)?; } } + if let Some(line) = self.line { + f.write_char(':')?; + line.fmt(f)?; + } if self.ansi { f.write_str(ANSI_RESET)?; } diff --git a/crates/zlog/src/zlog.rs b/crates/zlog/src/zlog.rs index 8254866b6f..3c154f7908 100644 --- a/crates/zlog/src/zlog.rs +++ b/crates/zlog/src/zlog.rs @@ -10,22 +10,22 @@ pub use sink::{flush, init_output_file, init_output_stderr, init_output_stdout}; pub const SCOPE_DEPTH_MAX: usize = 4; pub fn init() { - if let Err(err) = try_init() { + if let Err(err) = try_init(None) { log::error!("{err}"); eprintln!("{err}"); } } -pub fn try_init() -> anyhow::Result<()> { +pub fn try_init(filter: Option) -> anyhow::Result<()> { log::set_logger(&ZLOG)?; log::set_max_level(log::LevelFilter::max()); - process_env(); + process_env(filter); filter::refresh_from_settings(&std::collections::HashMap::default()); Ok(()) } pub fn init_test() { - if get_env_config().is_some() && try_init().is_ok() { + if get_env_config().is_some() && try_init(None).is_ok() { init_output_stdout(); } } @@ -34,10 +34,17 @@ fn get_env_config() -> Option { std::env::var("ZED_LOG") .or_else(|_| std::env::var("RUST_LOG")) .ok() + .or_else(|| { + if std::env::var("CI").is_ok() { + Some("info".to_owned()) + } else { + None + } + }) } -pub fn process_env() { - let Some(env_config) = get_env_config() else { +pub fn process_env(filter: Option) { + let Some(env_config) = get_env_config().or(filter) else { return; }; match env_config::parse(&env_config) { @@ -63,18 +70,21 @@ impl log::Log for Zlog { if !self.enabled(record.metadata()) { return; } - let (crate_name_scope, module_scope) = match record.module_path_static() { + let module_path = record.module_path().or(record.file()); + let (crate_name_scope, module_scope) = match module_path { Some(module_path) => { let crate_name = private::extract_crate_name_from_module_path(module_path); - let crate_name_scope = private::scope_new(&[crate_name]); - let module_scope = private::scope_new(&[module_path]); + let crate_name_scope = private::scope_ref_new(&[crate_name]); + let module_scope = private::scope_ref_new(&[module_path]); (crate_name_scope, module_scope) } - // TODO: when do we hit this - None => (private::scope_new(&[]), private::scope_new(&["*unknown*"])), + None => { + // TODO: when do we hit this + (private::scope_new(&[]), private::scope_new(&["*unknown*"])) + } }; let level = record.metadata().level(); - if !filter::is_scope_enabled(&crate_name_scope, record.module_path(), level) { + if !filter::is_scope_enabled(&crate_name_scope, Some(record.target()), level) { return; } sink::submit(sink::Record { @@ -82,7 +92,8 @@ impl log::Log for Zlog { level, message: record.args(), // PERF(batching): store non-static paths in a cache + leak them and pass static str here - module_path: record.module_path().or(record.file()), + module_path, + line: record.line(), }); } @@ -103,6 +114,7 @@ macro_rules! log { level, message: &format_args!($($arg)+), module_path: Some(module_path!()), + line: Some(line!()), }); } } @@ -174,7 +186,7 @@ macro_rules! time { $crate::Timer::new($logger, $name) }; ($name:expr) => { - time!($crate::default_logger!() => $name) + $crate::time!($crate::default_logger!() => $name) }; } @@ -243,6 +255,10 @@ pub mod private { } pub const fn scope_new(scopes: &[&'static str]) -> Scope { + scope_ref_new(scopes) + } + + pub const fn scope_ref_new<'a>(scopes: &[&'a str]) -> ScopeRef<'a> { assert!(scopes.len() <= SCOPE_DEPTH_MAX); let mut scope = [""; SCOPE_DEPTH_MAX]; let mut i = 0; @@ -266,6 +282,7 @@ pub mod private { } pub type Scope = [&'static str; SCOPE_DEPTH_MAX]; +pub type ScopeRef<'a> = [&'a str; SCOPE_DEPTH_MAX]; pub type ScopeAlloc = [String; SCOPE_DEPTH_MAX]; const SCOPE_STRING_SEP_STR: &str = "."; const SCOPE_STRING_SEP_CHAR: char = '.'; @@ -285,7 +302,7 @@ impl log::Log for Logger { return; } let level = record.metadata().level(); - if !filter::is_scope_enabled(&self.scope, record.module_path(), level) { + if !filter::is_scope_enabled(&self.scope, Some(record.target()), level) { return; } sink::submit(sink::Record { @@ -293,6 +310,7 @@ impl log::Log for Logger { level, message: record.args(), module_path: record.module_path(), + line: record.line(), }); } diff --git a/crates/zlog_settings/Cargo.toml b/crates/zlog_settings/Cargo.toml index 8ec63cefe4..39c3b6a193 100644 --- a/crates/zlog_settings/Cargo.toml +++ b/crates/zlog_settings/Cargo.toml @@ -19,4 +19,3 @@ gpui.workspace = true collections.workspace = true settings.workspace = true zlog.workspace = true -workspace-hack.workspace = true diff --git a/crates/zlog_settings/src/zlog_settings.rs b/crates/zlog_settings/src/zlog_settings.rs index cb564fcff3..cb09375b9a 100644 --- a/crates/zlog_settings/src/zlog_settings.rs +++ b/crates/zlog_settings/src/zlog_settings.rs @@ -2,11 +2,9 @@ use collections::HashMap; use gpui::App; -use settings::{Settings, SettingsStore}; +use settings::{RegisterSetting, Settings, SettingsStore}; pub fn init(cx: &mut App) { - ZlogSettings::register(cx); - cx.observe_global::(|cx| { let zlog_settings = ZlogSettings::get_global(cx); zlog::filter::refresh_from_settings(&zlog_settings.scopes); @@ -14,7 +12,7 @@ pub fn init(cx: &mut App) { .detach(); } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, RegisterSetting)] pub struct ZlogSettings { /// A map of log scopes to the desired log level. /// Useful for filtering out noisy logs or enabling more verbose logging. @@ -24,11 +22,9 @@ pub struct ZlogSettings { } impl Settings for ZlogSettings { - fn from_settings(content: &settings::SettingsContent, _: &mut App) -> Self { + fn from_settings(content: &settings::SettingsContent) -> Self { ZlogSettings { scopes: content.log.clone().unwrap(), } } - - fn import_from_vscode(_: &settings::VsCodeSettings, _: &mut settings::SettingsContent) {} } diff --git a/crates/ztracing/Cargo.toml b/crates/ztracing/Cargo.toml new file mode 100644 index 0000000000..0d9f15b9af --- /dev/null +++ b/crates/ztracing/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ztracing" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +tracy = ["tracing-tracy"] + +[dependencies] +zlog.workspace = true +tracing.workspace = true + +tracing-subscriber = "0.3.22" +tracing-tracy = { version = "0.11.4", optional = true, features = ["enable", "ondemand"] } + +ztracing_macro.workspace = true diff --git a/crates/ztracing/LICENSE-AGPL b/crates/ztracing/LICENSE-AGPL new file mode 120000 index 0000000000..5f5cf25dc4 --- /dev/null +++ b/crates/ztracing/LICENSE-AGPL @@ -0,0 +1 @@ +../../LICENSE-AGPL \ No newline at end of file diff --git a/crates/semantic_version/LICENSE-APACHE b/crates/ztracing/LICENSE-APACHE similarity index 100% rename from crates/semantic_version/LICENSE-APACHE rename to crates/ztracing/LICENSE-APACHE diff --git a/crates/ztracing/LICENSE-GPL b/crates/ztracing/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/ztracing/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ztracing/build.rs b/crates/ztracing/build.rs new file mode 100644 index 0000000000..dc0d0ad704 --- /dev/null +++ b/crates/ztracing/build.rs @@ -0,0 +1,9 @@ +use std::env; + +fn main() { + if env::var_os("ZTRACING").is_some() { + println!(r"cargo::rustc-cfg=ztracing"); + } + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rerun-if-env-changed=ZTRACING"); +} diff --git a/crates/ztracing/src/lib.rs b/crates/ztracing/src/lib.rs new file mode 100644 index 0000000000..b9b318cc35 --- /dev/null +++ b/crates/ztracing/src/lib.rs @@ -0,0 +1,52 @@ +pub use tracing::Level; + +#[cfg(ztracing)] +pub use tracing::{ + debug_span, error_span, event, info_span, instrument, span, trace_span, warn_span, +}; +#[cfg(not(ztracing))] +pub use ztracing_macro::instrument; + +#[cfg(not(ztracing))] +pub use __consume_all_tokens as trace_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as info_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as debug_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as warn_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as error_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as event; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as span; + +#[cfg(not(ztracing))] +#[macro_export] +macro_rules! __consume_all_tokens { + ($($t:tt)*) => { + $crate::FakeSpan + }; +} + +pub struct FakeSpan; +impl FakeSpan { + pub fn enter(&self) {} +} + +// #[cfg(not(ztracing))] +// pub use span; + +#[cfg(ztracing)] +pub fn init() { + zlog::info!("Starting tracy subscriber, you can now connect the profiler"); + use tracing_subscriber::prelude::*; + tracing::subscriber::set_global_default( + tracing_subscriber::registry().with(tracing_tracy::TracyLayer::default()), + ) + .expect("setup tracy layer"); +} + +#[cfg(not(ztracing))] +pub fn init() {} diff --git a/crates/ztracing_macro/Cargo.toml b/crates/ztracing_macro/Cargo.toml new file mode 100644 index 0000000000..dbd7adce5f --- /dev/null +++ b/crates/ztracing_macro/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ztracing_macro" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +proc-macro = true + +[dependencies] diff --git a/crates/ztracing_macro/LICENSE-AGPL b/crates/ztracing_macro/LICENSE-AGPL new file mode 120000 index 0000000000..5f5cf25dc4 --- /dev/null +++ b/crates/ztracing_macro/LICENSE-AGPL @@ -0,0 +1 @@ +../../LICENSE-AGPL \ No newline at end of file diff --git a/crates/ztracing_macro/LICENSE-APACHE b/crates/ztracing_macro/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/crates/ztracing_macro/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/ztracing_macro/LICENSE-GPL b/crates/ztracing_macro/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/ztracing_macro/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ztracing_macro/src/lib.rs b/crates/ztracing_macro/src/lib.rs new file mode 100644 index 0000000000..d9b073ed13 --- /dev/null +++ b/crates/ztracing_macro/src/lib.rs @@ -0,0 +1,7 @@ +#[proc_macro_attribute] +pub fn instrument( + _attr: proc_macro::TokenStream, + item: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + item +} diff --git a/docs/README.md b/docs/README.md index a225903674..e1649f4bc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ To preview the docs locally you will need to install [mdBook](https://rust-lang. mdbook serve docs ``` -It's important to note the version number above. For an unknown reason, as of 2025-04-23, running 0.4.48 will cause odd URL behavior that breaks docs. +It's important to note the version number above. For an unknown reason, as of 2025-04-23, running 0.4.48 will cause odd URL behavior that breaks things. Before committing, verify that the docs are formatted in the way Prettier expects with: @@ -20,10 +20,9 @@ cd docs && pnpm dlx prettier@3.5.0 . --write && cd .. ## Preprocessor -We have a custom mdbook preprocessor for interfacing with our crates (`crates/docs_preprocessor`). +We have a custom mdBook preprocessor for interfacing with our crates (`crates/docs_preprocessor`). -If for some reason you need to bypass the docs preprocessor, you can comment out `[preprocessor.zed_docs_preprocessor] -` from the `book.toml`.: +If for some reason you need to bypass the docs preprocessor, you can comment out `[preprocessor.zed_docs_preprocessor]` from the `book.toml`. ## Images and videos @@ -34,7 +33,7 @@ Putting binary assets such as images in the Git repository will bloat the reposi ## Internal notes: - We have a Cloudflare router called `docs-proxy` that intercepts requests to `zed.dev/docs` and forwards them to the "docs" Cloudflare Pages project. -- CI uploads a new version to the Pages project from `.github/workflows/deploy_docs.yml` on every push to `main`. +- The CI uploads a new version to the Cloudflare Pages project from `.github/workflows/deploy_docs.yml` on every push to `main`. ### Table of Contents @@ -46,15 +45,15 @@ Since all this preprocessor does is generate the static assets, we don't need to When referencing keybindings or actions, use the following formats: -### Keybindings: +### Keybindings `{#kb scope::Action}` - e.g., `{#kb zed::OpenSettings}`. -This will output a code element like: `Cmd+,|Ctrl+,`. We then use a client-side plugin to show the actual keybinding based on the user's platform. +This will output a code element like: `Cmd + , | Ctrl + ,`. We then use a client-side plugin to show the actual keybinding based on the user's platform. By using the action name, we can ensure that the keybinding is always up-to-date rather than hardcoding the keybinding. -### Actions: +### Actions `{#action scope::Action}` - e.g., `{#action zed::OpenSettings}`. @@ -62,19 +61,20 @@ This will render a human-readable version of the action name, e.g., "zed: open s ### Creating New Templates -Templates are just functions that modify the source of the docs pages (usually with a regex match & replace). You can see how the actions and keybindings are templated in `crates/docs_preprocessor/src/main.rs` for reference on how to create new templates. +Templates are functions that modify the source of the docs pages (usually with a regex match and replace). +You can see how the actions and keybindings are templated in `crates/docs_preprocessor/src/main.rs` for reference on how to create new templates. ### References -- Template Trait: crates/docs_preprocessor/src/templates.rs -- Example template: crates/docs_preprocessor/src/templates/keybinding.rs -- Client-side plugins: docs/theme/plugins.js +- Template Trait: `crates/docs_preprocessor/src/templates.rs` +- Example template: `crates/docs_preprocessor/src/templates/keybinding.rs` +- Client-side plugins: `docs/theme/plugins.js` ## Postprocessor -A postprocessor is implemented as a sub-command of `docs_preprocessor` that wraps the builtin `html` renderer and applies post-processing to the `html` files, to add support for page-specific title and meta description values. +A postprocessor is implemented as a sub-command of `docs_preprocessor` that wraps the built-in HTML renderer and applies post-processing to the HTML files, to add support for page-specific title and `meta` tag description values. -An example of the syntax can be found in `git.md`, as well as below +An example of the syntax can be found in `git.md`, as well as below: ```md --- @@ -85,7 +85,7 @@ description: A page-specific description # Editor ``` -The above will be transformed into (with non-relevant tags removed) +The above code will be transformed into (with non-relevant tags removed): ```html @@ -97,15 +97,16 @@ The above will be transformed into (with non-relevant tags removed) ``` -If no front-matter is provided, or If one or both keys aren't provided, the title and description will be set based on the `default-title` and `default-description` keys in `book.toml` respectively. +If no front matter is provided, or if one or both keys aren't provided, the `title` and `description` will be set based on the `default-title` and `default-description` keys in `book.toml` respectively. ### Implementation details -Unfortunately, `mdbook` does not support post-processing like it does pre-processing, and only supports defining one description to put in the meta tag per book rather than per file. So in order to apply post-processing (necessary to modify the html head tags) the global book description is set to a marker value `#description#` and the html renderer is replaced with a sub-command of `docs_preprocessor` that wraps the builtin `html` renderer and applies post-processing to the `html` files, replacing the marker value and the `(.*)` with the contents of the front-matter if there is one. +Unfortunately, mdBook does not support post-processing like it does pre-processing, and only supports defining one description to put in the `meta` tag per book rather than per file. +So in order to apply post-processing (necessary to modify the HTML `head` tags) the global book description is set to a marker value `#description#` and the HTML renderer is replaced with a sub-command of `docs_preprocessor` that wraps the built-in HTML renderer and applies post-processing to the HTML files, replacing the marker value and the `(.*)` with the contents of the front matter if there is one. ### Known limitations -The front-matter parsing is extremely simple, which avoids needing to take on an additional dependency, or implement full yaml parsing. +The front matter parsing is extremely simple, which avoids needing to take on an additional dependency, or implement full YAML parsing. - Double quotes and multi-line values are not supported, i.e. Keys and values must be entirely on the same line, with no double quotes around the value. @@ -119,7 +120,7 @@ title: Some --- ``` -And neither will: +neither this: ```md --- @@ -127,6 +128,5 @@ title: "Some title" --- ``` -- The front-matter must be at the top of the file, with only white-space preceding it - -- The contents of the title and description will not be html-escaped. They should be simple ascii text with no unicode or emoji characters +- The front matter must be at the top of the file, with only white-space preceding it. +- The contents of the `title` and `description` will not be HTML escaped. They should be simple ASCII text with no unicode or emoji characters. diff --git a/docs/book.toml b/docs/book.toml index 60ddc5ac51..2bb57c5c08 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -56,6 +56,10 @@ enable = false "/model-improvement.html" = "/docs/ai/ai-improvement.html" "/ai/temperature.html" = "/docs/ai/agent-settings.html#model-temperature" +# Collaboration +"/channels.html" = "/docs/collaboration/channels.html" +"/collaboration.html" = "/docs/collaboration/overview.html" + # Community "/community/feedback.html" = "/community-links" "/conversations.html" = "/community-links" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index eb54249723..9d1f6f61d4 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -1,15 +1,14 @@ # Summary -# General +# Welcome - [Getting Started](./getting-started.md) -- [System Requirements](./system-requirements.md) -- [Accounts](./accounts.md) -- [Linux](./linux.md) -- [Windows](./windows.md) +- [Installation](./installation.md) + - [Update](./update.md) + - [Uninstall](./uninstall.md) +- [Authenticate](./authentication.md) - [Telemetry](./telemetry.md) -- [Workspace Persistence](./workspace-persistence.md) -- [Additional Learning Materials](./additional-learning-materials.md) +- [Troubleshooting](./troubleshooting.md) # Configuration @@ -31,18 +30,27 @@ # Using Zed - [Multibuffers](./multibuffers.md) +- [Command Palette](./command-palette.md) +- [Command-line Interface](./command-line-interface.md) - [Outline Panel](./outline-panel.md) - [Code Completions](./completions.md) -- [Channels](./channels.md) -- [Collaboration](./collaboration.md) +- [Collaboration](./collaboration/overview.md) + - [Channels](./collaboration/channels.md) + - [Contacts and Private Calls](./collaboration/contacts-and-private-calls.md) - [Git](./git.md) - [Debugger](./debugger.md) - [Diagnostics](./diagnostics.md) - [Tasks](./tasks.md) +- [Tab Switcher](./tab-switcher.md) - [Remote Development](./remote-development.md) - [Environment Variables](./environment.md) - [REPL](./repl.md) +# Platform Support + +- [Windows](./windows.md) +- [Linux](./linux.md) + # AI - [Overview](./ai/overview.md) @@ -69,13 +77,19 @@ - [Overview](./extensions.md) - [Installing Extensions](./extensions/installing-extensions.md) - [Developing Extensions](./extensions/developing-extensions.md) +- [Extension Capabilities](./extensions/capabilities.md) - [Language Extensions](./extensions/languages.md) - [Debugger Extensions](./extensions/debugger-extensions.md) - [Theme Extensions](./extensions/themes.md) - [Icon Theme Extensions](./extensions/icon-themes.md) - [Slash Command Extensions](./extensions/slash-commands.md) +- [Agent Server Extensions](./extensions/agent-servers.md) - [MCP Server Extensions](./extensions/mcp-extensions.md) +# Migrate + +- [VS Code](./migrate/vs-code.md) + # Language Support - [All Languages](./languages.md) @@ -118,6 +132,7 @@ - [Markdown](./languages/markdown.md) - [Nim](./languages/nim.md) - [OCaml](./languages/ocaml.md) +- [OpenTofu](./languages/opentofu.md) - [PHP](./languages/php.md) - [PowerShell](./languages/powershell.md) - [Prisma](./languages/prisma.md) @@ -158,6 +173,7 @@ - [FreeBSD](./development/freebsd.md) - [Local Collaboration](./development/local-collaboration.md) - [Using Debuggers](./development/debuggers.md) + - [Performance](./performance.md) - [Glossary](./development/glossary.md) -- [Release Process](./development/releases.md) +- [Release Notes](./development/release-notes.md) - [Debugging Crashes](./development/debugging-crashes.md) diff --git a/docs/src/additional-learning-materials.md b/docs/src/additional-learning-materials.md deleted file mode 100644 index 66ff935abf..0000000000 --- a/docs/src/additional-learning-materials.md +++ /dev/null @@ -1,3 +0,0 @@ -# Additional Learning Materials - -- [Text Manipulation Kung Fu for the Aspiring Black Belt](https://zed.dev/blog/text-manipulation) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 445b853370..c383862ed6 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -1,17 +1,17 @@ # Agent Panel -The Agent Panel allows you to interact with many LLMs and coding agents that can help with in various types of tasks, such as generating code, codebase understanding, and other general inquiries like writing emails, documentation, and more. +The Agent Panel allows you to interact with many LLMs and coding agents that can help with various types of tasks, such as generating code, codebase understanding, and other general inquiries like writing emails, documentation, and more. To open it, use the `agent: new thread` action in [the Command Palette](../getting-started.md#command-palette) or click the ✨ (sparkles) icon in the status bar. -## Getting Started +## Getting Started {#getting-started} If you're using the Agent Panel for the first time, you need to have at least one LLM provider or external agent configured. You can do that by: 1. [subscribing to our Pro plan](https://zed.dev/pricing), so you have access to our hosted models 2. [using your own API keys](./llm-providers.md#use-your-own-keys), either from model providers like Anthropic or model gateways like OpenRouter. -3. using an external agent like [Gemini CLI](./external-agents.md#gemini-cli) or [Claude Code](./external-agents.md#claude-code) +3. using an [external agent](./external-agents.md) like [Gemini CLI](./external-agents.md#gemini-cli) or [Claude Code](./external-agents.md#claude-code) ## Overview {#overview} @@ -21,14 +21,14 @@ If you need extra room to type, you can expand the message editor with {#kb agen You should start to see the responses stream in with indications of [which tools](./tools.md) the model is using to fulfill your prompt. From this point on, you can interact with the many supported features outlined below. -> Note that for external agents, like [Gemini CLI](./external-agents.md#gemini-cli) or [Claude Code](./external-agents.md#claude-code), some of the features outlined below are _not_ currently supported—for example, _restoring threads from history_, _checkpoints_, _token usage display_, _model selection_, and others. All of them should hopefully be supported in the future. +> Note that for external agents, like [Gemini CLI](./external-agents.md#gemini-cli) or [Claude Code](./external-agents.md#claude-code), some of the features outlined below may _not_ be supported—for example, _restoring threads from history_, _checkpoints_, _token usage display_, and others. Their availability varies depending on the agent. ### Creating New Threads {#new-thread} By default, the Agent Panel uses Zed's first-party agent. To change that, go to the plus button in the top-right of the Agent Panel and choose another option. -You choose to create a new [Text Thread](./text-threads.md) or, if you have [external agents](./external-agents.md) connected, you can create new threads with them. +You can choose to create a new [Text Thread](./text-threads.md) or, if you have [external agents](./external-agents.md) connected, you can create new threads with them. ### Editing Messages {#editing-messages} @@ -37,7 +37,7 @@ You can click on the card that contains your message and re-submit it with an ad ### Checkpoints {#checkpoints} -Every time the AI performs an edit, you should see a "Restore Checkpoint" button to the top of your message, allowing you to return your code base to the state it was in prior to that message. +Every time the AI performs an edit, you should see a "Restore Checkpoint" button at the top of your message, allowing you to return your code base to the state it was in prior to that message. The checkpoint button appears even if you interrupt the thread midway through an edit attempt, as this is likely a moment when you've identified that the agent is not heading in the right direction and you want to revert back. @@ -78,15 +78,20 @@ Edit diffs also appear in individual buffers. If your active tab had edits made ## Adding Context {#adding-context} -Although Zed's agent is very efficient at reading through your code base to autonomously pick up relevant files, directories, and other context, manually adding context is still encouraged as a way to speed up and improve the AI's response quality. +Although Zed's agent is very efficient at reading through your code base to autonomously pick up relevant context, manually adding whatever would be useful to fulfill your prompt is still very encouraged as a way to not only improve the AI's response quality but also to speed up its response time. -To add any file, directory, symbol, previous threads, rules files, or even web pages as context, type `@` to mention them in the editor. +In Zed's Agent Panel, all pieces of context are added as mentions in the panel's message editor. +You can type `@` to mention files, directories, symbols, previous threads, and rules files. -Pasting images as context is also supported by the Agent Panel. +Copying images and pasting them in the panel's message editor is also supported. -### Token Usage {#token-usage} +### Selection as Context -Zed surfaces how many tokens you are consuming for your currently active thread nearby the profile selector in the panel's message editor. Depending on how many pieces of context you add, your token consumption can grow rapidly. +Additionally, you can also select text in a buffer and add it as context by using the {#kb agent::AddSelectionToThread} keybinding, running the {#action agent::AddSelectionToThread} action, or choosing the "Selection" item in the `@` menu. + +## Token Usage {#token-usage} + +Zed surfaces how many tokens you are consuming for your currently active thread near the profile selector in the panel's message editor. Depending on how many pieces of context you add, your token consumption can grow rapidly. Once you approach the model's context window, a banner appears below the message editor suggesting to start a new thread with the current one summarized and added as context. You can also do this at any time with an ongoing thread via the "Agent Options" menu on the top right. @@ -95,7 +100,8 @@ You can also do this at any time with an ongoing thread via the "Agent Options" After you've configured your LLM providers—either via [a custom API key](./llm-providers.md) or through [Zed's hosted models](./models.md)—you can switch between them by clicking on the model selector on the message editor or by using the {#kb agent::ToggleModelSelector} keybinding. -> The same model can be offered via multiple providers - for example, Claude Sonnet 4 is available via Zed Pro, OpenRouter, Anthropic directly, and more. Make sure you've selected the correct model **_provider_** for the model you'd like to use, delineated by the logo to the left of the model in the model selector. +> The same model can be offered via multiple providers - for example, Claude Sonnet 4 is available via Zed Pro, OpenRouter, Anthropic directly, and more. +> Make sure you've selected the correct model **_provider_** for the model you'd like to use, delineated by the logo to the left of the model in the model selector. ## Using Tools {#using-tools} @@ -115,19 +121,21 @@ Zed offers three built-in profiles and you can create as many custom ones as you - `Ask`: A profile with read-only tools. Best for asking questions about your code base without the concern of the agent making changes. - `Minimal`: A profile with no tools. Best for general conversations with the LLM where no knowledge of your code base is necessary. -You can explore the exact tools enabled in each profile by clicking on the profile selector button > `Configure Profiles…` > the one you want to check out. +You can explore the exact tools enabled in each profile by clicking on the profile selector button > `Configure` button > the one you want to check out. + +Alternatively, you can also use either the command palette, by running {#action agent::ManageProfiles}, or the keybinding directly, {#kb agent::ManageProfiles}, to have access to the profile management modal. #### Custom Profiles {#custom-profiles} -You can create a custom profile via the `Configure Profiles…` option in the profile selector. -From here, you can choose to `Add New Profile` or fork an existing one with a custom name and your preferred set of tools. +You can also create a custom profile through the Agent Profile modal. +From there, you can choose to `Add New Profile` or fork an existing one with a custom name and your preferred set of tools. -You can also override built-in profiles. -With a built-in profile selected, in the profile selector, navigate to `Configure Tools`, and select the tools you'd like. +It's also possible to override built-in profiles. +In the Agent Profile modal, select a built-in profile, navigate to `Configure Tools`, and rearrange the tools you'd like to keep or remove. Zed will store this profile in your settings using the same profile name as the default you overrode. -All custom profiles can be edited via the UI or by hand under the `assistant.profiles` key in your `settings.json` file. +All custom profiles can be edited via the UI or by hand under the `agent.profiles` key in your `settings.json` file. ### Tool Approval @@ -138,19 +146,24 @@ You can change that by setting this key to `true` in either your `settings.json` ### Model Support {#model-support} Tool calling needs to be individually supported by each model and model provider. -Therefore, despite the presence of tools, some models may not have the ability to pick them up yet in Zed. You should see a "No tools" label if you select a model that falls into this case. +Therefore, despite the presence of tools, some models may not have the ability to pick them up yet in Zed. +You should see a "No tools" label if you select a model that falls into this case. All [Zed's hosted models](./models.md) support tool calling out-of-the-box. ### MCP Servers {#mcp-servers} -Similarly to the built-in tools, some models may not support all tools included in a given MCP Server. Zed's UI will inform about this via a warning icon that appears close to the model selector. +Similarly to the built-in tools, some models may not support all tools included in a given MCP Server. +Zed's UI will inform you about this via a warning icon that appears close to the model selector. ## Text Threads {#text-threads} -["Text Threads"](./text-threads.md) present your conversation with the LLM in a different format—as raw text. With text threads, you have full control over the conversation data. You can remove and edit responses from the LLM, swap roles, and include more context earlier in the conversation. +["Text Threads"](./text-threads.md) present your conversation with the LLM in a different format—as raw text. +With text threads, you have full control over the conversation data. +You can remove and edit responses from the LLM, swap roles, and include more context earlier in the conversation. -For users who have been with us for some time, you'll notice that text threads are our original assistant panel—users love it for the control it offers. We do not plan to deprecate text threads, but it should be noted that if you want the AI to write to your code base autonomously, that's only available in the newer, and now default, "Threads". +For users who have been with us for some time, you'll notice that text threads are our original assistant panel—users love it for the control it offers. +We do not plan to deprecate text threads, but it should be noted that if you want the AI to write to your code base autonomously, that's only available in the newer, and now default, "Threads". ## Errors and Debugging {#errors-and-debugging} diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index 17f4a620ee..21607649ad 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -8,7 +8,7 @@ Learn about all the settings you can customize in Zed's Agent Panel. If you're using [Zed's hosted LLM service](./subscription.md), it sets `claude-sonnet-4` as the default model for agentic work (agent panel, inline assistant) and `gpt-5-nano` as the default "fast" model (thread summarization, git commit messages). If you're not subscribed or want to change these defaults, you can manually edit the `default_model` object in your settings: -```json +```json [settings] { "agent": { "default_model": { @@ -27,7 +27,7 @@ You can assign distinct and specific models for the following AI-powered feature - Inline assistant model: Used for the inline assistant feature - Commit message model: Used for generating Git commit messages -```json +```json [settings] { "agent": { "default_model": { @@ -54,17 +54,11 @@ You can assign distinct and specific models for the following AI-powered feature ### Alternative Models for Inline Assists {#alternative-assists} -The Inline Assist feature in particular has the capacity to perform multiple generations in parallel using different models. -That is possible by assigning more than one model to it, taking the configuration shown above one step further. +With the Inline Assistant in particular, you can send the same prompt to multiple models at once. -When configured, the inline assist UI will surface controls to cycle between the outputs generated by each model. +Here's how you can customize your `settings.json` to add this functionality: -The models you specify here are always used in _addition_ to your [default model](#default-model). - -For example, the following configuration will generate two outputs for every assist. -One with Claude Sonnet 4 (the default model), and one with GPT-5-mini. - -```json +```json [settings] { "agent": { "default_model": { @@ -81,11 +75,39 @@ One with Claude Sonnet 4 (the default model), and one with GPT-5-mini. } ``` +When multiple models are configured, you'll see in the Inline Assistant UI buttons that allow you to cycle between outputs generated by each model. + +The models you specify here are always used in _addition_ to your [default model](#default-model). + +For example, the following configuration will generate three outputs for every assist. +One with Claude Sonnet 4 (the default model), another with GPT-5-mini, and another one with Gemini 2.5 Flash. + +```json [settings] +{ + "agent": { + "default_model": { + "provider": "zed.dev", + "model": "claude-sonnet-4" + }, + "inline_alternatives": [ + { + "provider": "zed.dev", + "model": "gpt-4-mini" + }, + { + "provider": "zed.dev", + "model": "gemini-2.5-flash" + } + ] + } +} +``` + ### Model Temperature Specify a custom temperature for a provider and/or model: -```json +```json [settings] "model_parameters": [ // To set parameters for all requests to OpenAI models: { @@ -114,7 +136,7 @@ Note that some of these settings are also surfaced in the Agent Panel's settings Use the `default_view` setting to change the default view of the Agent Panel. You can choose between `thread` (the default) and `text_thread`: -```json +```json [settings] { "agent": { "default_view": "text_thread" @@ -126,7 +148,7 @@ You can choose between `thread` (the default) and `text_thread`: Use the `agent_font_size` setting to change the font size of rendered agent responses in the panel. -```json +```json [settings] { "agent": { "agent_font_size": 18 @@ -141,7 +163,7 @@ Use the `agent_font_size` setting to change the font size of rendered agent resp Control whether to allow the agent to run commands without asking you for permission. The default value is `false`. -```json +```json [settings] { "agent": { "always_allow_tool_actions": true @@ -154,7 +176,7 @@ The default value is `false`. Control whether to display review actions (accept & reject) in single buffers after the agent is done performing edits. The default value is `false`. -```json +```json [settings] { "agent": { "single_file_review": true @@ -169,7 +191,7 @@ When set to false, these controls are only available in the multibuffer review t Control whether to hear a notification sound when the agent is done generating changes or needs your input. The default value is `false`. -```json +```json [settings] { "agent": { "play_sound_when_agent_done": true @@ -179,10 +201,10 @@ The default value is `false`. ### Message Editor Size -Use the `message_editor_min_lines` setting to control minimum number of lines of height the agent message editor should have. +Use the `message_editor_min_lines` setting to control the minimum number of lines of height the agent message editor should have. It is set to `4` by default, and the max number of lines is always double of the minimum. -```json +```json [settings] { "agent": { "message_editor_min_lines": 4 @@ -196,7 +218,7 @@ Make a modifier (`cmd` on macOS, `ctrl` on Linux) required to send messages. This is encouraged for more thoughtful prompt crafting. The default value is `false`. -```json +```json [settings] { "agent": { "use_modifier_to_send": true @@ -209,7 +231,7 @@ The default value is `false`. Use the `expand_edit_card` setting to control whether edit cards show the full diff in the Agent Panel. It is set to `true` by default, but if set to false, the card's height is capped to a certain number of lines, requiring a click to be expanded. -```json +```json [settings] { "agent": { "expand_edit_card": false @@ -222,7 +244,7 @@ It is set to `true` by default, but if set to false, the card's height is capped Use the `expand_terminal_card` setting to control whether terminal cards show the command output in the Agent Panel. It is set to `true` by default, but if set to false, the card will be fully collapsed even while the command is running, requiring a click to be expanded. -```json +```json [settings] { "agent": { "expand_terminal_card": false @@ -232,10 +254,10 @@ It is set to `true` by default, but if set to false, the card will be fully coll ### Feedback Controls -Control whether to display the thumbs up/down buttons at the bottom of each agent response, allowing to give Zed feedback about the agent's performance. +Control whether to display the thumbs up/down buttons at the bottom of each agent response, allowing you to give Zed feedback about the agent's performance. The default value is `true`. -```json +```json [settings] { "agent": { "enable_feedback": false diff --git a/docs/src/ai/ai-improvement.md b/docs/src/ai/ai-improvement.md index 972b5908c0..857ca2c0ef 100644 --- a/docs/src/ai/ai-improvement.md +++ b/docs/src/ai/ai-improvement.md @@ -20,13 +20,9 @@ When using upstream services through Zed's hosted models, we require assurances | Provider | No Training Guarantee | Zero-Data Retention (ZDR) | | --------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Anthropic | [Yes](https://www.anthropic.com/legal/commercial-terms) | [Yes](https://privacy.anthropic.com/en/articles/8956058-i-have-a-zero-data-retention-agreement-with-anthropic-what-products-does-it-apply-to) | -| Google | [Yes](https://cloud.google.com/terms/service-terms) | **No**, in flight | +| Google | [Yes](https://cloud.google.com/terms/service-terms) | [Yes](https://cloud.google.com/terms/service-terms), see Service Terms sections 17 and 19h | | OpenAI | [Yes](https://openai.com/enterprise-privacy/) | [Yes](https://platform.openai.com/docs/guides/your-data) | -> Zed's use of Gemini models is currently supported via [Google AI Studio](https://ai.google.dev/aistudio), which **_does not_** support ZDR. We're migrating to [Vertex AI](https://cloud.google.com/vertex-ai?hl=en), which **_does_**, and upon completion of that migration will offer ZDR to all users of Zed's hosted Google/Gemini models. - -> If ZDR from upstream model providers is important to you, _please do not use Gemini models at this time_. Your data will never be used for training purposes by any model providers hosted by Zed, however. - When you use your own API keys or external agents, **Zed does not have control over how your data is used by that service provider.** You should reference your agreement with each service provider to understand what terms and conditions apply. @@ -63,7 +59,7 @@ Zed will intentionally exclude certain files from Predictive Edits entirely, eve You can inspect this exclusion list by opening `zed: open default settings` from the command palette: -```json +```json [settings] { "edit_predictions": { // A list of globs representing files that edit predictions should be disabled for. @@ -83,7 +79,7 @@ You can inspect this exclusion list by opening `zed: open default settings` from Users may explicitly exclude additional paths and/or file extensions by adding them to [`edit_predictions.disabled_globs`](https://zed.dev/docs/configuring-zed#edit-predictions) in their Zed settings.json: -```json +```json [settings] { "edit_predictions": { "disabled_globs": ["secret_dir/*", "**/*.log"] diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index 64ff871ce1..788c0c1cf7 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -5,7 +5,7 @@ For invoice-based billing, a Business plan is required. Contact [sales@zed.dev]( ## Billing Information {#settings} -You can access billing information and settings at [zed.dev/account](https://zed.dev/account). +You can access billing information and settings at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Most of the page embeds information from our invoicing/metering partner, Orb (we're planning on a more native experience soon!). ## Billing Cycles {#billing-cycles} @@ -28,7 +28,7 @@ If payment of an invoice fails, Zed will block usage of our hosted models until ## Invoice History {#invoice-history} -You can access your invoice history by navigating to [zed.dev/account](https://zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. +You can access your invoice history by navigating to [dashboard.zed.dev/account](https://dashboard.zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. If you require historical Stripe invoices, email [billing-support@zed.dev](mailto:billing-support@zed.dev) diff --git a/docs/src/ai/configuration.md b/docs/src/ai/configuration.md index c11a0fd65c..8877689e46 100644 --- a/docs/src/ai/configuration.md +++ b/docs/src/ai/configuration.md @@ -3,7 +3,7 @@ When using AI in Zed, you can configure multiple dimensions: 1. Which LLM providers you can use - - Zed's hosted models, which require [authentication](../accounts.md) and [subscription](./subscription.md) + - Zed's hosted models, which require [authentication](../authentication.md) and [subscription](./subscription.md) - [Using your own API keys](./llm-providers.md), which do not - Using [external agents like Claude Code](./external-agents.md), which do not 2. [Model parameters and usage](./agent-settings.md#model-settings) @@ -14,7 +14,7 @@ When using AI in Zed, you can configure multiple dimensions: We want to respect users who want to use Zed without interacting with AI whatsoever. To do that, add the following key to your `settings.json`: -```json +```json [settings] { "disable_ai": true } diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 7843b08ff7..65a427842c 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -1,16 +1,30 @@ # Edit Prediction -Edit Prediction is Zed's native mechanism for predicting the code you want to write through AI. -Each keystroke sends a new request to our [open source, open dataset Zeta model](https://huggingface.co/zed-industries/zeta) and it returns with individual or multi-line suggestions that can be quickly accepted by pressing `tab`. +Edit Prediction is Zed's LLM mechanism for predicting the code you want to write. +Each keystroke sends a new request to the edit prediction provider, which returns individual or multi-line suggestions that can be quickly accepted by pressing `tab`. + +The default provider is [Zeta, a proprietary open source and open dataset model](https://huggingface.co/zed-industries/zeta), but you can also use [other providers](#other-providers) like GitHub Copilot, Supermaven, and Codestral. ## Configuring Zeta -Zed's Edit Prediction was initially introduced via a banner on the title bar. -Clicking on it would take you to a modal with a button ("Enable Edit Prediction") that sets `zed` as your `edit_prediction_provider`. +To use Zeta, the only thing you need to do is [to sign in](../authentication.md#what-features-require-signing-in). +After doing that, you should already see predictions as you type on your files. -![Onboarding banner and modal](https://zed.dev/img/edit-prediction/docs.webp) +You can confirm that Zeta is properly configured either by verifying whether you have the following code in your `settings.json`: -But, if you haven't come across the banner, Zed's Edit Prediction is the default edit prediction provider and you should see it right away in your status bar. +```json [settings] +"features": { + "edit_prediction_provider": "zed" +}, +``` + +Or you can also look for a little Z icon in the right of your status bar at the bottom. + +### Pricing and Plans + +From just signing in, while in Zed's free plan, you get 2,000 Zeta-powered edit predictions per month. +But you can get _**unlimited edit predictions**_ by upgrading to [the Pro plan](../ai/plans-and-usage.md). +More information can be found in [Zed's pricing page](https://zed.dev/pricing). ### Switching Modes {#switching-modes} @@ -21,9 +35,9 @@ Zed's Edit Prediction comes with two different display modes: Toggle between them via the `mode` key: -```json +```json [settings] "edit_predictions": { - "mode": "eager" | "subtle" + "mode": "eager" // or "subtle" }, ``` @@ -31,6 +45,8 @@ Or directly via the UI through the status bar menu: ![Edit Prediction status bar menu, with the modes toggle.](https://zed.dev/img/edit-prediction/status-bar-menu.webp) +> Note that edit prediction modes work with any prediction provider. + ### Conflict With Other `tab` Actions {#edit-predictions-conflict} By default, when `tab` would normally perform a different action, Zed requires a modifier key to accept predictions: @@ -42,15 +58,14 @@ In these cases, `alt-tab` is used instead to accept the prediction. When the lan On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is provided as the default binding for accepting predictions. `tab` and `alt-tab` also work, but aren't displayed by default. -{#action editor::AcceptPartialEditPrediction} ({#kb editor::AcceptPartialEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. - -See the [Configuring GitHub Copilot](#github-copilot) and [Configuring Supermaven](#supermaven) sections below for configuration of other providers. Only text insertions at the current cursor are supported for these providers, whereas the Zeta model provides multiple predictions including deletions. +{#action editor::AcceptNextWordEditPrediction} ({#kb editor::AcceptNextWordEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. +{#action editor::AcceptNextLineEditPrediction} ({#kb editor::AcceptNextLineEditPrediction}) can be used to accept the current edit prediction up to the new line boundary. ## Configuring Edit Prediction Keybindings {#edit-predictions-keybinding} By default, `tab` is used to accept edit predictions. You can use another keybinding by inserting this in your keymap: -```json +```json [settings] { "context": "Editor && edit_prediction", "bindings": { @@ -60,9 +75,10 @@ By default, `tab` is used to accept edit predictions. You can use another keybin } ``` -When there's a [conflict with the `tab` key](#edit-predictions-conflict), Zed uses a different context to accept keybindings (`edit_prediction_conflict`). If you want to use a different one, you can insert this in your keymap: +When there's a [conflict with the `tab` key](#edit-predictions-conflict), Zed uses a different key context to accept keybindings (`edit_prediction_conflict`). +If you want to use a different one, you can insert this in your keymap: -```json +```json [settings] { "context": "Editor && edit_prediction_conflict", "bindings": { @@ -73,9 +89,10 @@ When there's a [conflict with the `tab` key](#edit-predictions-conflict), Zed us If your keybinding contains a modifier (`ctrl` in the example above), it will also be used to preview the edit prediction and temporarily hide the language server completion menu. -You can also bind this action to keybind without a modifier. In that case, Zed will use the default modifier (`alt`) to preview the edit prediction. +You can also bind this action to keybind without a modifier. +In that case, Zed will use the default modifier (`alt`) to preview the edit prediction. -```json +```json [settings] { "context": "Editor && edit_prediction_conflict", "bindings": { @@ -88,7 +105,7 @@ You can also bind this action to keybind without a modifier. In that case, Zed w To maintain the use of the modifier key for accepting predictions when there is a language server completions menu, but allow `tab` to accept predictions regardless of cursor position, you can specify the context further with `showing_completions`: -```json +```json [settings] { "context": "Editor && edit_prediction_conflict && !showing_completions", "bindings": { @@ -98,11 +115,28 @@ To maintain the use of the modifier key for accepting predictions when there is } ``` +### Keybinding Example: Always Use Tab + +If you want to use `tab` to always accept edit predictions, you can use the following keybinding: + +```json [keymap] +{ + "context": "Editor && edit_prediction_conflict && showing_completions", + "bindings": { + "tab": "editor::AcceptEditPrediction" + } +} +``` + +This will make `tab` work to accept edit predictions _even when_ you're also seeing language server completions. +That means that you need to rely on `enter` for accepting the latter. + ### Keybinding Example: Always Use Alt-Tab -The keybinding example below causes `alt-tab` to always be used instead of sometimes using `tab`. You might want this in order to have just one keybinding to use for accepting edit predictions, since the behavior of `tab` varies based on context. +The keybinding example below causes `alt-tab` to always be used instead of sometimes using `tab`. +You might want this in order to have just one (alternative) keybinding to use for accepting edit predictions, since the behavior of `tab` varies based on context. -```json +```json [keymap] { "context": "Editor && edit_prediction", "bindings": { @@ -124,9 +158,9 @@ The keybinding example below causes `alt-tab` to always be used instead of somet }, ``` -If `"vim_mode": true` is set within `settings.json`, then additional bindings are needed after the above to return `tab` to its original behavior: +If you are using [Vim mode](../vim.md), then additional bindings are needed after the above to return `tab` to its original behavior: -```json +```json [keymap] { "context": "(VimControl && !menu) || vim_mode == replace || vim_mode == waiting", "bindings": { @@ -143,9 +177,10 @@ If `"vim_mode": true` is set within `settings.json`, then additional bindings ar ### Keybinding Example: Displaying Tab and Alt-Tab on Linux -While `tab` and `alt-tab` are supported on Linux, `alt-l` is displayed instead. If your window manager does not reserve `alt-tab`, and you would prefer to use `tab` and `alt-tab`, include these bindings in `keymap.json`: +While `tab` and `alt-tab` are supported on Linux, `alt-l` is displayed instead. +If your window manager does not reserve `alt-tab`, and you would prefer to use `tab` and `alt-tab`, include these bindings in `keymap.json`: -```json +```json [keymap] { "context": "Editor && edit_prediction", "bindings": { @@ -170,7 +205,7 @@ Zed requires at least one keybinding for the {#action editor::AcceptEditPredicti If you have previously bound the default keybindings to different actions in the global context, you will not be able to preview or accept edit predictions. For example: -```json +```json [keymap] [ // Your keymap { @@ -184,7 +219,7 @@ If you have previously bound the default keybindings to different actions in the To fix this, you can specify your own keybinding for accepting edit predictions: -```json +```json [keymap] [ // ... { @@ -208,7 +243,7 @@ Alternatively, if you have Zed set as your provider, consider [using Subtle Mode To not have predictions appear automatically as you type, set this within `settings.json`: -```json +```json [settings] { "show_edit_predictions": false } @@ -221,7 +256,7 @@ Still, you can trigger edit predictions manually by executing {#action editor::S To not have predictions appear automatically as you type when working with a specific language, set this within `settings.json`: -```json +```json [settings] { "language": { "python": { @@ -235,7 +270,7 @@ To not have predictions appear automatically as you type when working with a spe To disable edit predictions for specific directories or files, set this within `settings.json`: -```json +```json [settings] { "edit_predictions": { "disabled_globs": ["~/.config/zed/settings.json"] @@ -247,17 +282,22 @@ To disable edit predictions for specific directories or files, set this within ` To completely turn off edit prediction across all providers, explicitly set the settings to `none`, like so: -```json +```json [settings] "features": { "edit_prediction_provider": "none" }, ``` -## Configuring GitHub Copilot {#github-copilot} +## Configuring Other Providers {#other-providers} + +Zed's Edit Prediction also work with other completion model providers aside from Zeta. +Learn about the available ones below. + +### GitHub Copilot {#github-copilot} To use GitHub Copilot as your provider, set this within `settings.json`: -```json +```json [settings] { "features": { "edit_prediction_provider": "copilot" @@ -267,11 +307,11 @@ To use GitHub Copilot as your provider, set this within `settings.json`: You should be able to sign-in to GitHub Copilot by clicking on the Copilot icon in the status bar and following the setup instructions. -### Using GitHub Copilot Enterprise {#github-copilot-enterprise} +#### Using GitHub Copilot Enterprise If your organization uses GitHub Copilot Enterprise, you can configure Zed to use your enterprise instance by specifying the enterprise URI in your `settings.json`: -```json +```json [settings] { "edit_predictions": { "copilot": { @@ -283,18 +323,20 @@ If your organization uses GitHub Copilot Enterprise, you can configure Zed to us Replace `"https://your.enterprise.domain"` with the URL provided by your GitHub Enterprise administrator (e.g., `https://foo.ghe.com`). -Once set, Zed will route Copilot requests through your enterprise endpoint. When you sign in by clicking the Copilot icon in the status bar, you will be redirected to your configured enterprise URL to complete authentication. All other Copilot features and usage remain the same. +Once set, Zed will route Copilot requests through your enterprise endpoint. +When you sign in by clicking the Copilot icon in the status bar, you will be redirected to your configured enterprise URL to complete authentication. +All other Copilot features and usage remain the same. Copilot can provide multiple completion alternatives, and these can be navigated with the following actions: - {#action editor::NextEditPrediction} ({#kb editor::NextEditPrediction}): To cycle to the next edit prediction - {#action editor::PreviousEditPrediction} ({#kb editor::PreviousEditPrediction}): To cycle to the previous edit prediction -## Configuring Supermaven {#supermaven} +### Supermaven {#supermaven} To use Supermaven as your provider, set this within `settings.json`: -```json +```json [settings] { "features": { "edit_prediction_provider": "supermaven" @@ -304,6 +346,21 @@ To use Supermaven as your provider, set this within `settings.json`: You should be able to sign-in to Supermaven by clicking on the Supermaven icon in the status bar and following the setup instructions. +### Codestral {#codestral} + +To use Mistral's Codestral as your provider, start by going to the Agent Panel settings view by running the {#action agent::OpenSettings} action. +Look for the Mistral item and add a Codestral API key in the corresponding text input. + +After that, you should be able to switch your provider to it in your `settings.json` file: + +```json [settings] +{ + "features": { + "edit_prediction_provider": "codestral" + } +} +``` + ## See also -You may also use the [Agent Panel](./agent-panel.md) or the [Inline Assistant](./inline-assistant.md) to interact with language models, see the [AI documentation](./overview.md) for more information on the other AI features in Zed. +To learn about other ways to interact with AI in Zed, you may also want to see more about the [Agent Panel](./agent-panel.md) or the [Inline Assistant](./inline-assistant.md) feature. diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index abe1486590..0467913b07 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -3,9 +3,10 @@ Zed supports terminal-based agents through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com). Currently, [Gemini CLI](https://github.com/google-gemini/gemini-cli) serves as the reference implementation. -[Claude Code](https://www.anthropic.com/claude-code) is also included by default, and you can [add custom ACP-compatible agents](#add-custom-agents) as well. +[Claude Code](https://www.anthropic.com/claude-code) and [Codex](https://developers.openai.com/codex) are also included by default, and you can [add custom ACP-compatible agents](#add-more-agents) as well. -Zed's affordance for external agents is strictly UI-based; the billing and legal/terms arrangement is directly between you and the agent provider. Zed does not charge for use of external agents, and our [zero-data retention agreements/privacy guarantees](./ai-improvement.md) are **_only_** applicable for Zed's hosted models. +> Note that Zed's affordance for external agents is strictly UI-based; the billing and legal/terms arrangement is directly between you and the agent provider. +> Zed does not charge for use of external agents, and our [zero-data retention agreements/privacy guarantees](./ai-improvement.md) are **_only_** applicable for Zed's hosted models. ## Gemini CLI {#gemini-cli} @@ -20,7 +21,7 @@ As of [Zed Stable v0.201.5](https://zed.dev/releases/stable/0.201.5) you should If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap` command to include: -```json +```json [keymap] [ { "bindings": { @@ -32,11 +33,11 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your #### Installation -The first time you create a Gemini CLI thread, Zed will install [@google/gemini-cli](https://github.com/zed-industries/claude-code-acp). This installation is only available to Zed and is kept up to date as you use the agent. +The first time you create a Gemini CLI thread, Zed will install [@google/gemini-cli](https://github.com/google-gemini/gemini-cli). This installation is only available to Zed and is kept up to date as you use the agent. By default, Zed will use this managed version of Gemini CLI even if you have it installed globally. However, you can configure it to use a version in your `PATH` by adding this to your settings: -```json +```json [settings] { "agent_servers": { "gemini": { @@ -63,7 +64,7 @@ For more information, see the [Gemini CLI docs](https://github.com/google-gemini Similar to Zed's first-party agent, you can use Gemini CLI to do anything that you need. And to give it context, you can @-mention files, recent threads, symbols, or fetch the web. -> Note that some first-party agent features don't yet work with Gemini CLI: editing past messages, resuming threads from history, checkpointing, and using the agent in SSH projects. +> Note that some first-party agent features don't yet work with Gemini CLI: editing past messages, resuming threads from history, and checkpointing. > We hope to add these features in the near future. ## Claude Code @@ -77,7 +78,7 @@ Open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap` command to include: -```json +```json [keymap] [ { "bindings": { @@ -97,7 +98,21 @@ To ensure you're using your billing method of choice, [open a new Claude Code th The first time you create a Claude Code thread, Zed will install [@zed-industries/claude-code-acp](https://github.com/zed-industries/claude-code-acp). This installation is only available to Zed and is kept up to date as you use the agent. -Zed will always use this managed version of Claude Code even if you have it installed globally. +Zed will always use this managed version of the Claude Code adapter, which includes a vendored version of the Claude Code CLI, even if you have it installed globally. + +If you want to override the executable used by the adapter, you can set the `CLAUDE_CODE_EXECUTABLE` environment variable in your settings to the path of your preferred executable. + +```json +{ + "agent_servers": { + "claude": { + "env": { + "CLAUDE_CODE_EXECUTABLE": "/path/to/alternate-claude-code-executable" + } + } + } +} +``` ### Usage @@ -111,7 +126,7 @@ However, the SDK doesn't yet expose everything needed to fully support all of th - [Subagents](https://docs.anthropic.com/en/docs/claude-code/sub-agents) are supported. - [Hooks](https://docs.anthropic.com/en/docs/claude-code/hooks-guide) are currently _not_ supported. -> Also note that some [first-party agent](./agent-panel.md) features don't yet work with Claude Code: editing past messages, resuming threads from history, checkpointing, and using the agent in SSH projects. +> Also note that some [first-party agent](./agent-panel.md) features don't yet work with Claude Code: editing past messages, resuming threads from history, and checkpointing. > We hope to add these features in the near future. #### CLAUDE.md @@ -120,14 +135,68 @@ Claude Code in Zed will automatically use any `CLAUDE.md` file found in your pro If you don't have a `CLAUDE.md` file, you can ask Claude Code to create one for you through the `init` slash command. -## Add Custom Agents {#add-custom-agents} +## Codex CLI -You can run any agent speaking ACP in Zed by changing your settings as follows: +You can also run [Codex CLI](https://github.com/openai/codex) directly via Zed's [agent panel](./agent-panel.md). +Under the hood, Zed runs Codex CLI and communicates to it over ACP, through [a dedicated adapter](https://github.com/zed-industries/codex-acp). + +### Getting Started + +As of Zed Stable v0.208 you should be able to use Codex directly from Zed. Open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a new Codex thread. + +If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap` command to include: ```json +[ + { + "bindings": { + "cmd-alt-c": ["agent::NewExternalAgentThread", { "agent": "codex" }] + } + } +] +``` + +### Authentication + +Authentication to Zed's Codex installation is decoupled entirely from Zed's agent. That is to say, an OpenAI API key added via the [Zed Agent's settings](./llm-providers.md#openai) will _not_ be utilized by Codex for authentication and billing. + +To ensure you're using your billing method of choice, [open a new Codex thread](./agent-panel.md#new-thread). The first time you will be prompted to authenticate with one of three methods: + +1. Login with ChatGPT - allows you to use your existing, paid ChatGPT subscription. _Note: This method isn't currently supported in remote projects_ +2. `CODEX_API_KEY` - uses an API key you have set in your environment under the variable `CODEX_API_KEY`. +3. `OPENAI_API_KEY` - uses an API key you have set in your environment under the variable `OPENAI_API_KEY`. + +If you are already logged in and want to change your authentication method, type `/logout` in the thread and authenticate again. + +If you want to use a third-party provider with Codex, you can configure that with your [Codex config.toml](https://github.com/openai/codex/blob/main/docs/config.md#model-selection) or pass extra [args/env variables](https://github.com/openai/codex/blob/main/docs/config.md#model-selection) to your Codex agent servers settings. + +#### Installation + +The first time you create a Codex thread, Zed will install [codex-acp](https://github.com/zed-industries/codex-acp). This installation is only available to Zed and is kept up to date as you use the agent. + +Zed will always use this managed version of Codex even if you have it installed globally. + +### Usage + +Similar to Zed's first-party agent, you can use Codex to do anything that you need. +And to give it context, you can @-mention files, symbols, or fetch the web. + +> Note that some first-party agent features don't yet work with Codex: editing past messages, resuming threads from history, and checkpointing. +> We hope to add these features in the near future. + +## Add More Agents {#add-more-agents} + +Add more external agents to Zed by installing [Agent Server extensions](../extensions/agent-servers.md). + +See what agents are available by filtering for "Agent Servers" in the extensions page, which you can access via the command palette with `zed: extensions`, or the [Zed website](https://zed.dev/extensions?filter=agent-servers). + +You can also add agents through your `settings.json`, by specifying certain fields under `agent_servers`, like so: + +```json [settings] { "agent_servers": { - "Custom Agent": { + "My Custom Agent": { + "type": "custom", "command": "node", "args": ["~/projects/agent/index.js", "--acp"], "env": {} @@ -136,12 +205,44 @@ You can run any agent speaking ACP in Zed by changing your settings as follows: } ``` -This can also be useful if you're in the middle of developing a new agent that speaks the protocol and you want to debug it. +This can be useful if you're in the middle of developing a new agent that speaks the protocol and you want to debug it. -You can also specify a custom path, arguments, or environment for the builtin integrations by using the `claude` and `gemini` names. +It's also possible to specify a custom path, arguments, or environment for the builtin integrations by using the `claude` and `gemini` names. + +### Custom Keybinding For Extension-Based Agents + +To assign a custom keybinding to start a new thread for agents that were added by installing agent server extensions, add the following snippet to your `keymap.json` file: + +```json [keymap] +{ + "bindings": { + "cmd-alt-n": [ // Your custom keybinding + "agent::NewExternalAgentThread", + { + "agent": { + "custom": { + "name": "My Agent", // The agent name as it appears in the UI (e.g., "OpenCode", "Auggie CLI", etc.) + "command": { + "command": "my-agent", // The agent name in lowercase with no spaces + "args": ["acp"] + } + } + } + } + ] + } +}, +``` ## Debugging Agents When using external agents in Zed, you can access the debug view via with `dev: open acp logs` from the Command Palette. This lets you see the messages being sent and received between Zed and the agent. ![The debug view for ACP logs.](https://zed.dev/img/acp/acp-logs.webp) + +## MCP Servers + +Note that for external agents, access to MCP servers [installed from Zed](./mcp.md) may vary depending on the ACP agent implementation. + +Regarding the built-in ones, Claude Code and Codex both support it, and Gemini CLI does not yet. +In the meantime, learn how to add MCP server support to Gemini CLI through [their documentation](https://github.com/google-gemini/gemini-cli?tab=readme-ov-file#using-mcp-servers). diff --git a/docs/src/ai/inline-assistant.md b/docs/src/ai/inline-assistant.md index 41923e85da..af232a837e 100644 --- a/docs/src/ai/inline-assistant.md +++ b/docs/src/ai/inline-assistant.md @@ -2,23 +2,110 @@ ## Usage Overview -Use `ctrl-enter` to open the Inline Assistant nearly anywhere you can enter text: editors, text threads, the rules library, channel notes, and even within the terminal panel. +Use {#kb assistant::InlineAssist} to open the Inline Assistant nearly anywhere you can enter text: editors, text threads, the rules library, channel notes, and even within the terminal panel. The Inline Assistant allows you to send the current selection (or the current line) to a language model and modify the selection with the language model's response. -You can also perform multiple generation requests in parallel by pressing `ctrl-enter` with multiple cursors, or by pressing the same binding with a selection that spans multiple excerpts in a multibuffer. +## Getting Started -## Context +If you're using the Inline Assistant for the first time, you need to have at least one LLM provider or external agent configured. +You can do that by: -Give the Inline Assistant context the same way you can in [the Agent Panel](./agent-panel.md), allowing you to provide additional instructions or rules for code transformations with @-mentions. +1. [subscribing to our Pro plan](https://zed.dev/pricing), so you have access to our hosted models +2. [using your own API keys](./llm-providers.md#use-your-own-keys), either from model providers like Anthropic or model gateways like OpenRouter. -A useful pattern here is to create a thread in the Agent Panel, and then mention that thread with `@thread` in the Inline Assistant to include it as context. +If you have already set up an LLM provider to interact with [the Agent Panel](./agent-panel.md#getting-started), then that will also work for the Inline Assistant. + +> Unlike the Agent Panel, though, the only exception at the moment is [external agents](./external-agents.md). +> They currently can't be used for generating changes with the Inline Assistant. + +## Adding Context + +You can add context in the Inline Assistant the same way you can in [the Agent Panel](./agent-panel.md#adding-context): + +- @-mention files, directories, past threads, rules, and symbols +- paste images that are copied on your clipboard + +Additionally, a useful pattern is to create a thread in the Agent Panel, and then mention it with `@thread` in the Inline Assistant to include it as context. +That often serves as a way to more quickly iterate over a specific part of a change that happened in the context of a larger thread. + +## Parallel Generations + +There are two ways in which you can generate multiple changes at once with the Inline Assistant: + +### Multiple Cursors + +If you have a multiple cursor selection and hit {#kb assistant::InlineAssist}, you can shoot the same prompt for all cursor positions and get a change in all of them. + +This is particularly useful when working on excerpts in [a multibuffer context](../multibuffers.md). + +### Multiple Models + +You can use the Inline Assistant to send the same prompt to multiple models at once. + +Here's how you can customize your `settings.json` to add this functionality: + +```json [settings] +{ + "agent": { + "default_model": { + "provider": "zed.dev", + "model": "claude-sonnet-4" + }, + "inline_alternatives": [ + { + "provider": "zed.dev", + "model": "gpt-4-mini" + } + ] + } +} +``` + +When multiple models are configured, you'll see in the Inline Assistant UI buttons that allow you to cycle between outputs generated by each model. + +The models you specify here are always used in _addition_ to your [default model](#default-model). + +For example, the following configuration will generate three outputs for every assist. +One with Claude Sonnet 4 (the default model), another with GPT-5-mini, and another one with Gemini 2.5 Flash. + +```json [settings] +{ + "agent": { + "default_model": { + "provider": "zed.dev", + "model": "claude-sonnet-4" + }, + "inline_alternatives": [ + { + "provider": "zed.dev", + "model": "gpt-4-mini" + }, + { + "provider": "zed.dev", + "model": "gemini-2.5-flash" + } + ] + } +} +``` + +## Inline Assistant vs. Edit Prediction + +Users often ask what's the difference between these two AI-powered features in Zed, particularly because both of them involve getting inline LLM code completions. + +Here's how they are different: + +- The Inline Assistant is more similar to the Agent Panel as in you're still writing a prompt yourself and crafting context. It works from within the buffer and is mostly centered around your selections. +- [Edit Predictions](./edit-prediction.md) is an AI-powered completion mechanism that intelligently suggests what you likely want to add next, based on context automatically gathered from your previous edits, recently visited files, and more. + +In summary, the key difference is that in the Inline Assistant, you're still manually prompting, whereas Edit Prediction will _automatically suggest_ edits to you. ## Prefilling Prompts To create a custom keybinding that prefills a prompt, you can add the following format in your keymap: -```json +```json [keymap] [ { "context": "Editor && mode == full", diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 5ff3efeb5d..a3f0b606dc 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -43,7 +43,7 @@ Ensure your credentials have the following permissions set up: Your IAM policy should look similar to: -```json +```json [settings] { "Version": "2012-10-17", "Statement": [ @@ -64,8 +64,8 @@ With that done, choose one of the two authentication methods: #### Authentication via Named Profile (Recommended) 1. Ensure you have the AWS CLI installed and configured with a named profile -2. Open your `settings.json` (`zed: open settings`) and include the `bedrock` key under `language_models` with the following settings: - ```json +2. Open your `settings.json` (`zed: open settings file`) and include the `bedrock` key under `language_models` with the following settings: + ```json [settings] { "language_models": { "bedrock": { @@ -89,12 +89,32 @@ To do this: #### Cross-Region Inference -The Zed implementation of Amazon Bedrock uses [Cross-Region inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html) for all the models and region combinations that support it. +The Zed implementation of Amazon Bedrock uses [Cross-Region inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html) to improve availability and throughput. With Cross-Region inference, you can distribute traffic across multiple AWS Regions, enabling higher throughput. -For example, if you use `Claude Sonnet 3.7 Thinking` from `us-east-1`, it may be processed across the US regions, namely: `us-east-1`, `us-east-2`, or `us-west-2`. -Cross-Region inference requests are kept within the AWS Regions that are part of the geography where the data originally resides. -For example, a request made within the US is kept within the AWS Regions in the US. +##### Regional vs Global Inference Profiles + +Bedrock supports two types of cross-region inference profiles: + +- **Regional profiles** (default): Route requests within a specific geography (US, EU, APAC). For example, `us-east-1` uses the `us.*` profile which routes across `us-east-1`, `us-east-2`, and `us-west-2`. +- **Global profiles**: Route requests across all commercial AWS Regions for maximum availability and performance. + +By default, Zed uses **regional profiles** which keep your data within the same geography. You can opt into global profiles by adding `"allow_global": true` to your Bedrock configuration: + +```json [settings] +{ + "language_models": { + "bedrock": { + "authentication_method": "named_profile", + "region": "your-aws-region", + "profile": "your-profile-name", + "allow_global": true + } + } +} +``` + +**Note:** Only select newer models support global inference profiles. See the [AWS Bedrock supported models documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system) for the current list of models that support global inference. If you encounter availability issues with a model in your region, enabling `allow_global` may resolve them. Although the data remains stored only in the source Region, your input prompts and output results might move outside of your source Region during cross-Region inference. All data will be transmitted encrypted across Amazon's secure network. @@ -120,7 +140,7 @@ Zed will also use the `ANTHROPIC_API_KEY` environment variable if it's defined. You can add custom models to the Anthropic provider by adding the following to your Zed `settings.json`: -```json +```json [settings] { "language_models": { "anthropic": { @@ -147,14 +167,14 @@ Custom models will be listed in the model dropdown in the Agent Panel. You can configure a model to use [extended thinking](https://docs.anthropic.com/en/docs/about-claude/models/extended-thinking-models) (if it supports it) by changing the mode in your model's configuration to `thinking`, for example: -```json +```json [settings] { "name": "claude-sonnet-4-latest", "display_name": "claude-sonnet-4-thinking", "max_tokens": 200000, "mode": { "type": "thinking", - "budget_tokens": 4_096 + "budget_tokens": 4096 } } ``` @@ -174,7 +194,7 @@ Zed will also use the `DEEPSEEK_API_KEY` environment variable if it's defined. The Zed agent comes pre-configured to use the latest version for common models (DeepSeek Chat, DeepSeek Reasoner). If you wish to use alternate models or customize the API endpoint, you can do so by adding the following to your Zed `settings.json`: -```json +```json [settings] { "language_models": { "deepseek": { @@ -231,7 +251,7 @@ By default, Zed will use `stable` versions of models, but you can use specific v Here is an example of a custom Google AI model you could add to your Zed `settings.json`: -```json +```json [settings] { "language_models": { "google": { @@ -286,7 +306,7 @@ The Zed agent comes pre-configured with several Mistral models (codestral-latest All the default models support tool use. If you wish to use alternate models or customize their parameters, you can do so by adding the following to your Zed `settings.json`: -```json +```json [settings] { "language_models": { "mistral": { @@ -327,6 +347,33 @@ Download and install Ollama from [ollama.com/download](https://ollama.com/downlo 3. In the Agent Panel, select one of the Ollama models using the model dropdown. +#### Ollama Autodiscovery + +Zed will automatically discover models that Ollama has pulled. You can turn this off by setting +the `auto_discover` field in the Ollama settings. If you do this, you should manually specify which +models are available. + +```json [settings] +{ + "language_models": { + "ollama": { + "api_url": "http://localhost:11434", + "auto_discover": false, + "available_models": [ + { + "name": "qwen2.5-coder", + "display_name": "qwen 2.5 coder", + "max_tokens": 32768, + "supports_tools": true, + "supports_thinking": true, + "supports_images": true + } + ] + } + } +} +``` + #### Ollama Context Length {#ollama-context} Zed has pre-configured maximum context lengths (`max_tokens`) to match the capabilities of common models. @@ -338,7 +385,7 @@ See [get_max_tokens in ollama.rs](https://github.com/zed-industries/zed/blob/mai Depending on your hardware or use-case you may wish to limit or increase the context length for a specific model via settings.json: -```json +```json [settings] { "language_models": { "ollama": { @@ -406,7 +453,7 @@ Zed will also use the `OPENAI_API_KEY` environment variable if it's defined. The Zed agent comes pre-configured to use the latest version for common models (GPT-5, GPT-5 mini, o4-mini, GPT-4.1, and others). To use alternate models, perhaps a preview release, or if you wish to control the request parameters, you can do so by adding the following to your Zed `settings.json`: -```json +```json [settings] { "language_models": { "openai": { @@ -457,7 +504,7 @@ Then, fill up the input fields available in the modal. To do it via your `settings.json`, add the following snippet under `language_models`: -```json +```json [settings] { "language_models": { "openai_compatible": { @@ -513,7 +560,7 @@ Zed will also use the `OPENROUTER_API_KEY` environment variable if it's defined. You can add custom models to the OpenRouter provider by adding the following to your Zed `settings.json`: -```json +```json [settings] { "language_models": { "open_router": { @@ -569,7 +616,7 @@ Supported fields (all optional): Example adding routing preferences to a model: -```json +```json [settings] { "language_models": { "open_router": { @@ -601,7 +648,7 @@ These routing controls let you fine‑tune cost, capability, and reliability tra ### Vercel v0 {#vercel-v0} -[Vercel v0](https://vercel.com/docs/v0/api) is an expert model for generating full-stack apps, with framework-aware completions optimized for modern stacks like Next.js and Vercel. +[Vercel v0](https://v0.app/docs/api/model) is an expert model for generating full-stack apps, with framework-aware completions optimized for modern stacks like Next.js and Vercel. It supports text and image inputs and provides fast streaming responses. The v0 models are [OpenAI-compatible models](/#openai-api-compatible), but Vercel is listed as first-class provider in the panel's settings view. @@ -627,7 +674,7 @@ The xAI API key will be saved in your keychain. Zed will also use the `XAI_API_K The Zed agent comes pre-configured with common Grok models. If you wish to use alternate models or customize their parameters, you can do so by adding the following to your Zed `settings.json`: -```json +```json [settings] { "language_models": { "x_ai": { @@ -657,7 +704,7 @@ The Zed agent comes pre-configured with common Grok models. If you wish to use a You can use a custom API endpoint for different providers, as long as it's compatible with the provider's API structure. To do so, add the following to your `settings.json`: -```json +```json [settings] { "language_models": { "some-provider": { diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 9f79bbb9ca..956477a1c2 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -11,7 +11,7 @@ Check out the [Anthropic news post](https://www.anthropic.com/news/model-context ### As Extensions One of the ways you can use MCP servers in Zed is by exposing them as an extension. -To learn how to create your own, check out the [MCP Server Extensions](../extensions/mcp-extensions.md) page for more details. +Check out the [MCP Server Extensions](../extensions/mcp-extensions.md) page to learn how to create your own. Thanks to our awesome community, many MCP servers have already been added as extensions. You can check which ones are available via any of these routes: @@ -20,7 +20,7 @@ You can check which ones are available via any of these routes: 2. in the app, open the Command Palette and run the `zed: extensions` action 3. in the app, go to the Agent Panel's top-right menu and look for the "View Server Extensions" menu item -In any case, here are some of the ones available: +In any case, here are some popular available servers: - [Context7](https://zed.dev/extensions/context7-mcp-server) - [GitHub](https://zed.dev/extensions/github-mcp-server) @@ -37,14 +37,17 @@ In any case, here are some of the ones available: Creating an extension is not the only way to use MCP servers in Zed. You can connect them by adding their commands directly to your `settings.json`, like so: -```json +```json [settings] { "context_servers": { - "your-mcp-server": { - "source": "custom", + "local-mcp-server": { "command": "some-command", "args": ["arg-1", "arg-2"], "env": {} + }, + "remote-mcp-server": { + "url": "custom", + "headers": { "Authorization": "Bearer " } } } } @@ -57,9 +60,9 @@ From there, you can add it through the modal that appears when you click the "Ad ### Configuration Check -Regardless of how you've installed MCP servers, whether as an extension or adding them directly, most servers out there still require some sort of configuration as part of the set up process. +Regardless of how you've installed MCP servers, whether as an extension or adding them directly, most servers out there still require some sort of configuration as part of the setup process. -In the case of server extensions, after installing it, Zed will pop up a modal displaying what is required for you to properly set it up. +In the case of extensions, after installing it, Zed will pop up a modal displaying what is required for you to properly set it up. For example, the GitHub MCP extension requires you to add a [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). In the case of custom servers, make sure you check the provider documentation to determine what type of command, arguments, and environment variables need to be added to the JSON. @@ -68,18 +71,18 @@ To check if your MCP server is properly configured, go to the Agent Panel's sett If they're running correctly, the indicator will be green and its tooltip will say "Server is active". If not, other colors and tooltip messages will indicate what is happening. -### Using it in the Agent Panel +### Agent Panel Usage Once installation is complete, you can return to the Agent Panel and start prompting. Some models are better than others when it comes to picking up tools from MCP servers. Mentioning your server by name always helps the model to pick it up. -However, if you want to ensure a given MCP server will be used, you can create [a custom profile](./agent-panel.md#custom-profiles) where all built-in tools (or the ones that could cause conflicts with the server's tools) are turned off and only the tools coming from the MCP server are turned on. +However, if you want to _ensure_ a given MCP server will be used, you can create [a custom profile](./agent-panel.md#custom-profiles) where all built-in tools (or the ones that could cause conflicts with the server's tools) are turned off and only the tools coming from the MCP server are turned on. As an example, [the Dagger team suggests](https://container-use.com/agent-integrations#zed) doing that with their [Container Use MCP server](https://zed.dev/extensions/mcp-server-container-use): -```json +```json [settings] "agent": { "profiles": { "container-use": { @@ -127,3 +130,10 @@ As an example, [the Dagger team suggests](https://container-use.com/agent-integr Zed's Agent Panel includes the `agent.always_allow_tool_actions` setting that, if set to `false`, will require you to give permission for any editing attempt as well as tool calls coming from MCP servers. You can change this by setting this key to `true` in either your `settings.json` or through the Agent Panel's settings view. + +### External Agents + +Note that for [external agents](./external-agents.md) connected through the [Agent Client Protocol](https://agentclientprotocol.com/), access to MCP servers installed from Zed may vary depending on the ACP agent implementation. + +Regarding the built-in ones, Claude Code and Codex both support it, and Gemini CLI does not yet. +In the meantime, learn how to add MCP server support to Gemini CLI through [their documentation](https://github.com/google-gemini/gemini-cli?tab=readme-ov-file#using-mcp-servers). diff --git a/docs/src/ai/models.md b/docs/src/ai/models.md index d36e8f976a..6033bf23fa 100644 --- a/docs/src/ai/models.md +++ b/docs/src/ai/models.md @@ -3,37 +3,59 @@ Zed’s plans offer hosted versions of major LLMs, generally with higher rate limits than using your API keys. We’re working hard to expand the models supported by Zed’s subscription offerings, so please check back often. -| Model | Provider | Token Type | Provider Price per 1M tokens | Zed Price per 1M tokens | -| ----------------- | --------- | ------------------- | ---------------------------- | ----------------------- | -| Claude Opus 4.1 | Anthropic | Input | $15.00 | $16.50 | -| | Anthropic | Output | $75.00 | $82.50 | -| | Anthropic | Input - Cache Write | $18.75 | $20.625 | -| | Anthropic | Input - Cache Read | $1.50 | $1.65 | -| Claude Sonnet 4.5 | Anthropic | Input | $3.00 | $3.30 | -| | Anthropic | Output | $15.00 | $16.50 | -| | Anthropic | Input - Cache Write | $3.75 | $4.125 | -| | Anthropic | Input - Cache Read | $0.30 | $0.33 | -| Claude Sonnet 4 | Anthropic | Input | $3.00 | $3.30 | -| | Anthropic | Output | $15.00 | $16.50 | -| | Anthropic | Input - Cache Write | $3.75 | $4.125 | -| | Anthropic | Input - Cache Read | $0.30 | $0.33 | -| Claude Sonnet 3.7 | Anthropic | Input | $3.00 | $3.30 | -| | Anthropic | Output | $15.00 | $16.50 | -| | Anthropic | Input - Cache Write | $3.75 | $4.125 | -| | Anthropic | Input - Cache Read | $0.30 | $0.33 | -| GPT-5 | OpenAI | Input | $1.25 | $1.375 | -| | OpenAI | Output | $10.00 | $11.00 | -| | OpenAI | Cached Input | $0.125 | $0.1375 | -| GPT-5 mini | OpenAI | Input | $0.25 | $0.275 | -| | OpenAI | Output | $2.00 | $2.20 | -| | OpenAI | Cached Input | $0.025 | $0.0275 | -| GPT-5 nano | OpenAI | Input | $0.05 | $0.055 | -| | OpenAI | Output | $0.40 | $0.44 | -| | OpenAI | Cached Input | $0.005 | $0.0055 | -| Gemini 2.5 Pro | Google | Input | $1.25 | $1.375 | -| | Google | Output | $10.00 | $11.00 | -| Gemini 2.5 Flash | Google | Input | $0.30 | $0.33 | -| | Google | Output | $2.50 | $2.75 | +| Model | Provider | Token Type | Provider Price per 1M tokens | Zed Price per 1M tokens | +| ---------------------- | --------- | ------------------- | ---------------------------- | ----------------------- | +| Claude Opus 4.5 | Anthropic | Input | $5.00 | $5.50 | +| | Anthropic | Output | $25.00 | $27.50 | +| | Anthropic | Input - Cache Write | $6.25 | $6.875 | +| | Anthropic | Input - Cache Read | $0.50 | $0.55 | +| Claude Opus 4.1 | Anthropic | Input | $15.00 | $16.50 | +| | Anthropic | Output | $75.00 | $82.50 | +| | Anthropic | Input - Cache Write | $18.75 | $20.625 | +| | Anthropic | Input - Cache Read | $1.50 | $1.65 | +| Claude Sonnet 4.5 | Anthropic | Input | $3.00 | $3.30 | +| | Anthropic | Output | $15.00 | $16.50 | +| | Anthropic | Input - Cache Write | $3.75 | $4.125 | +| | Anthropic | Input - Cache Read | $0.30 | $0.33 | +| Claude Sonnet 4 | Anthropic | Input | $3.00 | $3.30 | +| | Anthropic | Output | $15.00 | $16.50 | +| | Anthropic | Input - Cache Write | $3.75 | $4.125 | +| | Anthropic | Input - Cache Read | $0.30 | $0.33 | +| Claude Sonnet 3.7 | Anthropic | Input | $3.00 | $3.30 | +| | Anthropic | Output | $15.00 | $16.50 | +| | Anthropic | Input - Cache Write | $3.75 | $4.125 | +| | Anthropic | Input - Cache Read | $0.30 | $0.33 | +| Claude Haiku 4.5 | Anthropic | Input | $1.00 | $1.10 | +| | Anthropic | Output | $5.00 | $5.50 | +| | Anthropic | Input - Cache Write | $1.25 | $1.375 | +| | Anthropic | Input - Cache Read | $0.10 | $0.11 | +| GPT-5 | OpenAI | Input | $1.25 | $1.375 | +| | OpenAI | Output | $10.00 | $11.00 | +| | OpenAI | Cached Input | $0.125 | $0.1375 | +| GPT-5 mini | OpenAI | Input | $0.25 | $0.275 | +| | OpenAI | Output | $2.00 | $2.20 | +| | OpenAI | Cached Input | $0.025 | $0.0275 | +| GPT-5 nano | OpenAI | Input | $0.05 | $0.055 | +| | OpenAI | Output | $0.40 | $0.44 | +| | OpenAI | Cached Input | $0.005 | $0.0055 | +| Gemini 3.0 Pro | Google | Input | $2.00 | $2.20 | +| | Google | Output | $12.00 | $13.20 | +| Gemini 2.5 Pro | Google | Input | $1.25 | $1.375 | +| | Google | Output | $10.00 | $11.00 | +| Gemini 2.5 Flash | Google | Input | $0.30 | $0.33 | +| | Google | Output | $2.50 | $2.75 | +| Grok 4 | X.ai | Input | $3.00 | $3.30 | +| | X.ai | Output | $15.00 | $16.5 | +| | X.ai | Cached Input | $0.75 | $0.825 | +| Grok 4 Fast | X.ai | Input | $0.20 | $0.22 | +| | X.ai | Output | $0.50 | $0.55 | +| | X.ai | Cached Input | $0.05 | $0.055 | +| Grok 4 (Non-Reasoning) | X.ai | Input | $0.20 | $0.22 | +| | X.ai | Output | $0.50 | $0.55 | +| | X.ai | Cached Input | $0.05 | $0.055 | +| Grok Code Fast 1 | X.ai | Input | $0.20 | $0.22 | +| | X.ai | Output | $1.50 | $1.65 | +| | X.ai | Cached Input | $0.02 | $0.022 | ## Usage {#usage} @@ -47,14 +69,17 @@ A context window is the maximum span of text and code an LLM can consider at onc | Model | Provider | Zed-Hosted Context Window | | ----------------- | --------- | ------------------------- | +| Claude Opus 4.5 | Anthropic | 200k | | Claude Opus 4.1 | Anthropic | 200k | | Claude Sonnet 4 | Anthropic | 200k | | Claude Sonnet 3.7 | Anthropic | 200k | +| Claude Haiku 4.5 | Anthropic | 200k | | GPT-5 | OpenAI | 400k | | GPT-5 mini | OpenAI | 400k | | GPT-5 nano | OpenAI | 400k | | Gemini 2.5 Pro | Google | 200k | | Gemini 2.5 Flash | Google | 200k | +| Gemini 3.0 Pro | Google | 200k | > We're planning on expanding supported context windows for hosted Sonnet 4 and Gemini 2.5 Pro/Flash in the near future. Stay tuned! diff --git a/docs/src/ai/overview.md b/docs/src/ai/overview.md index ca06a4b1ed..e1a9cb77a9 100644 --- a/docs/src/ai/overview.md +++ b/docs/src/ai/overview.md @@ -18,11 +18,11 @@ Learn how to get started using AI with Zed and all its capabilities. - [Rules](./rules.md): How to define rules for AI interactions. -- [Tools](./tools.md): Explore the tools that enable agentic capabilities. +- [Tools](./tools.md): Explore the tools that power Zed's built-in agent. -- [Model Context Protocol](./mcp.md): Learn about how to install and configure MCP servers. +- [Model Context Protocol](./mcp.md): Learn about how to configure and use MCP servers. -- [Inline Assistant](./inline-assistant.md): Discover how to use the agent to power inline transformations directly within a file or terminal. +- [Inline Assistant](./inline-assistant.md): Discover how to use AI to generate inline transformations directly within a file or terminal. ## Edit Prediction @@ -30,4 +30,4 @@ Learn how to get started using AI with Zed and all its capabilities. ## Text Threads -- [Text Threads](./text-threads.md): Learn about an alternative, text-based interface for interacting with language models. +- [Text Threads](./text-threads.md): Learn about an editor-based interface for interacting with language models. diff --git a/docs/src/ai/plans-and-usage.md b/docs/src/ai/plans-and-usage.md index cbca689f9a..63f72211aa 100644 --- a/docs/src/ai/plans-and-usage.md +++ b/docs/src/ai/plans-and-usage.md @@ -4,7 +4,7 @@ For costs and more information on pricing, visit [Zed’s pricing page](https://zed.dev/pricing). -Please note that if you’re interested in just using Zed as the world’s fastest editor, with no AI or subscription features, you can always do so for free, without [authentication](../accounts.md). +Please note that if you’re interested in just using Zed as the world’s fastest editor, with no AI or subscription features, you can always do so for free, without [authentication](../authentication.md). ## Usage {#usage} @@ -12,11 +12,11 @@ Usage of Zed's hosted models is measured on a token basis, converted to dollars Zed Pro comes with $5 of monthly dollar credit. A trial of Zed Pro includes $20 of credit, usable for 14 days. Monthly included credit resets on your monthly billing date. -To view your current usage, you can visit your account at [zed.dev/account](https://zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. +To view your current usage, you can visit your account at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. ## Spend Limits {#usage-spend-limits} -At the top of [the Account page](https://zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription. +At the top of [the Account page](https://dashboard.zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription. The default value for all Pro users is $10, for a total monthly spend with Zed of $20 ($10 for your Pro subscription, $10 in incremental token spend). This can be set to $0 to limit your spend with Zed to exactly $10/month. If you adjust this limit _higher_ than $10 and consume more than $10 of incremental token spend, you'll be billed via [threshold billing](./billing.md#threshold-billing). diff --git a/docs/src/ai/privacy-and-security.md b/docs/src/ai/privacy-and-security.md index 23166df1d7..6921567b91 100644 --- a/docs/src/ai/privacy-and-security.md +++ b/docs/src/ai/privacy-and-security.md @@ -16,7 +16,9 @@ It is entirely possible to use Zed, including Zed's AI capabilities, without sha - [AI Improvement](./ai-improvement.md): Zed's opt-in-only approach to data collection for AI improvement, whether our Agentic offering or Edit Predictions. -- [Accounts](../accounts.md): When and why you'd need to authenticate into Zed, how to do so, and what scope we need from you. +- [Accounts](../authentication.md): When and why you'd need to authenticate into Zed, how to do so, and what scope we need from you. + +- [Collab](https://zed.dev/faq#data-and-privacy): How Zed's live collaboration works, and how data flows to provide the experience (we don't store your code!). ## Legal Links diff --git a/docs/src/ai/rules.md b/docs/src/ai/rules.md index 653b907a7d..4169920425 100644 --- a/docs/src/ai/rules.md +++ b/docs/src/ai/rules.md @@ -1,7 +1,7 @@ # Using Rules {#using-rules} A rule is essentially a prompt that is inserted at the beginning of each interaction with the Agent. -Currently, Zed supports `.rules` files at the directory's root and the Rules Library, which allows you to store multiple rules for on-demand usage. +Currently, Zed supports adding rules through files inserted directly in the worktree or through the Rules Library, which allows you to store multiple rules for constant or on-demand usage. ## `.rules` files @@ -30,7 +30,7 @@ You can use the inline assistant right in the rules editor, allowing you to auto 2. Click on the Agent menu (`...`) in the top right corner. 3. Select `Rules...` from the dropdown. -You can also use the `agent: open rules library` command while in the Agent Panel. +You can also reach it by running {#action agent::OpenRulesLibrary} in the command palette or through the {#kb agent::OpenRulesLibrary} keybinding. ### Managing Rules diff --git a/docs/src/ai/text-threads.md b/docs/src/ai/text-threads.md index ed439252b4..c82cda7265 100644 --- a/docs/src/ai/text-threads.md +++ b/docs/src/ai/text-threads.md @@ -2,9 +2,12 @@ ## Overview {#overview} -Text threads in the [Agent Panel](./agent-panel.md) function similarly to any other editor. You can use custom key bindings and work with multiple cursors, allowing for seamless transitions between coding and engaging in discussions with the language models. +Text threads in the [Agent Panel](./agent-panel.md) function similarly to any other editor. +You can use custom key bindings and work with multiple cursors, allowing for seamless transitions between coding and engaging in discussions with the language models. -However, the text threads differ with the inclusion of message blocks. These blocks serve as containers for text that correspond to different roles within the context. These roles include: +However, the text threads differ in the inclusion of message blocks. +These blocks serve as containers for text that correspond to different roles within the context. +These roles include: - `You` - `Assistant` @@ -16,28 +19,33 @@ To begin, type a message in a `You` block. As you type, the remaining tokens count for the selected model is updated. -Inserting text from an editor is as simple as highlighting the text and running `agent: quote selection` ({#kb agent::QuoteSelection}); Zed will wrap it in a fenced code block if it is code. +Inserting text from an editor is as simple as highlighting the text and running `agent: add selection to thread` ({#kb agent::AddSelectionToThread}); Zed will wrap it in a fenced code block if it is code. ![Quoting a selection](https://zed.dev/img/assistant/quoting-a-selection.png) -To submit a message, use {#kb assistant::Assist}(`assistant: assist`). Unlike normal threads, where pressing enter would submit the message, in text threads, our goal is to make it feel as close to a regular editor as possible. So, pressing {#kb editor::Newline} simply inserts a new line. +To submit a message, use {#kb assistant::Assist}(`assistant: assist`). +Unlike normal threads, where pressing enter would submit the message, in text threads, our goal is to make it feel as close to a regular editor as possible. +So, pressing {#kb editor::Newline} simply inserts a new line. After submitting a message, the response will be streamed below, in an `Assistant` message block. ![Receiving an answer](https://zed.dev/img/assistant/receiving-an-answer.png) -The stream can be canceled at any point with escape. This is useful if you realize early on that the response is not what you were looking for. +The stream can be canceled at any point with escape. +This is useful if you realize early on that the response is not what you were looking for. If you want to start a new conversation at any time, you can hit cmd-n|ctrl-n or use the `New Chat` menu option in the hamburger menu at the top left of the panel. -Simple back-and-forth conversations work well with the text threads. However, there may come a time when you want to modify the previous text in the conversation and steer it in a different direction. +Simple back-and-forth conversations work well with the text threads. +However, there may come a time when you want to modify the previous text in the conversation and steer it in a different direction. ## Editing a Text Thread {#edit-text-thread} Text threads give you the flexibility to have control over the context. You can freely edit any previous text, including the responses from the LLM. If you want to remove a message block entirely, simply place your cursor at the beginning of the block and use the `delete` key. -A typical workflow might involve making edits and adjustments throughout the context to refine your inquiry or provide additional information. Here's an example: +A typical workflow might involve making edits and adjustments throughout the context to refine your inquiry or provide additional information. +Here's an example: 1. Write text in a `You` block. 2. Submit the message with {#kb assistant::Assist}. @@ -47,7 +55,8 @@ A typical workflow might involve making edits and adjustments throughout the con 6. Add additional context to your original message. 7. Submit the message with {#kb assistant::Assist}. -Being able to edit previous messages gives you control over how tokens are used. You don't need to start up a new chat to correct a mistake or to add additional information, and you don't have to waste tokens by submitting follow-up corrections. +Being able to edit previous messages gives you control over how tokens are used. +You don't need to start up a new chat to correct a mistake or to add additional information, and you don't have to waste tokens by submitting follow-up corrections. > **Note**: The act of editing past messages is often referred to as "Rewriting History" in the context of the language models. @@ -57,7 +66,8 @@ Some additional points to keep in mind: ## Commands Overview {#commands} -Slash commands enhance the assistant's capabilities. Begin by typing a `/` at the beginning of the line to see a list of available commands: +Slash commands enhance the assistant's capabilities. +Begin by typing a `/` at the beginning of the line to see a list of available commands: - `/default`: Inserts the default rule - `/diagnostics`: Injects errors reported by the project's language server @@ -80,7 +90,8 @@ Usage: `/default` ### `/diagnostics` -The `/diagnostics` command injects errors reported by the project's language server into the context. This is useful for getting an overview of current issues in your project. +The `/diagnostics` command injects errors reported by the project's language server into the context. +This is useful for getting an overview of current issues in your project. Usage: `/diagnostics [--include-warnings] [path]` @@ -89,7 +100,8 @@ Usage: `/diagnostics [--include-warnings] [path]` ### `/file` -The `/file` command inserts the content of a single file or a directory of files into the context. This allows you to reference specific parts of your project in your conversation with the assistant. +The `/file` command inserts the content of a single file or a directory of files into the context. +This allows you to reference specific parts of your project in your conversation with the assistant. Usage: `/file ` @@ -103,13 +115,15 @@ Examples: ### `/now` -The `/now` command inserts the current date and time into the context. This can be useful letting the language model know the current time (and by extension, how old their current knowledge base is). +The `/now` command inserts the current date and time into the context. +This can be useful for letting the language model know the current time (and by extension, how old their current knowledge base is). Usage: `/now` ### `/prompt` -The `/prompt` command inserts a prompt from the prompt library into the context. It can also be used to nest prompts within prompts. +The `/prompt` command inserts a prompt from the prompt library into the context. +It can also be used to nest prompts within prompts. Usage: `/prompt ` @@ -117,13 +131,15 @@ Related: `/default` ### `/symbols` -The `/symbols` command inserts the active symbols (functions, classes, etc.) from the current tab into the context. This is useful for getting an overview of the structure of the current file. +The `/symbols` command inserts the active symbols (functions, classes, etc.) from the current tab into the context. +This is useful for getting an overview of the structure of the current file. Usage: `/symbols` ### `/tab` -The `/tab` command inserts the content of the active tab or all open tabs into the context. This allows you to reference the content you're currently working on. +The `/tab` command inserts the content of the active tab or all open tabs into the context. +This allows you to reference the content you're currently working on. Usage: `/tab [tab_name|all]` @@ -138,17 +154,19 @@ Examples: ### `/terminal` -The `/terminal` command inserts a select number of lines of output from the terminal into the context. This is useful for referencing recent command outputs or logs. +The `/terminal` command inserts a select number of lines of output from the terminal into the context. +This is useful for referencing recent command outputs or logs. Usage: `/terminal []` -- ``: Optional parameter to specify the number of lines to insert (default is a 50). +- ``: Optional parameter to specify the number of lines to insert (default is 50). ### `/selection` -The `/selection` command inserts the selected text in the editor into the context. This is useful for referencing specific parts of your code. +The `/selection` command inserts the selected text in the editor into the context. +This is useful for referencing specific parts of your code. -This is equivalent to the `agent: quote selection` command ({#kb agent::QuoteSelection}). +This is equivalent to the `agent: add selection to thread` command ({#kb agent::AddSelectionToThread}). Usage: `/selection` @@ -173,7 +191,7 @@ Here is some information about their project: /file Cargo.toml ``` -In the above example, the `@file` command is used to insert the contents of the `Cargo.toml` file (or all `Cargo.toml` files present in the project) into the rule. +In the above example, the `/file` command is used to insert the contents of the `Cargo.toml` file (or all `Cargo.toml` files present in the project) into the rule. ## Nesting Rules @@ -185,7 +203,7 @@ You might want to nest rules to: - Break collections like docs or references into smaller, mix-and-matchable parts - Create variants of a similar rule (e.g., `Async Rust - Tokio` vs. `Async Rust - Async-std`) -### Example: +### Example ```plaintext Title: Zed-Flavored Rust @@ -215,6 +233,18 @@ Additional slash commands can be provided by extensions. See [Extension: Slash Commands](../extensions/slash-commands.md) to learn how to create your own. +## Text Threads vs. Threads + +For some time, text threads were the only way to interact with AI in Zed. +In May 2025, we introduced a new version of the agent panel, which, as opposed to being editor-based, is optimized for readability. +Visit [the Agent Panel page](./agent-panel.md) to learn more about it. + +More importantly, aside from the many UI differences, the major aspect that sets one apart from the other is that tool calls don't work in Text Threads. +Due to that, it's accurate to say that Text Threads aren't conceptually agentic, as they can't perform any action on your behalf (or any action at all). + +Think of it more like a regular/"traditional" AI chat, where the only thing you can get from the model is simply just text. +Consequently, [MCP servers](./mcp.md) and [external agents](./external-agents.md) are also not available in Text Threads. + ## Advanced Concepts ### Rule Templates {#rule-templates} @@ -240,9 +270,11 @@ The following templates can be overridden: 2. [`terminal_assistant_prompt.hbs`](https://github.com/zed-industries/zed/tree/main/assets/prompts/terminal_assistant_prompt.hbs): Used for the terminal assistant feature. -> **Note:** Be sure you want to override these, as you'll miss out on iteration on our built-in features. This should be primarily used when developing Zed. +> **Note:** Be sure you want to override these, as you'll miss out on iteration on our built-in features. +> This should be primarily used when developing Zed. -You can customize these templates to better suit your needs while maintaining the core structure and variables used by Zed. Zed will automatically reload your prompt overrides when they change on disk. +You can customize these templates to better suit your needs while maintaining the core structure and variables used by Zed. +Zed will automatically reload your prompt overrides when they change on disk. Consult Zed's [assets/prompts](https://github.com/zed-industries/zed/tree/main/assets/prompts) directory for current versions you can play with. diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md index 06e80a863d..e40cfcec84 100644 --- a/docs/src/ai/tools.md +++ b/docs/src/ai/tools.md @@ -1,12 +1,14 @@ # Tools -Zed's Agent has access to a variety of tools that allow it to interact with your codebase and perform tasks. +Zed's built-in agent has access to a variety of tools that allow it to interact with your codebase and perform tasks. ## Read & Search Tools ### `diagnostics` Gets errors and warnings for either a specific file or the entire project, useful after making edits to determine if further changes are needed. +When a path is provided, shows all diagnostics for that specific file. +When no path is provided, shows a summary of error and warning counts for all files in the project. ### `fetch` @@ -54,10 +56,6 @@ Copies a file or directory recursively in the project, more efficient than manua Creates a new directory at the specified path within the project, creating all necessary parent directories (similar to `mkdir -p`). -### `create_file` - -Creates a new file at a specified path with given text content, the most efficient way to create new files or completely replace existing ones. - ### `delete_path` Deletes a file or directory (including contents recursively) at the specified path and confirms the deletion. diff --git a/docs/src/all-actions.md b/docs/src/all-actions.md index d20f7cfd63..e5a45a8fd8 100644 --- a/docs/src/all-actions.md +++ b/docs/src/all-actions.md @@ -1,3 +1,3 @@ -## All Actions +# All Actions {#ACTIONS_TABLE#} diff --git a/docs/src/accounts.md b/docs/src/authentication.md similarity index 81% rename from docs/src/accounts.md rename to docs/src/authentication.md index af4c4c172f..0ea97040a0 100644 --- a/docs/src/accounts.md +++ b/docs/src/authentication.md @@ -1,11 +1,11 @@ -# Accounts +# Authenticate with Zed -Signing in to Zed is not a requirement. You can use most features you'd expect in a code editor without ever doing so. We'll outline the few features that do require signing in, and how to do so, here. +Signing in to Zed is not required. You can use most features you'd expect in a code editor without ever doing so. We'll outline the few features that do require signing in, and how to do so, here. ## What Features Require Signing In? -1. All real-time [collaboration features](./collaboration.md). -2. [LLM-powered features](./ai/overview.md), if you are using Zed as the provider of your LLM models. Alternatively, you can [bring and configure your own API keys](./ai/llm-providers.md#use-your-own-keys) if you'd prefer, and avoid having to sign in. +1. All real-time [collaboration features](./collaboration/overview.md). +2. [LLM-powered features](./ai/overview.md), if you are using Zed as the provider of your LLM models. To use AI without signing in, you can [bring and configure your own API keys](./ai/llm-providers.md#use-your-own-keys). ## Signing In diff --git a/docs/src/channels.md b/docs/src/channels.md deleted file mode 100644 index afd97cdabc..0000000000 --- a/docs/src/channels.md +++ /dev/null @@ -1,52 +0,0 @@ -# Channels - -At Zed we believe that great things are built by great people working together. We have designed Zed to help every individual work faster and to help teams of people work together more effectively. - -## Overview - -Channels provide a way to streamline collaborating for software engineers in many ways, but particularly: - -- Pairing – when working on something together, you both have your own screen, mouse, and keyboard. -- Mentoring – it’s easy to jump in to someone else’s context, and help them get unstuck, without the friction of pushing code up. -- Refactoring – you can have multiple people join in on large refactoring without fear of conflict. -- Ambient awareness – you can see what everyone else is working on with no need for status emails or meetings. - -## Channels - -To open the collaboration panel hit {#kb collab_panel::ToggleFocus} or `collab panel: toggle focus`. - -Each channel corresponds to an ongoing project or work-stream. You can see who’s in a channel as their avatars will show up in the sidebar. This makes it easy to see what everyone is doing and where to find them if needed. - -You can create as many channels as you need. As in the example above, you can mix channels for your day job, as well as side-projects in one instance of Zed. - -Joining a channel adds you to a shared room where you can work on projects together. - -## Sharing projects - -After joining a channel, you can `Share` a project with the other people there. This will enable them to edit the code hosted on your machine as though they had it checked out locally. - -When you are editing someone else’s project, you still have the full power of the editor at your fingertips, you can jump to definitions, use the AI assistant, and see any diagnostic errors. This is extremely powerful for pairing, as one of you can be implementing the current method while the other is reading and researching the correct solution to the next problem. And, because you have your own config running, it feels like you’re using your own machine. - -See [our collaboration documentation](./collaboration.md) for more details about how this works. - -## Notes - -Each channel has a notes file associated with it to keep track of current status, new ideas, or to collaborate on building out the design for the feature that you’re working on before diving into code. - -This is similar to a Google Doc, except powered by Zed's collaborative software and persisted to our servers. - -## Inviting people - -By default, channels you create can only be accessed by you. You can invite collaborators by right clicking and selecting `Manage members`. - -When you have channels nested under each other, permissions are inherited. For instance, in the example above, we only need to add people to the `#zed` channel, and they will automatically gain access to `#core-editor`, `#new-languages`, and `#stability`. - -Once you have added someone, they can either join your channel by clicking on it in their Zed sidebar, or you can share the link to the channel so that they can join directly. - -## Livestreaming & Guests - -A Channel can also be made Public. This allows anyone to join the channel by clicking on the link. - -Guest users in channels can hear and see everything that is happening, and have read only access to projects and channel notes. - -If you'd like to invite a guest to participate in a channel for the duration of a call you can do so by right clicking on them in the Collaboration Panel. "Allowing Write Access" will allow them to edit any projects shared into the call, and to use their microphone and share their screen if they wish. diff --git a/docs/src/collaboration.md b/docs/src/collaboration.md deleted file mode 100644 index 8992c7d6ca..0000000000 --- a/docs/src/collaboration.md +++ /dev/null @@ -1,103 +0,0 @@ -# Collaboration - -Only collaborate with people that you trust. Since sharing a project gives them access to your local file system, you should not share projects with people you do not trust; they could potentially do some nasty things. - -In the future, we will do more to prevent this type of access beyond the shared project and add more control over what collaborators can do, but for now, only collaborate with people you trust. - -## Adding a collaborator to a call - -Before you can collaborate, you'll need to add a collaborator to your contacts. To do this: - -1. Open the contacts menu by clicking on the `Show contacts menu` button in the upper right-hand corner of the window or by running `collab: toggle contacts menu` (`cmd-shift-c`). -2. Click the add button to the right of the search box. -3. Search for the contact you want to add using their GitHub handle. Note: the person you are trying to add as a contact must be an existing Zed user. - -### Inviting a collaborator - -You can add an existing Zed user as a contact from the contacts menu, deployed from the `Show contacts menu` button in the upper right-hand corner of the window or by `collab: toggle contacts menu` (`cmd-shift-c`) and then clicking the `Search for new contact` button to the right of the search box. - -![Inviting a collaborator to the current project](https://zed.dev/img/collaboration/add-a-collaborator.png) - -When you invite a collaborator to a project not in a call they will receive a notification to join, and a new call is created. - -![Receiving an invite to join a call](https://zed.dev/img/collaboration/receiving-an-invite.jpg) - -### Inviting non-Zed users - -If someone you want to collaborate with has not yet signed up for Zed, they will need to [download the app](https://zed.dev/download) and sign in for the first time before you can add them. Identity is tied to GitHub accounts, so new users will need to authenticate with GitHub in order to sign into Zed. - -### Voice chat - -When joining a call, Zed will automatically share your microphone with other users in the call, if your OS allows it. This isn't tied to your project. You can disable this for your client via the [`mute_on_join`](./configuring-zed.md#calls) setting. - -## Collaborating on a project - -### Share a project - -When you invite a collaborator to join your project, a new call begins. Your Zed windows will show the call participants in the title bar of the window. - -![A new Zed call with two collaborators](https://zed.dev/img/collaboration/new-call.png) - -Collaborators in the same project as you are in color, and have a cursor color. Collaborators in other projects are shown in gray. Collaborators that have access to the current project will have their own cursor color under their avatar. - -We aim to eliminate the distinction between local and remote projects as much as possible. Collaborators can open, edit, and save files, perform searches, interact with the language server, etc. Guests have a read-only view of the project, including access to language server info. - -#### Unshared Projects - -If a collaborator is currently in a project that is not shared, you will not be able to jump to their project or follow them until they either share the project or return to a project that is shared. - -If you are in a project that isn't shared, others will not be able to join it or see its contents. - -### Follow a collaborator - -To follow a collaborator, click on their avatar in the top right of the window. You can also cycle through collaborators using `workspace: follow next collaborator` (`ctrl-alt-cmd-f`). - -When you join a project, you'll immediately start following the collaborator that invited you. - -![Automatically following the person inviting us to a project](https://zed.dev/img/collaboration/joining-a-call.png) - -When you are in a pane that is following a collaborator, you will: - -- follow their cursor and scroll position -- follow them to other files in the same project -- instantly swap to viewing their screen in that pane, if they are sharing their screen and leave the project - -If you move your cursor or make an edit in that pane, you will stop following. - -To start following again, you can click on a collaborator's avatar or cycle through following different participants by pressing `workspace: follow next collaborator` (`ctrl-alt-cmd-f`). - -#### How following works - -Following is confined to a particular pane. When a pane is following a collaborator, it is outlined in their cursor color. - -This pane-specific behavior allows you to follow someone in one pane while navigating independently in another and can be an effective layout for some collaboration styles. - -### Sharing your screen - -Share your screen with collaborators in the current call by clicking on the `Share screen` button in the top right of the window. - -Collaborators will see your screen if they are following you and you start viewing a window outside Zed or a project that is not shared. - -Collaborators can see your entire screen when you are screen sharing, so be careful not to share anything you don't want to share. Remember to stop screen sharing when you are finished. - -Call participants can open a dedicated tab for your screen share by opening the contacts menu in the top right and clicking on the `Screen` entry if you are sharing your screen. - -### Adding a project - -You can add a project to a call by clicking on the `Share` button next to the project name in the title bar. - -### Removing a project - -You can remove a project from a call by clicking on the `Unshare` button next to the project name in the title bar. - -Collaborators that are currently in that project will be disconnected from the project and will not be able to rejoin it unless you share it again. - -### Following a collaborator's terminal - -You can follow what a collaborator is doing in their terminal by having them share their screen and following it. - -In the future, we plan to allow you to collaborate in the terminal directly in a shared project. - -### Leave call - -You can leave a call by opening the contacts menu in the top right and clicking on the `Leave call` button. diff --git a/docs/src/collaboration/channels.md b/docs/src/collaboration/channels.md new file mode 100644 index 0000000000..ebc2760275 --- /dev/null +++ b/docs/src/collaboration/channels.md @@ -0,0 +1,122 @@ +# Channels + +Channels provide a way to streamline collaborating for software engineers in many ways, but particularly: + +- Pairing – when working on something together, you both have your own screen, mouse, and keyboard. +- Mentoring – it's easy to jump in to someone else's context, and help them get unstuck, without the friction of pushing code up. +- Refactoring – you can have multiple people join in on large refactoring without fear of conflict. +- Ambient awareness – you can see what everyone else is working on with no need for status emails or meetings. + +Each channel corresponds to an ongoing project or work-stream. +You can see who's in a channel as their avatars will show up in the sidebar. +This makes it easy to see what everyone is doing and where to find them if needed. + +Create a channel by clicking the `+` icon next to the `Channels` text in the collab panel. +Create a subchannel by right clicking an existing channel and selecting `New Subchannel`. + +You can mix channels for your day job, as well as side-projects in your collab panel. + +Joining a channel adds you to a shared room where you can work on projects together. + +_Join [our channel tree](https://zed.dev/channel/zed-283) to get an idea of how you can organize yours._ + +## Inviting People + +By default, channels you create can only be accessed by you. +You can invite collaborators by right clicking and selecting `Manage members`. + +When you have subchannels nested under others, permissions are inherited. +For instance, adding people to the top-level channel in your channel tree will automatically give them access to its subchannels. + +Once you have added someone, they can either join your channel by clicking on it in their Zed sidebar, or you can share the link to the channel so that they can join directly. + +## Voice Chat + +You can mute/unmute your microphone via the microphone icon in the upper right-hand side of the window. + +> Note: When joining a channel, Zed will automatically share your microphone with other users in the call, if your OS allows it. +> If you'd prefer your microphone to be off when joining a channel, you can do so via the [`mute_on_join`](../configuring-zed.md#calls) setting. + +## Sharing Projects + +After joining a channel, you can share a project over the channel via the `Share` button in the upper right-hand side of the window. +This will allow channel members to edit the code hosted on your machine as though they had it checked out locally. + +When you are editing someone else's project, you still have the full power of the editor at your fingertips; you can jump to definitions, use the AI assistant, and see any diagnostic errors. +This is extremely powerful for pairing, as one of you can be implementing the current method while the other is reading and researching the correct solution to the next problem. +And, because you have your own config running, it feels like you're using your own machine. + +We aim to eliminate the distinction between local and remote projects as much as possible. +Collaborators can open, edit, and save files, perform searches, interact with the language server, etc. +Guests have a read-only view of the project, including access to language server info. + +### Unsharing a Project + +You can remove a project from a channel by clicking on the `Unshare` button in the title bar. + +Collaborators that are currently in that project will be disconnected from the project and will not be able to rejoin it unless you share it again. + +## Channel Notes + +Each channel has a Markdown notes file associated with it to keep track of current status, new ideas, or to collaborate on building out the design for the feature that you're working on before diving into code. + +This is similar to a Google Doc, except powered by Zed's collaborative software and persisted to our servers. + +Open the channel notes by clicking on the document icon to the right of the channel name in the collaboration panel. + +> Note: You can view a channel's notes without joining the channel, if you'd just like to read up on what has been written. + +## Following Collaborators + +To follow a collaborator, click on their avatar in the top left of the title bar. +You can also cycle through collaborators using {#kb workspace::FollowNextCollaborator} or `workspace: follow next collaborator` in the command palette. + +When you join a project, you'll immediately start following the collaborator that invited you. + +When you are in a pane that is following a collaborator, you will: + +- follow their cursor and scroll position +- follow them to other files in the same project +- instantly swap to viewing their screenshare in that pane, if they are sharing their screen and leave the project + +To stop following, simply move your mouse or make an edit via your keyboard. + +### How Following Works + +Following is confined to a particular pane. +When a pane is following a collaborator, it is outlined in their cursor color. + +Avatars of collaborators in the same project as you are in color, and have a cursor color. +Collaborators in other projects are shown in gray. + +This pane-specific behavior allows you to follow someone in one pane while navigating independently in another and can be an effective layout for some collaboration styles. + +### Following a Terminal + +Following is not currently supported in the terminal in the way it is supported in the editor. +As a workaround, collaborators can share their screen and you can follow that instead. + +## Screen Sharing + +Share your screen with collaborators in the current channel by clicking on the `Share screen` (monitor icon) button in the top right of the title bar. +If you have multiple displays, you can choose which one to share via the chevron to the right of the monitor icon. + +After you've shared your screen, others can click on the `Screen` entry under your name in the collaboration panel to open a tab that always keeps it visible. +If they are following you, Zed will automatically switch between following your cursor in their Zed instance and your screen share, depending on whether you are focused on Zed or another application, like a web browser. + +> Note: Collaborators can see your entire screen when you are screen sharing, so be careful not to share anything you don't want to share. +> Remember to stop screen sharing when you are finished. + +## Livestreaming & Guests + +A Channel can also be made Public. +This allows anyone to join the channel by clicking on the link. + +Guest users in channels can hear and see everything that is happening, and have read only access to projects and channel notes. + +If you'd like to invite a guest to participate in a channel for the duration of a call you can do so by right clicking on them in the Collaboration Panel. +"Allowing Write Access" will allow them to edit any projects shared into the call, and to use their microphone and share their screen if they wish. + +## Leaving a Call + +You can leave a channel by clicking on the `Leave call` button in the upper right-hand side of the window. diff --git a/docs/src/collaboration/contacts-and-private-calls.md b/docs/src/collaboration/contacts-and-private-calls.md new file mode 100644 index 0000000000..f011fa2c67 --- /dev/null +++ b/docs/src/collaboration/contacts-and-private-calls.md @@ -0,0 +1,25 @@ +# Contacts and Private Calls + +Zed allows you to have private calls / collaboration sessions with those in your contacts. +These calls can be one-on-ones or contain any number of users from your contacts. + +## Adding a Contact + +1. In the collaboration panel, click the `+` button next to the `Contacts` section +1. Search for the contact using their GitHub handle.\ + _Note: Your contact must be an existing Zed user who has completed the GitHub authentication sign-in flow._ +1. Your contact will receive a notification. + Once they accept, you'll both appear in each other's contact list. + +## Private Calls + +To start up a private call... + +1. Click the `...` menu next to an online contact's name in the collaboration panel. +1. Click `Call ` + +Once you've begun a private call, you can add other online contacts by clicking on their name in the collaboration panel. + +--- + +_Aside from a few additional features (channel notes, etc.), collaboration in private calls is largely the same as it is in [channels](./channels.md)._ diff --git a/docs/src/collaboration/overview.md b/docs/src/collaboration/overview.md new file mode 100644 index 0000000000..719aa56ee3 --- /dev/null +++ b/docs/src/collaboration/overview.md @@ -0,0 +1,24 @@ +# Collaboration + +At Zed, we believe that great things are built by great people working together. +We have designed Zed to help individuals work faster and help teams of people work together more effectively. + +In Zed, all collaboration happens in the collaboration panel, which can be opened via {#kb collab_panel::ToggleFocus} or `collab panel: toggle focus` from the command palette. + +You will need to [sign in](../authentication.md#signing-in) in order to access features within the collaboration panel. + +## Collaboration panel + +The collaboration panel is broken down into two sections: + +1. [Channels](./channels.md): Ongoing project rooms where team members can share projects, collaborate on code, and maintain ambient awareness of what everyone is working on. +1. [Contacts and Private Calls](./contacts-and-private-calls.md): Your contacts list for ad-hoc private collaboration. + +--- + +> Note: Only collaborate with people that you trust. +> Since sharing a project gives them access to your local file system, you should not share projects with people you do not trust; they could potentially do some nasty things. +> +> In the future, we will do more to prevent this type of access beyond the shared project and add more control over what collaborators can do, but for now, only collaborate with people you trust. + +See our [Data and Privacy FAQs](https://zed.dev/faq#data-and-privacy) for collaboration. diff --git a/docs/src/command-line-interface.md b/docs/src/command-line-interface.md new file mode 100644 index 0000000000..1a7831811d --- /dev/null +++ b/docs/src/command-line-interface.md @@ -0,0 +1,18 @@ +# Command-line Interface + +Zed has a CLI, on Linux this should come with the distribution's Zed package (binary name can vary from distribution to distribution, `zed` will be used later for brevity). +For macOS, the CLI comes in the same package with the editor binary, and could be installed into the system with the `cli: install` Zed command which will create a symlink to the `/usr/local/bin/zed`. +It can also be built from source out of the `cli` crate in this repository. + +Use `zed --help` to see the full list of capabilities. +General highlights: + +- Opening another empty Zed window: `zed` + +- Opening a file or directory in Zed: `zed /path/to/entry` (use `-n` to open in the new window) + +- Reading from stdin: `ps axf | zed -` + +- Starting Zed with logs in the terminal: `zed --foreground` + +- Uninstalling Zed and all its related files: `zed --uninstall` diff --git a/docs/src/command-palette.md b/docs/src/command-palette.md new file mode 100644 index 0000000000..b573fc6a5f --- /dev/null +++ b/docs/src/command-palette.md @@ -0,0 +1,9 @@ +# Command Palette + +The Command Palette is the main way to access pretty much any functionality that's available in Zed. Its keybinding is the first one you should make yourself familiar with. To open it, hit: {#kb command_palette::Toggle}. + +![The opened Command Palette](https://zed.dev/img/features/command-palette.jpg) + +Try it! Open the Command Palette and type in `new file`. You should see the list of commands being filtered down to `workspace: new file`. Hit return and you end up with a new buffer. + +Any time you see instructions that include commands of the form `zed: ...` or `editor: ...` and so on that means you need to execute them in the Command Palette. diff --git a/docs/src/completions.md b/docs/src/completions.md index d14cf61d82..ff96ede750 100644 --- a/docs/src/completions.md +++ b/docs/src/completions.md @@ -9,7 +9,7 @@ Zed supports two sources for completions: When there is an appropriate language server available, Zed will provide completions of variable names, functions, and other symbols in the current file. You can disable these by adding the following to your Zed `settings.json` file: -```json +```json [settings] "show_completions_on_input": false ``` diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md index ef5adf97a0..9185b67906 100644 --- a/docs/src/configuring-languages.md +++ b/docs/src/configuring-languages.md @@ -1,4 +1,4 @@ -# Configuring supported languages +# Configuring Supported Languages Zed offers powerful customization options for each programming language it supports. This guide will walk you through the various ways you can tailor your coding experience to your preferences and project requirements. @@ -28,7 +28,7 @@ Zed allows you to override global settings for individual languages. These custo Here's an example of language-specific settings: -```json +```json [settings] "languages": { "Python": { "tab_size": 4, @@ -58,6 +58,7 @@ You can customize a wide range of settings for each language, including: - [`soft_wrap`](./configuring-zed.md#soft-wrap): How to wrap long lines of code - [`show_completions_on_input`](./configuring-zed.md#show-completions-on-input): Whether or not to show completions as you type - [`show_completion_documentation`](./configuring-zed.md#show-completion-documentation): Whether to display inline and alongside documentation for items in the completions menu +- [`colorize_brackets`](./configuring-zed.md#colorize-brackets): Whether to use tree-sitter bracket queries to detect and colorize the brackets in the editor (also known as "rainbow brackets") These settings allow you to maintain specific coding styles across different languages and projects. @@ -67,7 +68,7 @@ Zed automatically detects file types based on their extensions, but you can cust To set up custom file associations, use the [`file_types`](./configuring-zed.md#file-types) setting in your `settings.json`: -```json +```json [settings] "file_types": { "C++": ["c"], "TOML": ["MyLockFile"], @@ -119,10 +120,10 @@ Some languages in Zed offer multiple language server options. You might have mul You can specify your preference using the `language_servers` setting: -```json +```json [settings] "languages": { "PHP": { - "language_servers": ["intelephense", "!phpactor", "..."] + "language_servers": ["intelephense", "!phpactor", "!phptools", "..."] } } ``` @@ -145,7 +146,7 @@ Not all languages in Zed support toolchain discovery and selection, but for thos Many language servers accept custom configuration options. You can set these in the `lsp` section of your `settings.json`: -```json +```json [settings] "lsp": { "rust-analyzer": { "initialization_options": { @@ -170,7 +171,7 @@ Suppose you want to configure the following settings for TypeScript: Here's how you would structure these settings in Zed's `settings.json`: -```json +```json [settings] "lsp": { "typescript-language-server": { "initialization_options": { @@ -198,7 +199,7 @@ Sent once during language server startup, requires server's restart to reapply c For example, rust-analyzer and clangd rely on this way of configuring only. -```json +```json [settings] "lsp": { "rust-analyzer": { "initialization_options": { @@ -213,7 +214,7 @@ For example, rust-analyzer and clangd rely on this way of configuring only. May be queried by the server multiple times. Most of the servers would rely on this way of configuring only. -```json +```json [settings] "lsp": { "tailwindcss-language-server": { "settings": { @@ -229,7 +230,7 @@ Apart of the LSP-related server configuration options, certain servers in Zed al Language servers are automatically downloaded or launched if found in your path, if you wish to specify an explicit alternate binary you can specify that in settings: -```json +```json [settings] "lsp": { "rust-analyzer": { "binary": { @@ -249,7 +250,7 @@ Language servers are automatically downloaded or launched if found in your path, You can toggle language server support globally or per-language: -```json +```json [settings] "languages": { "Markdown": { "enable_language_server": false @@ -267,7 +268,7 @@ Zed provides support for code formatting and linting to maintain consistent code Zed supports both built-in and external formatters. See [`formatter`](./configuring-zed.md#formatter) docs for more. You can configure formatters globally or per-language in your `settings.json`: -```json +```json [settings] "languages": { "JavaScript": { "formatter": { @@ -289,7 +290,7 @@ This example uses Prettier for JavaScript and the language server's formatter fo To disable formatting for a specific language: -```json +```json [settings] "languages": { "Markdown": { "format_on_save": "off" @@ -301,7 +302,7 @@ To disable formatting for a specific language: Linting in Zed is typically handled by language servers. Many language servers allow you to configure linting rules: -```json +```json [settings] "lsp": { "eslint": { "settings": { @@ -317,11 +318,11 @@ This configuration sets up ESLint to organize imports on save for JavaScript fil To run linter fixes automatically on save: -```json +```json [settings] "languages": { "JavaScript": { - "code_actions_on_format": { - "source.fixAll.eslint": true + "formatter": { + "code_action": "source.fixAll.eslint" } } } @@ -331,18 +332,20 @@ To run linter fixes automatically on save: Zed allows you to run both formatting and linting on save. Here's an example that uses Prettier for formatting and ESLint for linting JavaScript files: -```json +```json [settings] "languages": { "JavaScript": { - "formatter": { - "external": { - "command": "prettier", - "arguments": ["--stdin-filepath", "{buffer_path}"] + "formatter": [ + { + "code_action": "source.fixAll.eslint" + }, + { + "external": { + "command": "prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } } - }, - "code_actions_on_format": { - "source.fixAll.eslint": true - }, + ], "format_on_save": "on" } } @@ -362,18 +365,20 @@ Zed offers customization options for syntax highlighting and themes, allowing yo ### Customizing Syntax Highlighting -Zed uses Tree-sitter grammars for syntax highlighting. Override the default highlighting using the `experimental.theme_overrides` setting. +Zed uses Tree-sitter grammars for syntax highlighting. Override the default highlighting using the `theme_overrides` setting. This example makes comments italic and changes the color of strings: -```json -"experimental.theme_overrides": { - "syntax": { - "comment": { - "font_style": "italic" - }, - "string": { - "color": "#00AA00" +```json [settings] +"theme_overrides": { + "One Dark": { + "syntax": { + "comment": { + "font_style": "italic" + }, + "string": { + "color": "#00AA00" + } } } } @@ -386,7 +391,7 @@ Change your theme: 1. Use the theme selector ({#kb theme_selector::Toggle}) 2. Or set it in your `settings.json`: -```json +```json [settings] "theme": { "mode": "dark", "dark": "One Dark", @@ -408,7 +413,7 @@ To create your own theme extension, refer to the [Developing Theme Extensions](. Inlay hints provide additional information inline in your code, such as parameter names or inferred types. Configure inlay hints in your `settings.json`: -```json +```json [settings] "inlay_hints": { "enabled": true, "show_type_hints": true, diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 130540e634..76c0b528fa 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -4,7 +4,14 @@ Zed is designed to be configured: we want to fit your workflow and preferences e In addition to the settings described here, you may also want to change your [theme](./themes.md), configure your [key bindings](./key-bindings.md), set up [tasks](./tasks.md) or install [extensions](https://github.com/zed-industries/extensions). -## Settings files +## Settings Editor + +You can browse through many of the supported settings via the Settings Editor, which can be opened with the {#kb zed::OpenSettings} keybinding, or through the `zed: open settings` action in the command palette. Through it, you can customize your local, user settings as well as project settings. + +> Note that not all settings that Zed supports are available through the Settings Editor yet. +> Some more intricate ones, such as language formatters, can only be changed through the JSON settings file {#kb zed::OpenSettingsFile}. + +## User Settings File -Your settings file can be opened with {#kb zed::OpenSettings}. By default it is located at `~/.config/zed/settings.json`, though if you have XDG_CONFIG_HOME in your environment on Linux it will be at `$XDG_CONFIG_HOME/zed/settings.json` instead. +Your settings JSON file can be opened with {#kb zed::OpenSettingsFile}. +By default it is located at `~/.config/zed/settings.json`, though if you have `XDG_CONFIG_HOME` in your environment on Linux it will be at `$XDG_CONFIG_HOME/zed/settings.json` instead. -This configuration is merged with any local configuration inside your projects. You can open the project settings by running {#action zed::OpenProjectSettings} from the command palette. This will create a `.zed` directory containing`.zed/settings.json`. +Whatever you have added to your user settings file gets merged with any local configuration inside your projects. -Although most projects will only need one settings file at the root, you can add more local settings files for subdirectories as needed. Not all settings can be set in local files, just those that impact the behavior of the editor and language tooling. For example you can set `tab_size`, `formatter` etc. but not `theme`, `vim_mode` and similar. +### Default Settings -The syntax for configuration files is a super-set of JSON that allows `//` comments. - -## Default settings - -You can find the default settings for your current Zed by running {#action zed::OpenDefaultSettings} from the command palette. +In the Settings Editor, the values you see set are the default ones. +You can also verify them in JSON by running {#action zed::OpenDefaultSettings} from the command palette. Extensions that provide language servers may also provide default settings for those language servers. +## Project Settings File + +Similarly to user files, you can open your project settings file by running {#action zed::OpenProjectSettings} from the command palette. +This will create a `.zed` directory containing`.zed/settings.json`. + +Although most projects will only need one settings file at the root, you can add more local settings files for subdirectories as needed. +Not all settings can be set in local files, just those that impact the behavior of the editor and language tooling. +For example you can set `tab_size`, `formatter` etc. but not `theme`, `vim_mode` and similar. + +The syntax for configuration files is a super-set of JSON that allows `//` comments. + +## Per-release Channel Overrides + +Zed reads the same `settings.json` across all release channels (Stable, Preview or Nightly). +However, you can scope overrides to a specific channel by adding top-level `stable`, `preview`, `nightly` or `dev` objects. +They are merged into the base configuration with settings from these keys taking precedence upon launching the specified build. For example: + +```json [settings] +{ + "theme": "sunset", + "vim_mode": false, + "nightly": { + "theme": "cave-light", + "vim_mode": true + }, + "preview": { + "theme": "zed-dark" + } +} +``` + +With this configuration, Stable keeps all base preferences, Preview switches to `zed-dark`, and Nightly enables Vim mode with a different theme. + +Changing settings in the Settings Editorwill always apply the change across all channels. + # Settings +Find below an extensive run-through of many supported settings by Zed. + ## Active Pane Modifiers - Description: Styling settings applied to the active pane. - Setting: `active_pane_modifiers` - Default: -```json +```json [settings] { "active_pane_modifiers": { "border_size": 0.0, @@ -74,7 +116,7 @@ Non-negative `float` values 1. Contain the bottom dock, giving the full height of the window to the left and right docks. -```json +```json [settings] { "bottom_dock_layout": "contained" } @@ -82,7 +124,7 @@ Non-negative `float` values 2. Give the bottom dock the full width of the window, truncating the left and right docks. -```json +```json [settings] { "bottom_dock_layout": "full" } @@ -90,7 +132,7 @@ Non-negative `float` values 3. Left align the bottom dock, truncating the left dock and giving the right dock the full height of the window. -```json +```json [settings] { "bottom_dock_layout": "left_aligned" } @@ -98,7 +140,7 @@ Non-negative `float` values 4. Right align the bottom dock, giving the left dock the full height of the window and truncating the right dock. -```json +```json [settings] { "bottom_dock_layout": "right_aligned" } @@ -124,25 +166,25 @@ Non-negative `float` values 1. Allow rewrap in comments only: -```json +```json [settings] { "allow_rewrap": "in_comments" } ``` -2. Allow rewrap everywhere: +2. Allow rewrap in selections only: -```json +```json [settings] { - "allow_rewrap": "everywhere" + "allow_rewrap": "in_selections" } ``` -3. Never allow rewrap: +3. Allow rewrap anywhere: -```json +```json [settings] { - "allow_rewrap": "never" + "allow_rewrap": "anywhere" } ``` @@ -171,7 +213,7 @@ Note: This setting has no effect in Vim mode, as rewrap is already allowed every ## Auto Install extensions - Description: Define extensions to be autoinstalled or never be installed. -- Setting: `auto_install_extension` +- Setting: `auto_install_extensions` - Default: `{ "html": true }` **Options** @@ -192,7 +234,7 @@ ls ~/.local/share/zed/extensions/installed Define extensions which should be installed (`true`) or never installed (`false`). -```json +```json [settings] { "auto_install_extensions": { "html": true, @@ -212,7 +254,7 @@ Define extensions which should be installed (`true`) or never installed (`false` 1. To disable autosave, set it to `off`: -```json +```json [settings] { "autosave": "off" } @@ -220,7 +262,7 @@ Define extensions which should be installed (`true`) or never installed (`false` 2. To autosave when focus changes, use `on_focus_change`: -```json +```json [settings] { "autosave": "on_focus_change" } @@ -228,7 +270,7 @@ Define extensions which should be installed (`true`) or never installed (`false` 3. To autosave when the active window changes, use `on_window_change`: -```json +```json [settings] { "autosave": "on_window_change" } @@ -236,7 +278,7 @@ Define extensions which should be installed (`true`) or never installed (`false` 4. To autosave after an inactivity period, use `after_delay`: -```json +```json [settings] { "autosave": { "after_delay": { @@ -298,7 +340,7 @@ Note that a save will be triggered when an unsaved tab is closed, even if this i 1. VS Code -```json +```json [settings] { "base_keymap": "VSCode" } @@ -306,7 +348,7 @@ Note that a save will be triggered when an unsaved tab is closed, even if this i 2. Atom -```json +```json [settings] { "base_keymap": "Atom" } @@ -314,7 +356,7 @@ Note that a save will be triggered when an unsaved tab is closed, even if this i 3. JetBrains -```json +```json [settings] { "base_keymap": "JetBrains" } @@ -322,7 +364,7 @@ Note that a save will be triggered when an unsaved tab is closed, even if this i 4. None -```json +```json [settings] { "base_keymap": "None" } @@ -330,7 +372,7 @@ Note that a save will be triggered when an unsaved tab is closed, even if this i 5. Sublime Text -```json +```json [settings] { "base_keymap": "SublimeText" } @@ -338,7 +380,7 @@ Note that a save will be triggered when an unsaved tab is closed, even if this i 6. TextMate -```json +```json [settings] { "base_keymap": "TextMate" } @@ -367,7 +409,7 @@ Zed supports all OpenType features that can be enabled or disabled for a given b For example, to disable font ligatures, add the following to your settings: -```json +```json [settings] { "buffer_font_features": { "calt": false @@ -377,7 +419,7 @@ For example, to disable font ligatures, add the following to your settings: You can also set other OpenType features, like setting `cv01` to `7`: -```json +```json [settings] { "buffer_font_features": { "cv01": 7 @@ -396,7 +438,7 @@ You can also set other OpenType features, like setting `cv01` to `7`: For example, to use `Nerd Font` as a fallback, add the following to your settings: -```json +```json [settings] { "buffer_font_fallbacks": ["Nerd Font"] } @@ -438,7 +480,7 @@ A font size from `6` to `100` pixels (inclusive) - Setting: `centered_layout` - Default: -```json +```json [settings] "centered_layout": { "left_padding": 0.2, "right_padding": 0.2, @@ -484,15 +526,15 @@ Note: Dirty files (files with unsaved changes) will not be automatically closed 1. Allow all diagnostics (default): -```json +```json [settings] { - "diagnostics_max_severity": null + "diagnostics_max_severity": "all" } ``` 2. Show only errors: -```json +```json [settings] { "diagnostics_max_severity": "error" } @@ -500,7 +542,7 @@ Note: Dirty files (files with unsaved changes) will not be automatically closed 3. Show errors and warnings: -```json +```json [settings] { "diagnostics_max_severity": "warning" } @@ -508,15 +550,15 @@ Note: Dirty files (files with unsaved changes) will not be automatically closed 4. Show errors, warnings, and information: -```json +```json [settings] { - "diagnostics_max_severity": "information" + "diagnostics_max_severity": "info" } ``` 5. Show all including hints: -```json +```json [settings] { "diagnostics_max_severity": "hint" } @@ -542,10 +584,11 @@ Note: Dirty files (files with unsaved changes) will not be automatically closed **Options** -There are two options to choose from: +There are three options to choose from: 1. `shell_hook`: Use the shell hook to load direnv. This relies on direnv to activate upon entering the directory. Supports POSIX shells and fish. 2. `direct`: Use `direnv export json` to load direnv. This will load direnv directly without relying on the shell hook and might cause some inconsistencies. This allows direnv to work with any shell. +3. `disabled`: No shell environment will be loaded automatically; direnv must be invoked manually (e.g. with `direnv exec`) to be used. ## Double Click In Multibuffer @@ -557,7 +600,7 @@ There are two options to choose from: 1. Behave as a regular buffer and select the whole word (default): -```json +```json [settings] { "double_click_in_multibuffer": "select" } @@ -565,7 +608,7 @@ There are two options to choose from: 2. Open the excerpt clicked as a new buffer in the new tab: -```json +```json [settings] { "double_click_in_multibuffer": "open" } @@ -589,7 +632,7 @@ For the case of "open", regular selection behavior can be achieved by holding `a - Setting: `edit_predictions` - Default: -```json +```json [settings] "edit_predictions": { "disabled_globs": [ "**/.env*", @@ -627,19 +670,19 @@ List of `string` values 1. Don't show edit predictions in comments: -```json +```json [settings] "disabled_in": ["comment"] ``` 2. Don't show edit predictions in strings and comments: -```json +```json [settings] "disabled_in": ["comment", "string"] ``` 3. Only in Go, don't show edit predictions in strings and comments: -```json +```json [settings] { "languages": { "Go": { @@ -659,25 +702,25 @@ List of `string` values 1. Don't highlight the current line: -```json +```json [settings] "current_line_highlight": "none" ``` 2. Highlight the gutter area: -```json +```json [settings] "current_line_highlight": "gutter" ``` 3. Highlight the editor area: -```json +```json [settings] "current_line_highlight": "line" ``` 4. Highlight the full line: -```json +```json [settings] "current_line_highlight": "all" ``` @@ -713,25 +756,25 @@ List of `string` values 1. A vertical bar: -```json +```json [settings] "cursor_shape": "bar" ``` 2. A block that surrounds the following character: -```json +```json [settings] "cursor_shape": "block" ``` 3. An underline / underscore that runs along the following character: -```json +```json [settings] "cursor_shape": "underline" ``` 4. An box drawn around the following character: -```json +```json [settings] "cursor_shape": "hollow" ``` @@ -741,7 +784,7 @@ List of `string` values - Setting: `gutter` - Default: -```json +```json [settings] { "gutter": { "line_numbers": true, @@ -771,19 +814,19 @@ List of `string` values 1. Never hide the mouse cursor: -```json +```json [settings] "hide_mouse": "never" ``` 2. Hide only when typing: -```json +```json [settings] "hide_mouse": "on_typing" ``` 3. Hide on both typing and cursor movement: -```json +```json [settings] "hide_mouse": "on_typing_and_movement" ``` @@ -797,25 +840,25 @@ List of `string` values 1. Place snippets at the top of the completion list: -```json +```json [settings] "snippet_sort_order": "top" ``` 2. Place snippets normally without any preference: -```json +```json [settings] "snippet_sort_order": "inline" ``` 3. Place snippets at the bottom of the completion list: -```json +```json [settings] "snippet_sort_order": "bottom" ``` 4. Do not show snippets in the completion list at all: -```json +```json [settings] "snippet_sort_order": "none" ``` @@ -825,7 +868,7 @@ List of `string` values - Setting: `scrollbar` - Default: -```json +```json [settings] "scrollbar": { "show": "auto", "cursors": true, @@ -851,7 +894,7 @@ List of `string` values 1. Show the scrollbar if there's important information or follow the system's configured behavior: -```json +```json [settings] "scrollbar": { "show": "auto" } @@ -859,7 +902,7 @@ List of `string` values 2. Match the system's configured behavior: -```json +```json [settings] "scrollbar": { "show": "system" } @@ -867,7 +910,7 @@ List of `string` values 3. Always show the scrollbar: -```json +```json [settings] "scrollbar": { "show": "always" } @@ -875,7 +918,7 @@ List of `string` values 4. Never show the scrollbar: -```json +```json [settings] "scrollbar": { "show": "never" } @@ -887,6 +930,8 @@ List of `string` values - Setting: `cursors` - Default: `true` +Cursor indicators appear as small marks on the scrollbar showing where other collaborators' cursors are positioned in the file. + **Options** `boolean` values @@ -897,6 +942,8 @@ List of `string` values - Setting: `git_diff` - Default: `true` +Git diff indicators appear as colored marks showing lines that have been added, modified, or deleted compared to the git HEAD. + **Options** `boolean` values @@ -907,6 +954,8 @@ List of `string` values - Setting: `search_results` - Default: `true` +Search result indicators appear as marks showing all locations in the file where your current search query matches. + **Options** `boolean` values @@ -917,6 +966,8 @@ List of `string` values - Setting: `selected_text` - Default: `true` +Selected text indicators appear as marks showing all occurrences of the currently selected text throughout the file. + **Options** `boolean` values @@ -927,6 +978,8 @@ List of `string` values - Setting: `selected_symbol` - Default: `true` +Selected symbol indicators appear as marks showing all occurrences of the currently selected symbol (like a function or variable name) throughout the file. + **Options** `boolean` values @@ -937,45 +990,47 @@ List of `string` values - Setting: `diagnostics` - Default: `all` +Diagnostic indicators appear as colored marks showing errors, warnings, and other language server diagnostics at their corresponding line positions in the file. + **Options** 1. Show all diagnostics: -```json +```json [settings] { - "diagnostics": "all" + "show_diagnostics": "all" } ``` 2. Do not show any diagnostics: -```json +```json [settings] { - "diagnostics": "none" + "show_diagnostics": "off" } ``` 3. Show only errors: -```json +```json [settings] { - "diagnostics": "error" + "show_diagnostics": "error" } ``` 4. Show only errors and warnings: -```json +```json [settings] { - "diagnostics": "warning" + "show_diagnostics": "warning" } ``` 5. Show only errors, warnings, and information: -```json +```json [settings] { - "diagnostics": "information" + "show_diagnostics": "info" } ``` @@ -985,7 +1040,7 @@ List of `string` values - Setting: `axes` - Default: -```json +```json [settings] "scrollbar": { "axes": { "horizontal": true, @@ -1020,7 +1075,7 @@ List of `string` values - Setting: `minimap` - Default: -```json +```json [settings] { "minimap": { "show": "never", @@ -1041,7 +1096,7 @@ List of `string` values 1. Always show the minimap: -```json +```json [settings] { "show": "always" } @@ -1049,7 +1104,7 @@ List of `string` values 2. Show the minimap if the editor's scrollbars are visible: -```json +```json [settings] { "show": "auto" } @@ -1057,7 +1112,7 @@ List of `string` values 3. Never show the minimap: -```json +```json [settings] { "show": "never" } @@ -1073,7 +1128,7 @@ List of `string` values 1. Show the minimap thumb when hovering over the minimap: -```json +```json [settings] { "thumb": "hover" } @@ -1081,7 +1136,7 @@ List of `string` values 2. Always show the minimap thumb: -```json +```json [settings] { "thumb": "always" } @@ -1097,7 +1152,7 @@ List of `string` values 1. Display a border on all sides of the thumb: -```json +```json [settings] { "thumb_border": "full" } @@ -1105,7 +1160,7 @@ List of `string` values 2. Display a border on all sides except the left side: -```json +```json [settings] { "thumb_border": "left_open" } @@ -1113,7 +1168,7 @@ List of `string` values 3. Display a border on all sides except the right side: -```json +```json [settings] { "thumb_border": "right_open" } @@ -1121,7 +1176,7 @@ List of `string` values 4. Display a border only on the left side: -```json +```json [settings] { "thumb_border": "left_only" } @@ -1129,7 +1184,7 @@ List of `string` values 5. Display the thumb without any border: -```json +```json [settings] { "thumb_border": "none" } @@ -1145,7 +1200,7 @@ List of `string` values 1. Inherit the editor's current line highlight setting: -```json +```json [settings] { "minimap": { "current_line_highlight": null @@ -1155,7 +1210,7 @@ List of `string` values 2. Highlight the current line in the minimap: -```json +```json [settings] { "minimap": { "current_line_highlight": "line" @@ -1165,7 +1220,7 @@ List of `string` values or -```json +```json [settings] { "minimap": { "current_line_highlight": "all" @@ -1175,7 +1230,7 @@ or 3. Do not highlight the current line in the minimap: -```json +```json [settings] { "minimap": { "current_line_highlight": "gutter" @@ -1185,7 +1240,7 @@ or or -```json +```json [settings] { "minimap": { "current_line_highlight": "none" @@ -1199,7 +1254,7 @@ or - Settings: `tab_bar` - Default: -```json +```json [settings] "tab_bar": { "show": true, "show_nav_history_buttons": true, @@ -1243,7 +1298,7 @@ or - Setting: `tabs` - Default: -```json +```json [settings] "tabs": { "close_position": "right", "file_icons": false, @@ -1264,7 +1319,7 @@ or 1. Display the close button on the right: -```json +```json [settings] { "close_position": "right" } @@ -1272,7 +1327,7 @@ or 2. Display the close button on the left: -```json +```json [settings] { "close_position": "left" } @@ -1300,7 +1355,7 @@ or 1. Activate the tab that was open previously: -```json +```json [settings] { "activate_on_close": "history" } @@ -1308,7 +1363,7 @@ or 2. Activate the right neighbour tab if present: -```json +```json [settings] { "activate_on_close": "neighbour" } @@ -1316,7 +1371,7 @@ or 3. Activate the left neighbour tab if present: -```json +```json [settings] { "activate_on_close": "left_neighbour" } @@ -1332,7 +1387,7 @@ or 1. Show it just upon hovering the tab: -```json +```json [settings] { "show_close_button": "hover" } @@ -1340,7 +1395,7 @@ or 2. Show it persistently: -```json +```json [settings] { "show_close_button": "always" } @@ -1348,7 +1403,7 @@ or 3. Never show it, even if hovering it: -```json +```json [settings] { "show_close_button": "hidden" } @@ -1364,7 +1419,7 @@ or 1. Do not mark any files: -```json +```json [settings] { "show_diagnostics": "off" } @@ -1372,7 +1427,7 @@ or 2. Only mark files with errors: -```json +```json [settings] { "show_diagnostics": "errors" } @@ -1380,7 +1435,7 @@ or 3. Mark files with errors and warnings: -```json +```json [settings] { "show_diagnostics": "all" } @@ -1402,7 +1457,7 @@ or - Setting: `drag_and_drop_selection` - Default: -```json +```json [settings] "drag_and_drop_selection": { "enabled": true, "delay": 300 @@ -1415,7 +1470,7 @@ or - Setting: `toolbar` - Default: -```json +```json [settings] "toolbar": { "breadcrumbs": true, "quick_actions": true, @@ -1495,10 +1550,11 @@ Positive `integer` value between 1 and 32. Values outside of this range will be - Setting: `status_bar` - Default: -```json +```json [settings] "status_bar": { "active_language_button": true, - "cursor_position_button": true + "cursor_position_button": true, + "line_endings_button": false }, ``` @@ -1529,7 +1585,7 @@ Some options are passed via `initialization_options` to the language server. The For example to pass the `check` option to `rust-analyzer`, use the following configuration: -```json +```json [settings] "lsp": { "rust-analyzer": { "initialization_options": { @@ -1543,7 +1599,7 @@ For example to pass the `check` option to `rust-analyzer`, use the following con While other options may be changed at a runtime and should be placed under `settings`: -```json +```json [settings] "lsp": { "yaml-language-server": { "settings": { @@ -1561,7 +1617,7 @@ While other options may be changed at a runtime and should be placed under `sett - Setting: `global_lsp_settings` - Default: -```json +```json [settings] { "global_lsp_settings": { "button": true @@ -1589,7 +1645,7 @@ While other options may be changed at a runtime and should be placed under `sett - Setting: `features` - Default: -```json +```json [settings] { "features": { "edit_prediction_provider": "zed" @@ -1607,7 +1663,7 @@ While other options may be changed at a runtime and should be placed under `sett 1. Use Zeta as the edit prediction provider: -```json +```json [settings] { "features": { "edit_prediction_provider": "zed" @@ -1617,7 +1673,7 @@ While other options may be changed at a runtime and should be placed under `sett 2. Use Copilot as the edit prediction provider: -```json +```json [settings] { "features": { "edit_prediction_provider": "copilot" @@ -1627,7 +1683,7 @@ While other options may be changed at a runtime and should be placed under `sett 3. Use Supermaven as the edit prediction provider: -```json +```json [settings] { "features": { "edit_prediction_provider": "supermaven" @@ -1637,7 +1693,7 @@ While other options may be changed at a runtime and should be placed under `sett 4. Turn off edit predictions across all providers -```json +```json [settings] { "features": { "edit_prediction_provider": "none" @@ -1655,7 +1711,7 @@ While other options may be changed at a runtime and should be placed under `sett 1. `on`, enables format on save obeying `formatter` setting: -```json +```json [settings] { "format_on_save": "on" } @@ -1663,7 +1719,7 @@ While other options may be changed at a runtime and should be placed under `sett 2. `off`, disables format on save: -```json +```json [settings] { "format_on_save": "off" } @@ -1679,7 +1735,7 @@ While other options may be changed at a runtime and should be placed under `sett 1. To use the current language server, use `"language_server"`: -```json +```json [settings] { "formatter": "language_server" } @@ -1687,7 +1743,7 @@ While other options may be changed at a runtime and should be placed under `sett 2. Or to use an external command, use `"external"`. Specify the name of the formatting program to run, and an array of arguments to pass to the program. The buffer's text will be passed to the program on stdin, and the formatted output should be written to stdout. For example, the following command would strip trailing spaces using [`sed(1)`](https://linux.die.net/man/1/sed): -```json +```json [settings] { "formatter": { "external": { @@ -1702,7 +1758,7 @@ While other options may be changed at a runtime and should be placed under `sett WARNING: `{buffer_path}` should not be used to direct your formatter to read from a filename. Your formatter should only read from standard input and should not read or write files directly. -```json +```json [settings] "formatter": { "external": { "command": "prettier", @@ -1713,22 +1769,20 @@ WARNING: `{buffer_path}` should not be used to direct your formatter to read fro 4. Or to use code actions provided by the connected language servers, use `"code_actions"`: -```json +```json [settings] { - "formatter": { - "code_actions": { - // Use ESLint's --fix: - "source.fixAll.eslint": true, - // Organize imports on save: - "source.organizeImports": true - } - } + "formatter": [ + // Use ESLint's --fix: + { "code_action": "source.fixAll.eslint" }, + // Organize imports on save: + { "code_action": "source.organizeImports" } + ] } ``` 5. Or to use multiple formatters consecutively, use an array of formatters: -```json +```json [settings] { "formatter": [ { "language_server": { "name": "rust-analyzer" } }, @@ -1745,74 +1799,6 @@ WARNING: `{buffer_path}` should not be used to direct your formatter to read fro Here `rust-analyzer` will be used first to format the code, followed by a call of sed. If any of the formatters fails, the subsequent ones will still be executed. -## Code Actions On Format - -- Description: The code actions to perform with the primary language server when formatting the buffer. -- Setting: `code_actions_on_format` -- Default: `{}`, except for Go it's `{ "source.organizeImports": true }` - -**Examples** - - - -1. Organize imports on format in TypeScript and TSX buffers: - -```json -{ - "languages": { - "TypeScript": { - "code_actions_on_format": { - "source.organizeImports": true - } - }, - "TSX": { - "code_actions_on_format": { - "source.organizeImports": true - } - } - } -} -``` - -2. Run ESLint `fixAll` code action when formatting: - -```json -{ - "languages": { - "JavaScript": { - "code_actions_on_format": { - "source.fixAll.eslint": true - } - } - } -} -``` - -3. Run only a single ESLint rule when using `fixAll`: - -```json -{ - "languages": { - "JavaScript": { - "code_actions_on_format": { - "source.fixAll.eslint": true - } - } - }, - "lsp": { - "eslint": { - "settings": { - "codeActionOnSave": { - "rules": ["import/order"] - } - } - } - } -} -``` - ## Auto close - Description: Whether to automatically add matching closing characters when typing opening parenthesis, bracket, brace, single or double quote characters. @@ -1849,7 +1835,7 @@ The result is still `)))` and not `))))))`, which is what it would be by default - Description: Files or globs of files that will be excluded by Zed entirely. They will be skipped during file scans, file searches, and not be displayed in the project file tree. Overrides `file_scan_inclusions`. - Default: -```json +```json [settings] "file_scan_exclusions": [ "**/.git", "**/.svn", @@ -1871,7 +1857,7 @@ Note, specifying `file_scan_exclusions` in settings.json will override the defau - Description: Files or globs of files that will be included by Zed, even when ignored by git. This is useful for files that are not tracked by git, but are still important to your project. Note that globs that are overly broad can slow down Zed's file scanning. `file_scan_exclusions` takes precedence over these inclusions. - Default: -```json +```json [settings] "file_scan_inclusions": [".env*"], ``` @@ -1881,7 +1867,7 @@ Note, specifying `file_scan_exclusions` in settings.json will override the defau - Description: Configure how Zed selects a language for a file based on its filename or extension. Supports glob entries. - Default: -```json +```json [settings] "file_types": { "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json"], "Shell Script": [".env.*"] @@ -1892,7 +1878,7 @@ Note, specifying `file_scan_exclusions` in settings.json will override the defau To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files starting with `Dockerfile` as Dockerfile: -```json +```json [settings] { "file_types": { "C++": ["c"], @@ -1908,7 +1894,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files - Setting: `diagnostics` - Default: -```json +```json [settings] { "diagnostics": { "include_warnings": true, @@ -1928,7 +1914,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files - Setting: `inline` - Default: -```json +```json [settings] { "diagnostics": { "inline": { @@ -1946,7 +1932,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files 1. Enable inline diagnostics. -```json +```json [settings] { "diagnostics": { "inline": { @@ -1958,7 +1944,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files 2. Delay diagnostic updates until some time after the last diagnostic update. -```json +```json [settings] { "diagnostics": { "inline": { @@ -1971,7 +1957,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files 3. Set padding between the end of the source line and the start of the diagnostic. -```json +```json [settings] { "diagnostics": { "inline": { @@ -1984,7 +1970,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files 4. Horizontally align inline diagnostics at the given column. -```json +```json [settings] { "diagnostics": { "inline": { @@ -1997,7 +1983,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files 5. Show only warning and error diagnostics. -```json +```json [settings] { "diagnostics": { "inline": { @@ -2014,7 +2000,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files - Setting: `git` - Default: -```json +```json [settings] { "git": { "git_gutter": "tracked_files", @@ -2039,7 +2025,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files 1. Show git gutter in tracked files -```json +```json [settings] { "git": { "git_gutter": "tracked_files" @@ -2049,7 +2035,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files 2. Hide git gutter -```json +```json [settings] { "git": { "git_gutter": "hide" @@ -2069,7 +2055,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files Example: -```json +```json [settings] { "git": { "gutter_debounce": 100 @@ -2083,7 +2069,7 @@ Example: - Setting: `inline_blame` - Default: -```json +```json [settings] { "git": { "inline_blame": { @@ -2097,7 +2083,7 @@ Example: 1. Disable inline git blame: -```json +```json [settings] { "git": { "inline_blame": { @@ -2109,7 +2095,7 @@ Example: 2. Only show inline git blame after a delay (that starts after cursor stops moving): -```json +```json [settings] { "git": { "inline_blame": { @@ -2121,7 +2107,7 @@ Example: 3. Show a commit summary next to the commit date and author: -```json +```json [settings] { "git": { "inline_blame": { @@ -2133,7 +2119,7 @@ Example: 4. Use this as the minimum column at which to display inline blame information: -```json +```json [settings] { "git": { "inline_blame": { @@ -2145,7 +2131,7 @@ Example: 5. Set the padding between the end of the line and the inline blame hint, in ems: -```json +```json [settings] { "git": { "inline_blame": { @@ -2161,7 +2147,7 @@ Example: - Setting: `branch_picker` - Default: -```json +```json [settings] { "git": { "branch_picker": { @@ -2175,7 +2161,7 @@ Example: 1. Show the author name in the branch picker: -```json +```json [settings] { "git": { "branch_picker": { @@ -2191,7 +2177,7 @@ Example: - Setting: `hunk_style` - Default: -```json +```json [settings] { "git": { "hunk_style": "staged_hollow" @@ -2203,7 +2189,7 @@ Example: 1. Show the staged hunks faded out and with a border: -```json +```json [settings] { "git": { "hunk_style": "staged_hollow" @@ -2213,7 +2199,7 @@ Example: 2. Show unstaged hunks faded out and with a border: -```json +```json [settings] { "git": { "hunk_style": "unstaged_hollow" @@ -2231,7 +2217,7 @@ Example: 1. Do nothing: -```json +```json [settings] { "go_to_definition_fallback": "none" } @@ -2239,7 +2225,7 @@ Example: 2. Find references for the same symbol (default): -```json +```json [settings] { "go_to_definition_fallback": "find_all_references" } @@ -2271,7 +2257,7 @@ Example: - Setting: `indent_guides` - Default: -```json +```json [settings] { "indent_guides": { "enabled": true, @@ -2287,7 +2273,7 @@ Example: 1. Disable indent guides -```json +```json [settings] { "indent_guides": { "enabled": false @@ -2297,7 +2283,7 @@ Example: 2. Enable indent guides for a specific language. -```json +```json [settings] { "languages": { "Python": { @@ -2312,7 +2298,7 @@ Example: 3. Enable indent aware coloring ("rainbow indentation"). The colors that are used for different indentation levels are defined in the theme (theme key: `accents`). They can be customized by using theme overrides. -```json +```json [settings] { "indent_guides": { "enabled": true, @@ -2324,7 +2310,7 @@ Example: 4. Enable indent aware background coloring ("rainbow indentation"). The colors that are used for different indentation levels are defined in the theme (theme key: `accents`). They can be customized by using theme overrides. -```json +```json [settings] { "indent_guides": { "enabled": true, @@ -2366,7 +2352,7 @@ Example: - Setting: `icon_theme` - Default: -```json +```json [settings] "icon_theme": { "mode": "system", "dark": "Zed (Default)", @@ -2384,7 +2370,7 @@ Example: 1. Set the icon theme to dark mode -```json +```json [settings] { "mode": "dark" } @@ -2392,7 +2378,7 @@ Example: 2. Set the icon theme to light mode -```json +```json [settings] { "mode": "light" } @@ -2400,7 +2386,7 @@ Example: 3. Set the icon theme to system mode -```json +```json [settings] { "mode": "system" } @@ -2432,7 +2418,7 @@ Run the {#action icon_theme_selector::Toggle} action in the command palette to s - Setting: `image_viewer` - Default: -```json +```json [settings] { "image_viewer": { "unit": "binary" @@ -2452,7 +2438,7 @@ Run the {#action icon_theme_selector::Toggle} action in the command palette to s 1. Use binary units (KiB, MiB): -```json +```json [settings] { "image_viewer": { "unit": "binary" @@ -2462,7 +2448,7 @@ Run the {#action icon_theme_selector::Toggle} action in the command palette to s 2. Use decimal units (KB, MB): -```json +```json [settings] { "image_viewer": { "unit": "decimal" @@ -2476,7 +2462,7 @@ Run the {#action icon_theme_selector::Toggle} action in the command palette to s - Setting: `inlay_hints` - Default: -```json +```json [settings] "inlay_hints": { "enabled": false, "show_type_hints": true, @@ -2509,7 +2495,7 @@ Settings-related hint updates are not debounced. All possible config values for `toggle_on_modifiers_press` are: -```json +```json [settings] "inlay_hints": { "toggle_on_modifiers_press": { "control": true, @@ -2529,16 +2515,17 @@ Unspecified values have a `false` value, hints won't be toggled if all the modif - Setting: `journal` - Default: -```json +```json [settings] "journal": { "path": "~", "hour_format": "hour12" } + ``` ### Path -- Description: The path of the directory where journal entries are stored. +- Description: The path of the directory where journal entries are stored. If an invalid path is specified, the journal will fall back to using `~` (the home directory). - Setting: `path` - Default: `~` @@ -2556,7 +2543,7 @@ Unspecified values have a `false` value, hints won't be toggled if all the modif 1. 12-hour format: -```json +```json [settings] { "hour_format": "hour12" } @@ -2564,7 +2551,7 @@ Unspecified values have a `false` value, hints won't be toggled if all the modif 2. 24-hour format: -```json +```json [settings] { "hour_format": "hour24" } @@ -2576,7 +2563,7 @@ Unspecified values have a `false` value, hints won't be toggled if all the modif - Setting: `jsx_tag_auto_close` - Default: -```json +```json [settings] { "jsx_tag_auto_close": { "enabled": true @@ -2598,7 +2585,7 @@ Unspecified values have a `false` value, hints won't be toggled if all the modif To override settings for a language, add an entry for that languages name to the `languages` value. Example: -```json +```json [settings] "languages": { "C": { "format_on_save": "off", @@ -2636,7 +2623,7 @@ These values take in the same options as the root-level settings with the same n - Setting: `language_models` - Default: -```json +```json [settings] { "language_models": { "anthropic": { @@ -2669,7 +2656,7 @@ Configuration for various AI model providers including API URLs and authenticati 1. Short format: -```json +```json [settings] { "line_indicator_format": "short" } @@ -2677,7 +2664,7 @@ Configuration for various AI model providers including API URLs and authenticati 2. Long format: -```json +```json [settings] { "line_indicator_format": "long" } @@ -2733,7 +2720,7 @@ Positive `integer` values or `null` for unlimited tabs 1. Maps to `Alt` on Linux and Windows and to `Option` on macOS: -```json +```json [settings] { "multi_cursor_modifier": "alt" } @@ -2741,7 +2728,7 @@ Positive `integer` values or `null` for unlimited tabs 2. Maps `Control` on Linux and Windows and to `Command` on macOS: -```json +```json [settings] { "multi_cursor_modifier": "cmd_or_ctrl" // alias: "cmd", "ctrl" } @@ -2753,7 +2740,7 @@ Positive `integer` values or `null` for unlimited tabs - Setting: `node` - Default: -```json +```json [settings] { "node": { "ignore_system_version": false, @@ -2794,7 +2781,7 @@ By default no proxy will be used, or Zed will attempt to retrieve proxy settings For example, to set an `http` proxy, add the following to your settings: -```json +```json [settings] { "proxy": "http://127.0.0.1:10809" } @@ -2802,7 +2789,7 @@ For example, to set an `http` proxy, add the following to your settings: Or to set a `socks5` proxy: -```json +```json [settings] { "proxy": "socks5h://localhost:10808" } @@ -2820,7 +2807,7 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en 1. Use platform default behavior: -```json +```json [settings] { "on_last_window_closed": "platform_default" } @@ -2828,7 +2815,7 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en 2. Always quit the application: -```json +```json [settings] { "on_last_window_closed": "quit_app" } @@ -2844,7 +2831,7 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en Configuration object for defining settings profiles. Example: -```json +```json [settings] { "profiles": { "presentation": { @@ -2871,14 +2858,28 @@ Configuration object for defining settings profiles. Example: - Setting: `preview_tabs` - Default: -```json +```json [settings] "preview_tabs": { "enabled": true, + "enable_preview_from_project_panel": true, "enable_preview_from_file_finder": false, - "enable_preview_from_code_navigation": false, + "enable_preview_from_multibuffer": true, + "enable_preview_multibuffer_from_code_navigation": false, + "enable_preview_file_from_code_navigation": true, + "enable_keep_preview_on_code_navigation": false, } ``` +### Enable preview from project panel + +- Description: Determines whether to open files in preview mode when opened from the project panel with a single click. +- Setting: `enable_preview_from_project_panel` +- Default: `true` + +**Options** + +`boolean` values + ### Enable preview from file finder - Description: Determines whether to open files in preview mode when selected from the file finder. @@ -2889,10 +2890,40 @@ Configuration object for defining settings profiles. Example: `boolean` values -### Enable preview from code navigation +### Enable preview from multibuffer -- Description: Determines whether a preview tab gets replaced when code navigation is used to navigate away from the tab. -- Setting: `enable_preview_from_code_navigation` +- Description: Determines whether to open files in preview mode when opened from a multibuffer. +- Setting: `enable_preview_from_multibuffer` +- Default: `true` + +**Options** + +`boolean` values + +### Enable preview multibuffer from code navigation + +- Description: Determines whether to open tabs in preview mode when code navigation is used to open a multibuffer. +- Setting: `enable_preview_multibuffer_from_code_navigation` +- Default: `false` + +**Options** + +`boolean` values + +### Enable preview file from code navigation + +- Description: Determines whether to open tabs in preview mode when code navigation is used to open a single file. +- Setting: `enable_preview_file_from_code_navigation` +- Default: `true` + +**Options** + +`boolean` values + +### Enable keep preview on code navigation + +- Description: Determines whether to keep tabs in preview mode when code navigation is used to navigate away from them. If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. +- Setting: `enable_keep_preview_on_code_navigation` - Default: `false` **Options** @@ -2929,7 +2960,7 @@ Configuration object for defining settings profiles. Example: 1. Split upward: -```json +```json [settings] { "pane_split_direction_horizontal": "up" } @@ -2937,7 +2968,7 @@ Configuration object for defining settings profiles. Example: 2. Split downward: -```json +```json [settings] { "pane_split_direction_horizontal": "down" } @@ -2953,7 +2984,7 @@ Configuration object for defining settings profiles. Example: 1. Split to the left: -```json +```json [settings] { "pane_split_direction_vertical": "left" } @@ -2961,7 +2992,7 @@ Configuration object for defining settings profiles. Example: 2. Split to the right: -```json +```json [settings] { "pane_split_direction_vertical": "right" } @@ -3021,11 +3052,33 @@ List of `string` glob patterns - Description: Whether to show relative line numbers in the gutter - Setting: `relative_line_numbers` -- Default: `false` +- Default: `"disabled"` **Options** -`boolean` values +1. Show relative line numbers in the gutter whilst counting wrapped lines as one line: + +```json [settings] +{ + "relative_line_numbers": "enabled" +} +``` + +2. Show relative line numbers in the gutter, including wrapped lines in the counting: + +```json [settings] +{ + "relative_line_numbers": "wrapped" +} +``` + +2. Do not use relative line numbers: + +```json [settings] +{ + "relative_line_numbers": "disabled" +} +``` ## Remove Trailing Whitespace On Save @@ -3071,7 +3124,7 @@ List of strings containing any combination of: 1. Restore all workspaces that were open when quitting Zed: -```json +```json [settings] { "restore_on_startup": "last_session" } @@ -3079,7 +3132,7 @@ List of strings containing any combination of: 2. Restore the workspace that was closed last: -```json +```json [settings] { "restore_on_startup": "last_workspace" } @@ -3087,7 +3140,7 @@ List of strings containing any combination of: 3. Always start with an empty editor: -```json +```json [settings] { "restore_on_startup": "none" } @@ -3103,7 +3156,7 @@ List of strings containing any combination of: 1. Scroll one page beyond the last line by one page: -```json +```json [settings] { "scroll_beyond_last_line": "one_page" } @@ -3111,7 +3164,7 @@ List of strings containing any combination of: 2. The editor will scroll beyond the last line by the same amount of lines as `vertical_scroll_margin`: -```json +```json [settings] { "scroll_beyond_last_line": "vertical_scroll_margin" } @@ -3119,7 +3172,7 @@ List of strings containing any combination of: 3. The editor will not scroll beyond the last line: -```json +```json [settings] { "scroll_beyond_last_line": "off" } @@ -3175,21 +3228,67 @@ Non-negative `integer` values - Setting: `search` - Default: -```json +```json [settings] "search": { + "button": true, "whole_word": false, "case_sensitive": false, "include_ignored": false, - "regex": false + "regex": false, + "center_on_match": false }, ``` +### Button + +- Description: Whether to show the project search button in the status bar. +- Setting: `button` +- Default: `true` + +### Whole Word + +- Description: Whether to only match on whole words. +- Setting: `whole_word` +- Default: `false` + +### Case Sensitive + +- Description: Whether to match case sensitively. This setting affects both + searches and editor actions like "Select Next Occurrence", "Select Previous + Occurrence", and "Select All Occurrences". +- Setting: `case_sensitive` +- Default: `false` + +### Include Ignore + +- Description: Whether to include gitignored files in search results. +- Setting: `include_ignored` +- Default: `false` + +### Regex + +- Description: Whether to interpret the search query as a regular expression. +- Setting: `regex` +- Default: `false` + +### Center On Match + +- Description: Whether to center the cursor on each search match when navigating. +- Setting: `center_on_match` +- Default: `false` + ## Search Wrap - Description: If `search_wrap` is disabled, search result do not wrap around the end of the file - Setting: `search_wrap` - Default: `true` +## Center on Match + +- Description: If `center_on_match` is enabled, the editor will center the cursor on the current match when searching. +- Setting: `center_on_match` +- Default: `false` + ## Seed Search Query From Cursor - Description: When to populate a new search's query based on the text under the cursor. @@ -3234,7 +3333,7 @@ Examples: - Setting: `completions` - Default: -```json +```json [settings] { "completions": { "words": "fallback", @@ -3351,12 +3450,13 @@ Positive integer values - Setting: `whitespace_map` - Default: -```json +```json [settings] { "whitespace_map": { "space": "•", "tab": "→" - }, + } +} ``` ## Soft Wrap @@ -3395,7 +3495,7 @@ Positive integer values ## Use Auto Surround -- Description: Whether to automatically surround selected text when typing opening parenthesis, bracket, brace, single or double quote characters. For example, when you select text and type (, Zed will surround the text with (). +- Description: Whether to automatically surround selected text when typing opening parenthesis, bracket, brace, single or double quote characters. For example, when you select text and type '(', Zed will surround the text with (). - Setting: `use_auto_surround` - Default: `true` @@ -3449,7 +3549,7 @@ List of `integer` column numbers - Setting: `tasks` - Default: -```json +```json [settings] { "tasks": { "variables": {}, @@ -3471,7 +3571,7 @@ List of `integer` column numbers - Setting: `telemetry` - Default: -```json +```json [settings] "telemetry": { "diagnostics": true, "metrics": true @@ -3506,13 +3606,13 @@ List of `integer` column numbers - Setting: `terminal` - Default: -```json +```json [settings] { "terminal": { "alternate_scroll": "off", "blinking": "terminal_controlled", "copy_on_select": false, - "keep_selection_on_copy": false, + "keep_selection_on_copy": true, "dock": "bottom", "default_width": 640, "default_height": 320, @@ -3531,6 +3631,7 @@ List of `integer` column numbers "option_as_meta": false, "button": true, "shell": "system", + "scroll_multiplier": 3.0, "toolbar": { "breadcrumbs": false }, @@ -3562,7 +3663,7 @@ List of `integer` column numbers 1. Default alternate scroll mode to off -```json +```json [settings] { "terminal": { "alternate_scroll": "off" @@ -3572,7 +3673,7 @@ List of `integer` column numbers 2. Default alternate scroll mode to on -```json +```json [settings] { "terminal": { "alternate_scroll": "on" @@ -3590,7 +3691,7 @@ List of `integer` column numbers 1. Never blink the cursor, ignore the terminal mode -```json +```json [settings] { "terminal": { "blinking": "off" @@ -3600,7 +3701,7 @@ List of `integer` column numbers 2. Default the cursor blink to off, but allow the terminal to turn blinking on -```json +```json [settings] { "terminal": { "blinking": "terminal_controlled" @@ -3610,7 +3711,7 @@ List of `integer` column numbers 3. Always blink the cursor, ignore the terminal mode -```json +```json [settings] { "terminal": { "blinking": "on" @@ -3630,7 +3731,7 @@ List of `integer` column numbers **Example** -```json +```json [settings] { "terminal": { "copy_on_select": true @@ -3648,7 +3749,7 @@ List of `integer` column numbers 1. A block that surrounds the following character -```json +```json [settings] { "terminal": { "cursor_shape": "block" @@ -3658,7 +3759,7 @@ List of `integer` column numbers 2. A vertical bar -```json +```json [settings] { "terminal": { "cursor_shape": "bar" @@ -3668,7 +3769,7 @@ List of `integer` column numbers 3. An underline / underscore that runs along the following character -```json +```json [settings] { "terminal": { "cursor_shape": "underline" @@ -3678,7 +3779,7 @@ List of `integer` column numbers 4. A box drawn around the following character -```json +```json [settings] { "terminal": { "cursor_shape": "hollow" @@ -3690,7 +3791,7 @@ List of `integer` column numbers - Description: Whether or not to keep the selection in the terminal after copying text. - Setting: `keep_selection_on_copy` -- Default: `false` +- Default: `true` **Options** @@ -3698,10 +3799,10 @@ List of `integer` column numbers **Example** -```json +```json [settings] { "terminal": { - "keep_selection_on_copy": true + "keep_selection_on_copy": false } } ``` @@ -3714,7 +3815,7 @@ List of `integer` column numbers **Example** -```json +```json [settings] { "terminal": { "env": { @@ -3735,7 +3836,7 @@ List of `integer` column numbers `integer` values -```json +```json [settings] { "terminal": { "font_size": 15 @@ -3753,7 +3854,7 @@ List of `integer` column numbers The name of any font family installed on the user's system -```json +```json [settings] { "terminal": { "font_family": "Berkeley Mono" @@ -3772,7 +3873,7 @@ The name of any font family installed on the user's system See Buffer Font Features -```json +```json [settings] { "terminal": { "font_features": { @@ -3793,7 +3894,7 @@ See Buffer Font Features 1. Use a line height that's `comfortable` for reading, 1.618. -```json +```json [settings] { "terminal": { "line_height": "comfortable" @@ -3803,7 +3904,7 @@ See Buffer Font Features 2. Use a `standard` line height, 1.3. This option is useful for TUIs, particularly if they use box characters. (default) -```json +```json [settings] { "terminal": { "line_height": "standard" @@ -3813,7 +3914,7 @@ See Buffer Font Features 3. Use a custom line height. -```json +```json [settings] { "terminal": { "line_height": { @@ -3839,7 +3940,7 @@ See Buffer Font Features - `75`: Minimum for body text - `90`: Preferred for body text -```json +```json [settings] { "terminal": { "minimum_contrast": 45 @@ -3857,7 +3958,7 @@ See Buffer Font Features `boolean` values -```json +```json [settings] { "terminal": { "option_as_meta": true @@ -3875,7 +3976,7 @@ See Buffer Font Features 1. Use the system's default terminal configuration (usually the `/etc/passwd` file). -```json +```json [settings] { "terminal": { "shell": "system" @@ -3885,7 +3986,7 @@ See Buffer Font Features 2. A program to launch: -```json +```json [settings] { "terminal": { "shell": { @@ -3897,7 +3998,7 @@ See Buffer Font Features 3. A program with arguments: -```json +```json [settings] { "terminal": { "shell": { @@ -3916,7 +4017,7 @@ See Buffer Font Features - Setting: `detect_venv` - Default: -```json +```json [settings] { "terminal": { "detect_venv": { @@ -3935,7 +4036,7 @@ See Buffer Font Features Disable with: -```json +```json [settings] { "terminal": { "detect_venv": "off" @@ -3943,13 +4044,33 @@ Disable with: } ``` +### Terminal: Scroll Multiplier + +- Description: The multiplier for scrolling speed in the terminal when using mouse wheel or trackpad. +- Setting: `scroll_multiplier` +- Default: `1.0` + +**Options** + +Positive floating point values. Values less than or equal to 0 will be clamped to a minimum of 0.01. + +**Example** + +```json +{ + "terminal": { + "scroll_multiplier": 5.0 + } +} +``` + ## Terminal: Toolbar - Description: Whether or not to show various elements in the terminal toolbar. - Setting: `toolbar` - Default: -```json +```json [settings] { "terminal": { "toolbar": { @@ -3979,7 +4100,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` `boolean` values -```json +```json [settings] { "terminal": { "button": false @@ -3995,9 +4116,9 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` **Options** -1. Use the current file's project directory. Will Fallback to the first project directory strategy if unsuccessful +1. Use the current file's project directory. Fallback to the first project directory strategy if unsuccessful. -```json +```json [settings] { "terminal": { "working_directory": "current_project_directory" @@ -4005,9 +4126,9 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` } ``` -2. Use the first project in this workspace's directory. Will fallback to using this platform's home directory. +2. Use the first project in this workspace's directory. Fallback to using this platform's home directory. -```json +```json [settings] { "terminal": { "working_directory": "first_project_directory" @@ -4015,9 +4136,9 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` } ``` -3. Always use this platform's home directory (if we can find it) +3. Always use this platform's home directory if it can be found. -```json +```json [settings] { "terminal": { "working_directory": "always_home" @@ -4027,7 +4148,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` 4. Always use a specific directory. This value will be shell expanded. If this path is not a valid directory the terminal will default to this platform's home directory. -```json +```json [settings] { "terminal": { "working_directory": { @@ -4039,13 +4160,60 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` } ``` +### Terminal: Path Hyperlink Regexes + +- Description: Regexes used to identify path hyperlinks. The regexes can be specified in two forms - a single regex string, or an array of strings (which will be collected into a single multi-line regex string). +- Setting: `path_hyperlink_regexes` +- Default: + +```json [settings] +{ + "terminal": { + "path_hyperlink_regexes": [ + // Python-style diagnostics + "File \"(?[^\"]+)\", line (?[0-9]+)", + // Common path syntax with optional line, column, description, trailing punctuation, or + // surrounding symbols or quotes + [ + "(?x)", + "# optionally starts with 0-2 opening prefix symbols", + "[({\\[<]{0,2}", + "# which may be followed by an opening quote", + "(?[\"'`])?", + "# `path` is the shortest sequence of any non-space character", + "(?(?[^ ]+?", + " # which may end with a line and optionally a column,", + " (?:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?", + "))", + "# which must be followed by a matching quote", + "(?()\\k)", + "# and optionally a single closing symbol", + "[)}\\]>]?", + "# if line/column matched, may be followed by a description", + "(?():[^ 0-9][^ ]*)?", + "# which may be followed by trailing punctuation", + "[.,:)}\\]>]*", + "# and always includes trailing whitespace or end of line", + "([ ]+|$)" + ] + ] + } +} +``` + +### Terminal: Path Hyperlink Timeout (ms) + +- Description: Maximum time to search for a path hyperlink. When set to 0, path hyperlinks are disabled. +- Setting: `path_hyperlink_timeout_ms` +- Default: `1` + ## REPL - Description: Repl settings. - Setting: `repl` - Default: -```json +```json [settings] "repl": { // Maximum number of columns to keep in REPL's scrollback buffer. // Clamped with [20, 512] range. @@ -4068,7 +4236,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` - Setting: `theme` - Default: -```json +```json [settings] "theme": { "mode": "system", "dark": "One Dark", @@ -4086,7 +4254,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` 1. Set the theme to dark mode -```json +```json [settings] { "mode": "dark" } @@ -4094,7 +4262,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` 2. Set the theme to light mode -```json +```json [settings] { "mode": "light" } @@ -4102,7 +4270,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` 3. Set the theme to system mode -```json +```json [settings] { "mode": "system" } @@ -4134,13 +4302,14 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - Setting: `title_bar` - Default: -```json +```json [settings] "title_bar": { "show_branch_icon": false, "show_branch_name": true, "show_project_items": true, "show_onboarding_banner": true, "show_user_picture": true, + "show_user_menu": true, "show_sign_in": true, "show_menus": false } @@ -4153,6 +4322,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `show_project_items`: Whether to show the project host and name in the titlebar - `show_onboarding_banner`: Whether to show onboarding banners in the titlebar - `show_user_picture`: Whether to show user picture in the titlebar +- `show_user_menu`: Whether to show the user menu button in the titlebar (the one that displays your avatar by default and contains options like Settings, Keymap, Themes, etc.) - `show_sign_in`: Whether to show the sign in button in the titlebar - `show_menus`: Whether to show the menus in the titlebar @@ -4172,7 +4342,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 1. Use platform default behavior: -```json +```json [settings] { "when_closing_with_no_tabs": "platform_default" } @@ -4180,7 +4350,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 2. Always close the window: -```json +```json [settings] { "when_closing_with_no_tabs": "close_window" } @@ -4188,7 +4358,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 3. Never close the window: -```json +```json [settings] { "when_closing_with_no_tabs": "keep_window_open" } @@ -4200,7 +4370,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - Setting: `project_panel` - Default: -```json +```json [settings] { "project_panel": { "button": true, @@ -4222,8 +4392,15 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "indent_guides": { "show": "always" }, + "sort_mode": "directories_first", "hide_root": false, - "starts_open": true + "hide_hidden": false, + "starts_open": true, + "auto_open": { + "on_create": true, + "on_paste": true, + "on_drop": true + } } } ``` @@ -4238,7 +4415,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 1. Default dock position to left -```json +```json [settings] { "dock": "left" } @@ -4246,7 +4423,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 2. Default dock position to right -```json +```json [settings] { "dock": "right" } @@ -4262,7 +4439,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 1. Comfortable entry spacing -```json +```json [settings] { "entry_spacing": "comfortable" } @@ -4270,7 +4447,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 2. Standard entry spacing -```json +```json [settings] { "entry_spacing": "standard" } @@ -4286,7 +4463,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 1. Default enable git status -```json +```json [settings] { "git_status": true } @@ -4294,7 +4471,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 2. Default disable git status -```json +```json [settings] { "git_status": false } @@ -4320,7 +4497,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 1. Enable auto reveal entries -```json +```json [settings] { "auto_reveal_entries": true } @@ -4328,7 +4505,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 2. Disable auto reveal entries -```json +```json [settings] { "auto_reveal_entries": false } @@ -4344,7 +4521,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 1. Enable auto fold dirs -```json +```json [settings] { "auto_fold_dirs": true } @@ -4352,7 +4529,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 2. Disable auto fold dirs -```json +```json [settings] { "auto_fold_dirs": false } @@ -4370,7 +4547,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - Setting: `indent_guides` - Default: -```json +```json [settings] "indent_guides": { "show": "always" } @@ -4380,7 +4557,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 1. Show indent guides in the project panel -```json +```json [settings] { "indent_guides": { "show": "always" @@ -4390,7 +4567,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 2. Hide indent guides in the project panel -```json +```json [settings] { "indent_guides": { "show": "never" @@ -4404,7 +4581,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - Setting: `scrollbar` - Default: -```json +```json [settings] "scrollbar": { "show": null } @@ -4414,7 +4591,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 1. Show scrollbar in the project panel -```json +```json [settings] { "scrollbar": { "show": "always" @@ -4424,7 +4601,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a 2. Hide scrollbar in the project panel -```json +```json [settings] { "scrollbar": { "show": "never" @@ -4432,6 +4609,58 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a } ``` +### Sort Mode + +- Description: Sort order for entries in the project panel +- Setting: `sort_mode` +- Default: `directories_first` + +**Options** + +1. Show directories first, then files + +```json [settings] +{ + "sort_mode": "directories_first" +} +``` + +2. Mix directories and files together + +```json [settings] +{ + "sort_mode": "mixed" +} +``` + +3. Show files first, then directories + +```json [settings] +{ + "sort_mode": "files_first" +} +``` + +### Auto Open + +- Description: Control whether files are opened automatically after different creation flows in the project panel. +- Setting: `auto_open` +- Default: + +```json [settings] +"auto_open": { + "on_create": true, + "on_paste": true, + "on_drop": true +} +``` + +**Options** + +- `on_create`: Whether to automatically open newly created files in the editor. +- `on_paste`: Whether to automatically open files after pasting or duplicating them. +- `on_drop`: Whether to automatically open files dropped from external sources. + ## Agent Visit [the Configuration page](./ai/configuration.md) under the AI section to learn more about all the agent-related settings. @@ -4442,7 +4671,7 @@ Visit [the Configuration page](./ai/configuration.md) under the AI section to le - Setting: `collaboration_panel` - Default: -```json +```json [settings] { "collaboration_panel": { "button": true, @@ -4464,7 +4693,7 @@ Visit [the Configuration page](./ai/configuration.md) under the AI section to le - Setting: `debugger` - Default: -```json +```json [settings] { "debugger": { "stepping_granularity": "line", @@ -4483,7 +4712,7 @@ See the [debugger page](./debugger.md) for more information about debugging supp - Setting: `git_panel` - Default: -```json +```json [settings] { "git_panel": { "button": true, @@ -4511,13 +4740,41 @@ See the [debugger page](./debugger.md) for more information about debugging supp - `collapse_untracked_diff`: Whether to collapse untracked files in the diff panel - `scrollbar`: When to show the scrollbar in the git panel +## Git Hosting Providers + +- Description: Register self-hosted GitHub, GitLab, or Bitbucket instances so commit hashes, issue references, and permalinks resolve to the right host. +- Setting: `git_hosting_providers` +- Default: `[]` + +**Options** + +Each entry accepts: + +- `provider`: One of `github`, `gitlab`, or `bitbucket` +- `name`: Display name for the instance +- `base_url`: Base URL, e.g. `https://git.example.corp` + +You can define these in user or project settings; project settings are merged on top of user settings. + +```json [settings] +{ + "git_hosting_providers": [ + { + "provider": "github", + "name": "BigCorp GitHub", + "base_url": "https://git.example.corp" + } + ] +} +``` + ## Outline Panel - Description: Customize outline Panel - Setting: `outline_panel` - Default: -```json +```json [settings] "outline_panel": { "button": true, "default_width": 300, @@ -4543,7 +4800,7 @@ See the [debugger page](./debugger.md) for more information about debugging supp - Setting: `calls` - Default: -```json +```json [settings] "calls": { // Join calls with the microphone live by default "mute_on_join": false, @@ -4552,6 +4809,18 @@ See the [debugger page](./debugger.md) for more information about debugging supp }, ``` +## Colorize Brackets + +- Description: Whether to use tree-sitter bracket queries to detect and colorize the brackets in the editor (also known as "rainbow brackets"). +- Setting: `colorize_brackets` +- Default: `false` + +**Options** + +`boolean` values + +The colors that are used for different indentation levels are defined in the theme (theme key: `accents`). They can be customized by using theme overrides. + ## Unnecessary Code Fade - Description: How much to fade out unused code. @@ -4567,7 +4836,7 @@ Float values between `0.0` and `0.9`, where: **Example** -```json +```json [settings] { "unnecessary_code_fade": 0.5 } @@ -4589,7 +4858,7 @@ The name of any font family installed on the system, `".ZedSans"` to use the Zed - Setting: `ui_font_features` - Default: -```json +```json [settings] "ui_font_features": { "calt": false } @@ -4603,7 +4872,7 @@ Zed supports all OpenType features that can be enabled or disabled for a given U For example, to disable font ligatures, add the following to your settings: -```json +```json [settings] { "ui_font_features": { "calt": false @@ -4613,7 +4882,7 @@ For example, to disable font ligatures, add the following to your settings: You can also set other OpenType features, like setting `cv01` to `7`: -```json +```json [settings] { "ui_font_features": { "cv01": 7 @@ -4632,7 +4901,7 @@ You can also set other OpenType features, like setting `cv01` to `7`: For example, to use `Nerd Font` as a fallback, add the following to your settings: -```json +```json [settings] { "ui_font_fallbacks": ["Nerd Font"] } @@ -4658,9 +4927,47 @@ For example, to use `Nerd Font` as a fallback, add the following to your setting `integer` values between `100` and `900` +## Settings Profiles + +- Description: Configure any number of settings profiles that are temporarily applied on top of your existing user settings when selected from `settings profile selector: toggle`. +- Setting: `profiles` +- Default: `{}` + +In your `settings.json` file, add the `profiles` object. +Each key within this object is the name of a settings profile, and each value is an object that can include any of Zed's settings. + +Example: + +```json [settings] +"profiles": { + "Presenting (Dark)": { + "agent_buffer_font_size": 18.0, + "buffer_font_size": 18.0, + "theme": "One Dark", + "ui_font_size": 18.0 + }, + "Presenting (Light)": { + "agent_buffer_font_size": 18.0, + "buffer_font_size": 18.0, + "theme": "One Light", + "ui_font_size": 18.0 + }, + "Writing": { + "agent_buffer_font_size": 15.0, + "buffer_font_size": 15.0, + "theme": "Catppuccin Frappé - No Italics", + "ui_font_size": 15.0, + "tab_bar": { "show": false }, + "toolbar": { "breadcrumbs": false } + } +} +``` + +To preview and enable a settings profile, open the command palette via {#kb command_palette::Toggle} and search for `settings profile selector: toggle`. + ## An example configuration: -```json +```json [settings] // ~/.config/zed/settings.json { "theme": "cave-light", @@ -4681,7 +4988,8 @@ For example, to use `Nerd Font` as a fallback, add the following to your setting }, "languages": { "C": { - "format_on_save": "language_server", + "format_on_save": "on", + "formatter": "language_server", "preferred_line_length": 64, "soft_wrap": "preferred_line_length" } diff --git a/docs/src/debugger.md b/docs/src/debugger.md index eef8281233..15094be360 100644 --- a/docs/src/debugger.md +++ b/docs/src/debugger.md @@ -17,6 +17,7 @@ To debug code written in a specific language, Zed needs to find a debug adapter - [C](./languages/c.md#debugging) (built-in) - [C++](./languages/cpp.md#debugging) (built-in) - [Go](./languages/go.md#debugging) (built-in) +- [Java](./languages/java.md#debugging) (provided by extension) - [JavaScript](./languages/javascript.md#debugging) (built-in) - [PHP](./languages/php.md#debugging) (built-in) - [Python](./languages/python.md#debugging) (built-in) @@ -37,7 +38,7 @@ You can open the same modal by clicking the "plus" button at the top right of th For languages that don't provide preconfigured debug tasks (this includes C, C++, and some extension-supported languages), you can define debug configurations in the `.zed/debug.json` file in your project root. This file should be an array of configuration objects: -```json +```json [debug] [ { "adapter": "CodeLLDB", @@ -56,6 +57,16 @@ Check the documentation for your language for example configurations covering ty Zed will also load debug configurations from `.vscode/launch.json`, and show them in the new process modal if no configurations are found in `.zed/debug.json`. +#### Global debug configurations + +If you run the same launch profiles across multiple projects, you can store them once in your user configuration. Invoke {#action zed::OpenDebugTasks} from the command palette to open the global `debug.json` file; Zed creates it next to your user `settings.json` and keeps it in sync with the debugger UI. The file lives at: + +- **macOS:** `~/Library/Application Support/Zed/debug.json` +- **Linux/BSD:** `$XDG_CONFIG_HOME/zed/debug.json` (falls back to `~/.config/zed/debug.json`) +- **Windows:** `%APPDATA%\Zed\debug.json` + +Populate this file with the same array of objects you would place in `.zed/debug.json`. Any scenarios defined there are merged into every workspace, so your favorite launch presets appear automatically in the "New Debug Session" dialog. + ### Launching & Attaching Zed debugger offers two ways to debug your program; you can either _launch_ a new instance of your program or _attach_ to an existing process. @@ -68,9 +79,11 @@ Compared to launching, attaching to an existing process might seem inferior, but ## Configuration -While configuration fields are debug adapter-dependent, most adapters support the following fields: +Zed requires the `adapter` and `label` fields for all debug tasks. In addition, Zed will use the `build` field to run any necessary setup steps before the debugger starts [(see below)](#build-tasks), and can accept a `tcp_connection` field to connect to an existing process. -```json +All other fields are provided by the debug adapter and can contain [task variables](./tasks.md#variables). Most adapters support `request`, `program`, and `cwd`: + +```json [debug] [ { // The label for the debug configuration and used to identify the debug session inside the debug panel & new process modal @@ -89,13 +102,13 @@ While configuration fields are debug adapter-dependent, most adapters support th ] ``` -All configuration fields support [task variables](./tasks.md#variables). +Check your debug adapter's documentation for more information on the fields it supports. ### Build tasks -Zed also allows embedding a Zed task in a `build` field that is run before the debugger starts. This is useful for setting up the environment or running any necessary setup steps before the debugger starts. +Zed allows embedding a Zed task in the `build` field that is run before the debugger starts. This is useful for setting up the environment or running any necessary setup steps before the debugger starts. -```json +```json [debug] [ { "label": "Build Binary", @@ -112,7 +125,7 @@ Zed also allows embedding a Zed task in a `build` field that is run before the d Build tasks can also refer to the existing tasks by unsubstituted label: -```json +```json [debug] [ { "label": "Build Binary", @@ -169,7 +182,7 @@ The settings for the debugger are grouped under the `debugger` key in `settings. 2. `right` - The debug panel will be docked to the right side of the UI. 3. `bottom` - The debug panel will be docked to the bottom of the UI. -```json +```json [settings] "debugger": { "dock": "bottom" }, @@ -187,7 +200,7 @@ The settings for the debugger are grouped under the `debugger` key in `settings. The meaning of a statement is determined by the adapter and it may be considered equivalent to a line. For example 'for(int i = 0; i < 10; i++)' could be considered to have 3 statements 'int i = 0', 'i < 10', and 'i++'. -```json +```json [settings] { "debugger": { "stepping_granularity": "statement" @@ -197,7 +210,7 @@ The settings for the debugger are grouped under the `debugger` key in `settings. 2. Line - The step should allow the program to run until the current source line has executed. -```json +```json [settings] { "debugger": { "stepping_granularity": "line" @@ -207,7 +220,7 @@ The settings for the debugger are grouped under the `debugger` key in `settings. 3. Instruction - The step should allow one instruction to execute (e.g. one x86 instruction). -```json +```json [settings] { "debugger": { "stepping_granularity": "instruction" @@ -225,7 +238,7 @@ The settings for the debugger are grouped under the `debugger` key in `settings. `boolean` values -```json +```json [settings] { "debugger": { "save_breakpoints": true @@ -243,7 +256,7 @@ The settings for the debugger are grouped under the `debugger` key in `settings. `boolean` values -```json +```json [settings] { "debugger": { "show_button": true @@ -261,7 +274,7 @@ The settings for the debugger are grouped under the `debugger` key in `settings. `integer` values -```json +```json [settings] { "debugger": { "timeout": 3000 @@ -277,7 +290,7 @@ The settings for the debugger are grouped under the `debugger` key in `settings. **Options** -```json +```json [settings] { "inlay_hints": { "show_value_hints": false @@ -297,7 +310,7 @@ Inline value hints can also be toggled from the Editor Controls menu in the edit `boolean` values -```json +```json [settings] { "debugger": { "log_dap_communications": true @@ -315,7 +328,7 @@ Inline value hints can also be toggled from the Editor Controls menu in the edit `boolean` values -```json +```json [settings] { "debugger": { "format_dap_log_messages": true @@ -331,7 +344,7 @@ Inline value hints can also be toggled from the Editor Controls menu in the edit You can pass `binary`, `args`, or both. `binary` should be a path to a _debug adapter_ (like `lldb-dap`) not a _debugger_ (like `lldb` itself). The `args` setting overrides any arguments that Zed would otherwise pass to the adapter. -```json +```json [settings] { "dap": { "CodeLLDB": { diff --git a/docs/src/development.md b/docs/src/development.md index 6cb5f0b827..31bb245ac4 100644 --- a/docs/src/development.md +++ b/docs/src/development.md @@ -88,7 +88,6 @@ in-depth examples and explanations. ## Contributor links - [CONTRIBUTING.md](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md) -- [Releases](./development/releases.md) - [Debugging Crashes](./development/debugging-crashes.md) - [Code of Conduct](https://zed.dev/code-of-conduct) - [Zed Contributor License](https://zed.dev/cla) diff --git a/docs/src/development/debuggers.md b/docs/src/development/debuggers.md index a5713f6c8a..11f49390d4 100644 --- a/docs/src/development/debuggers.md +++ b/docs/src/development/debuggers.md @@ -5,7 +5,7 @@ ## Using Zed's built-in debugger -While the Zed project is open you can open the `New Process Modal` and select the `Debug` tab. There you can see to debug configurations to debug Zed with, one for GDB and one for LLDB. Select the configuration you want and Zed will build and launch the binary. +While the Zed project is open you can open the `New Process Modal` and select the `Debug` tab. There you can see two debug configurations to debug Zed with, one for GDB and one for LLDB. Select the configuration you want and Zed will build and launch the binary. Please note, GDB isn't supported on arm Macbooks diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md index a6799378bc..df3b840fa1 100644 --- a/docs/src/development/linux.md +++ b/docs/src/development/linux.md @@ -165,6 +165,58 @@ $ cargo heaptrack -b zed When this zed instance is exited, terminal output will include a command to run `heaptrack_interpret` to convert the `*.raw.zst` profile to a `*.zst` file which can be passed to `heaptrack_gui` for viewing. +## Perf recording + +How to get a flamegraph with resolved symbols from a running zed instance. Use +when zed is using a lot of CPU. Not useful for hangs. + +### During the incident + +- Find the PID (process ID) using: + `ps -eo size,pid,comm | grep zed | sort | head -n 1 | cut -d ' ' -f 2` + Or find the pid of the command zed-editor with the most ram usage in something + like htop/btop/top. + +- Install perf: + On Ubuntu (derivatives) run `sudo apt install linux-tools`. + +- Perf Record: + run `sudo perf record -p `, wait a few seconds to gather data then press Ctrl+C. You should now have a perf.data file + +- Make the output file user owned: + run `sudo chown $USER:$USER perf.data` + +- Get build info: + Run zed again and type `zed: about` in the command pallet to get the exact commit. + +The `data.perf` file can be send to zed together with the exact commit. + +### Later + +This can be done by Zed staff. + +- Build Zed with symbols: + Check out the commit found previously and modify `Cargo.toml`. + Apply the following diff then make a release build. + +```diff +[profile.release] +-debug = "limited" ++debug = "full" +``` + +- Add the symbols to perf database: + `pref buildid-cache -v -a ` + +- Resolve the symbols from the db: + `perf inject -i perf.data -o perf_with_symbols.data` + +- Install flamegraph: + `cargo install cargo-flamegraph` + +- Render the flamegraph: + `flamegraph --perfdata perf_with_symbols.data` + ## Troubleshooting ### Cargo errors claiming that a dependency is using unstable features diff --git a/docs/src/development/local-collaboration.md b/docs/src/development/local-collaboration.md index 87363a4269..393c6f0bbf 100644 --- a/docs/src/development/local-collaboration.md +++ b/docs/src/development/local-collaboration.md @@ -106,7 +106,7 @@ cat crates/collab/seed.default.json To use a different set of admin users, you can create your own version of that json file and export the `SEED_PATH` environment variable. Note that the usernames listed in the admins list currently must correspond to valid GitHub users. -```json +```json [settings] { "admins": ["admin1", "admin2"], "channels": ["zed"] @@ -196,7 +196,7 @@ By default Zed assumes that the DATABASE_URL is a Postgres database, but you can To authenticate you must first configure the server by creating a seed.json file that contains at a minimum your github handle. This will be used to create the user on demand. -```json +```json [settings] { "admins": ["nathansobo"] } diff --git a/docs/src/development/release-notes.md b/docs/src/development/release-notes.md new file mode 100644 index 0000000000..90e1ad21b1 --- /dev/null +++ b/docs/src/development/release-notes.md @@ -0,0 +1,29 @@ +# Release Notes + +Whenever you open a pull request, the body is automatically populated based on this [pull request template](https://github.com/zed-industries/zed/blob/main/.github/pull_request_template.md). + +```md +... + +Release Notes: + +- N/A _or_ Added/Fixed/Improved ... +``` + +On Wednesdays, we run a [`get-preview-channel-changes`](https://github.com/zed-industries/zed/blob/main/script/get-preview-channel-changes) script that scrapes `Release Notes` lines from pull requests landing in preview, as documented in our [Release](https://zed.dev/docs/development/release-notes) docs. + +The script outputs everything below the `Release Notes` line, including additional data such as the pull request author (if not a Zed team member) and a link to the pull request. +If you use `N/A`, the script skips your pull request entirely. + +## Guidelines for crafting your `Release Notes` line(s) + +- A `Release Notes` line should only be written if the user can see or feel the difference in Zed. +- A `Release Notes` line should be written such that a Zed user can understand what the change is. + Don't assume a user knows technical editor developer lingo; phrase your change in language they understand as a user of a text editor. +- If you want to include technical details about your pull request for other team members to see, do so above the `Release Notes` line. +- Changes to docs should be labeled as `N/A`. +- If your pull request adds/changes a setting or a keybinding, always mention that setting or keybinding. + Don't make the user dig into docs or the pull request to find this information (although it should be included in docs as well). +- For pull requests that are reverts: + - If the item being reverted **has already been shipped**, include a `Release Notes` line explaining why we reverted, as this is a breaking change. + - If the item being reverted **hasn't been shipped**, edit the original PR's `Release Notes` line to be `N/A`; otherwise, it will be included and the compiler of the release notes may not know to skip it, leading to a potentially-awkward situation where we are stating we shipped something we actually didn't. diff --git a/docs/src/development/releases.md b/docs/src/development/releases.md deleted file mode 100644 index 04190aeb9c..0000000000 --- a/docs/src/development/releases.md +++ /dev/null @@ -1,117 +0,0 @@ -# Zed Releases - -Read about Zed's [release channels here](https://zed.dev/faq#what-are-the-release-channels). - -## Wednesday Release Process - -You will need write access to the Zed repository to do this. - -Credentials for various services used in this process can be found in 1Password. - -Use the `releases` Slack channel to notify the team that releases will be starting. -This is mostly a formality on Wednesday's minor update releases, but can be beneficial when doing patch releases, as other devs may have landed fixes they'd like to cherry pick. - ---- - -1. Checkout `main` and ensure your working copy is clean. - -1. Run `git fetch && git pull` to ensure you have the latest commits locally. - -1. Run `git fetch --tags --force` to forcibly ensure your local tags are in sync with the remote. - -1. Run `./script/get-stable-channel-release-notes`. - - - Follow the instructions at the end of the script and aggregate the release notes into one structure. - -1. Run `./script/bump-zed-minor-versions`. - - - Push the tags and branches as instructed. - -1. Run `./script/get-preview-channel-changes`. - - - Take the script's output and build release notes by organizing each release note line into a category. - - Use a prior release for the initial outline. - - Make sure to append the `Credit` line, if present, to the end of the release note line. - -1. Once release drafts are up on [GitHub Releases](https://github.com/zed-industries/zed/releases), paste both preview and stable release notes into each and **save**. - - - **Do not publish the drafts!** - -1. Check the release assets. - - - Ensure the stable and preview release jobs have finished without error. - - Ensure each draft has the proper number of assets—releases currently have 10 assets each. - - Download the artifacts for each release draft and test that you can run them locally. - -1. Publish the drafts. - - - Publish stable and preview drafts, one at a time. - - Use [Vercel](https://vercel.com/zed-industries/zed-dev) to check the progress of the website rebuild. - The release will be public once the rebuild has completed. - -1. Post the stable release notes to social media. - - - Bluesky and X posts will already be built as drafts in [Buffer](https://buffer.com). - - Publish both, one at a time, ensuring both are posted to each respective platform. - -1. Send the stable release notes email. - - - The email broadcast will already be built as a draft in [Kit](https://kit.com). - -1. Build social media posts based on the popular items in preview. - - - Draft the copy in the [tweets](https://zed.dev/channel/tweets-23331) channel. - - Create the preview media (videos, screenshots). - - For features that you film videos around, try to create alternative photo-only versions to be used in the email, as videos and GIFs aren't great for email. - - Store all created media in `Feature Media` in our Google Drive. - - Build X and Bluesky post drafts (copy and media) in [Buffer](https://buffer.com), to be sent for next week's stable release. - - **Note: These are preview items and you may discover bugs.** - **This is a very good time to report these findings to the team!** - -1. Build email based on the popular items in preview. - - - You can reuse the copy and photo media from the preview social media posts. - - Create a draft email in [Kit](https://kit.com), to be sent for next week's stable release. - -## Patch Release Process - -If your PR fixes a panic or a crash, you should cherry-pick it to the current stable and preview branches. -If your PR fixes a regression in recently released code, you should cherry-pick it to preview. - -You will need write access to the Zed repository to do this: - ---- - -1. Send a PR containing your change to `main` as normal. - -1. Once it is merged, cherry-pick the commit locally to either of the release branches (`v0.XXX.x`). - - - In some cases, you may have to handle a merge conflict. - More often than not, this will happen when cherry-picking to stable, as the stable branch is more "stale" than the preview branch. - -1. After the commit is cherry-picked, run `./script/trigger-release {preview|stable}`. - This will bump the version numbers, create a new release tag, and kick off a release build. - - - This can also be run from the [GitHub Actions UI](https://github.com/zed-industries/zed/actions/workflows/bump_patch_version.yml): - ![](https://github.com/zed-industries/zed/assets/1486634/9e31ae95-09e1-4c7f-9591-944f4f5b63ea) - -1. Once release drafts are up on [GitHub Releases](https://github.com/zed-industries/zed/releases), proofread and edit the release notes as needed and **save**. - - - **Do not publish the drafts, yet.** - -1. Check the release assets. - - - Ensure the stable / preview release jobs have finished without error. - - Ensure each draft has the proper number of assets—releases currently have 10 assets each. - - Download the artifacts for each release draft and test that you can run them locally. - -1. Publish stable / preview drafts, one at a time. - - Use [Vercel](https://vercel.com/zed-industries/zed-dev) to check the progress of the website rebuild. - The release will be public once the rebuild has completed. - -## Nightly release process - -In addition to the public releases, we also have a nightly build that we encourage employees to use. -Nightly is released by cron once a day, and can be shipped as often as you'd like. -There are no release notes or announcements, so you can just merge your changes to main and run `./script/trigger-release nightly`. diff --git a/docs/src/development/windows.md b/docs/src/development/windows.md index ccbc17b708..17382e0bee 100644 --- a/docs/src/development/windows.md +++ b/docs/src/development/windows.md @@ -18,7 +18,7 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). If you can't compile Zed, make sure that you have at least the following components installed in case of a Visual Studio installation: -```json +```json [settings] { "version": "1.0", "components": [ @@ -36,7 +36,7 @@ If you can't compile Zed, make sure that you have at least the following compone Or if in case of just Build Tools, the following components: -```json +```json [settings] { "version": "1.0", "components": [ diff --git a/docs/src/diagnostics.md b/docs/src/diagnostics.md index 9603c8197c..47cc586008 100644 --- a/docs/src/diagnostics.md +++ b/docs/src/diagnostics.md @@ -8,7 +8,7 @@ By default, Zed displays all diagnostics as underlined text in the editor and th Editor diagnostics could be filtered with the -```json5 +```json [settings] "diagnostics_max_severity": null ``` @@ -16,7 +16,7 @@ editor setting (possible values: `"off"`, `"error"`, `"warning"`, `"info"`, `"hi The scrollbar ones are configured with the -```json5 +```json [settings] "scrollbar": { "diagnostics": "all", } @@ -32,7 +32,7 @@ Or, `editor::GoToDiagnostic` and `editor::GoToPreviousDiagnostic` could be used Zed supports showing diagnostic as lens to the right of the code. This is disabled by default, but can either be temporarily turned on (or off) using the editor menu, or permanently, using the -```json5 +```json [settings] "diagnostics": { "inline": { "enabled": true, @@ -49,7 +49,7 @@ Project panel can have its entries coloured based on the severity of the diagnos To configure, use -```json5 +```json [settings] "project_panel": { "show_diagnostics": "all", } @@ -61,7 +61,7 @@ configuration (possible values: `"off"`, `"errors"`, `"all"` (default)) Similar to the project panel, editor tabs can be colorized with the -```json5 +```json [settings] "tabs": { "show_diagnostics": "off", } diff --git a/docs/src/extensions.md b/docs/src/extensions.md index 5378222e56..627fe8f4c0 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -3,10 +3,12 @@ Zed lets you add new functionality using user-defined extensions. - [Installing Extensions](./extensions/installing-extensions.md) +- [Extension Capabilities](./extensions/capabilities.md) - [Developing Extensions](./extensions/developing-extensions.md) - [Developing Language Extensions](./extensions/languages.md) - [Developing Debugger Extensions](./extensions/debugger-extensions.md) - [Developing Themes](./extensions/themes.md) - [Developing Icon Themes](./extensions/icon-themes.md) - [Developing Slash Commands](./extensions/slash-commands.md) + - [Developing Agent Servers](./extensions/agent-servers.md) - [Developing MCP Servers](./extensions/mcp-extensions.md) diff --git a/docs/src/extensions/agent-servers.md b/docs/src/extensions/agent-servers.md new file mode 100644 index 0000000000..c8367a8418 --- /dev/null +++ b/docs/src/extensions/agent-servers.md @@ -0,0 +1,173 @@ +# Agent Server Extensions + +Agent Servers are programs that provide AI agent implementations through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com). +Agent Server Extensions let you package up an Agent Server so that users can install the extension and have your agent easily available to use in Zed. + +You can see the current Agent Server extensions either by opening the Extensions tab in Zed (execute the `zed: extensions` command) and changing the filter from `All` to `Agent Servers`, or by visiting [the Zed website](https://zed.dev/extensions?filter=agent-servers). + +## Defining Agent Server Extensions + +An extension can register one or more agent servers in the `extension.toml` like so: + +```toml +[agent_servers.my-agent] +name = "My Agent" + +[agent_servers.my-agent.targets.darwin-aarch64] +archive = "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.tar.gz" +cmd = "./agent" +args = ["--serve"] + +[agent_servers.my-agent.targets.linux-x86_64] +archive = "https://github.com/owner/repo/releases/download/v1.0.0/agent-linux-x64.tar.gz" +cmd = "./agent" +args = ["--serve"] + +[agent_servers.my-agent.targets.windows-x86_64] +archive = "https://github.com/owner/repo/releases/download/v1.0.0/agent-windows-x64.zip" +cmd = "./agent.exe" +args = ["--serve"] +``` + +### Required Fields + +- `name`: A human-readable display name for the agent server (shown in menus) +- `targets`: Platform-specific configurations for downloading and running the agent + +### Target Configuration + +Each target key uses the format `{os}-{arch}` where: + +- **os**: `darwin` (macOS), `linux`, or `windows` +- **arch**: `aarch64` (ARM64) or `x86_64` + +Each target must specify: + +- `archive`: URL to download the archive from (supports `.tar.gz`, `.zip`, etc.) +- `cmd`: Command to run the agent server (relative to the extracted archive) +- `args`: Command-line arguments to pass to the agent server (optional) +- `sha256`: SHA-256 hash string of the archive's bytes (optional, but recommended for security) +- `env`: Environment variables specific to this target (optional, overrides agent-level env vars with the same name) + +### Optional Fields + +You can also optionally specify at the agent server level: + +- `env`: Environment variables to set in the agent's spawned process. These apply to all targets by default. +- `icon`: Path to an SVG icon (relative to extension root) for display in menus. + +### Environment Variables + +Environment variables can be configured at two levels: + +1. **Agent-level** (`[agent_servers.my-agent.env]`): Variables that apply to all platforms +2. **Target-level** (`[agent_servers.my-agent.targets.{platform}.env]`): Variables specific to a platform + +When both are specified, target-level environment variables override agent-level variables with the same name. Variables defined only at the agent level are inherited by all targets. + +### Complete Example + +Here's a more complete example with all optional fields: + +```toml +[agent_servers.example-agent] +name = "Example Agent" +icon = "icon/agent.svg" + +[agent_servers.example-agent.env] +AGENT_LOG_LEVEL = "info" +AGENT_MODE = "production" + +[agent_servers.example-agent.targets.darwin-aarch64] +archive = "https://github.com/example/agent/releases/download/v2.0.0/agent-darwin-arm64.tar.gz" +cmd = "./bin/agent" +args = ["serve", "--port", "8080"] +sha256 = "abc123def456..." + +[agent_servers.example-agent.targets.linux-x86_64] +archive = "https://github.com/example/agent/releases/download/v2.0.0/agent-linux-x64.tar.gz" +cmd = "./bin/agent" +args = ["serve", "--port", "8080"] +sha256 = "def456abc123..." + +[agent_servers.example-agent.targets.linux-x86_64.env] +AGENT_MEMORY_LIMIT = "2GB" # Linux-specific override +``` + +## Installation Process + +When a user installs your extension and selects the agent server: + +1. Zed downloads the appropriate archive for the user's platform +2. The archive is extracted to a cache directory +3. Zed launches the agent using the specified command and arguments +4. Environment variables are set as configured +5. The agent server runs in the background, ready to assist the user + +Archives are cached locally, so subsequent launches are fast. + +## Distribution Best Practices + +### Use GitHub Releases + +GitHub Releases are a reliable way to distribute agent server binaries: + +1. Build your agent for each platform (macOS ARM64, macOS x86_64, Linux x86_64, Windows x86_64) +2. Package each build as a compressed archive (`.tar.gz` or `.zip`) +3. Create a GitHub release and upload the archives +4. Use the release URLs in your `extension.toml` + +## SHA-256 Hashes + +It's good for security to include SHA-256 hashes of your archives in `extension.toml`. Here's how to generate it: + +### macOS and Linux + +```bash +shasum -a 256 agent-darwin-arm64.tar.gz +``` + +### Windows + +```bash +certutil -hashfile agent-windows-x64.zip SHA256 +``` + +Then add that string to your target configuration: + +```toml +[agent_servers.my-agent.targets.darwin-aarch64] +archive = "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.tar.gz" +cmd = "./agent" +sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +``` + +## Testing + +To test your Agent Server Extension: + +1. [Install it as a dev extension](./developing-extensions.md#developing-an-extension-locally) +2. Open the [Agent Panel](../ai/agent-panel.md) +3. Select your Agent Server from the list +4. Verify that it downloads, installs, and launches correctly +5. Test its functionality by conversing with it and watching the [ACP logs](../ai/external-agents.md#debugging-agents) + +## Icon Guideline + +In case your agent server has a logo, we highly recommend adding it as an SVG icon. +For optimal display, follow these guidelines: + +- Make sure you resize your SVG to fit a 16x16 bounding box, with a padding of around one or two pixels +- Ensure you have a clean SVG code by processing it through [SVGOMG](https://jakearchibald.github.io/svgomg/) +- Avoid including icons with gradients as they will often make the SVG more complicated and possibly not render perfectly + +Note that we'll automatically convert your icon to monochrome to preserve Zed's design consistency. +(You can still use opacity in different paths of your SVG to add visual layering.) + +--- + +This is all you need to distribute an agent server through Zed's extension system! + +## Publishing + +Once your extension is ready, see [Publishing your extension](./developing-extensions.md#publishing-your-extension) to learn how to submit it to the Zed extension registry. diff --git a/docs/src/extensions/capabilities.md b/docs/src/extensions/capabilities.md new file mode 100644 index 0000000000..4d935a0725 --- /dev/null +++ b/docs/src/extensions/capabilities.md @@ -0,0 +1,96 @@ +# Extension Capabilities + +The operations that Zed extensions are able to perform are governed by a capability system. + +## Restricting capabilities + +As a user, you have the option of restricting the capabilities that are granted to extensions. + +This is controlled via the `granted_extension_capabilities` setting. + +Restricting or removing a capability will cause an error to be returned when an extension attempts to call the corresponding extension API without sufficient capabilities. + +For instance, if you wanted to restrict downloads to just files from GitHub, you could modify `host` for the `download_file` capability: + +```diff +{ + "granted_extension_capabilities": [ + { "kind": "process:exec", "command": "*", "args": ["**"] }, +- { "kind": "download_file", "host": "*", "path": ["**"] }, ++ { "kind": "download_file", "host": "github.com", "path": ["**"] }, + { "kind": "npm:install", "package": "*" } + ] +} +``` + +If you don't want extensions to be able to perform _any_ capabilities, you can remove all granted capabilities: + +```json +{ + "granted_extension_capabilities": [] +} +``` + +> Note that this will likely make many extensions non-functional, at least in their default configuration. + +## Capabilities + +### `process:exec` + +The `process:exec` capability grants extensions the ability to invoke commands using [`zed_extension_api::process::Command`](https://docs.rs/zed_extension_api/latest/zed_extension_api/process/struct.Command.html). + +#### Examples + +To allow any command to be executed with any arguments: + +```toml +{ kind = "process:exec", command = "*", args = ["**"] } +``` + +To allow a specific command (e.g., `gem`) to be executed with any arguments: + +```toml +{ kind = "process:exec", command = "gem", args = ["**"] } +``` + +### `download_file` + +The `download_file` capability grants extensions the ability to download files using [`zed_extension_api::download_file`](https://docs.rs/zed_extension_api/latest/zed_extension_api/fn.download_file.html). + +#### Examples + +To allow any file to be downloaded: + +```toml +{ kind = "download_file", host = "github.com", path = ["**"] } +``` + +To allow any file to be downloaded from `github.com`: + +```toml +{ kind = "download_file", host = "github.com", path = ["**"] } +``` + +To allow any file to be downloaded from a specific GitHub repository: + +```toml +{ kind = "download_file", host = "github.com", path = ["zed-industries", "zed", "**"] } +``` + +### `npm:install` + +The `npm:install` capability grants extensions the ability to install npm packages using [`zed_extension_api::npm_install_package`](https://docs.rs/zed_extension_api/latest/zed_extension_api/fn.npm_install_package.html). + +#### Examples + +To allow any npm package to be installed: + +```toml +{ kind = "npm:install", package = "*" } +``` + +To allow a specific npm package (e.g., `typescript`) to be installed: + +```toml +{ kind = "npm:install", package = "typescript" } +``` diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index ae801dd9da..dc8a693291 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -1,8 +1,8 @@ # Developing Extensions -## Extension Capabilities +## Extension Features -Extensions can add the following capabilities to Zed: +Extensions are able to provide the following features to Zed: - [Languages](./languages.md) - [Debuggers](./debugger-extensions.md) @@ -23,11 +23,7 @@ From the extensions page, click the `Install Dev Extension` button (or the {#act If you need to troubleshoot, you can check the Zed.log ({#action zed::OpenLog}) for additional output. For debug output, close and relaunch zed with the `zed --foreground` from the command line which show more verbose INFO level logging. -If you already have a published extension with the same name installed, your dev extension will override it. - -After installing the `Extensions` page will indicate that that the upstream extension is "Overridden by dev extension". - -Pre-installed extensions with the same name have to be uninstalled before installing the dev extension. See [#31106](https://github.com/zed-industries/zed/issues/31106) for more. +If you already have the published version of the extension installed, the published version will be uninstalled prior to the installation of the dev extension. After successful installation, the `Extensions` page will indicate that the upstream extension is "Overridden by dev extension". ## Directory Structure of a Zed Extension @@ -115,10 +111,13 @@ git submodule update ## Extension License Requirements -As of October 1st, 2025, extension repositories must include one of the following licenses: +As of October 1st, 2025, extension repositories must include a license. +The following licenses are accepted: -- [MIT](https://opensource.org/license/mit) - [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) +- [BSD 3-Clause](https://opensource.org/license/bsd-3-clause) +- [GNU GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html) +- [MIT](https://opensource.org/license/mit) This allows us to distribute the resulting binary produced from your extension code to our users. Without a valid license, the pull request to add or update your extension in the following steps will fail CI. @@ -166,7 +165,15 @@ To update an extension, open a PR to [the `zed-industries/extensions` repo](http In your PR do the following: -1. Update the extension's submodule to the commit of the new version. +1. Update the extension's submodule to the commit of the new version. For this, you can run + +```sh +# From the root of the repository: +git submodule update --remote extensions/your-extension-name +``` + +to update your extension to the latest commit available in your remote repository. + 2. Update the `version` field for the extension in `extensions.toml` - Make sure the `version` matches the one set in `extension.toml` at the particular commit. diff --git a/docs/src/extensions/icon-themes.md b/docs/src/extensions/icon-themes.md index a76f03d068..676cae59cd 100644 --- a/docs/src/extensions/icon-themes.md +++ b/docs/src/extensions/icon-themes.md @@ -11,13 +11,13 @@ The [Material Icon Theme](https://github.com/zed-extensions/material-icon-theme) There are two important directories for an icon theme extension: - `icon_themes`: This directory will contain one or more JSON files containing the icon theme definitions. -- `icons`: This directory contains the icons assets that will be distributed with the extension. You can created subdirectories in this directory, if so desired. +- `icons`: This directory contains the icon assets that will be distributed with the extension. You can created subdirectories in this directory, if so desired. Each icon theme file should adhere to the JSON schema specified at [`https://zed.dev/schema/icon_themes/v0.3.0.json`](https://zed.dev/schema/icon_themes/v0.3.0.json). Here is an example of the structure of an icon theme: -```json +```json [icon-theme] { "$schema": "https://zed.dev/schema/icon_themes/v0.3.0.json", "name": "My Icon Theme", @@ -34,8 +34,8 @@ Here is an example of the structure of an icon theme: "stylesheets": { "collapsed": "./icons/folder-stylesheets.svg", "expanded": "./icons/folder-stylesheets-open.svg" - }, - } + } + }, "chevron_icons": { "collapsed": "./icons/chevron-right.svg", "expanded": "./icons/chevron-down.svg" diff --git a/docs/src/extensions/installing-extensions.md b/docs/src/extensions/installing-extensions.md index 801fe5c55c..d9573556f0 100644 --- a/docs/src/extensions/installing-extensions.md +++ b/docs/src/extensions/installing-extensions.md @@ -8,6 +8,7 @@ Here you can view the extensions that you currently have installed or search and - On macOS, extensions are installed in `~/Library/Application Support/Zed/extensions`. - On Linux, they are installed in either `$XDG_DATA_HOME/zed/extensions` or `~/.local/share/zed/extensions`. +- On Windows, the directory is `%LOCALAPPDATA%\Zed\extensions`. This directory contains two subdirectories: diff --git a/docs/src/extensions/languages.md b/docs/src/extensions/languages.md index 5c63b880c8..f3ffcd71ba 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -154,6 +154,14 @@ This query identifies opening and closing brackets, braces, and quotation marks. | @open | Captures opening brackets, braces, and quotes | | @close | Captures closing brackets, braces, and quotes | +Zed uses these to highlight matching brackets: painting each bracket pair with a different color ("rainbow brackets") and highlighting the brackets if the cursor is inside the bracket pair. + +To opt out of rainbow brackets colorization, add the following to the corresponding `brackets.scm` entry: + +```scheme +(("\"" @open "\"" @close) (#set! rainbow.exclude)) +``` + ### Code outline/structure The `outline.scm` file defines the structure for the code outline. @@ -324,7 +332,7 @@ This query marks number and string values in key-value pairs and arrays for reda The `runnables.scm` file defines rules for detecting runnable code. -Here's an example from an `runnables.scm` file for JSON: +Here's an example from a `runnables.scm` file for JSON: ```scheme ( diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 22af3b36d7..77bf9cef30 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -1,88 +1,19 @@ # Getting Started -Welcome to Zed! We are excited to have you. Here is a jumping-off point to getting started. +Welcome to Zed! We are excited to have you. Zed is a powerful multiplayer code editor designed to stay out of your way and help you build what's next. -## Download Zed +## Key Features -### macOS +- [Smooth Editing](./configuring-zed.md): Built in Rust, Zed is responsive and intuitive, with a minimalistic aesthetic and pixel-level editing controls. +- [Agentic Editing](./ai/overview.md): Use Zed's hosted models to collaborate with agents directly in an IDE. You can also plug into a third-party agent or bring your own keys. +- [Debugger](./debugger.md): Debug your code in seconds, not hours, with minimal setup required. +- [Remote Development](./remote-development.md): Offload the heavy lifting to the cloud, so you can focus on writing code. +- [Extensions](./extensions.md): Leverage Zed's extensions to customize how you work. -Get the latest stable builds via [the download page](https://zed.dev/download). If you want to download our preview build, you can find it on its [releases page](https://zed.dev/releases/preview). After the first manual installation, Zed will periodically check for install updates. +## Join the Zed Community -You can also install Zed stable via Homebrew: +Zed is proudly open source, and we get better with every contribution. Join us on GitHub or in Discord to contribute code, report bugs, or suggest features. -```sh -brew install --cask zed -``` - -As well as Zed preview: - -```sh -brew install --cask zed@preview -``` - -### Linux - -For most Linux users, the easiest way to install Zed is through our installation script: - -```sh -curl -f https://zed.dev/install.sh | sh -``` - -If you'd like to help us test our new features, you can also install our preview build: - -```sh -curl -f https://zed.dev/install.sh | ZED_CHANNEL=preview sh -``` - -This script supports `x86_64` and `AArch64`, as well as common Linux distributions: Ubuntu, Arch, Debian, RedHat, CentOS, Fedora, and more. - -If Zed is installed using this installation script, it can be uninstalled at any time by running the shell command `zed --uninstall`. The shell will then prompt you whether you'd like to keep your preferences or delete them. After making a choice, you should see a message that Zed was successfully uninstalled. - -If this script is insufficient for your use case, you run into problems running Zed, or there are errors in uninstalling Zed, please see our [Linux-specific documentation](./linux.md). - -## Command Palette - -The Command Palette is the main way to access pretty much any functionality that's available in Zed. Its keybinding is the first one you should make yourself familiar with. To open it, hit: {#kb command_palette::Toggle}. - -![The opened Command Palette](https://zed.dev/img/features/command-palette.jpg) - -Try it! Open the Command Palette and type in `new file`. You should see the list of commands being filtered down to `workspace: new file`. Hit return and you end up with a new buffer. - -Any time you see instructions that include commands of the form `zed: ...` or `editor: ...` and so on that means you need to execute them in the Command Palette. - -## CLI - -Zed has a CLI, on Linux this should come with the distribution's Zed package (binary name can vary from distribution to distribution, `zed` will be used later for brevity). -For macOS, the CLI comes in the same package with the editor binary, and could be installed into the system with the `cli: install` Zed command which will create a symlink to the `/usr/local/bin/zed`. -It can also be built from source out of the `cli` crate in this repository. - -Use `zed --help` to see the full list of capabilities. -General highlights: - -- Opening another empty Zed window: `zed` - -- Opening a file or directory in Zed: `zed /path/to/entry` (use `-n` to open in the new window) - -- Reading from stdin: `ps axf | zed -` - -- Starting Zed with logs in the terminal: `zed --foreground` - -- Uninstalling Zed and all its related files: `zed --uninstall` - -## Configure Zed - -To open your custom settings to set things like fonts, formatting settings, per-language settings, and more, use the {#kb zed::OpenSettings} keybinding. - -To see all available settings, open the Command Palette with {#kb command_palette::Toggle} and search for `zed: open default settings`. -You can also check them all out in the [Configuring Zed](./configuring-zed.md) documentation. - -## Configure AI in Zed - -Zed smoothly integrates LLMs in multiple ways across the editor. -Visit [the AI overview page](./ai/overview.md) to learn how to quickly get started with LLMs on Zed. - -## Set up your key bindings - -To edit your custom keymap and add or remap bindings, you can either use {#kb zed::OpenKeymapEditor} to spawn the Zed Keymap Editor ({#action zed::OpenKeymapEditor}) or you can directly open your Zed Keymap json (`~/.config/zed/keymap.json`) with {#action zed::OpenKeymap}. - -To access the default key binding set, open the Command Palette with {#kb command_palette::Toggle} and search for "zed: open default keymap". See [Key Bindings](./key-bindings.md) for more info. +- [Join Discord](https://discord.com/invite/zedindustries) +- [GitHub Discussions](https://github.com/zed-industries/zed/discussions) +- [Zed Reddit](https://www.reddit.com/r/ZedEditor) diff --git a/docs/src/git.md b/docs/src/git.md index f40040bec8..8a94a79973 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -17,6 +17,7 @@ Here's an overview of all currently supported features: - Git status in the Project Panel - Branch creating and switching - Git blame viewing +- Git stash pop, apply, drop and view ## Git Panel @@ -28,6 +29,23 @@ In the panel you can see the state of your project at a glance—which repositor Zed monitors your repository so that changes you make on the command line are instantly reflected. +### Configuration + +You can configure how Zed hard wraps commit messages with the `preferred-line-length` setting of the "Git Commit" language. The default is `72`, but it can be set to any number of characters `0` or more. + +The Git Panel also allows configuring the `soft_wrap` setting to adjust how commit messages display while you are typing them in the Git Panel. The default setting is `editor_width`, however, `none`, `preferred_line_length`, and `bounded` are also options. + +#### Example + +```json +"languages": { + "Git Commit": { + "soft_wrap": "editor_width", + "preferred_line_length": 72 + }, +} +``` + ## Project Diff You can see all of the changes captured by Git in Zed by opening the Project Diff ({#kb git::Diff}), accessible via the {#action git::Diff} action in the Command Palette or the Git Panel. @@ -74,6 +92,47 @@ Zed offers two commit textareas: As soon as you commit in Zed, in the Git Panel, you'll see a bar right under the commit textarea, which will show the recently submitted commit. In there, you can use the "Uncommit" button, which performs the `git reset HEADˆ--soft` command. +### Configuring Commit Line Length + +By default, Zed sets the commit line length to `72` but it can be configured in your local `settings.json` file. + +Find more information about setting the `preferred-line-length` in the [Configuration](#configuration) section. + +## Stashing + +Git stash allows you to temporarily save your uncommitted changes and revert your working directory to a clean state. This is particularly useful when you need to quickly switch branches or pull updates without committing incomplete work. + +### Creating Stashes + +To stash all your current changes, use the {#action git::StashAll} action. This will save both staged and unstaged changes to a new stash entry and clean your working directory. + +### Managing Stashes + +Zed provides a comprehensive stash picker accessible via {#action git::ViewStash}. From the stash picker, you can: + +- **View stash list**: Browse all your saved stashes with their descriptions and timestamps +- **Open diffs**: See exactly what changes are stored in each stash +- **Apply stashes**: Apply stash changes to your working directory while keeping the stash entry +- **Pop stashes**: Apply stash changes and remove the stash entry from the list +- **Drop stashes**: Delete unwanted stash entries without applying them + +### Quick Stash Operations + +For faster workflows, Zed provides direct actions to work with the most recent stash: + +- **Apply latest stash**: Use {#action git::StashApply} to apply the most recent stash without removing it +- **Pop latest stash**: Use {#action git::StashPop} to apply and remove the most recent stash + +### Stash Diff View + +When viewing a specific stash in the diff view, you have additional options available through the interface: + +- Apply the current stash to your working directory +- Pop the current stash (apply and remove) +- Remove the stash without applying changes + +To open the stash diff view, select a stash from the stash picker and use the {#action stash_picker::ShowStashItem} ({#kb stash_picker::ShowStashItem}) keybinding. + ## AI Support in Git Zed currently supports LLM-powered commit message generation. @@ -83,10 +142,9 @@ You can ask AI to generate a commit message by focusing on the message editor wi You can specify your preferred model to use by providing a `commit_message_model` agent setting. See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) for more information. -```json +```json [settings] { "agent": { - "version": "2", "commit_message_model": { "provider": "anthropic", "model": "claude-3-5-haiku" @@ -110,6 +168,20 @@ Zed currently supports links to the hosted versions of [SourceHut](https://sr.ht) and [Codeberg](https://codeberg.org). +For self-hosted GitHub, GitLab, or Bitbucket instances, add them to the `git_hosting_providers` setting so commit hashes and permalinks resolve to your domain: + +```json [settings] +{ + "git_hosting_providers": [ + { + "provider": "gitlab", + "name": "Corp GitLab", + "base_url": "https://git.example.corp" + } + ] +} +``` + Zed also has a Copy Permalink feature to create a permanent link to a code snippet on your Git hosting service. These links are useful for sharing a specific line or range of lines in a file at a specific commit. Trigger this action via the [Command Palette](./getting-started.md#command-palette) (search for `permalink`), @@ -143,6 +215,7 @@ When viewing files with changes, Zed displays diff hunks that can be expanded or | {#action git::Push} | {#kb git::Push} | | {#action git::ForcePush} | {#kb git::ForcePush} | | {#action git::Pull} | {#kb git::Pull} | +| {#action git::PullRebase} | {#kb git::PullRebase} | | {#action git::Fetch} | {#kb git::Fetch} | | {#action git::Diff} | {#kb git::Diff} | | {#action git::Restore} | {#kb git::Restore} | @@ -151,6 +224,10 @@ When viewing files with changes, Zed displays diff hunks that can be expanded or | {#action git::Switch} | {#kb git::Switch} | | {#action git::CheckoutBranch} | {#kb git::CheckoutBranch} | | {#action git::Blame} | {#kb git::Blame} | +| {#action git::StashAll} | {#kb git::StashAll} | +| {#action git::StashPop} | {#kb git::StashPop} | +| {#action git::StashApply} | {#kb git::StashApply} | +| {#action git::ViewStash} | {#kb git::ViewStash} | | {#action editor::ToggleGitBlameInline} | {#kb editor::ToggleGitBlameInline} | | {#action editor::ExpandAllDiffHunks} | {#kb editor::ExpandAllDiffHunks} | | {#action editor::ToggleSelectedDiffHunks} | {#kb editor::ToggleSelectedDiffHunks} | diff --git a/docs/src/globs.md b/docs/src/globs.md index 4039d7c455..2f86fb9158 100644 --- a/docs/src/globs.md +++ b/docs/src/globs.md @@ -53,11 +53,11 @@ If instead you wanted to restrict yourself only to [Zed Language-Specific Docume ### Implicit Wildcards -When using the "Include" / "Exclude" filters on a Project Search each glob is wrapped in implicit wildcards. For example to exclude any files with license in the path or filename from your search just type type `license` in the exclude box. Behind the scenes Zed transforms `license` to `**license**`. This means that files named `license.*`, `*.license` or inside a `license` subdirectory will all be filtered out. This enables users to easily filter for `*.ts` without having to remember to type `**/*.ts` every time. +When using the "Include" / "Exclude" filters on a Project Search each glob is wrapped in implicit wildcards. For example to exclude any files with license in the path or filename from your search just type `license` in the exclude box. Behind the scenes Zed transforms `license` to `**license**`. This means that files named `license.*`, `*.license` or inside a `license` subdirectory will all be filtered out. This enables users to easily filter for `*.ts` without having to remember to type `**/*.ts` every time. Alternatively, if in your Zed settings you wanted a [`file_types`](./configuring-zed.md#file-types) override which only applied to a certain directory you must explicitly include the wildcard globs. For example, if you had a directory of template files with the `html` extension that you wanted to recognize as Jinja2 template you could use the following: -```json +```json [settings] { "file_types": { "C++": ["[cC]"], diff --git a/docs/src/icon-themes.md b/docs/src/icon-themes.md index 70dd1267ac..72fc51b834 100644 --- a/docs/src/icon-themes.md +++ b/docs/src/icon-themes.md @@ -4,21 +4,23 @@ Zed comes with a built-in icon theme, with more icon themes available as extensi ## Selecting an Icon Theme -See what icon themes are installed and preview them via the Icon Theme Selector, which you can open from the command palette with "icon theme selector: toggle". +See what icon themes are installed and preview them via the Icon Theme Selector, which you can open from the command palette with `icon theme selector: toggle`. Navigating through the icon theme list by moving up and down will change the icon theme in real time and hitting enter will save it to your settings file. ## Installing more Icon Themes -More icon themes are available from the Extensions page, which you can access via the command palette with "zed: Extensions" or the [Zed website](https://zed.dev/extensions). +More icon themes are available from the Extensions page, which you can access via the command palette with `zed: extensions` or the [Zed website](https://zed.dev/extensions?filter=icon-themes). ## Configuring Icon Themes -Your selected icon theme is stored in your settings file. You can open your settings file from the command palette with "zed: open settings" (bound to `cmd-,` on macOS and `ctrl-,` on Linux). +Your selected icon theme is stored in your settings file. +You can open your settings file from the command palette with {#action zed::OpenSettingsFile} (bound to {#kb zed::OpenSettingsFile}). -Just like with themes, Zed allows for configuring different icon themes for light and dark mode. You can set the mode to `"light"` or `"dark"` to ignore the current system mode. +Just like with themes, Zed allows for configuring different icon themes for light and dark mode. +You can set the mode to `"light"` or `"dark"` to ignore the current system mode. -```json +```json [settings] { "icon_theme": { "mode": "system", diff --git a/docs/src/installation.md b/docs/src/installation.md new file mode 100644 index 0000000000..7d2009e3a0 --- /dev/null +++ b/docs/src/installation.md @@ -0,0 +1,115 @@ +# Installing Zed + +## Download Zed + +### macOS + +Get the latest stable builds via [the download page](https://zed.dev/download). If you want to download our preview build, you can find it on its [releases page](https://zed.dev/releases/preview). After the first manual installation, Zed will periodically check for install updates. + +You can also install Zed stable via Homebrew: + +```sh +brew install --cask zed +``` + +As well as Zed preview: + +```sh +brew install --cask zed@preview +``` + +### Windows + +Get the latest stable builds via [the download page](https://zed.dev/download). If you want to download our preview build, you can find it on its [releases page](https://zed.dev/releases/preview). After the first manual installation, Zed will periodically check for install updates. + +Additionally, you can install Zed using winget: + +```sh +winget install -e --id ZedIndustries.Zed +``` + +### Linux + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +If you'd like to help us test our new features, you can also install our preview build: + +```sh +curl -f https://zed.dev/install.sh | ZED_CHANNEL=preview sh +``` + +This script supports `x86_64` and `AArch64`, as well as common Linux distributions: Ubuntu, Arch, Debian, RedHat, CentOS, Fedora, and more. + +If Zed is installed using this installation script, it can be uninstalled at any time by running the shell command `zed --uninstall`. The shell will then prompt you whether you'd like to keep your preferences or delete them. After making a choice, you should see a message that Zed was successfully uninstalled. + +If this script is insufficient for your use case, you run into problems running Zed, or there are errors in uninstalling Zed, please see our [Linux-specific documentation](./linux.md). + +## System Requirements + +### macOS + +Zed supports the follow macOS releases: + +| Version | Codename | Apple Status | Zed Status | +| ------------- | -------- | -------------- | ------------------- | +| macOS 26.x | Tahoe | Supported | Supported | +| macOS 15.x | Sequoia | Supported | Supported | +| macOS 14.x | Sonoma | Supported | Supported | +| macOS 13.x | Ventura | Supported | Supported | +| macOS 12.x | Monterey | EOL 2024-09-16 | Supported | +| macOS 11.x | Big Sur | EOL 2023-09-26 | Partially Supported | +| macOS 10.15.x | Catalina | EOL 2022-09-12 | Partially Supported | + +The macOS releases labelled "Partially Supported" (Big Sur and Catalina) do not support screen sharing via Zed Collaboration. These features use the [LiveKit SDK](https://livekit.io) which relies upon [ScreenCaptureKit.framework](https://developer.apple.com/documentation/screencapturekit/) only available on macOS 12 (Monterey) and newer. + +#### Mac Hardware + +Zed supports machines with Intel (x86_64) or Apple (aarch64) processors that meet the above macOS requirements: + +- MacBook Pro (Early 2015 and newer) +- MacBook Air (Early 2015 and newer) +- MacBook (Early 2016 and newer) +- Mac Mini (Late 2014 and newer) +- Mac Pro (Late 2013 or newer) +- iMac (Late 2015 and newer) +- iMac Pro (all models) +- Mac Studio (all models) + +### Linux + +Zed supports 64-bit Intel/AMD (x86_64) and 64-bit Arm (aarch64) processors. + +Zed requires a Vulkan 1.3 driver and the following desktop portals: + +- `org.freedesktop.portal.FileChooser` +- `org.freedesktop.portal.OpenURI` +- `org.freedesktop.portal.Secret` or `org.freedesktop.Secrets` + +### Windows + +Zed supports the following Windows releases: +| Version | Zed Status | +| ------------------------- | ------------------- | +| Windows 11, version 22H2 and later | Supported | +| Windows 10, version 1903 and later | Supported | + +A 64-bit operating system is required to run Zed. + +#### Windows Hardware + +Zed supports machines with x64 (Intel, AMD) or Arm64 (Qualcomm) processors that meet the following requirements: + +- Graphics: A GPU that supports DirectX 11 (most PCs from 2012+). +- Driver: Current NVIDIA/AMD/Intel/Qualcomm driver (not the Microsoft Basic Display Adapter). + +### FreeBSD + +Not yet available as an official download. Can be built [from source](./development/freebsd.md). + +### Web + +Not supported at this time. See our [Platform Support issue](https://github.com/zed-industries/zed/issues/5391). diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index e8ddbc46b2..f0f1e472c7 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -2,9 +2,9 @@ Zed has a very customizable key binding system—you can tweak everything to work exactly how your fingers expect! -## Predefined keymaps +## Predefined Keymaps -If you're used to a specific editor's defaults, you can set a `base_keymap` in your [settings file](./configuring-zed.md). +If you're used to a specific editor's defaults, you can change your `base_keymap` through the settings window ({#kb zed::OpenSettings}) or directly through your `settings.json` file ({#kb zed::OpenSettingsFile}). We currently support: - VS Code (default) @@ -21,20 +21,37 @@ This setting can also be changed via the command palette through the `zed: toggl You can also enable `vim_mode` or `helix_mode`, which add modal bindings. For more information, see the documentation for [Vim mode](./vim.md) and [Helix mode](./helix.md). -## User keymaps +## Keymap Editor -Zed reads your keymap from `~/.config/zed/keymap.json`, which you can open with the {#action zed::OpenKeymap} action from the command palette. -You can also edit your keymap through the Zed Keymap Editor, accessible via the {#action zed::OpenKeymapEditor} action or the {#kb zed::OpenKeymapEditor} keybinding. +You can access the keymap editor through the {#kb zed::OpenKeymap} action or by running {#action zed::OpenKeymap} action from the command palette. You can easily add or change a keybind for an action with the `Change Keybinding` or `Add Keybinding` button on the command pallets left bottom corner. -The `keymap.json` file contains a JSON array of objects with `"bindings"`. If no `"context"` is set, the bindings are always active. If it is set, the binding is only active when the [context matches](#contexts). +In there, you can see all of the existing actions in Zed as well as the associated keybindings set to them by default. -Within each binding section, a [key sequence](#keybinding-syntax) is mapped to [an action](#actions). If conflicts are detected, they are resolved as [described below](#precedence). +You can also customize them right from there, either by clicking on the pencil icon that appears when you hover over a particular action, by double-clicking on the action row, or by pressing the `enter` key. + +Anything that you end up doing on the keymap editor also gets reflected on the `keymap.json` file. + +## User Keymaps + +The keymap file is stored in the following locations for each platform: + +- macOS/Linux: `~/.config/zed/keymap.json` +- Windows: `~\AppData\Roaming\Zed/keymap.json` + +You can open the keymap with the {#action zed::OpenKeymapFile} action from the command palette. + +This file contains a JSON array of objects with `"bindings"`. +If no `"context"` is set, the bindings are always active. +If it is set, the binding is only active when the [context matches](#contexts). + +Within each binding section, a [key sequence](#keybinding-syntax) is mapped to [an action](#actions). +If conflicts are detected, they are resolved as [described below](#precedence). If you are using a non-QWERTY, Latin-character keyboard, you may want to set `use_key_equivalents` to `true`. See [Non-QWERTY keyboards](#non-qwerty-keyboards) for more information. For example: -```json +```json [keymap] [ { "bindings": { @@ -51,11 +68,16 @@ For example: ] ``` -You can see all of Zed's default bindings in the default keymaps for [macOS](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-macos.json) or [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json). +You can see all of Zed's default bindings for each platform in the default keymaps files: -If you want to debug problems with custom keymaps, you can use `dev: Open Key Context View` from the command palette. Please file [an issue](https://github.com/zed-industries/zed) if you run into something you think should work but isn't. +- [macOS](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-macos.json) +- [Windows](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-windows.json) +- [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json). -### Keybinding syntax +If you want to debug problems with custom keymaps, you can use `dev: Open Key Context View` from the command palette. +Please file [an issue](https://github.com/zed-industries/zed) if you run into something you think should work but isn't. + +### Keybinding Syntax Zed has the ability to match against not just a single keypress, but a sequence of keys typed in order. Each key in the `"bindings"` map is a sequence of keypresses separated with a space. @@ -72,7 +94,7 @@ The keys can be any single Unicode codepoint that your keyboard generates (for e A few examples: -```json +```json [settings] "bindings": { "cmd-k cmd-s": "zed::OpenKeymap", // matches ⌘-k then ⌘-s "space e": "editor::Complete", // type space then e @@ -117,20 +139,20 @@ Context expressions can contain the following syntax: For example: - `"context": "Editor"` - matches any editor (including inline inputs) -- `"context": "Editor && mode=full"` - matches the main editors used for editing code +- `"context": "Editor && mode == full"` - matches the main editors used for editing code - `"context": "!Editor && !Terminal"` - matches anywhere except where an Editor or Terminal is focused -- `"context": "os=macos > Editor"` - matches any editor on macOS. +- `"context": "os == macos > Editor"` - matches any editor on macOS. It's worth noting that attributes are only available on the node they are defined on. This means that if you want to (for example) only enable a keybinding when the debugger is stopped in vim normal mode, you need to do `debugger_stopped > vim_mode == normal`. -> Note: Before Zed v0.197.x, the `!` operator only looked at one node at a time, and `>` meant "parent" not "ancestor". This meant that `!Editor` would match the context `Workspace > Pane > Editor`, because (confusingly) the Pane matches `!Editor`, and that `os=macos > Editor` did not match the context `Workspace > Pane > Editor` because of the intermediate `Pane` node. +> Note: Before Zed v0.197.x, the `!` operator only looked at one node at a time, and `>` meant "parent" not "ancestor". This meant that `!Editor` would match the context `Workspace > Pane > Editor`, because (confusingly) the Pane matches `!Editor`, and that `os == macos > Editor` did not match the context `Workspace > Pane > Editor` because of the intermediate `Pane` node. If you're using Vim mode, we have information on how [vim modes influence the context](./vim.md#contexts). Helix mode is built on top of Vim mode and uses the same contexts. ### Actions Almost all of Zed's functionality is exposed as actions. -Although there is no explicitly documented list, you can find most of them by searching in the command palette, by looking in the default keymaps for [macOS](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-macos.json) or [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json), or by using Zed's autocomplete in your keymap file. +Although there is no explicitly documented list, you can find most of them by searching in the command palette, by looking in the default keymaps for [macOS](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-macos.json), [Windows](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-windows.json) or [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json), or by using Zed's autocomplete in your keymap file. Most actions do not require any arguments, and so you can bind them as strings: `"ctrl-a": "language_selector::Toggle"`. Some require a single argument and must be bound as an array: `"cmd-1": ["workspace::ActivatePane", 0]`. Some actions require multiple arguments and are bound as an array of a string and an object: `"ctrl-a": ["pane::DeploySearch", { "replace_enabled": true }]`. @@ -161,7 +183,7 @@ On keyboards that support extended Latin alphabets (French AZERTY, German QWERTZ If you are defining shortcuts in your personal keymap, you can opt into the key equivalent mapping by setting `use_key_equivalents` to `true` in your keymap: -```json +```json [keymap] [ { "use_key_equivalents": true, @@ -187,7 +209,7 @@ If you'd like a given binding to do nothing in a given context, you can use want to disable it, or if you want to type the character that would be typed by the sequence, or if you want to disable multikey bindings starting with that key. -```json +```json [keymap] [ { "context": "Workspace", @@ -202,7 +224,7 @@ A `null` binding follows the same precedence rules as normal actions, so it disa This is useful for preventing Zed from falling back to a default key binding when the action you specified is conditional and propagates. For example, `buffer_search::DeployReplace` only triggers when the search bar is not in view. If the search bar is in view, it would propagate and trigger the default action set for that key binding, such as opening the right dock. To prevent this from happening: -```json +```json [keymap] [ { "context": "Workspace", @@ -223,7 +245,7 @@ This is useful for preventing Zed from falling back to a default key binding whe A common request is to be able to map from a single keystroke to a sequence. You can do this with the `workspace::SendKeystrokes` action. -```json +```json [keymap] [ { "bindings": { @@ -262,7 +284,7 @@ If you're on Linux or Windows, you might find yourself wanting to forward key co For example, `ctrl-n` creates a new tab in Zed on Linux. If you want to send `ctrl-n` to the built-in terminal when it's focused, add the following to your keymap: -```json +```json [settings] { "context": "Terminal", "bindings": { diff --git a/docs/src/languages/ansible.md b/docs/src/languages/ansible.md index 16b6cef5ab..bce25ddc6c 100644 --- a/docs/src/languages/ansible.md +++ b/docs/src/languages/ansible.md @@ -11,7 +11,7 @@ Support for Ansible in Zed is provided via a community-maintained [Ansible exten To avoid mishandling non-Ansible YAML files, the Ansible Language is not associated with any file extensions by default. To change this behavior you can add a `"file_types"` section to Zed settings inside your project (`.zed/settings.json`) or your Zed user settings (`~/.config/zed/settings.json`) to match your folder/naming conventions. For example: -```json +```json [settings] "file_types": { "Ansible": [ "**.ansible.yml", @@ -50,7 +50,7 @@ If your inventory file is in the YAML format, you can either: - Or configure the yaml language server settings to set this schema for all your inventory files, that match your inventory pattern, under your Zed settings ([ref](https://zed.dev/docs/languages/yaml)): -```json +```json [settings] "lsp": { "yaml-language-server": { "settings": { @@ -71,7 +71,7 @@ If your inventory file is in the YAML format, you can either: By default, the following default config is passed to the Ansible language server. It conveniently mirrors the defaults set by [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig/blob/03bc581e05e81d33808b42b2d7e76d70adb3b595/lua/lspconfig/configs/ansiblels.lua) for the Ansible language server: -```json +```json [settings] { "ansible": { "ansible": { @@ -99,7 +99,7 @@ By default, the following default config is passed to the Ansible language serve When desired, any of the above default settings can be overridden under the `"lsp"` section of your Zed settings file. For example: -```json +```json [settings] "lsp": { // Note, the Zed Ansible extension prefixes all settings with `ansible` // so instead of using `ansible.ansible.path` use `ansible.path`. diff --git a/docs/src/languages/astro.md b/docs/src/languages/astro.md index 5691a0de48..cbfe8de74e 100644 --- a/docs/src/languages/astro.md +++ b/docs/src/languages/astro.md @@ -3,7 +3,7 @@ Astro support is available through the [Astro extension](https://github.com/zed-extensions/astro). - Tree-sitter: [virchau13/tree-sitter-astro](https://github.com/virchau13/tree-sitter-astro) -- Language Server: [withastro/language-tools](https://github.com/withastro/language-tools) +- Language Server: [withastro/language-tools](https://github.com/withastro/astro/tree/main/packages/language-tools/language-server) +1. Inside your Godot editor, open Editor Settings, look for `Text Editor -> External` and set the following options: + - Exec Path: `/path/to/zed` + - Exec Flags: `{project} {file}:{line}:{col}` + - Use External Editor: "✅ On" +2. Open any \*.gd file through Godot and Zed will launch. ## Usage -When Godot is running, the GDScript extension will connect to the language server provided by the Godot runtime and will provide `jump to definition`, hover states when you hold cmd and other language server features. - -> Note: If Zed is already running with an existing workspace, spawning from Godot will fail. Quit Zed and it should work again. +When Godot is running, the GDScript extension will connect to the language server provided by the Godot runtime and will provide `jump to definition`, hover states when you hold Ctrl/cmd and other language server features. diff --git a/docs/src/languages/go.md b/docs/src/languages/go.md index 0a12616b1c..3c4e505f8f 100644 --- a/docs/src/languages/go.md +++ b/docs/src/languages/go.md @@ -41,7 +41,7 @@ If `gopls` is not found you will likely need to add `export PATH="$PATH:$HOME/go Zed sets the following initialization options for inlay hints: -```json +```json [settings] "hints": { "assignVariableTypes": true, "compositeLiteralFields": true, @@ -57,12 +57,12 @@ to make the language server send back inlay hints when Zed has them enabled in t Use -```json +```json [settings] "lsp": { "gopls": { "initialization_options": { "hints": { - .... + // .... } } } @@ -75,15 +75,17 @@ See [gopls inlayHints documentation](https://github.com/golang/tools/blob/master ## Debugging -Zed supports zero-configuration debugging of Go tests and entry points (`func main`). Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these preconfigured debug tasks. +Zed supports zero-configuration debugging of Go tests and entry points (`func main`) using Delve. Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these preconfigured debug tasks. For more control, you can add debug configurations to `.zed/debug.json`. See below for examples. +- [Delve configuration documentation](https://github.com/go-delve/delve/blob/master/Documentation/api/dap/README.md#launch-and-attach-configurations) + ### Debug Go Packages To debug a specific package, you can do so by setting the Delve mode to "debug". In this case "program" should be set to the package name. -```json +```json [debug] [ { "label": "Go (Delve)", @@ -110,7 +112,7 @@ To debug a specific package, you can do so by setting the Delve mode to "debug". To debug the tests for a package, set the Delve mode to "test". The "program" is still the package name, and you can use the "buildFlags" to do things like set tags, and the "args" to set args on the test binary. (See `go help testflags` for more information on doing that). -```json +```json [debug] [ { "label": "Run integration tests", @@ -130,7 +132,7 @@ The "program" is still the package name, and you can use the "buildFlags" to do If you need to build your application with a specific command, you can use the "exec" mode of Delve. In this case "program" should point to an executable, and the "build" command should build that. -```json +```json [debug] [ { "label": "Debug Prebuilt Unit Tests", @@ -160,7 +162,7 @@ and the "build" command should build that. You might find yourself needing to connect to an existing instance of Delve that's not necessarily running on your machine; in such case, you can use `tcp_arguments` to instrument Zed's connection to Delve. -```json +```json [debug] [ { "adapter": "Delve", @@ -172,7 +174,7 @@ You might find yourself needing to connect to an existing instance of Delve that "request": "launch", "mode": "exec", "stopOnEntry": false, - "tcp_connection": { "host": "123.456.789.012", "port": 53412 } + "tcp_connection": { "host": "127.0.0.1", "port": 53412 } } ] ``` diff --git a/docs/src/languages/haskell.md b/docs/src/languages/haskell.md index fec9142a5f..901bd9ded1 100644 --- a/docs/src/languages/haskell.md +++ b/docs/src/languages/haskell.md @@ -19,7 +19,7 @@ which haskell-language-server-wrapper If you need to configure haskell-language-server (hls) you can add configuration options to your Zed settings.json: -```json +```json [settings] { "lsp": { "hls": { @@ -37,7 +37,7 @@ See the official [configuring haskell-language-server](https://haskell-language- If you would like to use a specific hls binary, or perhaps use [static-ls](https://github.com/josephsumabat/static-ls) as a drop-in replacement instead, you can specify the binary path and arguments: -```json +```json [settings] { "lsp": { "hls": { diff --git a/docs/src/languages/helm.md b/docs/src/languages/helm.md index a6e3c8fa49..f8a6f5c5fa 100644 --- a/docs/src/languages/helm.md +++ b/docs/src/languages/helm.md @@ -9,7 +9,7 @@ Support for Helm in Zed is provided by the community-maintained [Helm extension] Enable Helm language for Helm files by editing your `.zed/settings.json` and adding: -```json +```json [settings] "file_types": { "Helm": [ "**/templates/**/*.tpl", diff --git a/docs/src/languages/html.md b/docs/src/languages/html.md index 3afa34068d..274083adee 100644 --- a/docs/src/languages/html.md +++ b/docs/src/languages/html.md @@ -7,7 +7,7 @@ HTML support is available through the [HTML extension](https://github.com/zed-in This extension is automatically installed, but if you do not want to use it, you can add the following to your settings: -```json +```json [settings] { "auto_install_extensions": { "html": false @@ -21,7 +21,7 @@ By default Zed uses [Prettier](https://prettier.io/) for formatting HTML. You can disable `format_on_save` by adding the following to your Zed `settings.json`: -```json +```json [settings] "languages": { "HTML": { "format_on_save": "off", @@ -35,7 +35,7 @@ You can still trigger formatting manually with {#kb editor::Format} or by openin To use the `vscode-html-language-server` language server auto-formatting instead of Prettier, add the following to your Zed settings: -```json +```json [settings] "languages": { "HTML": { "formatter": "language_server", @@ -45,7 +45,7 @@ To use the `vscode-html-language-server` language server auto-formatting instead You can customize various [formatting options](https://code.visualstudio.com/docs/languages/html#_formatting) for `vscode-html-language-server` via your Zed `settings.json`: -```json +```json [settings] "lsp": { "vscode-html-language-server": { "settings": { diff --git a/docs/src/languages/java.md b/docs/src/languages/java.md index 3117767685..482429aef3 100644 --- a/docs/src/languages/java.md +++ b/docs/src/languages/java.md @@ -19,150 +19,149 @@ Or manually download and install [OpenJDK 23](https://jdk.java.net/23/). ## Extension Install -You can install either by opening {#action zed::Extensions}({#kb zed::Extensions}) and searching for `java`. +You can install by opening {#action zed::Extensions}({#kb zed::Extensions}) and searching for `java`. -## Settings / Initialization Options +## Quick start and configuration -The extension will automatically download the language server, see: [Manual JDTLS Install](#manual-jdts-install) below if you'd prefer to manage that yourself. +For the majority of users, Java support should work out of the box. -For available `initialization_options` please see the [Initialize Request section of the Eclipse.jdt.ls Wiki](https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line#initialize-request). +- It is generally recommended to open projects with the Zed-project root at the Java project root folder (where you would commonly have your `pom.xml` or `build.gradle` file). -You can add these customizations to your Zed Settings by launching {#action zed::OpenSettings}({#kb zed::OpenSettings}) or by using a `.zed/setting.json` inside your project. +- By default the extension will download and run the latest official version of JDTLS for you, but this requires Java version 21 to be available on your system via either the `$JAVA_HOME` environment variable or as a `java(.exe)` executable on your `$PATH`. If your project requires a lower Java version in the environment, you can specify a different JDK to use for running JDTLS via the `java_home` configuration option. -### Zed Java Settings +- You can provide a **custom launch script for JDTLS**, by adding an executable named `jdtls` (or `jdtls.bat` on Windows) to your `$PATH` environment variable. If this is present, the extension will skip downloading and launching a managed instance and use the one from the environment. -```json +- To support [Lombok](https://projectlombok.org/), the lombok-jar must be downloaded and registered as a Java-Agent when launching JDTLS. By default the extension automatically takes care of that, but in case you don't want that you can set the `lombok_support` configuration-option to `false`. + +Here is a common `settings.json` including the above mentioned configurations: + +```jsonc { "lsp": { "jdtls": { - "initialization_options": {} - } - } + "settings": { + "java_home": "/path/to/your/JDK21+", + "lombok_support": true, + }, + }, + }, } ``` -## Example Configs +## Debugging -### JDTLS Binary +Debug support is enabled via our [Fork of Java Debug](https://github.com/zed-industries/java-debug), which the extension will automatically download and start for you. Please refer to the [Debugger Documentation](https://zed.dev/docs/debugger#getting-started) for general information about how debugging works in Zed. -By default, zed will look in your `PATH` for a `jdtls` binary, if you wish to specify an explicit binary you can do so via settings: +To get started with Java, click the `edit debug.json` button in the Debug menu, and replace the contents of the file with the following: -```json - "lsp": { - "jdtls": { - "binary": { - "path": "/path/to/java/bin/jdtls", - // "arguments": [], - // "env": {}, - "ignore_system_version": true - } - } - } +```jsonc +[ + { + "adapter": "Java", + "request": "launch", + "label": "Launch Debugger", + // if your project has multiple entry points, specify the one to use: + // "mainClass": "com.myorganization.myproject.MyMainClass", + // + // this effectively sets a breakpoint at your program entry: + "stopOnEntry": true, + // the working directory for the debug process + "cwd": "$ZED_WORKTREE_ROOT", + }, +] ``` -### Zed Java Initialization Options +You should then be able to start a new Debug Session with the "Launch Debugger" scenario from the debug menu. -There are also many more options you can pass directly to the language server, for example: +## Launch Scripts (aka Tasks) in Windows -```json +This extension provides tasks for running your application and tests from within Zed via little play buttons next to tests/entry points. However, due to current limitations of Zed's extension interface, we can not provide scripts that will work across Maven and Gradle on both Windows and Unix-compatible systems, so out of the box the launch scripts only work on Mac and Linux. + +There is a fairly straightforward fix that you can apply to make it work on Windows by supplying your own task scripts. Please see [this Issue](https://github.com/zed-extensions/java/issues/94) for information on how to do that and read the [Tasks section in Zeds documentation](https://zed.dev/docs/tasks) for more information. + +## Advanced Configuration/JDTLS initialization Options + +JDTLS provides many configuration options that can be passed via the `initialize` LSP-request. The extension will pass the JSON-object from `lsp.jdtls.settings.initialization_options` in your settings on to JDTLS. Please refer to the [JDTLS Configuration Wiki Page](https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line#initialize-request) for the available options and values. Below is an example `settings.json` that would pass on the example configuration from the above wiki page to JDTLS: + +```jsonc { "lsp": { "jdtls": { - "initialization_options": { - "bundles": [], - "workspaceFolders": ["file:///home/snjeza/Project"], - "settings": { - "java": { - "home": "/usr/local/jdk-9.0.1", - "errors": { - "incompleteClasspath": { - "severity": "warning" - } - }, - "configuration": { - "updateBuildConfiguration": "interactive", - "maven": { - "userSettings": null - } - }, - "trace": { - "server": "verbose" - }, - "import": { - "gradle": { - "enabled": true + "settings": { + // this will be sent to JDTLS as initializationOptions: + "initialization_options": { + "bundles": [], + // use this if your zed project root folder is not the same as the java project root: + "workspaceFolders": ["file:///home/snjeza/Project"], + "settings": { + "java": { + "home": "/usr/local/jdk-9.0.1", + "errors": { + "incompleteClasspath": { + "severity": "warning", + }, }, - "maven": { - "enabled": true + "configuration": { + "updateBuildConfiguration": "interactive", + "maven": { + "userSettings": null, + }, + }, + "import": { + "gradle": { + "enabled": true, + }, + "maven": { + "enabled": true, + }, + "exclusions": [ + "**/node_modules/**", + "**/.metadata/**", + "**/archetype-resources/**", + "**/META-INF/maven/**", + "/**/test/**", + ], + }, + "referencesCodeLens": { + "enabled": false, + }, + "signatureHelp": { + "enabled": false, + }, + "implementationCodeLens": "all", + "format": { + "enabled": true, + }, + "saveActions": { + "organizeImports": false, + }, + "contentProvider": { + "preferred": null, + }, + "autobuild": { + "enabled": false, + }, + "completion": { + "favoriteStaticMembers": [ + "org.junit.Assert.*", + "org.junit.Assume.*", + "org.junit.jupiter.api.Assertions.*", + "org.junit.jupiter.api.Assumptions.*", + "org.junit.jupiter.api.DynamicContainer.*", + "org.junit.jupiter.api.DynamicTest.*", + ], + "importOrder": ["java", "javax", "com", "org"], }, - "exclusions": [ - "**/node_modules/**", - "**/.metadata/**", - "**/archetype-resources/**", - "**/META-INF/maven/**", - "/**/test/**" - ] }, - "jdt": { - "ls": { - "lombokSupport": { - "enabled": false // Set this to true to enable lombok support - } - } - }, - "referencesCodeLens": { - "enabled": false - }, - "signatureHelp": { - "enabled": false - }, - "implementationsCodeLens": { - "enabled": false - }, - "format": { - "enabled": true - }, - "saveActions": { - "organizeImports": false - }, - "contentProvider": { - "preferred": null - }, - "autobuild": { - "enabled": false - }, - "completion": { - "favoriteStaticMembers": [ - "org.junit.Assert.*", - "org.junit.Assume.*", - "org.junit.jupiter.api.Assertions.*", - "org.junit.jupiter.api.Assumptions.*", - "org.junit.jupiter.api.DynamicContainer.*", - "org.junit.jupiter.api.DynamicTest.*" - ], - "importOrder": ["java", "javax", "com", "org"] - } - } - } - } - } - } + }, + }, + }, + }, + }, } ``` -## Manual JDTLS Install - -If you prefer, you can install JDTLS yourself and the extension can be configured to use that instead. - -- macOS: `brew install jdtls` -- Arch: [`jdtls` from AUR](https://aur.archlinux.org/packages/jdtls) - -Or manually download install: - -- [JDTLS Milestone Builds](http://download.eclipse.org/jdtls/milestones/) (updated every two weeks) -- [JDTLS Snapshot Builds](https://download.eclipse.org/jdtls/snapshots/) (frequent updates) - ## See also -- [Zed Java Repo](https://github.com/zed-extensions/java) -- [Zed Java Issues](https://github.com/zed-extensions/java/issues) +[Zed Java Repo](https://github.com/zed-extensions/java) +[Eclipse JDTLS Repo](https://github.com/eclipse-jdtls/eclipse.jdt.ls) diff --git a/docs/src/languages/javascript.md b/docs/src/languages/javascript.md index c71071a9b3..1b87dac555 100644 --- a/docs/src/languages/javascript.md +++ b/docs/src/languages/javascript.md @@ -3,7 +3,8 @@ JavaScript support is available natively in Zed. - Tree-sitter: [tree-sitter/tree-sitter-javascript](https://github.com/tree-sitter/tree-sitter-javascript) -- Language Server: [typescript-language-server/typescript-language-server](https://github.com/typescript-language-server/typescript-language-server) +- Language Server: [yioneko/vtsls](https://github.com/yioneko/vtsls) +- Alternate Language Server: [typescript-language-server/typescript-language-server](https://github.com/typescript-language-server/typescript-language-server) - Debug Adapter: [vscode-js-debug](https://github.com/microsoft/vscode-js-debug) ## Code formatting @@ -15,7 +16,7 @@ See [the configuration docs](../configuring-zed.md) for more information. For example, if you have Prettier installed and on your `PATH`, you can use it to format JavaScript files by adding the following to your `settings.json`: -```json +```json [settings] { "languages": { "JavaScript": { @@ -34,7 +35,7 @@ For example, if you have Prettier installed and on your `PATH`, you can use it t Zed supports JSX syntax highlighting out of the box. -In JSX strings, the [`tailwindcss-language-server`](./tailwindcss.md) is used provide autocompletion for Tailwind CSS classes. +In JSX strings, the [`tailwindcss-language-server`](./tailwindcss.md) is used to provide autocompletion for Tailwind CSS classes. ## JSDoc @@ -45,7 +46,7 @@ Zed uses [tree-sitter/tree-sitter-jsdoc](https://github.com/tree-sitter/tree-sit You can configure Zed to format code using `eslint --fix` by running the ESLint code action when formatting: -```json +```json [settings] { "languages": { "JavaScript": { @@ -59,7 +60,7 @@ You can configure Zed to format code using `eslint --fix` by running the ESLint You can also only execute a single ESLint rule when using `fixAll`: -```json +```json [settings] { "languages": { "JavaScript": { @@ -88,14 +89,13 @@ You can also only execute a single ESLint rule when using `fixAll`: If you **only** want to run ESLint on save, you can configure code actions as the formatter: -```json +```json [settings] { "languages": { "JavaScript": { - "formatter": { - "code_actions": { - "source.fixAll.eslint": true - } + "formatter": [], + "code_actions_on_format": { + "source.fixAll.eslint": true } } } @@ -106,7 +106,7 @@ the formatter: You can configure ESLint's `nodePath` setting: -```json +```json [settings] { "lsp": { "eslint": { @@ -124,7 +124,7 @@ You can configure ESLint's `problems` setting. For example, here's how to set `problems.shortenToSingleLine`: -```json +```json [settings] { "lsp": { "eslint": { @@ -142,7 +142,7 @@ For example, here's how to set `problems.shortenToSingleLine`: You can configure ESLint's `rulesCustomizations` setting: -```json +```json [settings] { "lsp": { "eslint": { @@ -161,7 +161,7 @@ You can configure ESLint's `rulesCustomizations` setting: You can configure ESLint's `workingDirectory` setting: -```json +```json [settings] { "lsp": { "eslint": { @@ -177,21 +177,31 @@ You can configure ESLint's `workingDirectory` setting: ## Debugging -Zed supports debugging JavaScript code out of the box. +Zed supports debugging JavaScript code out of the box with `vscode-js-debug`. The following can be debugged without writing additional configuration: - Tasks from `package.json` -- Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine) +- Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine, Bun, Node) Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks. +> **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`. +> +> **Note:** Node test is automatically detected when `@types/node` is present in `package.json` (requires Node.js 20+). + As for all languages, configurations from `.vscode/launch.json` are also available for debugging in Zed. If your use-case isn't covered by any of these, you can take full control by adding debug configurations to `.zed/debug.json`. See below for example configurations. -### Debug the current file +### Configuring JavaScript debug tasks -```json +JavaScript debugging is more complicated than other languages because there are two different environments: Node.js and the browser. `vscode-js-debug` exposes a `type` field, that you can use to specify the environment, either `node` or `chrome`. + +- [vscode-js-debug configuration documentation](https://github.com/microsoft/vscode-js-debug/blob/main/OPTIONS.md) + +### Debug the current file with Node + +```json [debug] [ { "adapter": "JavaScript", @@ -204,11 +214,9 @@ If your use-case isn't covered by any of these, you can take full control by add ] ``` -This implicitly runs the current file using `node`. - ### Launch a web app in Chrome -```json +```json [debug] [ { "adapter": "JavaScript", diff --git a/docs/src/languages/json.md b/docs/src/languages/json.md index 94f56999d5..33acdb172e 100644 --- a/docs/src/languages/json.md +++ b/docs/src/languages/json.md @@ -16,7 +16,7 @@ If you use files with the `*.jsonc` extension when using `Format Document` or ha To workaround this behavior you can add the following to your `.prettierrc` configuration file: -```json +```json [settings] { "overrides": [ { @@ -40,7 +40,7 @@ To specify a schema inline with your JSON files, add a `$schema` top level key l For example to for a `.luarc.json` for use with [lua-language-server](https://github.com/LuaLS/lua-language-server/): -```json +```json [settings] { "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json", "runtime.version": "Lua 5.4" @@ -53,7 +53,7 @@ You can alternatively associate JSON Schemas with file paths by via Zed LSP sett To -```json +```json [settings] "lsp": { "json-language-server": { "settings": { diff --git a/docs/src/languages/jsonnet.md b/docs/src/languages/jsonnet.md index df4e39b98d..405087766b 100644 --- a/docs/src/languages/jsonnet.md +++ b/docs/src/languages/jsonnet.md @@ -11,7 +11,7 @@ Workspace configuration options can be passed to the language server via the `ls The following example enables support for resolving [tanka](https://tanka.dev) import paths in `jsonnet-language-server`: -```json +```json [settings] { "lsp": { "jsonnet-language-server": { diff --git a/docs/src/languages/kotlin.md b/docs/src/languages/kotlin.md index 60d66f277e..a81643ab7d 100644 --- a/docs/src/languages/kotlin.md +++ b/docs/src/languages/kotlin.md @@ -20,7 +20,7 @@ under `class Configuration` and initialization_options under `class Initializati The following example changes the JVM target from `default` (which is 1.8) to `17`: -```json +```json [settings] { "lsp": { "kotlin-language-server": { @@ -40,7 +40,7 @@ The following example changes the JVM target from `default` (which is 1.8) to To use a specific java installation, just specify the `JAVA_HOME` environment variable with: -```json +```json [settings] { "lsp": { "kotlin-language-server": { diff --git a/docs/src/languages/lua.md b/docs/src/languages/lua.md index 7e92b12b91..65b709b391 100644 --- a/docs/src/languages/lua.md +++ b/docs/src/languages/lua.md @@ -9,7 +9,7 @@ Lua support is available through the [Lua extension](https://github.com/zed-exte To configure LuaLS you can create a `.luarc.json` file in the root of your workspace. -```json +```json [settings] { "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", "runtime.version": "Lua 5.4", @@ -55,7 +55,7 @@ cd .. && git clone https://github.com/notpeter/playdate-luacats Then in your `.luarc.json`: -```json +```json [settings] { "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", "runtime.version": "Lua 5.4", @@ -90,7 +90,7 @@ To enable [Inlay Hints](../configuring-languages.md#inlay-hints) for LuaLS in Ze 1. Add the following to your Zed settings.json: -```json +```json [settings] "languages": { "Lua": { "inlay_hints": { @@ -111,7 +111,7 @@ To enable [Inlay Hints](../configuring-languages.md#inlay-hints) for LuaLS in Ze To enable auto-formatting with your LuaLS (provided by [CppCXY/EmmyLuaCodeStyle](https://github.com/CppCXY/EmmyLuaCodeStyle)) make sure you have `"format.enable": true,` in your .luarc.json: -```json +```json [settings] { "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json", "format.enable": true @@ -120,7 +120,7 @@ To enable auto-formatting with your LuaLS (provided by [CppCXY/EmmyLuaCodeStyle] Then add the following to your Zed `settings.json`: -```json +```json [settings] { "languages": { "Lua": { @@ -140,7 +140,7 @@ Alternatively to use [StyLua](https://github.com/JohnnyMorganz/StyLua) for auto- 1. Install [StyLua](https://github.com/JohnnyMorganz/StyLua): `brew install stylua` or `cargo install stylua --features lua52,lua53,lua54,luau,luajit` (feel free to remove any Lua versions you don't need). 2. Add the following to your `settings.json`: -```json +```json [settings] { "languages": { "Lua": { diff --git a/docs/src/languages/luau.md b/docs/src/languages/luau.md index 58d78855a1..b99cfc86ac 100644 --- a/docs/src/languages/luau.md +++ b/docs/src/languages/luau.md @@ -27,7 +27,7 @@ cargo install stylua --features lua52,lua53,lua54,luau Then add the following to your Zed `settings.json`: -```json +```json [settings] "languages": { "Luau": { "formatter": { diff --git a/docs/src/languages/markdown.md b/docs/src/languages/markdown.md index 38a2b1c43f..36ce734f7c 100644 --- a/docs/src/languages/markdown.md +++ b/docs/src/languages/markdown.md @@ -25,7 +25,7 @@ def fib(n): Zed supports using Prettier to automatically re-format Markdown documents. You can trigger this manually via the {#action editor::Format} action or via the {#kb editor::Format} keyboard shortcut. Alternately, you can automatically format by enabling [`format_on_save`](../configuring-zed.md#format-on-save) in your settings.json: -```json +```json [settings] "languages": { "Markdown": { "format_on_save": "on" @@ -37,7 +37,7 @@ Zed supports using Prettier to automatically re-format Markdown documents. You c By default Zed will remove trailing whitespace on save. If you rely on invisible trailing whitespace being converted to `
` in Markdown files you can disable this behavior with: -```json +```json [settings] "languages": { "Markdown": { "remove_trailing_whitespace_on_save": false diff --git a/docs/src/languages/nim.md b/docs/src/languages/nim.md index 514810183c..03c2bc0609 100644 --- a/docs/src/languages/nim.md +++ b/docs/src/languages/nim.md @@ -10,7 +10,7 @@ Report issues to: [https://github.com/foxoman/zed-nim/issues](https://github.com To use [arnetheduck/nph](https://github.com/arnetheduck/nph) as a formatter, follow the [nph installation instructions](https://github.com/arnetheduck/nph?tab=readme-ov-file#installation) and add this to your Zed `settings.json`: -```json +```json [settings] "languages": { "Nim": { "formatter": { diff --git a/docs/src/languages/ocaml.md b/docs/src/languages/ocaml.md index cf61defc1a..10c3c1ac09 100644 --- a/docs/src/languages/ocaml.md +++ b/docs/src/languages/ocaml.md @@ -33,4 +33,4 @@ Once you have the cli, simply from a terminal, navigate to your project and run zed . ``` -Voila! You should have Zed running with OCaml support, no additional setup required. +Voilà! You should have Zed running with OCaml support, no additional setup required. diff --git a/docs/src/languages/opentofu.md b/docs/src/languages/opentofu.md new file mode 100644 index 0000000000..dfe8fa7b81 --- /dev/null +++ b/docs/src/languages/opentofu.md @@ -0,0 +1,20 @@ +# OpenTofu + +OpenTofu support is available through the [OpenTofu extension](https://github.com/ashpool37/zed-extension-opentofu). + +- Tree-sitter: [MichaHoffmann/tree-sitter-hcl](https://github.com/MichaHoffmann/tree-sitter-hcl) +- Language Server: [opentofu/tofu-ls](https://github.com/opentofu/tofu-ls) + +## Configuration + +In order to automatically use the OpenTofu extension and language server when editing .tf and .tfvars files, +either uninstall the Terraform extension or add this to your settings.json: + +```json +"file_types": { + "OpenTofu": ["tf"], + "OpenTofu Vars": ["tfvars"] +}, +``` + +See the [full list of server settings here](https://github.com/opentofu/tofu-ls/blob/main/docs/SETTINGS.md). diff --git a/docs/src/languages/php.md b/docs/src/languages/php.md index 4e94c13446..1a9f1cdade 100644 --- a/docs/src/languages/php.md +++ b/docs/src/languages/php.md @@ -2,48 +2,60 @@ PHP support is available through the [PHP extension](https://github.com/zed-extensions/php). -- Tree-sitter: https://github.com/tree-sitter/tree-sitter-php -- Language Servers: - - [phpactor](https://github.com/phpactor/phpactor) - - [intelephense](https://github.com/bmewburn/vscode-intelephense/) +- Tree-sitter: [tree-sitter/tree-sitter-php](https://github.com/tree-sitter/tree-sitter-php) +- Language Server: [phpactor/phpactor](https://github.com/phpactor/phpactor) +- Alternate Language Server: [bmewburn/vscode-intelephense](https://github.com/bmewburn/vscode-intelephense/) + +## Install PHP + +The PHP extension requires PHP to be installed and available in your `PATH`: + +```sh +# macOS via Homebrew +brew install php + +# Debian/Ubuntu +sudo apt-get install php-cli + +# CentOS 8+/RHEL +sudo dnf install php-cli + +# Arch Linux +sudo pacman -S php + +# check PHP path +## macOS and Linux +which php + +## Windows +where php +``` ## Choosing a language server -The PHP extension offers both `phpactor` and `intelephense` language server support. +The PHP extension uses [LSP language servers](https://microsoft.github.io/language-server-protocol) with Phpactor as the default. If you want to use other language servers that support Zed (e.g. Intelephense or PHP Tools), make sure to follow the documentation on how to implement it. -`phpactor` is enabled by default. +### Intelephense -## Phpactor +[Intelephense](https://intelephense.com/) is a [proprietary](https://github.com/bmewburn/vscode-intelephense/blob/master/LICENSE.txt#L29) language server for PHP operating under a freemium model. Certain features require purchase of a [premium license](https://intelephense.com/buy). -The Zed PHP Extension can install `phpactor` automatically but requires `php` to be installed and available in your path: +To use Intelephense, add the following to your `settings.json`: -```sh -# brew install php # macOS -# sudo apt-get install php # Debian/Ubuntu -# yum install php # CentOS/RHEL -# pacman -S php # Arch Linux -which php -``` - -## Intelephense - -[Intelephense](https://intelephense.com/) is a [proprietary](https://github.com/bmewburn/vscode-intelephense/blob/master/LICENSE.txt#L29) language server for PHP operating under a freemium model. Certain features require purchase of a [premium license](https://intelephense.com/). - -To switch to `intelephense`, add the following to your `settings.json`: - -```json +```json [settings] { "languages": { "PHP": { - "language_servers": ["intelephense", "!phpactor", "..."] + "language_servers": ["intelephense", "!phpactor", "!phptools", "..."] } } } ``` -To use the premium features, you can place your [licence.txt file](https://intelephense.com/faq.html) at `~/intelephense/licence.txt` inside your home directory. Alternatively, you can pass the licence key or a path to a file containing the licence key as an initialization option for the `intelephense` language server. To do this, add the following to your `settings.json`: +To use the premium features, you can place your license file inside your home directory at `~/intelephense/licence.txt` for macOS and Linux, or `%USERPROFILE%/intelephense/licence.txt` on Windows. -```json +Alternatively, you can pass the licence key or a path to a file containing the licence key as an initialization option. To do this, add the following to your `settings.json`: + +```json [settings] { "lsp": { "intelephense": { @@ -55,8 +67,90 @@ To use the premium features, you can place your [licence.txt file](https://intel } ``` +### PHP Tools + +[PHP Tools](https://www.devsense.com/) is a proprietary language server that offers free and premium features. You need to [purchase a license](https://www.devsense.com/en/purchase) to activate the premium features. + +To use PHP Tools, add the following to your `settings.json`: + +```json [settings] +{ + "languages": { + "PHP": { + "language_servers": ["phptools", "!intelephense", "!phpactor", "..."] + } + } +} +``` + +To use the premium features, you can add your license in `initialization_options` in your `settings.json`: + +```json [settings] +{ + "lsp": { + "phptools": { + "initialization_options": { + "0": "your_license_key" + } + } + } +} +``` + +or, set environment variable `DEVSENSE_PHP_LS_LICENSE` on `.env` file in your project. + +```env +DEVSENSE_PHP_LS_LICENSE="your_license_key" +``` + +Check out the documentation of [PHP Tools for Zed](https://docs.devsense.com/other/zed/) for more details. + +### Phpactor + +To use Phpactor instead of Intelephense or any other tools, add the following to your `settings.json`: + +```json [settings] +{ + "languages": { + "PHP": { + "language_servers": ["phpactor", "!intelephense", "!phptools", "..."] + } + } +} +``` + ## PHPDoc Zed supports syntax highlighting for PHPDoc comments. - Tree-sitter: [claytonrcarter/tree-sitter-phpdoc](https://github.com/claytonrcarter/tree-sitter-phpdoc) + +## Debugging + +The PHP extension provides a debug adapter for PHP via Xdebug. There are several ways to use it: + +```json +[ + { + "label": "PHP: Listen to Xdebug", + "adapter": "Xdebug", + "request": "launch", + "port": 9003 + }, + { + "label": "PHP: Debug this test", + "adapter": "Xdebug", + "request": "launch", + "program": "vendor/bin/phpunit", + "args": ["--filter", "$ZED_SYMBOL"] + } +] +``` + +These are common troubleshooting tips, in case you run into issues: + +- Ensure that you have Xdebug installed for the version of PHP you’re running. +- Ensure that Xdebug is configured to run in `debug` mode. +- Ensure that Xdebug is actually starting a debugging session. +- Ensure that the host and port matches between Xdebug and Zed. +- Look at the diagnostics log by using the `xdebug_info()` function in the page you’re trying to debug. diff --git a/docs/src/languages/powershell.md b/docs/src/languages/powershell.md index d4d7064256..195ce4ad36 100644 --- a/docs/src/languages/powershell.md +++ b/docs/src/languages/powershell.md @@ -24,7 +24,7 @@ The Zed PowerShell extensions will attempt to download [PowerShell Editor Servic If want to use a specific binary, you can specify in your that in your Zed settings.json: -```json +```json [settings] "lsp": { "powershell-es": { "binary": { diff --git a/docs/src/languages/proto.md b/docs/src/languages/proto.md index d8feaf4c42..8d9b8350fa 100644 --- a/docs/src/languages/proto.md +++ b/docs/src/languages/proto.md @@ -30,7 +30,7 @@ which protols ## Configuration -```json +```json [settings] "lsp": { "protobuf-language-server": { "binary": { @@ -62,7 +62,7 @@ ColumnLimit: 120 Or you can have zed directly invoke `clang-format` by specifying it as a [formatter](https://zed.dev/docs/configuring-zed#formatter) in your settings: -```json +```json [settings] "languages": { "Proto": { "format_on_save": "on", diff --git a/docs/src/languages/python.md b/docs/src/languages/python.md index 98eca1fcc9..2323fe2f95 100644 --- a/docs/src/languages/python.md +++ b/docs/src/languages/python.md @@ -77,7 +77,7 @@ Other built-in language servers are: These are disabled by default, but can be enabled in your settings. For example: -```json +```json [settings] { "languages": { "Python": { @@ -123,14 +123,16 @@ For example, in order to: You can use the following configuration: -```json +```json [settings] { "lsp": { "basedpyright": { "settings": { "basedpyright.analysis": { "diagnosticMode": "workspace", - "inlayHints.callArgumentNames": false + "inlayHints": { + "callArgumentNames": false + } } } } @@ -144,7 +146,7 @@ basedpyright reads project-specific configuration from the `pyrightconfig.json` Here's an example `pyrightconfig.json` file that configures basedpyright to use the `strict` type-checking mode and not to issue diagnostics for any files in `__pycache__` directories: -```json +```json [settings] { "typeCheckingMode": "strict", "ignore": ["**/__pycache__"] @@ -194,7 +196,7 @@ Zed provides the [Ruff](https://docs.astral.sh/ruff/) formatter and linter for P You can disable format-on-save for Python files in your `settings.json`: -```json +```json [settings] { "languages": { "Python": { @@ -206,7 +208,7 @@ You can disable format-on-save for Python files in your `settings.json`: Alternatively, you can use the `black` command-line tool for Python formatting, while keeping Ruff enabled for linting: -```json +```json [settings] { "languages": { "Python": { @@ -228,7 +230,7 @@ Like basedpyright, Ruff reads options from both Zed's language server settings a Here's an example of using language server settings in Zed's `settings.json` to disable all Ruff lints in Zed (while still using Ruff as a formatter): -```json +```json [settings] { "lsp": { "ruff": { @@ -256,6 +258,25 @@ quote-style = "single" For more details, refer to the Ruff documentation about [configuration files](https://docs.astral.sh/ruff/configuration/) and [language server settings](https://docs.astral.sh/ruff/editors/settings/), and the [list of options](https://docs.astral.sh/ruff/settings/). +### Embedded Language Highlighting + +Zed supports syntax highlighting for code embedded in Python strings by adding a comment with the language name. + +```python +# sql +query = "SELECT * FROM users" + +#sql +query = """ + SELECT * + FROM users +""" + +result = func( #sql + "SELECT * FROM users" +) +``` + ## Debugging Zed supports Python debugging through the `debugpy` adapter. You can start with no configuration or define custom launch profiles in `.zed/debug.json`. @@ -275,9 +296,11 @@ Zed uses `debugpy` under the hood, but no manual adapter configuration is requir For reusable setups, create a `.zed/debug.json` file in your project root. This gives you more control over how Zed runs and debugs your code. +- [debugpy configuration documentation](https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings#launchattach-settings) + #### Debug Active File -```json +```json [debug] [ { "label": "Python Active File", @@ -309,7 +332,7 @@ requirements.txt …the following configuration can be used: -```json +```json [debug] [ { "label": "Python: Flask", diff --git a/docs/src/languages/r.md b/docs/src/languages/r.md index 226a6f8668..a21afb9976 100644 --- a/docs/src/languages/r.md +++ b/docs/src/languages/r.md @@ -8,7 +8,7 @@ R support is available via multiple R Zed extensions: - Language-Server: [REditorSupport/languageserver](https://github.com/REditorSupport/languageserver) - [posit-dev/air](https://github.com/posit-dev/air/tree/main/editors/zed) - - Language-Server: [posit-dev/air](https://github.com/posit-dev/air) + - Formatter: [posit-dev/air](https://posit-dev.github.io/air/) ## Installation @@ -20,22 +20,11 @@ install.packages("languageserver") install.packages("lintr") ``` -3. Install the [ocsmit/zed-r](https://github.com/ocsmit/zed-r) through Zed's extensions manager. +3. Install the [R](https://github.com/ocsmit/zed-r) extension through Zed's extensions manager for basic R language support (syntax highlighting, tree-sitter support) and for [REditorSupport/languageserver](https://github.com/REditorSupport/languageserver) support. -For example on macOS: +4. Install the [Air](https://posit-dev.github.io/air/) extension through Zed's extensions manager for R code formatting via Air. -```sh -brew install --cask r -Rscript --version -Rscript -e 'options(repos = "https://cran.rstudio.com/"); install.packages("languageserver")' -Rscript -e 'options(repos = "https://cran.rstudio.com/"); install.packages("lintr")' -Rscript -e 'packageVersion("languageserver")' -Rscript -e 'packageVersion("lintr")' -``` - -## Configuration - -### Linting +## Linting `REditorSupport/languageserver` bundles support for [r-lib/lintr](https://github.com/r-lib/lintr) as a linter. This can be configured via the use of a `.lintr` inside your project (or in your home directory for global defaults). @@ -59,7 +48,56 @@ exclusions: list(".") See [Using lintr](https://lintr.r-lib.org/articles/lintr.html) for a complete list of options, -### Formatting +## Formatting + +### Air + +[Air](https://posit-dev.github.io/air/) provides code formatting for R, including support for format-on-save. The [Air documentation for Zed](https://posit-dev.github.io/air/editor-zed.html) contains the most up-to-date advice for running Air in Zed. + +Ensure that you have installed both the [ocsmit/zed-r](https://github.com/ocsmit/zed-r) extension (for general R language awareness in Zed) and the [Air](https://posit-dev.github.io/air/) extension. + +Enable Air in your `settings.json`: + +```json [settings] +{ + "languages": { + "R": { + "language_servers": ["air"] + } + } +} +``` + +If you use the `"r_language_server"` from `REditorSupport/languageserver`, but would still like to use Air for formatting, use the following configuration: + +```json [settings] +{ + "languages": { + "R": { + "language_servers": ["air", "r_language_server"], + "use_on_type_format": false + } + } +} +``` + +Note that `"air"` must come first in this list, otherwise [r-lib/styler](https://github.com/r-lib/styler) will be invoked via `"r_language_server"`. + +`"r_language_server"` provides on-type-formatting that differs from Air's formatting rules. To avoid this entirely and let Air be fully in charge of formatting your R files, also set `"use_on_type_format": false` as shown above. + +#### Configuring Air + +Air is minimally configurable via an `air.toml` file placed in the root directory of your project: + +```toml +[format] +line-width = 80 +indent-width = 2 +``` + +For more details, refer to the Air documentation about [configuration](https://posit-dev.github.io/air/configuration.html). + +### Styler `REditorSupport/languageserver` bundles support for [r-lib/styler](https://github.com/r-lib/styler) as a formatter. See [Customizing Styler](https://cran.r-project.org/web/packages/styler/vignettes/customizing_styler.html) for more information on how to customize its behavior. @@ -72,7 +110,7 @@ You can configure the [R languageserver settings](https://github.com/REditorSupp For example to disable Lintr linting and suppress code snippet suggestions (both enabled by default): -```json +```json [settings] { "lsp": { "r_language_server": { diff --git a/docs/src/languages/rego.md b/docs/src/languages/rego.md index 21192a5c53..c52cccea54 100644 --- a/docs/src/languages/rego.md +++ b/docs/src/languages/rego.md @@ -3,11 +3,11 @@ Rego language support in Zed is provided by the community-maintained [Rego extension](https://github.com/StyraInc/zed-rego). - Tree-sitter: [FallenAngel97/tree-sitter-rego](https://github.com/FallenAngel97/tree-sitter-rego) -- Language Server: [StyraInc/regal](https://github.com/StyraInc/regal) +- Language Server: [open-policy-agent/regal](https://github.com/open-policy-agent/regal) ## Installation -The extensions is largely based on the [Regal](https://docs.styra.com/regal/language-server) language server which should be installed to make use of the extension. Read the [getting started](https://docs.styra.com/regal#getting-started) instructions for more information. +The extension is largely based on the [Regal](https://docs.styra.com/regal/language-server) language server which should be installed to make use of the extension. Read the [getting started](https://docs.styra.com/regal#getting-started) instructions for more information. ## Configuration diff --git a/docs/src/languages/ruby.md b/docs/src/languages/ruby.md index bcab5333d7..7e072ac5d3 100644 --- a/docs/src/languages/ruby.md +++ b/docs/src/languages/ruby.md @@ -46,7 +46,7 @@ For all supported Ruby language servers (`solargraph`, `ruby-lsp`, `rubocop`, `s You can skip step 1 and force using the system executable by setting `use_bundler` to `false` in your settings: -```json +```json [settings] { "lsp": { "": { @@ -66,11 +66,23 @@ You can skip step 1 and force using the system executable by setting `use_bundle To switch to `ruby-lsp`, add the following to your `settings.json`: -```json +```json [settings] { "languages": { "Ruby": { "language_servers": ["ruby-lsp", "!solargraph", "!rubocop", "..."] + }, + // Enable herb and ruby-lsp for *.html.erb files + "HTML+ERB": { + "language_servers": ["herb", "ruby-lsp", "..."] + }, + // Enable ruby-lsp for *.js.erb files + "JS+ERB": { + "language_servers": ["ruby-lsp", "..."] + }, + // Enable ruby-lsp for *.yaml.erb files + "YAML+ERB": { + "language_servers": ["ruby-lsp", "..."] } } } @@ -84,7 +96,7 @@ The Ruby extension also provides support for `rubocop` language server for offen To enable it, add the following to your `settings.json`: -```json +```json [settings] { "languages": { "Ruby": { @@ -96,7 +108,7 @@ To enable it, add the following to your `settings.json`: Or, conversely, you can disable `ruby-lsp` and enable `solargraph` and `rubocop` by adding the following to your `settings.json`: -```json +```json [settings] { "languages": { "Ruby": { @@ -110,7 +122,7 @@ Or, conversely, you can disable `ruby-lsp` and enable `solargraph` and `rubocop` Solargraph has formatting and diagnostics disabled by default. We can tell Zed to enable them by adding the following to your `settings.json`: -```json +```json [settings] { "lsp": { "solargraph": { @@ -131,7 +143,7 @@ Solargraph reads its configuration from a file called `.solargraph.yml` in the r You can pass Ruby LSP configuration to `initialization_options`, e.g. -```json +```json [settings] { "languages": { "Ruby": { @@ -152,7 +164,7 @@ You can pass Ruby LSP configuration to `initialization_options`, e.g. LSP `settings` and `initialization_options` can also be project-specific. For example to use [standardrb/standard](https://github.com/standardrb/standard) as a formatter and linter for a particular project, add this to a `.zed/settings.json` inside your project repo: -```json +```json [settings] { "lsp": { "ruby-lsp": { @@ -169,7 +181,7 @@ LSP `settings` and `initialization_options` can also be project-specific. For ex Rubocop has unsafe autocorrection disabled by default. We can tell Zed to enable it by adding the following to your `settings.json`: -```json +```json [settings] { "languages": { "Ruby": { @@ -200,7 +212,7 @@ Rubocop has unsafe autocorrection disabled by default. We can tell Zed to enable To enable Sorbet, add `\"sorbet\"` to the `language_servers` list for Ruby in your `settings.json`. You may want to disable other language servers if Sorbet is intended to be your primary LSP, or if you plan to use it alongside another LSP for specific features like type checking. -```json +```json [settings] { "languages": { "Ruby": { @@ -224,7 +236,7 @@ For all aspects of installing Sorbet, setting it up in your project, and configu To enable Steep, add `\"steep\"` to the `language_servers` list for Ruby in your `settings.json`. You may need to adjust the order or disable other LSPs depending on your desired setup. -```json +```json [settings] { "languages": { "Ruby": { @@ -242,7 +254,7 @@ To enable Steep, add `\"steep\"` to the `language_servers` list for Ruby in your ## Setting up Herb -`Herb` is enabled by default for the `HTML/ERB` language. +`Herb` is enabled by default for the `HTML+ERB` language. ## Using the Tailwind CSS Language Server with Ruby @@ -250,7 +262,7 @@ It's possible to use the [Tailwind CSS Language Server](https://github.com/tailw In order to do that, you need to configure the language server so that it knows about where to look for CSS classes in Ruby/ERB files by adding the following to your `settings.json`: -```json +```json [settings] { "languages": { "Ruby": { @@ -260,10 +272,6 @@ In order to do that, you need to configure the language server so that it knows "lsp": { "tailwindcss-language-server": { "settings": { - "includeLanguages": { - "html/erb": "html", - "ruby": "html" - }, "experimental": { "classRegex": ["\\bclass:\\s*['\"]([^'\"]*)['\"]"] } @@ -294,7 +302,7 @@ To run tests in your Ruby project, you can set up custom tasks in your local `.z ### Minitest with Rails -```json +```json [tasks] [ { "label": "test $ZED_RELATIVE_FILE -n /$ZED_CUSTOM_RUBY_TEST_NAME/", @@ -315,7 +323,7 @@ To run tests in your Ruby project, you can set up custom tasks in your local `.z Plain minitest does not support running tests by line number, only by name, so we need to use `$ZED_CUSTOM_RUBY_TEST_NAME` instead: -```json +```json [tasks] [ { "label": "-Itest $ZED_RELATIVE_FILE -n /$ZED_CUSTOM_RUBY_TEST_NAME/", @@ -336,7 +344,7 @@ Plain minitest does not support running tests by line number, only by name, so w ### RSpec -```json +```json [tasks] [ { "label": "test $ZED_RELATIVE_FILE:$ZED_ROW", @@ -358,7 +366,7 @@ The Ruby extension provides a debug adapter for debugging Ruby code. Zed's name #### Debug a Ruby script -```json +```json [debug] [ { "label": "Debug current file", @@ -372,7 +380,7 @@ The Ruby extension provides a debug adapter for debugging Ruby code. Zed's name #### Debug Rails server -```json +```json [debug] [ { "label": "Debug Rails server", @@ -394,15 +402,15 @@ The Ruby extension provides a debug adapter for debugging Ruby code. Zed's name To format ERB templates, you can use the `erb-formatter` formatter. This formatter uses the [`erb-formatter`](https://rubygems.org/gems/erb-formatter) gem to format ERB templates. -```jsonc +```json [settings] { - "HTML/ERB": { + "HTML+ERB": { "formatter": { "external": { "command": "erb-formatter", - "arguments": ["--stdin-filename", "{buffer_path}"], - }, - }, - }, + "arguments": ["--stdin-filename", "{buffer_path}"] + } + } + } } ``` diff --git a/docs/src/languages/rust.md b/docs/src/languages/rust.md index 359af77371..d696cfe411 100644 --- a/docs/src/languages/rust.md +++ b/docs/src/languages/rust.md @@ -16,7 +16,7 @@ TBD: Provide explicit examples not just `....` The following configuration can be used to change the inlay hint settings for `rust-analyzer` in Rust: -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -43,7 +43,7 @@ See [Inlay Hints](https://rust-analyzer.github.io/book/features.html#inlay-hints The `rust-analyzer` target directory can be set in `initialization_options`: -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -67,7 +67,7 @@ By default, Zed will try to find a `rust-analyzer` in your `$PATH` and try to us If you want to install pre-release `rust-analyzer` version instead you can instruct Zed to do so by setting `pre_release` to `true` in your `settings.json`: -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -81,7 +81,7 @@ If you want to install pre-release `rust-analyzer` version instead you can instr If you want to disable Zed looking for a `rust-analyzer` binary, you can set `ignore_system_version` to `true` in your `settings.json`: -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -95,7 +95,7 @@ If you want to disable Zed looking for a `rust-analyzer` binary, you can set `ig If you want to use a binary in a custom location, you can specify a `path` and optional `arguments`: -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -114,7 +114,7 @@ This `"path"` has to be an absolute path. If you want rust-analyzer to provide diagnostics for a target other than your current platform (e.g. for windows when running on macOS) you can use the following Zed lsp settings: -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -139,7 +139,7 @@ rustup target list --installed Zed provides tasks using tree-sitter, but rust-analyzer has an LSP extension method for querying file-related tasks via LSP. This is enabled by default and can be configured as -```json +```json [settings] "lsp": { "rust-analyzer": { "enable_lsp_tasks": true, @@ -191,7 +191,7 @@ Check on save feature is responsible for returning part of the diagnostics based Consider more `rust-analyzer.cargo.` and `rust-analyzer.check.` and `rust-analyzer.diagnostics.` settings from the manual for more fine-grained configuration. Here's a snippet for Zed settings.json (the language server will restart automatically after the `lsp.rust-analyzer` section is edited and saved): -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -225,7 +225,7 @@ Here's a snippet for Zed settings.json (the language server will restart automat If you want rust-analyzer to analyze multiple Rust projects in the same folder that are not listed in `[members]` in the Cargo workspace, you can list them in `linkedProjects` in the local project settings: -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -241,7 +241,7 @@ you can list them in `linkedProjects` in the local project settings: There's a way to get custom completion items from rust-analyzer, that will transform the code according to the snippet body: -```json +```json [settings] { "lsp": { "rust-analyzer": { @@ -294,13 +294,16 @@ There's a way to get custom completion items from rust-analyzer, that will trans ## Debugging -Zed supports debugging Rust binaries and tests out of the box. Run {#action debugger::Start} ({#kb debugger::Start}) to launch one of these preconfigured debug tasks. +Zed supports debugging Rust binaries and tests out of the box with `CodeLLDB` and `GDB`. Run {#action debugger::Start} ({#kb debugger::Start}) to launch one of these preconfigured debug tasks. For more control, you can add debug configurations to `.zed/debug.json`. See the examples below. +- [CodeLLDB configuration documentation](https://github.com/vadimcn/codelldb/blob/master/MANUAL.md#starting-a-new-debug-session) +- [GDB configuration documentation](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Debugger-Adapter-Protocol.html) + ### Build binary then debug -```json +```json [debug] [ { "label": "Build & Debug native binary", @@ -321,7 +324,7 @@ For more control, you can add debug configurations to `.zed/debug.json`. See the When you use `cargo build` or `cargo test` as the build command, Zed can infer the path to the output binary. -```json +```json [debug] [ { "label": "Build & Debug native binary", diff --git a/docs/src/languages/sh.md b/docs/src/languages/sh.md index abc8f03a6c..cf88c89bfa 100644 --- a/docs/src/languages/sh.md +++ b/docs/src/languages/sh.md @@ -8,7 +8,7 @@ Shell Scripts (bash, zsh, dash, sh) are supported natively by Zed. You can configure various settings for Shell Scripts in your Zed User Settings (`~/.config/zed/settings.json`) or Zed Project Settings (`.zed/settings.json`): -```json +```json [settings] "languages": { "Shell Script": { "tab_size": 2, @@ -41,7 +41,7 @@ shfmt --version 3. Configure Zed to automatically format Shell Scripts with `shfmt` on save: -```json +```json [settings] "languages": { "Shell Script": { "format_on_save": "on", diff --git a/docs/src/languages/sql.md b/docs/src/languages/sql.md index 7993450a04..fd257c9ab0 100644 --- a/docs/src/languages/sql.md +++ b/docs/src/languages/sql.md @@ -23,7 +23,7 @@ sql-formatter --version 3. Configure Zed to automatically format SQL with `sql-formatter`: -```json +```json [settings] "languages": { "SQL": { "formatter": { @@ -44,7 +44,7 @@ You can add this to Zed project settings (`.zed/settings.json`) or via your Zed Sql-formatter also allows more precise control by providing [sql-formatter configuration options](https://github.com/sql-formatter-org/sql-formatter#configuration-options). To provide these, create a `.sql-formatter.json` file in your project: -```json +```json [settings] { "language": "postgresql", "tabWidth": 2, @@ -55,7 +55,7 @@ Sql-formatter also allows more precise control by providing [sql-formatter confi When using a `.sql-formatter.json` file you can use a more simplified set of Zed settings since the language need not be specified inline: -```json +```json [settings] "languages": { "SQL": { "formatter": { diff --git a/docs/src/languages/svelte.md b/docs/src/languages/svelte.md index 66d0d0cb50..139195987b 100644 --- a/docs/src/languages/svelte.md +++ b/docs/src/languages/svelte.md @@ -9,7 +9,7 @@ Svelte support is available through the [Svelte extension](https://github.com/ze You can modify how certain styles, such as directives and modifiers, appear in attributes: -```json +```json [settings] "syntax": { // Styling for directives (e.g., `class:foo` or `on:click`) (the `on` or `class` part of the attribute). "attribute.function": { @@ -26,7 +26,7 @@ You can modify how certain styles, such as directives and modifiers, appear in a When inlay hints is enabled in Zed, to make the language server send them back, Zed sets the following initialization options: -```json +```json [settings] "inlayHints": { "parameterNames": { "enabled": "all", @@ -53,16 +53,16 @@ When inlay hints is enabled in Zed, to make the language server send them back, To override these settings, use the following: -```json +```json [settings] "lsp": { "svelte-language-server": { "initialization_options": { "configuration": { "typescript": { - ...... + // ...... }, "javascript": { - ...... + // ...... } } } diff --git a/docs/src/languages/swift.md b/docs/src/languages/swift.md index 9b056be5bc..1492942fe8 100644 --- a/docs/src/languages/swift.md +++ b/docs/src/languages/swift.md @@ -18,11 +18,13 @@ Zed's name for the adapter (in the UI and `debug.json`) is `Swift`, and under th The extension tries to find an `lldb-dap` binary using `swiftly`, using `xcrun`, and by searching `$PATH`, in that order of preference. The extension doesn't attempt to download `lldb-dap` if it's not found. +- [lldb-dap configuration documentation](https://github.com/llvm/llvm-project/blob/main/lldb/tools/lldb-dap/README.md#configuration-settings-reference) + ### Examples #### Build and debug a Swift binary -```json +```json [debug] [ { "label": "Debug Swift", diff --git a/docs/src/languages/tailwindcss.md b/docs/src/languages/tailwindcss.md index 4409a12bf0..be9c9437d1 100644 --- a/docs/src/languages/tailwindcss.md +++ b/docs/src/languages/tailwindcss.md @@ -8,18 +8,18 @@ Zed has built-in support for Tailwind CSS autocomplete, linting, and hover previ To configure the Tailwind CSS language server, refer [to the extension settings](https://github.com/tailwindlabs/tailwindcss-intellisense?tab=readme-ov-file#extension-settings) and add them to the `lsp` section of your `settings.json`: -```jsonc +```json [settings] { "lsp": { "tailwindcss-language-server": { "settings": { "classFunctions": ["cva", "cx"], "experimental": { - "classRegex": ["[cls|className]\\s\\:\\=\\s\"([^\"]*)"], - }, - }, - }, - }, + "classRegex": ["[cls|className]\\s\\:\\=\\s\"([^\"]*)"] + } + } + } + } } ``` @@ -28,6 +28,7 @@ Languages which can be used with Tailwind CSS in Zed: - [Astro](./astro.md) - [CSS](./css.md) - [ERB](./ruby.md) +- [Gleam](./gleam.md) - [HEEx](./elixir.md#heex) - [HTML](./html.md) - [TypeScript](./typescript.md) @@ -40,7 +41,7 @@ Languages which can be used with Tailwind CSS in Zed: Zed supports Prettier out of the box, which means that if you have the [Tailwind CSS Prettier plugin](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) installed, adding it to your Prettier configuration will make it work automatically: -```json +```json [settings] // .prettierrc { "plugins": ["prettier-plugin-tailwindcss"] diff --git a/docs/src/languages/terraform.md b/docs/src/languages/terraform.md index 401526f169..c1ff03a83a 100644 --- a/docs/src/languages/terraform.md +++ b/docs/src/languages/terraform.md @@ -13,7 +13,7 @@ TBD: Add example using `rootModulePaths` to match upstream example https://githu The Terraform language server can be configured in your `settings.json`, e.g.: -```json +```json [settings] { "lsp": { "terraform-ls": { diff --git a/docs/src/languages/typescript.md b/docs/src/languages/typescript.md index 02d2672cb6..a6ec5b71ec 100644 --- a/docs/src/languages/typescript.md +++ b/docs/src/languages/typescript.md @@ -16,7 +16,7 @@ TBD: Document the difference between Language servers By default Zed uses [vtsls](https://github.com/yioneko/vtsls) for TypeScript, TSX, and JavaScript files. You can configure the use of [typescript-language-server](https://github.com/typescript-language-server/typescript-language-server) per language in your settings file: -```json +```json [settings] { "languages": { "TypeScript": { @@ -34,7 +34,7 @@ You can configure the use of [typescript-language-server](https://github.com/typ Prettier will also be used for TypeScript files by default. To disable this: -```json +```json [settings] { "languages": { "TypeScript": { @@ -49,7 +49,7 @@ Prettier will also be used for TypeScript files by default. To disable this: `vtsls` may run out of memory on very large projects. We default the limit to 8092 (8 GiB) vs. the default of 3072 but this may not be sufficient for you: -```json +```json [settings] { "lsp": { "vtsls": { @@ -70,7 +70,7 @@ Zed sets the following initialization options to make the language server send b You can override these settings in your Zed `settings.json` when using `typescript-language-server`: -```json +```json [settings] { "lsp": { "typescript-language-server": { @@ -95,7 +95,7 @@ See [typescript-language-server inlayhints documentation](https://github.com/typ When using `vtsls`: -```json +```json [settings] { "lsp": { "vtsls": { @@ -158,23 +158,33 @@ When using `vtsls`: ## Debugging -Zed supports debugging TypeScript code out of the box. +Zed supports debugging TypeScript code out of the box with `vscode-js-debug`. The following can be debugged without writing additional configuration: - Tasks from `package.json` -- Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine) +- Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine, Bun, Node) Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks. +> **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`. +> +> **Note:** Node test is automatically detected when `@types/node` is present in `package.json` (requires Node.js 20+). + As for all languages, configurations from `.vscode/launch.json` are also available for debugging in Zed. If your use-case isn't covered by any of these, you can take full control by adding debug configurations to `.zed/debug.json`. See below for example configurations. +### Configuring JavaScript debug tasks + +JavaScript debugging is more complicated than other languages because there are two different environments: Node.js and the browser. `vscode-js-debug` exposes a `type` field, that you can use to specify the environment, either `node` or `chrome`. + +- [vscode-js-debug configuration documentation](https://github.com/microsoft/vscode-js-debug/blob/main/OPTIONS.md) + ### Attach debugger to a server running in web browser (`npx serve`) Given an externally-ran web server (e.g., with `npx serve` or `npx live-server`) one can attach to it and open it with a browser. -```json +```json [debug] [ { "label": "Launch Chrome (TypeScript)", diff --git a/docs/src/languages/xml.md b/docs/src/languages/xml.md index 4318756a10..df3d845d6d 100644 --- a/docs/src/languages/xml.md +++ b/docs/src/languages/xml.md @@ -8,7 +8,7 @@ XML support is available through the [XML extension](https://github.com/sweetppr If you have additional file extensions that are not being automatically recognized as XML just add them to [file_types](../configuring-zed.md#file-types) in your Zed settings: -```json +```json [settings] "file_types": { "XML": ["rdf", "gpx", "kml"] } diff --git a/docs/src/languages/yaml.md b/docs/src/languages/yaml.md index 68167e8734..33b92df94e 100644 --- a/docs/src/languages/yaml.md +++ b/docs/src/languages/yaml.md @@ -9,7 +9,7 @@ YAML support is available natively in Zed. You can configure various [yaml-language-server settings](https://github.com/redhat-developer/yaml-language-server?tab=readme-ov-file#language-server-settings) by adding them to your Zed settings.json in a `yaml-language-server` block under the `lsp` key. For example: -```json +```json [settings] "lsp": { "yaml-language-server": { "settings": { @@ -19,7 +19,7 @@ You can configure various [yaml-language-server settings](https://github.com/red "singleQuote": true }, "schemas": { - "http://json.schemastore.org/composer": ["/*"], + "https://getcomposer.org/schema.json": ["/*"], "../relative/path/schema.json": ["/config*.yaml"] } } @@ -38,7 +38,7 @@ By default, Zed uses Prettier for formatting YAML files. You can customize the formatting behavior of Prettier. For example to use single-quotes in yaml files add the following to your `.prettierrc` configuration file: -```json +```json [settings] { "overrides": [ { @@ -55,7 +55,7 @@ You can customize the formatting behavior of Prettier. For example to use single To use `yaml-language-server` instead of Prettier for YAML formatting, add the following to your Zed `settings.json`: -```json +```json [settings] "languages": { "YAML": { "formatter": "language_server" @@ -70,16 +70,16 @@ By default yaml-language-server will attempt to determine the correct schema for You can override any auto-detected schema via the `schemas` settings key (demonstrated above) or by providing an [inlined schema](https://github.com/redhat-developer/yaml-language-server#using-inlined-schema) reference via a modeline comment at the top of your yaml file: ```yaml -# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +# yaml-language-server: $schema=https://www.schemastore.org/github-action.json name: Issue Assignment on: issues: - types: [oppened] + types: [opened] ``` You can disable the automatic detection and retrieval of schemas from the JSON Schema if desired: -```json +```json [settings] "lsp": { "yaml-language-server": { "settings": { @@ -99,7 +99,7 @@ Yaml-language-server supports [custom tags](https://github.com/redhat-developer/ For example Amazon CloudFormation YAML uses a number of custom tags, to support these you can add the following to your settings.json: -```json +```json [settings] "lsp": { "yaml-language-server": { "settings": { diff --git a/docs/src/linux.md b/docs/src/linux.md index 1b9c061e71..b535a5e78a 100644 --- a/docs/src/linux.md +++ b/docs/src/linux.md @@ -41,6 +41,7 @@ There are several third-party Zed packages for various Linux distributions and p - Arch: [`zed`](https://archlinux.org/packages/extra/x86_64/zed/) - Arch (AUR): [`zed-git`](https://aur.archlinux.org/packages/zed-git), [`zed-preview`](https://aur.archlinux.org/packages/zed-preview), [`zed-preview-bin`](https://aur.archlinux.org/packages/zed-preview-bin) - Alpine: `zed` ([aarch64](https://pkgs.alpinelinux.org/package/edge/testing/aarch64/zed)) ([x86_64](https://pkgs.alpinelinux.org/package/edge/testing/x86_64/zed)) +- Conda: [`zed`](https://anaconda.org/conda-forge/zed) - Nix: `zed-editor` ([unstable](https://search.nixos.org/packages?channel=unstable&show=zed-editor)) - Fedora/Ultramarine (Terra): [`zed`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/stable), [`zed-preview`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/preview), [`zed-nightly`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/nightly) - Solus: [`zed`](https://github.com/getsolus/packages/tree/main/packages/z/zed) @@ -51,19 +52,31 @@ There are several third-party Zed packages for various Linux distributions and p See [Repology](https://repology.org/project/zed-editor/versions) for a list of Zed packages in various repositories. +### Community + When installing a third-party package please be aware that it may not be completely up to date and may be slightly different from the Zed we package (a common change is to rename the binary to `zedit` or `zeditor` to avoid conflicting with other packages). We'd love your help making Zed available for everyone. If Zed is not yet available for your package manager, and you would like to fix that, we have some notes on [how to do it](./development/linux.md#notes-for-packaging-zed). +The packages in this section provide binary installs for Zed but are not official packages within the associated distributions. These packages are maintained by community members and as such a higher level of caution should be taken when installing them. + +#### Debian + +Zed is available in [this community-maintained repository](https://debian.griffo.io/). + +Instructions for each version are available in the README of the repository where packages are built. +Build, packaging and instructions for each version are available in the README of the [repository](https://github.com/dariogriffo/zed-debian) + ### Downloading manually If you'd prefer, you can install Zed by downloading our pre-built .tar.gz. This is the same artifact that our install script uses, but you can customize the location of your installation by modifying the instructions below: Download the `.tar.gz` file: -- [zed-linux-x86_64.tar.gz](https://zed.dev/api/releases/stable/latest/zed-linux-x86_64.tar.gz) ([preview](https://zed.dev/api/releases/preview/latest/zed-linux-x86_64.tar.gz)) -- [zed-linux-aarch64.tar.gz](https://zed.dev/api/releases/stable/latest/zed-linux-aarch64.tar.gz) - ([preview](https://zed.dev/api/releases/preview/latest/zed-linux-aarch64.tar.gz)) +- [zed-linux-x86_64.tar.gz](https://cloud.zed.dev/releases/stable/latest/download?asset=zed&arch=x86_64&os=linux&source=docs) + ([preview](https://cloud.zed.dev/releases/preview/latest/download?asset=zed&arch=x86_64&os=linux&source=docs)) +- [zed-linux-aarch64.tar.gz](https://cloud.zed.dev/releases/stable/latest/download?asset=zed&arch=aarch64&os=linux&source=docs) + ([preview](https://cloud.zed.dev/releases/preview/latest/download?asset=zed&arch=aarch64&os=linux&source=docs)) Then ensure that the `zed` binary in the tarball is on your path. The easiest way is to unpack the tarball and create a symlink: @@ -179,7 +192,7 @@ Make sure to export the variable if you choose to define it globally in a `.bash ##### Option B -If you are using Mesa, you can run `MESA_VK_DEVICE_SELECT=list zed --foreground` to get a list of available GPUs and then export `MESA_VK_DEVICE_SELECT=xxxx:yyyy` to choose a specific device. +If you are using Mesa, you can run `MESA_VK_DEVICE_SELECT=list zed --foreground` to get a list of available GPUs and then export `MESA_VK_DEVICE_SELECT=xxxx:yyyy` to choose a specific device. Furthermore, you can fallback to xwayland with an additional export of `WAYLAND_DISPLAY=""`. ##### Option C diff --git a/docs/src/migrate/vs-code.md b/docs/src/migrate/vs-code.md new file mode 100644 index 0000000000..dd7419e3ff --- /dev/null +++ b/docs/src/migrate/vs-code.md @@ -0,0 +1,373 @@ +# How to Migrate from VS Code to Zed + +This guide is for developers who spent serious time in VS Code and want to try Zed without starting from scratch. + +If you’re here, you might be looking for a faster editor. Or something less cluttered. Or you’re curious about built-in collaboration. Whatever brought you here, this guide helps you move over your habits, shortcuts, and settings. + +We’ll cover what to bring, what to change, and what’s different. You can ease in gradually or switch all at once. Either way, you’ll stay productive. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: +`brew install zed-editor/zed/zed` + +For most Linux users, the easiest way to install Zed is through our installation script: +`curl -f https://zed.dev/install.sh | sh` + +After installation, you can launch Zed from your Applications folder (macOS) or directly from the terminal (Linux) using: +`zed .` +This opens the current directory in Zed. + +## Import Settings from VS Code + +During setup, you have the option to import key settings from VS Code. Zed imports the following settings: + +### Settings Imported from VS Code + +The following VS Code settings are automatically imported when you use **Import Settings from VS Code**: + +**Editor** + +| VS Code Setting | Zed Setting | +| ------------------------------------------- | ---------------------------------------------- | +| `editor.fontFamily` | `buffer_font_family` | +| `editor.fontSize` | `buffer_font_size` | +| `editor.fontWeight` | `buffer_font_weight` | +| `editor.tabSize` | `tab_size` | +| `editor.insertSpaces` | `hard_tabs` (inverted) | +| `editor.wordWrap` | `soft_wrap` | +| `editor.wordWrapColumn` | `preferred_line_length` | +| `editor.cursorStyle` | `cursor_shape` | +| `editor.cursorBlinking` | `cursor_blink` | +| `editor.renderLineHighlight` | `current_line_highlight` | +| `editor.lineNumbers` | `gutter.line_numbers`, `relative_line_numbers` | +| `editor.showFoldingControls` | `gutter.folds` | +| `editor.minimap.enabled` | `minimap.show` | +| `editor.minimap.autohide` | `minimap.show` | +| `editor.minimap.showSlider` | `minimap.thumb` | +| `editor.minimap.maxColumn` | `minimap.max_width_columns` | +| `editor.stickyScroll.enabled` | `sticky_scroll.enabled` | +| `editor.scrollbar.horizontal` | `scrollbar.axes.horizontal` | +| `editor.scrollbar.vertical` | `scrollbar.axes.vertical` | +| `editor.mouseWheelScrollSensitivity` | `scroll_sensitivity` | +| `editor.fastScrollSensitivity` | `fast_scroll_sensitivity` | +| `editor.cursorSurroundingLines` | `vertical_scroll_margin` | +| `editor.hover.enabled` | `hover_popover_enabled` | +| `editor.hover.delay` | `hover_popover_delay` | +| `editor.parameterHints.enabled` | `auto_signature_help` | +| `editor.multiCursorModifier` | `multi_cursor_modifier` | +| `editor.selectionHighlight` | `selection_highlight` | +| `editor.roundedSelection` | `rounded_selection` | +| `editor.find.seedSearchStringFromSelection` | `seed_search_query_from_cursor` | +| `editor.rulers` | `wrap_guides` | +| `editor.renderWhitespace` | `show_whitespaces` | +| `editor.guides.indentation` | `indent_guides.enabled` | +| `editor.linkedEditing` | `linked_edits` | +| `editor.autoSurround` | `use_auto_surround` | +| `editor.formatOnSave` | `format_on_save` | +| `editor.formatOnPaste` | `auto_indent_on_paste` | +| `editor.formatOnType` | `use_on_type_format` | +| `editor.trimAutoWhitespace` | `remove_trailing_whitespace_on_save` | +| `editor.suggestOnTriggerCharacters` | `show_completions_on_input` | +| `editor.suggest.showWords` | `completions.words` | +| `editor.inlineSuggest.enabled` | `show_edit_predictions` | + +**Files & Workspace** + +| VS Code Setting | Zed Setting | +| --------------------------- | ------------------------------ | +| `files.autoSave` | `autosave` | +| `files.autoSaveDelay` | `autosave.milliseconds` | +| `files.insertFinalNewline` | `ensure_final_newline_on_save` | +| `files.associations` | `file_types` | +| `files.watcherExclude` | `file_scan_exclusions` | +| `files.watcherInclude` | `file_scan_inclusions` | +| `files.simpleDialog.enable` | `use_system_path_prompts` | +| `search.smartCase` | `use_smartcase_search` | +| `search.useIgnoreFiles` | `search.include_ignored` | + +**Terminal** + +| VS Code Setting | Zed Setting | +| ------------------------------------- | ----------------------------------- | +| `terminal.integrated.fontFamily` | `terminal.font_family` | +| `terminal.integrated.fontSize` | `terminal.font_size` | +| `terminal.integrated.lineHeight` | `terminal.line_height` | +| `terminal.integrated.cursorStyle` | `terminal.cursor_shape` | +| `terminal.integrated.cursorBlinking` | `terminal.blinking` | +| `terminal.integrated.copyOnSelection` | `terminal.copy_on_select` | +| `terminal.integrated.scrollback` | `terminal.max_scroll_history_lines` | +| `terminal.integrated.macOptionIsMeta` | `terminal.option_as_meta` | +| `terminal.integrated.{platform}Exec` | `terminal.shell` | +| `terminal.integrated.env.{platform}` | `terminal.env` | + +**Tabs & Panels** + +| VS Code Setting | Zed Setting | +| -------------------------------------------------- | -------------------------------------------------- | +| `workbench.editor.showTabs` | `tab_bar.show` | +| `workbench.editor.showIcons` | `tabs.file_icons` | +| `workbench.editor.tabActionLocation` | `tabs.close_position` | +| `workbench.editor.tabActionCloseVisibility` | `tabs.show_close_button` | +| `workbench.editor.focusRecentEditorAfterClose` | `tabs.activate_on_close` | +| `workbench.editor.enablePreview` | `preview_tabs.enabled` | +| `workbench.editor.enablePreviewFromQuickOpen` | `preview_tabs.enable_preview_from_file_finder` | +| `workbench.editor.enablePreviewFromCodeNavigation` | `preview_tabs.enable_preview_from_code_navigation` | +| `workbench.editor.editorActionsLocation` | `tab_bar.show_tab_bar_buttons` | +| `workbench.editor.limit.enabled` / `value` | `max_tabs` | +| `workbench.editor.restoreViewState` | `restore_on_file_reopen` | +| `workbench.statusBar.visible` | `status_bar.show` | + +**Project Panel (File Explorer)** + +| VS Code Setting | Zed Setting | +| ------------------------------ | ----------------------------------- | +| `explorer.compactFolders` | `project_panel.auto_fold_dirs` | +| `explorer.autoReveal` | `project_panel.auto_reveal_entries` | +| `explorer.excludeGitIgnore` | `project_panel.hide_gitignore` | +| `problems.decorations.enabled` | `project_panel.show_diagnostics` | +| `explorer.decorations.badges` | `project_panel.git_status` | + +**Git** + +| VS Code Setting | Zed Setting | +| ------------------------------------ | ---------------------------------------------- | +| `git.enabled` | `git_panel.button` | +| `git.defaultBranchName` | `git_panel.fallback_branch_name` | +| `git.decorations.enabled` | `git.inline_blame`, `project_panel.git_status` | +| `git.blame.editorDecoration.enabled` | `git.inline_blame.enabled` | + +**Window & Behavior** + +| VS Code Setting | Zed Setting | +| ------------------------------------------------ | ---------------------------------------- | +| `window.confirmBeforeClose` | `confirm_quit` | +| `window.nativeTabs` | `use_system_window_tabs` | +| `window.closeWhenEmpty` | `when_closing_with_no_tabs` | +| `accessibility.dimUnfocused.enabled` / `opacity` | `active_pane_modifiers.inactive_opacity` | + +**Other** + +| VS Code Setting | Zed Setting | +| -------------------------- | -------------------------------------------------------- | +| `http.proxy` | `proxy` | +| `npm.packageManager` | `node.npm_path` | +| `telemetry.telemetryLevel` | `telemetry.metrics`, `telemetry.diagnostics` | +| `outline.icons` | `outline_panel.file_icons`, `outline_panel.folder_icons` | +| `chat.agent.enabled` | `agent.enabled` | +| `mcp` | `context_servers` | + +Zed doesn’t import extensions or keybindings, but this is the fastest way to get a familiar feel while trying something new. If you skip that step during setup, you can still import settings manually later via the command palette: + +`Cmd+Shift+P → Zed: Import VS Code Settings` + +## Set Up Editor Preferences + +You can also configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Here’s how common VS Code settings translate: +| VS Code | Zed | Notes | +| --- | --- | --- | +| editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default | +| editor.fontSize | buffer_font_size | Set in pixels | +| editor.tabSize | tab_size | Can override per language | +| editor.insertSpaces | insert_spaces | Boolean | +| editor.formatOnSave | format_on_save | Works with formatter enabled | +| editor.wordWrap | soft_wrap | Supports optional wrap column | + +Zed also supports per-project settings. You can find these in the Settings Editor as well. + +## Open or Create a Project + +After setup, press `Cmd+O` (`Ctrl+O` on Linux) to open a folder. This becomes your workspace in Zed. There's no support for multi-root workspaces or `.code-workspace` files like in VS Code. Zed keeps it simple: one folder, one workspace. + +To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. + +You can also launch Zed from the terminal inside any folder with: +`zed .` + +Once inside a project, use `Cmd+P` to jump between files quickly. `Cmd+Shift+P` (`Ctrl+Shift+P` on Linux) opens the command palette for running actions / tasks, toggling settings, or starting a collaboration session. + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Collapse it with `Cmd+B` for a distraction-free view. + +## Differences in Keybindings + +If you chose the VS Code keymap during onboarding, you're likely good to go, and most of your shortcuts should already feel familiar. +Here’s a quick reference guide for how our keybindings compare to what you’re used to coming from VS Code. + +### Common Shared Keybindings (Zed <> VS Code) + +| Action | Shortcut | +| --------------------------- | ---------------------- | +| Find files | `Cmd + P` | +| Run a command | `Cmd + Shift + P` | +| Search text (project-wide) | `Cmd + Shift + F` | +| Find symbols (project-wide) | `Cmd + T` | +| Find symbols (file-wide) | `Cmd + Shift + O` | +| Toggle left dock | `Cmd + B` | +| Toggle bottom dock | `Cmd + J` | +| Open terminal | `Ctrl + ~` | +| Open file tree explorer | `Cmd + Shift + E` | +| Close current buffer | `Cmd + W` | +| Close whole project | `Cmd + Shift + W` | +| Refactor: rename symbol | `F2` | +| Change theme | `Cmd + K, Cmd + T` | +| Wrap text | `Opt + Z` | +| Navigate open tabs | `Cmd + Opt + Arrow` | +| Syntactic fold / unfold | `Cmd + Opt + {` or `}` | + +### Different Keybindings (Zed <> VS Code) + +| Action | VS Code | Zed | +| ------------------- | --------------------- | ---------------------- | +| Open recent project | `Ctrl + R` | `Cmd + Opt + O` | +| Move lines up/down | `Opt + Up/Down` | `Cmd + Ctrl + Up/Down` | +| Split panes | `Cmd + \` | `Cmd + K, Arrow Keys` | +| Expand Selection | `Shift + Alt + Right` | `Opt + Up` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ------------------- | ---------------------------- | ------------------------------------------------ | +| Toggle right dock | `Cmd + R` or `Cmd + Alt + B` | | +| Syntactic selection | `Opt + Up/Down` | Selects code by structure (e.g., inside braces). | + +### How to Customize Keybindings + +To edit your keybindings: + +- Open the command palette (`Cmd+Shift+P`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts, remove conflicts, or build a layout that works better for your setup. + +Zed also supports chords (multi-key sequences) like `Cmd+K Cmd+C`, like VS Code does. + +## Differences in User Interfaces + +### No Workspace + +VS Code uses a dedicated Workspace concept, with multi-root folders, `.code-workspace` files, and a clear distinction between “a window” and “a workspace.” +Zed simplifies this model. + +In Zed: + +- There is no workspace file format. Opening a folder is your project context. + +- Zed does not support multi-root workspaces. You can only open one folder at a time in a window. + +- Most project-level behavior is scoped to the folder you open. Search, Git integration, tasks, and environment detection all treat the opened directory as the project root. + +- Per-project settings are optional. You can add a `.zed/settings.json` file inside a project to override global settings, but Zed does not use `.code-workspace` files and won’t import them. + +- You can start from a single file or an empty window. Zed doesn’t require you to open a folder to begin editing. + +The result is a simpler model: +Open a folder → work inside that folder → no additional workspace layer. + +### Navigating in a Project + +In VS Code, the standard entry point is opening a folder. From there, the left-hand sidebar is central to your navigation. +Zed takes a different approach: + +- You can still open folders, but you don’t need to. Opening a single file or even starting with an empty workspace is valid. +- The Command Palette (`Cmd+Shift+P`) and File Finder (`Cmd+P`) are your primary navigation tools. The File Finder searches across the entire workspace instantly; files, symbols, commands, even teammates if you're collaborating. +- Instead of a persistent sidebar, Zed encourages you to: + - Fuzzy-find files by name (`Cmd+P`) + - Jump directly to symbols (`Cmd+Shift+O`) + - Use split panes and tabs for context, rather than keeping a large file tree open (though you can do this with the Project Panel if you prefer). + +The UI is intentionally minimal. Panels slide in only when needed, then get out of your way. The focus is on flowing between code instead of managing panes. + +### Extensions vs. Marketplace + +Zed does not offer as many extensions as VS Code. The available extensions are focused on language support, themes, syntax highlighting, and other core editing enhancements. + +However there are several features that typically require extensions in VS Code which we built directly into Zed: + +- Real-time collaboration with voice and cursor sharing (no Live Share required) +- AI coding assistance (no Copilot extension needed) +- Built-in terminal panel +- Project-wide fuzzy search +- Task runner with JSON config +- Inline diagnostics and code actions via LSP + +You won’t find one-to-one replacements for every VS Code extension, especially if you rely on tools for DevOps, containers, or test runners. Zed's extension ecosystem is still growing, and the catalog is smaller by design. + +### Collaboration in Zed vs. VS Code + +Unlike VS Code, Zed doesn’t require an extension to collaborate. It’s built into the core experience. + +- Open the Collab Panel in the left dock. +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join. +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly. + +Once connected, you’ll see each other's cursors, selections, and edits in real time. Voice chat is included, so you can talk as you work. There’s no need for separate tools or third-party logins. Zed’s collaboration is designed for everything from quick pair programming to longer team sessions. + +Learn how [Zed uses Zed](https://zed.dev/blog/zed-is-our-office) to plan work and collaborate. + +### Using AI in Zed + +If you’re used to GitHub Copilot in VS Code, you can do the same in Zed. You can also explore other agents through Zed Pro, or bring your own keys and connect without authentication. Zed is designed to enable many options for using AI, including disabling it entirely. + +#### Configuring GitHub Copilot + +You should be able to sign-in to GitHub Copilot by clicking on the Zeta icon in the status bar and following the setup instructions. +You can also add this to your settings: + +```json +{ + "features": { + "edit_prediction_provider": "copilot" + } +} +``` + +To invoke completions, just start typing. Zed will offer suggestions inline for you to accept. + +#### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed’s hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html). + +### Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks: + +**Format on Save:** + +```json +"format_on_save": "on" +``` + +**Enable direnv support:** + +```json +"load_direnv": "shell_hook" +``` + +**Custom Tasks**: Define build or run commands in your `tasks.json` (accessed via command palette: `zed: open tasks`): + +```json +[ + { + "label": "build", + "command": "cargo build" + } +] +``` + +**Bring over custom snippets** +Copy your VS Code snippet JSON directly into Zed's snippets folder (`zed: configure snippets`). diff --git a/docs/src/performance.md b/docs/src/performance.md new file mode 100644 index 0000000000..544e39e94b --- /dev/null +++ b/docs/src/performance.md @@ -0,0 +1,93 @@ +How to use our internal tools to profile and keep Zed fast. + +# Rough quick CPU profiling (Flamechart) + +See what the CPU spends the most time on. Strongly recommend you use +[samply](https://github.com/mstange/samply). It opens an interactive profile in +the browser (specifically a local instance of [firefox_profiler](https://profiler.firefox.com/)). + +See [samply](https://github.com/mstange/samply)'s README on how to install and run. + +The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner. + +image + +# In depth CPU profiling (Tracing) + +See how long each annotated function call took and its arguments (if +configured). + +Annotate any function you need appear in the profile with instrument. For more +details see +[tracing-instrument](https://docs.rs/tracing/latest/tracing/attr.instrument.html): + +```rust +#[instrument(skip_all)] +fn should_appear_in_profile(kitty: Cat) { + sleep(QUITE_LONG) +} +``` + +Then either compile Zed with `ZTRACING=1 cargo r --features tracy --release`. The release build is optional but highly recommended as like every program Zeds performance characteristics change dramatically with optimizations. You do not want to chase slowdowns that do not exist in release. + +## One time Setup/Building the profiler: + +Download the profiler: +[linux x86_64](https://zed-tracy-import-miniprofiler.nyc3.digitaloceanspaces.com/tracy-profiler-linux-x86_64) +[macos aarch64](https://zed-tracy-import-miniprofiler.nyc3.digitaloceanspaces.com/tracy-profiler-0.13.0-macos-aarch64) + +### Alternative: Building it yourself + +- Clone the repo at git@github.com:wolfpld/tracy.git +- `cd profiler && mkdir build && cd build` +- Run cmake to generate build files: `cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ..` +- Build the profiler: `ninja` +- [Optional] move the profiler somewhere nice like ~/.local/bin on linux + +## Usage + +Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it. +image + +To find functions that take a long time follow this image: +image + +# Task/Async profiling + +Get a profile of the zed foreground executor and background executors. Check if +anything is blocking the foreground too long or taking too much (clock) time in +the background. + +The profiler always runs in the background. You can save a trace from its UI or +look at the results live. + +## Setup/Building the importer: + +Download the importer +[linux x86_64](https://zed-tracy-import-miniprofiler.nyc3.digitaloceanspaces.com/tracy-import-miniprofiler-linux-x86_64) +[mac aarch64](https://zed-tracy-import-miniprofiler.nyc3.digitaloceanspaces.com/tracy-import-miniprofiler-macos-aarch64) + +### Alternative: Building it yourself + +- Clone the repo at git@github.com:zed-industries/tracy.git on v0.12.2 branch +- `cd import && mkdir build && cd build` +- Run cmake to generate build files: `cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ..` +- Build the importer: `ninja` +- Run the importer on the trace file: `./tracy-import-miniprofiler /path/to/trace.miniprof /path/to/output.tracy` +- Open the trace in tracy: + - If you're on windows download the v0.12.2 version from the releases on the upstream repo + - If you're on other platforms open it on the website: https://tracy.nereid.pl/ (the version might mismatch so your luck might vary, we need to host our own ideally..) + +## To Save a Trace: + +- Run the action: `zed open performance profiler` +- Hit the save button. This opens a save dialog or if that fails to open the trace gets saved in your working directory. +- Convert the profile so it can be imported in tracy using the importer: `./tracy-import-miniprofiler output.tracy` +- Go to hit the 'power button' in the top left and then open saved trace. +- Now zoom in to see the tasks and how long they took + +# Warn if function is slow + +```rust +let _timer = zlog::time!("my_function_name").warn_if_gt(std::time::Duration::from_millis(100)); +``` diff --git a/docs/src/quick-start.md b/docs/src/quick-start.md new file mode 100644 index 0000000000..05cf8c1fd0 --- /dev/null +++ b/docs/src/quick-start.md @@ -0,0 +1 @@ +# Quick Start diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index e597e7a6c5..c25d160a17 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -29,13 +29,13 @@ The remote machine must be able to run Zed's server. The following platforms sho - macOS Catalina or later (Intel or Apple Silicon) - Linux (x86_64 or arm64, we do not yet support 32-bit platforms) -- Windows is not yet supported. +- Windows is not yet supported as a remote server, but Windows can be used as a local machine to connect to remote servers. ## Configuration The list of remote servers is stored in your settings file {#kb zed::OpenSettings}. You can edit this list using the Remote Projects dialog {#kb projects::OpenRemote}, which provides some robustness - for example it checks that the connection can be established before writing it to the settings file. -```json +```json [settings] { "ssh_connections": [ { @@ -48,7 +48,7 @@ The list of remote servers is stored in your settings file {#kb zed::OpenSetting Zed shells out to the `ssh` on your path, and so it will inherit any configuration you have in `~/.ssh/config` for the given host. That said, if you need to override anything you can configure the following additional options on each connection: -```json +```json [settings] { "ssh_connections": [ { @@ -66,7 +66,7 @@ Zed shells out to the `ssh` on your path, and so it will inherit any configurati There are two additional Zed-specific options per connection, `upload_binary_over_ssh` and `nickname`: -```json +```json [settings] { "ssh_connections": [ { @@ -87,11 +87,33 @@ If you use the command line to open a connection to a host by doing `zed ssh://1 Additionally it's worth noting that while you can pass a password on the command line `zed ssh://user:password@host/~`, we do not support writing a password to your settings file. If you're connecting repeatedly to the same host, you should configure key-based authentication. +## Remote Development on Windows (SSH) + +Zed on Windows supports SSH remoting and will prompt for credentials when needed. + +If you encounter authentication issues, confirm that your SSH key agent is running (e.g., ssh-agent or your Git client's agent) and that ssh.exe is on PATH. + +### Troubleshooting SSH on Windows + +When prompted for credentials, use the graphical askpass dialog. If it doesn't appear, check for credential manager conflicts and that GUI prompts aren't blocked by your terminal. + +## WSL Support + +Zed supports opening folders inside of WSL natively on Windows. + +### Opening a local folder in WSL + +To open a local folder inside a WSL container, use the `projects: open in wsl` action and select the folder you want to open. You will be presented with a list of available WSL distributions to open the folder in. + +### Opening a folder already in WSL + +To open a folder that's already located inside of a WSL container, use the `projects: open wsl` action and select the WSL distribution. The distribution will be added to the `Remote Projects` window where you will be able to open the folder. + ## Port forwarding If you'd like to be able to connect to ports on your remote server from your local machine, you can configure port forwarding in your settings file. This is particularly useful for developing websites so you can load the site in your browser while working. -```json +```json [settings] { "ssh_connections": [ { @@ -106,7 +128,7 @@ This will cause requests from your local machine to `localhost:8080` to be forwa By default these ports are bound to localhost, so other computers in the same network as your development machine cannot access them. You can set the local_host to bind to a different interface, for example, 0.0.0.0 will bind to all local interfaces. -```json +```json [settings] { "ssh_connections": [ { @@ -125,7 +147,7 @@ By default these ports are bound to localhost, so other computers in the same ne These ports also default to the `localhost` interface on the remote host. If you need to change this, you can also set the remote host: -```json +```json [settings] { "ssh_connections": [ { @@ -152,14 +174,38 @@ When opening a remote project there are three relevant settings locations: Both the local Zed and the server Zed read the project settings, but they are not aware of the other's main `settings.json`. -Depending on the kind of setting you want to make, which settings file you should use: +Which settings file you should use depends on the kind of setting you want to make: - Project settings should be used for things that affect the project: indentation settings, which formatter / language server to use, etc. -- Server settings should be used for things that affect the server: paths to language servers, etc. +- Server settings should be used for things that affect the server: paths to language servers, proxy settings, etc. - Local settings should be used for things that affect the UI: font size, etc. In addition any extensions you have installed locally will be propagated to the remote server. This means that language servers, etc. will run correctly. +## Proxy Configuration + +The remote server will not use your local machine's proxy configuration because they may be under different network policies. If your remote server requires a proxy to access the internet, you must configure it on the remote server itself. + +In most cases, your remote server will already have proxy environment variables configured. Zed will automatically use them when downloading language servers, communicating with LLM models, etc. + +If needed, you can set these environment variables in the server's shell configuration (e.g., `~/.bashrc`): + +```bash +export http_proxy="http://proxy.example.com:8080" +export https_proxy="http://proxy.example.com:8080" +export no_proxy="localhost,127.0.0.1" +``` + +Alternatively, you can configure the proxy in the remote machine's `~/.config/zed/settings.json` (Linux) or `~/.zed/settings.json` (macOS): + +```json +{ + "proxy": "http://proxy.example.com:8080" +} +``` + +See the [proxy documentation](./configuring-zed.md#network-proxy) for supported proxy types and additional configuration options. + ## Initializing the remote server Once you provide the SSH options, Zed shells out to `ssh` on your local machine to create a ControlMaster connection with the options you provide. @@ -184,7 +230,7 @@ If you are struggling with connection issues, you should be able to see more inf ## Supported SSH Options -Under the hood, Zed shells out to the `ssh` binary to connect to the remote server. We create one SSH control master per project, and use then use that to multiplex SSH connections for the Zed protocol itself, any terminals you open and tasks you run. We read settings from your SSH config file, but if you want to specify additional options to the SSH control master you can configure Zed to set them. +Under the hood, Zed shells out to the `ssh` binary to connect to the remote server. We create one SSH control master per project, and then use that to multiplex SSH connections for the Zed protocol itself, any terminals you open and tasks you run. We read settings from your SSH config file, but if you want to specify additional options to the SSH control master you can configure Zed to set them. When typing in the "Connect New Server" dialog, you can use bash-style quoting to pass options containing a space. Once you have created a server it will be added to the `"ssh_connections": []` array in your settings file. You can edit the settings file directly to make changes to SSH connections. diff --git a/docs/src/repl.md b/docs/src/repl.md index 92b3d81f24..692093007c 100644 --- a/docs/src/repl.md +++ b/docs/src/repl.md @@ -149,7 +149,7 @@ TBD: Improve Julia REPL instructions Zed automatically detects the available kernels on your system. If you need to configure a different default kernel for a language, you can assign a kernel for any supported language in your `settings.json`. -```json +```json [settings] { "jupyter": { "kernel_selections": { diff --git a/docs/src/snippets.md b/docs/src/snippets.md index 6dc5355907..e84210d0fa 100644 --- a/docs/src/snippets.md +++ b/docs/src/snippets.md @@ -1,12 +1,12 @@ # Snippets -Use the {#action snippets::ConfigureSnippets} action to create a new snippets file or edit a existing snippets file for a specified [scope](#scopes). +Use the {#action snippets::ConfigureSnippets} action to create a new snippets file or edit an existing snippets file for a specified [scope](#scopes). The snippets are located in `~/.config/zed/snippets` directory to which you can navigate to with the {#action snippets::OpenFolder} action. ## Example configuration -```json +```json [settings] { // Each snippet must have a name and body, but the prefix and description are optional. // The prefix is used to trigger the snippet, but when omitted then the name is used. @@ -35,7 +35,7 @@ To create JSX snippets you have to use `javascript.json` snippets file, instead ## Known Limitations -- Only the first prefix is used when an list of prefixes is passed in. +- Only the first prefix is used when a list of prefixes is passed in. - Currently only the `json` snippet file format is supported, even though the `simple-completion-language-server` supports both `json` and `toml` file formats. ## See also @@ -44,7 +44,7 @@ The `feature_paths` option in `simple-completion-language-server` is disabled by If you want to enable it you can add the following to your `settings.json`: -```json +```json [settings] { "lsp": { "snippet-completion-server": { diff --git a/docs/src/system-requirements.md b/docs/src/system-requirements.md deleted file mode 100644 index 46c559c507..0000000000 --- a/docs/src/system-requirements.md +++ /dev/null @@ -1,54 +0,0 @@ -# System Requirements - -## Apple - -### macOS - -Zed supports the follow macOS releases: - -| Version | Codename | Apple Status | Zed Status | -| ------------- | -------- | -------------- | ------------------- | -| macOS 15.x | Sequoia | Supported | Supported | -| macOS 14.x | Sonoma | Supported | Supported | -| macOS 13.x | Ventura | Supported | Supported | -| macOS 12.x | Monterey | EOL 2024-09-16 | Supported | -| macOS 11.x | Big Sur | EOL 2023-09-26 | Partially Supported | -| macOS 10.15.x | Catalina | EOL 2022-09-12 | Partially Supported | -| macOS 10.14.x | Mojave | EOL 2021-10-25 | Unsupported | - -The macOS releases labelled "Partially Supported" (Big Sur and Catalina) do not support screen sharing via Zed Collaboration. These features use the [LiveKit SDK](https://livekit.io) which relies upon [ScreenCaptureKit.framework](https://developer.apple.com/documentation/screencapturekit/) only available on macOS 12 (Monterey) and newer. - -### Mac Hardware - -Zed supports machines with Intel (x86_64) or Apple (aarch64) processors that meet the above macOS requirements: - -- MacBook Pro (Early 2015 and newer) -- MacBook Air (Early 2015 and newer) -- MacBook (Early 2016 and newer) -- Mac Mini (Late 2014 and newer) -- Mac Pro (Late 2013 or newer) -- iMac (Late 2015 and newer) -- iMac Pro (all models) -- Mac Studio (all models) - -## Linux - -Zed supports 64bit Intel/AMD (x86_64) and 64Bit ARM (aarch64) processors. - -Zed requires a Vulkan 1.3 driver, and the following desktop portals: - -- `org.freedesktop.portal.FileChooser` -- `org.freedesktop.portal.OpenURI` -- `org.freedesktop.portal.Secret`, or `org.freedesktop.Secrets` - -## Windows - -Not yet available as an official download. Can be built [from source](./development/windows.md). - -## FreeBSD - -Not yet available as an official download. Can be built [from source](./development/freebsd.md). - -## Web - -Not supported at this time. See our [Platform Support issue](https://github.com/zed-industries/zed/issues/5391). diff --git a/docs/src/tab-switcher.md b/docs/src/tab-switcher.md new file mode 100644 index 0000000000..5cc72be449 --- /dev/null +++ b/docs/src/tab-switcher.md @@ -0,0 +1,46 @@ +# Tab Switcher + +The Tab Switcher provides a quick way to navigate between open tabs in Zed. It +displays a list of your open tabs sorted by recent usage, making it easy to jump +back to whatever you were just working on. + +![Tab Switcher with multiple panes](https://zed.dev/img/features/tab-switcher.png) + +## Quick Switching + +When the Tab Switcher is opened using {#kb tab_switcher::Toggle}, instead of +running the {#action tab_switcher::Toggle} from the command palette, it'll stay +active as long as the ctrl key is held down. + +While holding down ctrl, each subsequent tab press cycles to the next item (shift to cycle backwards) and, when ctrl is released, the selected item is confirmed and +the switcher is closed. + +## Opening the Tab Switcher + +The Tab Switcher can also be opened with either {#action tab_switcher::Toggle} +or {#action tab_switcher::ToggleAll}. Using {#kb tab_switcher::Toggle} will show +only the tabs for the current pane, while {#kb tab_switcher::ToggleAll} shows +all tabs for all panes. + +While the Tab Switcher is open, you can: + +- Press {#kb menu::SelectNext} to move to the next tab in the list +- Press {#kb menu::SelectPrevious} to move to the previous tab +- Press enter to confirm the selected tab and close the switcher +- Press escape to close the switcher and return to the original tab from which + the switcher was opened +- Press {#kb tab_switcher::CloseSelectedItem} to close the currently selected tab + +As you navigate through the list, Zed will update the pane's active item to +match the selected tab. + +## Action Reference + +| Action | Description | +| ----------------------------------------- | ------------------------------------------------- | +| {#action tab_switcher::Toggle} | Open the Tab Switcher for the current pane | +| {#action tab_switcher::ToggleAll} | Open the Tab Switcher showing tabs from all panes | +| {#action tab_switcher::CloseSelectedItem} | Close the selected tab in the Tab Switcher | diff --git a/docs/src/tasks.md b/docs/src/tasks.md index f2986c9951..a11988d9a0 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -2,7 +2,7 @@ Zed supports ways to spawn (and rerun) commands using its integrated terminal to output the results. These commands can read a limited subset of Zed state (such as a path to the file currently being edited or selected text). -```json +```json [tasks] [ { "label": "Example task", @@ -89,7 +89,7 @@ These variables allow you to pull information from the current editor and use it To use a variable in a task, prefix it with a dollar sign (`$`): -```json +```json [settings] { "label": "echo current file's path", "command": "echo $ZED_FILE" @@ -106,7 +106,7 @@ When working with paths containing spaces or other special characters, please en For example, instead of this (which will fail if the path has a space): -```json +```json [settings] { "label": "stat current file", "command": "stat $ZED_FILE" @@ -115,7 +115,7 @@ For example, instead of this (which will fail if the path has a space): Provide the following: -```json +```json [settings] { "label": "stat current file", "command": "stat", @@ -125,7 +125,7 @@ Provide the following: Or explicitly include escaped quotes like so: -```json +```json [settings] { "label": "stat current file", "command": "stat \"$ZED_FILE\"" @@ -137,7 +137,7 @@ Or explicitly include escaped quotes like so: Task definitions with variables which are not present at the moment the task list is determined are filtered out. For example, the following task will appear in the spawn modal only if there is a text selection: -```json +```json [settings] { "label": "selected text", "command": "echo \"$ZED_SELECTED_TEXT\"" @@ -146,7 +146,7 @@ For example, the following task will appear in the spawn modal only if there is Set default values to such variables to have such tasks always displayed: -```json +```json [settings] { "label": "selected text with default", "command": "echo \"${ZED_SELECTED_TEXT:no text selected}\"" @@ -172,7 +172,7 @@ By default, tasks capture their variables into a context once, and this "resolve This can be controlled with the `"reevaluate_context"` argument to the task: setting it to `true` will force the task to be reevaluated before each run. -```json +```json [keymap] { "context": "Workspace", "bindings": { @@ -185,7 +185,7 @@ This can be controlled with the `"reevaluate_context"` argument to the task: set You can define your own keybindings for your tasks via an additional argument to `task::Spawn`. If you wanted to bind the aforementioned `echo current file's path` task to `alt-g`, you would add the following snippet in your [`keymap.json`](./key-bindings.md) file: -```json +```json [keymap] { "context": "Workspace", "bindings": { @@ -197,7 +197,7 @@ You can define your own keybindings for your tasks via an additional argument to Note that these tasks can also have a 'target' specified to control where the spawned task should show up. This could be useful for launching a terminal application that you want to use in the center area: -```json +```json [tasks] // In tasks.json { "label": "start lazygit", @@ -205,7 +205,7 @@ This could be useful for launching a terminal application that you want to use i } ``` -```json +```json [keymap] // In keymap.json { "context": "Workspace", @@ -228,7 +228,7 @@ Zed supports overriding the default action for inline runnable indicators via wo To tag a task, add the runnable tag name to the `tags` field on the task template: -```json +```json [settings] { "label": "echo current file's path", "command": "echo $ZED_FILE", diff --git a/docs/src/telemetry.md b/docs/src/telemetry.md index 46c39a88ae..8dca8c1ee6 100644 --- a/docs/src/telemetry.md +++ b/docs/src/telemetry.md @@ -9,7 +9,7 @@ To enable or disable some or all telemetry types, open your `settings.json` file Insert and tweak the following: -```json +```json [settings] "telemetry": { "diagnostics": false, "metrics": false diff --git a/docs/src/themes.md b/docs/src/themes.md index 363c99f065..615cd2c7b3 100644 --- a/docs/src/themes.md +++ b/docs/src/themes.md @@ -4,23 +4,25 @@ Zed comes with a number of built-in themes, with more themes available as extens ## Selecting a Theme -See what themes are installed and preview them via the Theme Selector, which you can open from the command palette with "theme selector: Toggle" (bound to `cmd-k cmd-t` on macOS and `ctrl-k ctrl-t` on Linux). +See what themes are installed and preview them via the Theme Selector, which you can open from the command palette with `theme selector: toggle` (bound to {#kb theme_selector::Toggle}). Navigating through the theme list by moving up and down will change the theme in real time and hitting enter will save it to your settings file. ## Installing more Themes -More themes are available from the Extensions page, which you can access via the command palette with "zed: Extensions" or the [Zed website](https://zed.dev/extensions). +More themes are available from the Extensions page, which you can access via the command palette with `zed: extensions` or the [Zed website](https://zed.dev/extensions?filter=themes). Many popular themes have been ported to Zed, and if you're struggling to choose one, visit [zed-themes.com](https://zed-themes.com), a third-party gallery with visible previews for many of them. ## Configuring a Theme -Your selected theme is stored in your settings file. You can open your settings file from the command palette with "zed: Open Settings" (bound to `cmd-,` on macOS and `ctrl-,` on Linux). +Your selected theme is stored in your settings file. +You can open your settings file from the command palette with {#action zed::OpenSettingsFile} (bound to {#kb zed::OpenSettingsFile}). -By default, Zed maintains two themes: one for light mode and one for dark mode. You can set the mode to `"dark"` or `"light"` to ignore the current system mode. +By default, Zed maintains two themes: one for light mode and one for dark mode. +You can set the mode to `"dark"` or `"light"` to ignore the current system mode. -```json +```json [settings] { "theme": { "mode": "system", @@ -32,37 +34,48 @@ By default, Zed maintains two themes: one for light mode and one for dark mode. ## Theme Overrides -To override specific attributes of a theme, use the `experimental.theme_overrides` setting. +To override specific attributes of a theme, use the `theme_overrides` setting. +This setting can be used to configure theme-specific overrides. For example, add the following to your `settings.json` if you wish to override the background color of the editor and display comments and doc comments as italics: -```json +```json [settings] { - "experimental.theme_overrides": { - "editor.background": "#333", - "syntax": { - "comment": { - "font_style": "italic" + "theme_overrides": { + "One Dark": { + "editor.background": "#333", + "syntax": { + "comment": { + "font_style": "italic" + }, + "comment.doc": { + "font_style": "italic" + } }, - "comment.doc": { - "font_style": "italic" - } + "accents": [ + "#ff0000", + "#ff7f00", + "#ffff00", + "#00ff00", + "#0000ff", + "#8b00ff" + ] } } } ``` -To see a comprehensive list of list of captures (like `comment` and `comment.doc`) see: [Language Extensions: Syntax highlighting](./extensions/languages.md#syntax-highlighting). +To see a comprehensive list of list of captures (like `comment` and `comment.doc`) see [Language Extensions: Syntax highlighting](./extensions/languages.md#syntax-highlighting). -To see a list of available theme attributes look at the JSON file for your theme. For example, [assets/themes/one/one.json](https://github.com/zed-industries/zed/blob/main/assets/themes/one/one.json) for the default One Dark and One Light themes. +To see a list of available theme attributes look at the JSON file for your theme. +For example, [assets/themes/one/one.json](https://github.com/zed-industries/zed/blob/main/assets/themes/one/one.json) for the default One Dark and One Light themes. ## Local Themes -Store new themes locally by placing them in the `~/.config/zed/themes` directory. +Store new themes locally by placing them in the `~/.config/zed/themes` directory (macOS and Linux) or `%USERPROFILE%\AppData\Roaming\Zed\themes\` (Windows). -For example, to create a new theme called `my-cool-theme`, create a file called `my-cool-theme.json` in that directory. It will be available in the theme selector the next time Zed loads. - -Find more themes at [zed-themes.com](https://zed-themes.com). +For example, to create a new theme called `my-cool-theme`, create a file called `my-cool-theme.json` in that directory. +It will be available in the theme selector the next time Zed loads. ## Theme Development diff --git a/docs/src/toolchains.md b/docs/src/toolchains.md index 68e7baa8cf..f9f5f3fe0e 100644 --- a/docs/src/toolchains.md +++ b/docs/src/toolchains.md @@ -8,7 +8,7 @@ With toolchain selector, you don't need to spend time configuring your language You can even select different toolchains for different subprojects within your Zed project. A definition of a subproject is language-specific. In collaborative scenarios, only the project owner can see and modify an active toolchain. -In [remote projects](./remote-development.md), you can use the toolchain selector to control the active toolchain on the SSH host. When [sharing your project](./collaboration.md), the toolchain selector is not available to guests. +In [remote projects](./remote-development.md), you can use the toolchain selector to control the active toolchain on the SSH host. When [sharing your project](./collaboration/overview.md), the toolchain selector is not available to guests. ## Why do we need toolchains? diff --git a/docs/src/troubleshooting.md b/docs/src/troubleshooting.md new file mode 100644 index 0000000000..4aeeda6e3d --- /dev/null +++ b/docs/src/troubleshooting.md @@ -0,0 +1,80 @@ +# Troubleshooting + +This guide covers common troubleshooting techniques for Zed. +Sometimes you'll be able to identify and resolve issues on your own using this information. +Other times, troubleshooting means gathering the right information—logs, profiles, or reproduction steps—to help us diagnose and fix the problem. + +> **Note**: To open the command palette, use `cmd-shift-p` on macOS or `ctrl-shift-p` on Windows / Linux. + +## Retrieve Zed and System Information + +When reporting issues or seeking help, it's useful to know your Zed version and system specifications. You can retrieve this information using the following actions from the command palette: + +- {#action zed::About}: Find your Zed version number +- {#action zed::CopySystemSpecsIntoClipboard}: Populate your clipboard with Zed version number, operating system version, and hardware specs + +## Zed Log + +Often, a good first place to look when troubleshooting any issue in Zed is the Zed log, which might contain clues about what's going wrong. +You can review the most recent 1000 lines of the log by running the {#action zed::OpenLog} action from the command palette. +If you want to view the full file, you can reveal it in your operating system's native file manager via {#action zed::RevealLogInFileManager} from the command palette. + +You'll find the Zed log in the respective location on each operating system: + +- macOS: `~/Library/Logs/Zed/Zed.log` +- Windows: `C:\Users\YOU\AppData\Local\Zed\logs\Zed.log` +- Linux: `~/.local/share/zed/logs/Zed.log` or `$XDG_DATA_HOME` + +> Note: In some cases, it might be useful to monitor the log live, such as when [developing a Zed extension](https://zed.dev/docs/extensions/developing-extensions). +> Example: `tail -f ~/Library/Logs/Zed/Zed.log` + +The log may contain enough context to help you debug the issue yourself, or you may find specific errors that are useful when filing a [GitHub issue](https://github.com/zed-industries/zed/issues/new/choose) or when talking to Zed staff in our [Discord server](https://zed.dev/community-links#forums-and-discussions). + +## Performance Issues (Profiling) + +If you're running into performance issues in Zed—such as hitches, hangs, or general unresponsiveness—having a performance profile attached to your issue will help us zero in on what is getting stuck, so we can fix it. + +### macOS + +Xcode Instruments (which comes bundled with your [Xcode](https://apps.apple.com/us/app/xcode/id497799835) download) is the standard tool for profiling on macOS. + +1. With Zed running, open Instruments +1. Select `Time Profiler` as the profiling template +1. In the `Time Profiler` configuration, set the target to the running Zed process +1. Start recording +1. If the performance issue occurs when performing a specific action in Zed, perform that action now +1. Stop recording +1. Save the trace file +1. Compress the trace file into a zip archive +1. File a [GitHub issue](https://github.com/zed-industries/zed/issues/new/choose) with the trace zip attached + + + + + +## Startup and Workspace Issues + +Zed creates local SQLite databases to persist data relating to its workspace and your projects. These databases store, for instance, the tabs and panes you have open in a project, the scroll position of each open file, the list of all projects you've opened (for the recent projects modal picker), etc. You can find and explore these databases in the following locations: + +- macOS: `~/Library/Application Support/Zed/db` +- Linux and FreeBSD: `~/.local/share/zed/db` (or within `XDG_DATA_HOME` or `FLATPAK_XDG_DATA_HOME`) +- Windows: `%LOCALAPPDATA%\Zed\db` + +The naming convention of these databases takes on the form of `0-`: + +- Stable: `0-stable` +- Preview: `0-preview` +- Nightly: `0-nightly` +- Dev: `0-dev` + +While rare, we've seen a few cases where workspace databases became corrupted, which prevented Zed from starting. +If you're experiencing startup issues, you can test whether it's workspace-related by temporarily moving the database from its location, then trying to start Zed again. + +> **Note**: Moving the workspace database will cause Zed to create a fresh one. +> Your recent projects, open tabs, etc. will be reset to "factory". + +If your issue persists after regenerating the database, please [file an issue](https://github.com/zed-industries/zed/issues/new/choose). + +## Language Server Issues + +If you're experiencing language-server related issues, such as stale diagnostics or issues jumping to definitions, restarting the language server via {#action editor::RestartLanguageServer} from the command palette will often resolve the issue. diff --git a/docs/src/uninstall.md b/docs/src/uninstall.md new file mode 100644 index 0000000000..c1f71a6609 --- /dev/null +++ b/docs/src/uninstall.md @@ -0,0 +1,113 @@ +# Uninstall + +This guide covers how to uninstall Zed on different operating systems. + +## macOS + +### Standard Installation + +If you installed Zed by downloading it from the website: + +1. Quit Zed if it's running +2. Open Finder and go to your Applications folder +3. Drag Zed to the Trash (or right-click and select "Move to Trash") +4. Empty the Trash + +### Homebrew Installation + +If you installed Zed using Homebrew, use the following command: + +```sh +brew uninstall --cask zed +``` + +Or for the preview version: + +```sh +brew uninstall --cask zed@preview +``` + +### Removing User Data (Optional) + +To completely remove all Zed configuration files and data: + +1. Open Finder +2. Press `Cmd + Shift + G` to open "Go to Folder" +3. Delete the following directories if they exist: + - `~/Library/Application Support/Zed` + - `~/Library/Saved Application State/dev.zed.Zed.savedState` + - `~/Library/Logs/Zed` + - `~/Library/Caches/dev.zed.Zed` + +## Linux + +### Standard Uninstall + +If Zed was installed using the default installation script, run: + +```sh +zed --uninstall +``` + +You'll be prompted whether to keep or delete your preferences. After making a choice, you should see a message that Zed was successfully uninstalled. + +If the `zed` command is not found in your PATH, try: + +```sh +$HOME/.local/bin/zed --uninstall +``` + +or: + +```sh +$HOME/.local/zed.app/bin/zed --uninstall +``` + +### Package Manager + +If you installed Zed using a package manager (such as Flatpak, Snap, or a distribution-specific package manager), consult that package manager's documentation for uninstallation instructions. + +### Manual Removal + +If the uninstall command fails or Zed was installed to a custom location, you can manually remove: + +- Installation directory: `~/.local/zed.app` (or your custom installation path) +- Binary symlink: `~/.local/bin/zed` +- Configuration and data: `~/.config/zed` + +## Windows + +### Standard Installation + +1. Quit Zed if it's running +2. Open Settings (Windows key + I) +3. Go to "Apps" > "Installed apps" (or "Apps & features" on Windows 10) +4. Search for "Zed" +5. Click the three dots menu next to Zed and select "Uninstall" +6. Follow the prompts to complete the uninstallation + +Alternatively, you can: + +1. Open the Start menu +2. Right-click on Zed +3. Select "Uninstall" + +### Removing User Data (Optional) + +To completely remove all Zed configuration files and data: + +1. Press `Windows key + R` to open Run +2. Type `%APPDATA%` and press Enter +3. Delete the `Zed` folder if it exists +4. Press `Windows key + R` again, type `%LOCALAPPDATA%` and press Enter +5. Delete the `Zed` folder if it exists + +## Troubleshooting + +If you encounter issues during uninstallation: + +- **macOS/Windows**: Ensure Zed is completely quit before attempting to uninstall. Check Activity Manager (macOS) or Task Manager (Windows) for any running Zed processes. +- **Linux**: If the uninstall script fails, check the error message and consider manual removal of the directories listed above. +- **All platforms**: If you want to start fresh while keeping Zed installed, you can delete the configuration directories instead of uninstalling the application entirely. + +For additional help, see our [Linux-specific documentation](./linux.md) or visit the [Zed community](https://zed.dev/community-links). diff --git a/docs/src/update.md b/docs/src/update.md new file mode 100644 index 0000000000..d828e5edf0 --- /dev/null +++ b/docs/src/update.md @@ -0,0 +1,21 @@ +# Update Zed + +Zed is designed to keep itself up to date automatically. You can always update this behavior in your settings. + +## Auto-updates + +By default, Zed checks for updates and installs them automatically the next time you restart the app. You’ll always be running the latest version with no extra steps. + +If an update is available, Zed will download it in the background and apply it on restart. + +## How to check your current version + +To check which version of Zed you're using: + +Open the Command Palette (Cmd+Shift+P on macOS, Ctrl+Shift+P on Linux/Windows). + +Type and select `zed: about`. A modal will appear with your version information. + +## How to control update behavior + +If you want to turn off auto-updates, open the Settings Editor (Cmd ,) and find `Auto Update` under General Settings. diff --git a/docs/src/vim.md b/docs/src/vim.md index b62ded0989..09baa9b54f 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -39,7 +39,7 @@ If you missed this, you can toggle vim mode on or off anytime by opening the com > **Note**: This command toggles the following property in your user settings: > -> ```json +> ```json [settings] > { > "vim_mode": true > } @@ -219,7 +219,7 @@ These text objects implement the behavior of the [mini.ai](https://github.com/ec To use these text objects, you need to add bindings to your keymap. Here's an example configuration that makes them available when using text object operators (`i` and `a`) or change-surrounds (`cs`): -```json +```json [settings] { "context": "vim_operator == a || vim_operator == i || vim_operator == cs", "bindings": { @@ -237,9 +237,9 @@ To use these text objects, you need to add bindings to your keymap. Here's an ex With this configuration, you can use commands like: - `cib` - Change inside brackets using AnyBrackets behavior -- `cim` - Change inside brackets using MiniBrackets behavior +- `ciB` - Change inside brackets using MiniBrackets behavior - `ciq` - Change inside quotes using AnyQuotes behavior -- `ciM` - Change inside quotes using MiniQuotes behavior +- `ciQ` - Change inside quotes using MiniQuotes behavior ## Command palette @@ -377,7 +377,7 @@ In this section, we'll learn how to customize the key bindings of Zed's vim mode Zed's key bindings are evaluated only when the `"context"` property matches your location in the editor. For example, if you add key bindings to the `"Editor"` context, they will only work when you're editing a file. If you add key bindings to the `"Workspace"` context, they will work everywhere in Zed. Here's an example of a key binding that saves when you're editing a file: -```json +```json [settings] { "context": "Editor", "bindings": { @@ -388,12 +388,12 @@ Zed's key bindings are evaluated only when the `"context"` property matches your Contexts are nested, so when you're editing a file, the context is the `"Editor"` context, which is inside the `"Pane"` context, which is inside the `"Workspace"` context. That's why any key bindings you add to the `"Workspace"` context will work when you're editing a file. Here's an example: -```json +```json [keymap] // This key binding will work when you're editing a file. It comes built into Zed by default as the workspace: save command. { "context": "Workspace", "bindings": { - "ctrl-s": "file::Save" + "ctrl-s": "workspace::Save" } } ``` @@ -419,7 +419,7 @@ Vim mode adds several contexts to the `"Editor"` context: Here's a template with useful vim mode contexts to help you customize your vim mode key bindings. You can copy it and integrate it into your user keymap. -```json +```json [keymap] [ { "context": "VimControl && !menu", @@ -458,7 +458,7 @@ By default, you can navigate between the different files open in the editor with But you cannot use the same shortcuts to move between all the editor docks (the terminal, project panel, assistant panel, ...). If you want to use the same shortcuts to navigate to the docks, you can add the following key bindings to your user keymap. -```json +```json [settings] { "context": "Dock", "bindings": { @@ -471,9 +471,9 @@ But you cannot use the same shortcuts to move between all the editor docks (the } ``` -Subword motion, which allows you to navigate and select individual words in camelCase or snake_case, is not enabled by default. To enable it, add these bindings to your keymap. +Subword motion, which allows you to navigate and select individual words in `camelCase` or `snake_case`, is not enabled by default. To enable it, add these bindings to your keymap. -```json +```json [settings] { "context": "VimControl && !menu && vim_mode != operator", "bindings": { @@ -485,9 +485,12 @@ Subword motion, which allows you to navigate and select individual words in came } ``` +> Note: Operations like `dw` remain unaffected. If you would like operations to +> also use subword motion, remove `vim_mode != operator` from the `context`. + Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), but it doesn't have a shortcut to add surrounds in visual mode. By default, `shift-s` substitutes the selection (erases the text and enters insert mode). To use `shift-s` to add surrounds in visual mode, you can add the following object to your keymap. -```json +```json [settings] { "context": "vim_mode == visual", "bindings": { @@ -498,7 +501,7 @@ Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), b In non-modal text editors, cursor navigation typically wraps when moving past line ends. Zed, however, handles this behavior exactly like Vim by default: the cursor stops at line boundaries. If you prefer your cursor to wrap between lines, override these keybindings: -```json +```json [settings] // In VimScript, this would look like this: // set whichwrap+=<,>,[,],h,l { @@ -514,7 +517,7 @@ In non-modal text editors, cursor navigation typically wraps when moving past li The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for quick navigation to any two-character sequence in your text. You can enable it by adding the following keybindings to your keymap. By default, the `s` key is mapped to `vim::Substitute`. Adding these bindings will override that behavior, so ensure this change aligns with your workflow preferences. -```json +```json [settings] { "context": "vim_mode == normal || vim_mode == visual", "bindings": { @@ -526,7 +529,7 @@ The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for qui The [vim-exchange](https://github.com/tommcdo/vim-exchange) feature does not have a default binding for visual mode, as the `shift-x` binding conflicts with the default `shift-x` binding for visual mode (`vim::VisualDeleteLine`). To assign the default vim-exchange binding, add the following keybinding to your keymap: -```json +```json [settings] { "context": "vim_mode == visual", "bindings": { @@ -539,7 +542,7 @@ The [vim-exchange](https://github.com/tommcdo/vim-exchange) feature does not hav If you're using vim mode on Linux or Windows, you may find it overrides keybindings you can't live without: `ctrl+v` to paste, `ctrl+f` to search, etc. You can restore them by copying this data into your keymap: -```json +```json [keymap] { "context": "Editor && !menu", "bindings": { @@ -566,13 +569,14 @@ You can change the following settings to modify vim mode's behavior: | use_system_clipboard | Determines how system clipboard is used:
  • "always": use for all operations
  • "never": only use when explicitly specified
  • "on_yank": use for yank operations
| "always" | | use_multiline_find | deprecated | | use_smartcase_find | If `true`, `f` and `t` motions are case-insensitive when the target letter is lowercase. | false | -| toggle_relative_line_numbers | If `true`, line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | false | +| toggle_relative_line_numbers | deprecated | false | +| relative_line_numbers | If "enabled", line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | "disabled" | | custom_digraphs | An object that allows you to add custom digraphs. Read below for an example. | {} | | highlight_on_yank_duration | The duration of the highlight animation(in ms). Set to `0` to disable | 200 | Here's an example of adding a digraph for the zombie emoji. This allows you to type `ctrl-k f z` to insert a zombie emoji. You can add as many digraphs as you like. -```json +```json [settings] { "vim": { "custom_digraphs": { @@ -584,13 +588,13 @@ Here's an example of adding a digraph for the zombie emoji. This allows you to t Here's an example of these settings changed: -```json +```json [settings] { "vim": { "default_mode": "insert", "use_system_clipboard": "never", "use_smartcase_find": true, - "toggle_relative_line_numbers": true, + "relative_line_numbers": "enabled", "highlight_on_yank_duration": 50, "custom_digraphs": { "fz": "🧟‍♀️" @@ -606,7 +610,7 @@ Here are a few general Zed settings that can help you fine-tune your Vim experie | Property | Description | Default Value | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | | cursor_blink | If `true`, the cursor blinks. | `true` | -| relative_line_numbers | If `true`, line numbers in the left gutter are relative to the cursor. | `false` | +| relative_line_numbers | If `"enabled"`, line numbers in the left gutter are relative to the cursor. If `"wrapped"`, they also display for wrapped lines. | `"disabled"` | | scrollbar | Object that controls the scrollbar display. Set to `{ "show": "never" }` to hide the scroll bar. | `{ "show": "auto" }` | | scroll_beyond_last_line | If set to `"one_page"`, allows scrolling up to one page beyond the last line. Set to `"off"` to prevent this behavior. | `"one_page"` | | vertical_scroll_margin | The number of lines to keep above or below the cursor when scrolling. Set to `0` to allow the cursor to go up to the edges of the screen vertically. | `3` | @@ -615,12 +619,12 @@ Here are a few general Zed settings that can help you fine-tune your Vim experie Here's an example of these settings changed: -```json +```json [settings] { // Disable cursor blink "cursor_blink": false, // Use relative line numbers - "relative_line_numbers": true, + "relative_line_numbers": "enabled", // Hide the scroll bar "scrollbar": { "show": "never" }, // Prevent the buffer from scrolling beyond the last line @@ -628,7 +632,7 @@ Here's an example of these settings changed: // Allow the cursor to reach the edges of the screen "vertical_scroll_margin": 0, "gutter": { - // Disable line numbers completely: + // Disable line numbers completely "line_numbers": false }, "command_aliases": { diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 002ebdd4a8..234776b1d3 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -1,16 +1,16 @@ # Visual Customization -Various aspects of Zed's visual layout can be configured via Zed settings.json which you can access via {#action zed::OpenSettings} ({#kb zed::OpenSettings}). +Various aspects of Zed's visual layout can be configured via either the settings window or the `settings.json` file, which you can access via {#action zed::OpenSettings} ({#kb zed::OpenSettings}) and {#action zed::OpenSettingsFile} ({#kb zed::OpenSettingsFile}) respectively. See [Configuring Zed](./configuring-zed.md) for additional information and other non-visual settings. ## Themes -Use may install zed extensions providing [Themes](./themes.md) and [Icon Themes](./icon-themes.md) via {#action zed::Extensions} from the command palette or menu. +You can install many [themes](./themes.md) and [icon themes](./icon-themes.md) in form of extensions by running {#action zed::Extensions} from the command palette. -You can preview/choose amongst your installed themes and icon themes with {#action theme_selector::Toggle} ({#kb theme_selector::Toggle}) and ({#action icon_theme_selector::Toggle}) which will modify the following settings: +You can preview/choose amongst your installed themes and icon themes with {#action theme_selector::Toggle} ({#kb theme_selector::Toggle}) and {#action icon_theme_selector::Toggle} which will modify the following settings: -```json +```json [settings] { "theme": "One Dark", "icon_theme": "Zed (Default)" @@ -19,26 +19,26 @@ You can preview/choose amongst your installed themes and icon themes with {#acti If you would like to use distinct themes for light mode/dark mode that can be set with: -```json +```json [settings] { "theme": { - "dark": "One Dark" + "dark": "One Dark", "light": "One Light", // Mode to use (dark, light) or "system" to follow the OS's light/dark mode (default) - "mode": "system", + "mode": "system" }, "icon_theme": { - "dark": "Zed (Default)" + "dark": "Zed (Default)", "light": "Zed (Default)", // Mode to use (dark, light) or "system" to follow the OS's light/dark mode (default) - "mode": "system", + "mode": "system" } } ``` ## Fonts -```json +```json [settings] // UI Font. Use ".SystemUIFont" to use the default system font (SF Pro on macOS), // or ".ZedSans" for the bundled default (currently IBM Plex) "ui_font_family": ".SystemUIFont", @@ -61,19 +61,24 @@ If you would like to use distinct themes for light mode/dark mode that can be se "line_height": "standard", }, - // Agent Panel Font Settings - "agent_font_size": 15 + // Controls the font size for agent responses in the agent panel. + // If not specified, it falls back to the UI font size. + "agent_ui_font_size": 15, + // Controls the font size for the agent panel's message editor, user message, + // and any other snippet of code. + "agent_buffer_font_size": 12 ``` ### Font ligatures By default Zed enable font ligatures which will visually combines certain adjacent characters. -For example `=>` will be displayed as `→` and `!=` will be `≠`. This is purely cosmetic and the individual characters remain unchanged. +For example `=>` will be displayed as `→` and `!=` will be `≠`. +This is purely cosmetic and the individual characters remain unchanged. To disable this behavior use: -```json +```json [settings] { "buffer_font_features": { "calt": false // Disable ligatures @@ -83,7 +88,7 @@ To disable this behavior use: ### Status Bar -```json +```json [settings] { // Whether to show full labels in line indicator or short ones // - `short`: "2 s, 15 l, 32 c" @@ -105,7 +110,7 @@ To disable this behavior use: ### Titlebar -```json +```json [settings] // Control which items are shown/hidden in the title bar "title_bar": { "show_branch_icon": false, // Show/hide branch icon beside branch switcher @@ -113,6 +118,7 @@ To disable this behavior use: "show_project_items": true, // Show/hide project host and name "show_onboarding_banner": true, // Show/hide onboarding banners "show_user_picture": true, // Show/hide user avatar + "show_user_menu": true, // Show/hide app user button "show_sign_in": true, // Show/hide sign-in button "show_menus": false // Show/hide menus }, @@ -120,7 +126,7 @@ To disable this behavior use: ## Workspace -```json +```json [settings] { // Force usage of Zed build in path prompts (file and directory pickers) // instead of OS native pickers (false). @@ -129,10 +135,6 @@ To disable this behavior use: // instead of OS native prompts (false). On linux this is ignored (always false). "use_system_prompts": true, - // Whether to use the system provided dialogs for Open and Save As (true) or - // Zed's built-in keyboard-first pickers (false) - "use_system_path_prompts": true, - // Active pane styling settings. "active_pane_modifiers": { // Inset border size of the active pane, in pixels. @@ -152,7 +154,7 @@ To disable this behavior use: + + + {{ title }} {{#if is_print }} @@ -48,160 +71,227 @@ {{/if}} - +
+
+ - - - - - - - - - + {{#if search_enabled}} + + {{/if}} +
+ - - + + + + + + Download + + {{#if git_repository_url}} + + + + {{/if}} + {{#if git_repository_edit_url}} + + + + {{/if}} +
+
-
- {{> header}} - - - - {{#if search_enabled}} - diff --git a/docs/theme/page-toc.css b/docs/theme/page-toc.css index c8210e9a29..6a16265976 100644 --- a/docs/theme/page-toc.css +++ b/docs/theme/page-toc.css @@ -1,77 +1,79 @@ -@media only screen and (max-width: 1674px) { - .sidetoc { - display: none; - } +.pagetoc { + box-sizing: border-box; + position: sticky; + top: 50px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 28px 0 120px 0; + width: 200px; + max-height: calc(100svh - 50px); + overflow-x: hidden; +} +.pagetoc > :last-child { + margin-bottom: 16px; +} +.pagetoc a { + width: fit-content; + font-size: 1.4rem; + color: var(--fg) !important; + display: inline-block; + padding: 2px 0; + text-align: left; + text-decoration: underline; + text-decoration-color: var(--toc-link-underline); +} +.pagetoc a:hover { + text-decoration-color: var(--toc-link-underline-hover); +} +.pagetoc a.active { + background-color: var(--sidebar-active-bg); + color: var(--sidebar-active) !important; + text-decoration-color: hsl(219, 93%, 42%, 0.1); +} +.pagetoc a.active:hover { + text-decoration-color: hsl(219, 93%, 42%, 0.8); +} +.pagetoc .active { + background: var(--sidebar-bg); + color: var(--sidebar-fg); +} +.pagetoc .pagetoc-H1 { + display: none; +} +.pagetoc .pagetoc-H3 { + margin-left: 2ch; +} +.pagetoc .pagetoc-H4 { + margin-left: 4ch; +} +.pagetoc .pagetoc-H5 { + display: none; +} +.pagetoc .pagetoc-H6 { + display: none; +} +.toc-title { + margin: 0; + margin-bottom: 6px; + font-size: 1.4rem; + color: var(--full-contrast); } -@media only screen and (min-width: 1675px) { - main { - position: relative; - } - .sidetoc { - margin-left: auto; - margin-right: auto; - left: calc(100% + (var(--content-max-width)) / 3 - 160px); - position: absolute; - } - .pagetoc { - position: fixed; - top: 64px; - width: 220px; - height: calc(100vh - var(--menu-bar-height) - 0.67em * 4); - padding: 80px 16px 40px 0; - overflow: auto; - } - .pagetoc > :last-child { - margin-bottom: 64px; - } - .pagetoc a { - width: fit-content; - font-size: 1.4rem; - border-left: 1px solid var(--sidebar-bg); - color: var(--fg) !important; - display: block; - padding: 2px; - margin: 8px 0 8px 12px; - text-align: left; - text-decoration: underline; - text-decoration-color: hsl(0, 0%, 0%, 0.1); - } - .pagetoc a:hover { - text-decoration-color: hsl(0, 0%, 0%, 0.5); - } - .pagetoc a.active { - background-color: var(--sidebar-active-bg); - color: var(--sidebar-active) !important; - text-decoration-color: hsl(219, 93%, 42%, 0.1); - } - .pagetoc a.active:hover { - text-decoration-color: hsl(219, 93%, 42%, 0.8); - } - .pagetoc .active { - background: var(--sidebar-bg); - color: var(--sidebar-fg); - } - .pagetoc .pagetoc-H1 { +.toc-container { + visibility: hidden; +} + +.toc-container.has-toc { + visibility: visible; +} + +.toc-container.no-toc { + display: none; +} + +@media only screen and (max-width: 1200px) { + .toc-container { display: none; } - .pagetoc .pagetoc-H3 { - margin-left: 24px; - } - .pagetoc .pagetoc-H4 { - margin-left: 42px; - } - .pagetoc .pagetoc-H5 { - display: none; - } - .pagetoc .pagetoc-H6 { - display: none; - } - .toc-title { - margin: 0; - margin-bottom: 12px; - padding-left: 12px; - font-size: 1.4rem; - color: var(--full-contrast); - } } diff --git a/docs/theme/page-toc.js b/docs/theme/page-toc.js index 647a381058..627416fddf 100644 --- a/docs/theme/page-toc.js +++ b/docs/theme/page-toc.js @@ -3,21 +3,18 @@ let scrollTimeout; const listenActive = () => { const elems = document.querySelector(".pagetoc").children; [...elems].forEach((el) => { - el.addEventListener("click", (event) => { + el.addEventListener("click", (_) => { clearTimeout(scrollTimeout); [...elems].forEach((el) => el.classList.remove("active")); el.classList.add("active"); - // Prevent scroll updates for a short period + scrollTimeout = setTimeout(() => { scrollTimeout = null; - }, 100); // Adjust timing as needed + }, 100); }); }); }; -const getPagetoc = () => - document.querySelector(".pagetoc") || autoCreatePagetoc(); - const autoCreatePagetoc = () => { const main = document.querySelector("#content > main"); const content = Object.assign(document.createElement("div"), { @@ -27,51 +24,73 @@ const autoCreatePagetoc = () => { main.prepend(content); main.insertAdjacentHTML( "afterbegin", - '
', + '
', ); return document.querySelector(".pagetoc"); }; -const updateFunction = () => { - if (scrollTimeout) return; // Skip updates if within the cooldown period from a click - const headers = [...document.getElementsByClassName("header")]; - const scrolledY = window.scrollY; - let lastHeader = null; - // Find the last header that is above the current scroll position - for (let i = headers.length - 1; i >= 0; i--) { - if (scrolledY >= headers[i].offsetTop) { - lastHeader = headers[i]; - break; +const getPagetoc = () => + document.querySelector(".pagetoc") || autoCreatePagetoc(); + +const updateFunction = () => { + if (scrollTimeout) return; + + const headers = [...document.getElementsByClassName("header")]; + if (headers.length === 0) return; + + const threshold = 100; + let activeHeader = null; + + for (const header of headers) { + const rect = header.getBoundingClientRect(); + + if (rect.top <= threshold) { + activeHeader = header; } } + if (!activeHeader && headers.length > 0) { + activeHeader = headers[0]; + } + const pagetocLinks = [...document.querySelector(".pagetoc").children]; pagetocLinks.forEach((link) => link.classList.remove("active")); - if (lastHeader) { + if (activeHeader) { const activeLink = pagetocLinks.find( - (link) => lastHeader.href === link.href, + (link) => activeHeader.href === link.href, ); if (activeLink) activeLink.classList.add("active"); } }; -window.addEventListener("load", () => { +document.addEventListener("DOMContentLoaded", () => { const pagetoc = getPagetoc(); const headers = [...document.getElementsByClassName("header")]; const nonH1Headers = headers.filter( (header) => !header.parentElement.tagName.toLowerCase().startsWith("h1"), ); - const sidetoc = document.querySelector(".sidetoc"); + const tocContainer = document.querySelector(".toc-container"); if (nonH1Headers.length === 0) { - if (sidetoc) { - sidetoc.style.display = "none"; + if (tocContainer) { + tocContainer.classList.add("no-toc"); } return; } + if (tocContainer) { + tocContainer.classList.add("has-toc"); + } + + const tocTitle = Object.assign(document.createElement("p"), { + className: "toc-title", + textContent: "On This Page", + }); + + pagetoc.appendChild(tocTitle); + headers.forEach((header) => { const link = Object.assign(document.createElement("a"), { textContent: header.text, @@ -80,7 +99,14 @@ window.addEventListener("load", () => { }); pagetoc.appendChild(link); }); + updateFunction(); listenActive(); - window.addEventListener("scroll", updateFunction); + + const pageElement = document.querySelector(".page"); + if (pageElement) { + pageElement.addEventListener("scroll", updateFunction); + } else { + window.addEventListener("scroll", updateFunction); + } }); diff --git a/docs/theme/plugins.js b/docs/theme/plugins.js index 44c4c59978..1e20fe65c1 100644 --- a/docs/theme/plugins.js +++ b/docs/theme/plugins.js @@ -21,11 +21,11 @@ function detectOS() { return "Unknown"; } -// Usage var os = detectOS(); console.log("Operating System:", os); -(function updateKeybindings() { +// Defer keybinding processing to avoid blocking initial render +function updateKeybindings() { const os = detectOS(); const isMac = os === "Mac" || os === "iOS"; @@ -35,60 +35,28 @@ console.log("Operating System:", os); element.classList.add("keybinding"); } - function walkDOM(node) { - if (node.nodeType === Node.ELEMENT_NODE) { - if (node.tagName.toLowerCase() === "kbd") { - processKeybinding(node); - } else { - Array.from(node.children).forEach(walkDOM); - } - } - } + // Process all kbd elements at once (more efficient than walking entire DOM) + const kbdElements = document.querySelectorAll("kbd"); + kbdElements.forEach(processKeybinding); +} - // Start the process from the body - walkDOM(document.body); -})(); +// Use requestIdleCallback if available, otherwise requestAnimationFrame +if (typeof requestIdleCallback === "function") { + requestIdleCallback(updateKeybindings); +} else { + requestAnimationFrame(updateKeybindings); +} function darkModeToggle() { var html = document.documentElement; - var themeToggleButton = document.getElementById("theme-toggle"); - var themePopup = document.getElementById("theme-list"); - var themePopupButtons = themePopup.querySelectorAll("button"); function setTheme(theme) { html.setAttribute("data-theme", theme); html.setAttribute("data-color-scheme", theme); html.className = theme; localStorage.setItem("mdbook-theme", theme); - - // Force a repaint to ensure the changes take effect in the client immediately - document.body.style.display = "none"; - document.body.offsetHeight; - document.body.style.display = ""; } - themeToggleButton.addEventListener("click", function (event) { - event.preventDefault(); - themePopup.style.display = - themePopup.style.display === "block" ? "none" : "block"; - }); - - themePopupButtons.forEach(function (button) { - button.addEventListener("click", function () { - setTheme(this.id); - themePopup.style.display = "none"; - }); - }); - - document.addEventListener("click", function (event) { - if ( - !themePopup.contains(event.target) && - !themeToggleButton.contains(event.target) - ) { - themePopup.style.display = "none"; - } - }); - // Set initial theme var currentTheme = localStorage.getItem("mdbook-theme"); if (currentTheme) { @@ -227,5 +195,127 @@ const copyMarkdown = () => { // Initialize functionality when DOM is loaded document.addEventListener("DOMContentLoaded", () => { + darkModeToggle(); copyMarkdown(); }); + +// Collapsible sidebar navigation for entire sections +// Note: Initial collapsed state is applied in index.hbs to prevent flicker +function initCollapsibleSidebar() { + var sidebar = document.getElementById("sidebar"); + if (!sidebar) return; + + var chapterList = sidebar.querySelector("ol.chapter"); + if (!chapterList) return; + + var partTitles = Array.from(chapterList.querySelectorAll("li.part-title")); + + partTitles.forEach(function (partTitle) { + // Get all sibling elements that belong to this section + var sectionItems = getSectionItems(partTitle); + + if (sectionItems.length > 0) { + setupCollapsibleSection(partTitle, sectionItems); + } + }); +} + +// Saves the list of collapsed section names to sessionStorage +// This gets reset when the tab is closed and opened again +function saveCollapsedSections() { + var collapsedSections = []; + var partTitles = document.querySelectorAll( + "#sidebar li.part-title.collapsible", + ); + + partTitles.forEach(function (partTitle) { + if (!partTitle.classList.contains("expanded")) { + collapsedSections.push(partTitle._sectionName); + } + }); + + try { + sessionStorage.setItem( + "sidebar-collapsed-sections", + JSON.stringify(collapsedSections), + ); + } catch (e) { + // sessionStorage might not be available + } +} + +function getSectionItems(partTitle) { + var items = []; + var sibling = partTitle.nextElementSibling; + + while (sibling) { + // Stop when we hit another part-title + if (sibling.classList.contains("part-title")) { + break; + } + items.push(sibling); + sibling = sibling.nextElementSibling; + } + + return items; +} + +function setupCollapsibleSection(partTitle, sectionItems) { + partTitle.classList.add("collapsible"); + partTitle.setAttribute("role", "button"); + partTitle.setAttribute("tabindex", "0"); + partTitle._sectionItems = sectionItems; + + var isCurrentlyCollapsed = partTitle._isCollapsed; + if (isCurrentlyCollapsed) { + partTitle.setAttribute("aria-expanded", "false"); + } else { + partTitle.classList.add("expanded"); + partTitle.setAttribute("aria-expanded", "true"); + } + + partTitle.addEventListener("click", function (e) { + e.preventDefault(); + toggleSection(partTitle); + }); + + // a11y: Add keyboard support (Enter and Space) + partTitle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSection(partTitle); + } + }); +} + +function toggleSection(partTitle) { + var isExpanded = partTitle.classList.contains("expanded"); + var sectionItems = partTitle._sectionItems; + var spacerAfter = partTitle._spacerAfter; + + if (isExpanded) { + partTitle.classList.remove("expanded"); + partTitle.setAttribute("aria-expanded", "false"); + sectionItems.forEach(function (item) { + item.classList.add("section-hidden"); + }); + if (spacerAfter) { + spacerAfter.classList.add("section-hidden"); + } + } else { + partTitle.classList.add("expanded"); + partTitle.setAttribute("aria-expanded", "true"); + sectionItems.forEach(function (item) { + item.classList.remove("section-hidden"); + }); + if (spacerAfter) { + spacerAfter.classList.remove("section-hidden"); + } + } + + saveCollapsedSections(); +} + +document.addEventListener("DOMContentLoaded", function () { + initCollapsibleSidebar(); +}); diff --git a/extensions/html/Cargo.toml b/extensions/html/Cargo.toml index 22cdb401a7..2c89f86cb4 100644 --- a/extensions/html/Cargo.toml +++ b/extensions/html/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_html" -version = "0.2.3" +version = "0.3.0" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/html/extension.toml b/extensions/html/extension.toml index 1ded7af641..68ab0e4b9d 100644 --- a/extensions/html/extension.toml +++ b/extensions/html/extension.toml @@ -1,7 +1,7 @@ id = "html" name = "HTML" description = "HTML support." -version = "0.2.3" +version = "0.3.0" schema_version = 1 authors = ["Isaac Clayton "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/html/languages/html/brackets.scm b/extensions/html/languages/html/brackets.scm index f9be89a263..53d6a6bb23 100644 --- a/extensions/html/languages/html/brackets.scm +++ b/extensions/html/languages/html/brackets.scm @@ -1,5 +1,5 @@ ("<" @open "/>" @close) ("" @close) ("<" @open ">" @close) -("\"" @open "\"" @close) -((element (start_tag) @open (end_tag) @close) (#set! newline.only)) +(("\"" @open "\"" @close) (#set! rainbow.exclude)) +((element (start_tag) @open (end_tag) @close) (#set! newline.only) (#set! rainbow.exclude)) diff --git a/extensions/html/languages/html/config.toml b/extensions/html/languages/html/config.toml index 388949d95c..fc7d557198 100644 --- a/extensions/html/languages/html/config.toml +++ b/extensions/html/languages/html/config.toml @@ -14,3 +14,6 @@ brackets = [ ] completion_query_characters = ["-"] prettier_parser_name = "html" + +[overrides.default] +linked_edit_characters = ["-"] diff --git a/extensions/html/languages/html/injections.scm b/extensions/html/languages/html/injections.scm index 0884d8f516..525b3efe29 100644 --- a/extensions/html/languages/html/injections.scm +++ b/extensions/html/languages/html/injections.scm @@ -1,3 +1,7 @@ +((comment) @injection.content + (#set! injection.language "comment") +) + (script_element (raw_text) @injection.content (#set! injection.language "javascript")) diff --git a/extensions/html/languages/html/overrides.scm b/extensions/html/languages/html/overrides.scm index 7108d48fbd..434f610e70 100644 --- a/extensions/html/languages/html/overrides.scm +++ b/extensions/html/languages/html/overrides.scm @@ -1,2 +1,7 @@ (comment) @comment (quoted_attribute_value) @string + +[ + (start_tag) + (end_tag) +] @default diff --git a/extensions/html/src/html.rs b/extensions/html/src/html.rs index 27fd2d1e22..337689ebdd 100644 --- a/extensions/html/src/html.rs +++ b/extensions/html/src/html.rs @@ -68,22 +68,24 @@ impl zed::Extension for HtmlExtension { worktree: &zed::Worktree, ) -> Result { let server_path = if let Some(path) = worktree.which(BINARY_NAME) { - path + return Ok(zed::Command { + command: path, + args: vec!["--stdio".to_string()], + env: Default::default(), + }); } else { - self.server_script_path(language_server_id)? + let server_path = self.server_script_path(language_server_id)?; + env::current_dir() + .unwrap() + .join(&server_path) + .to_string_lossy() + .to_string() }; self.cached_binary_path = Some(server_path.clone()); Ok(zed::Command { command: zed::node_binary_path()?, - args: vec![ - env::current_dir() - .unwrap() - .join(&server_path) - .to_string_lossy() - .to_string(), - "--stdio".to_string(), - ], + args: vec![server_path, "--stdio".to_string()], env: Default::default(), }) } diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index 6198da82a4..c3606f668a 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_proto" -version = "0.2.2" +version = "0.3.0" edition.workspace = true publish.workspace = true license = "Apache-2.0" @@ -13,4 +13,4 @@ path = "src/proto.rs" crate-type = ["cdylib"] [dependencies] -zed_extension_api = "0.1.0" +zed_extension_api = "0.7.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index 66ba5a2ff5..13c4054eef 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -1,15 +1,24 @@ id = "proto" name = "Proto" description = "Protocol Buffers support." -version = "0.2.2" +version = "0.3.0" schema_version = 1 authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" [grammars.proto] -repository = "https://github.com/zed-industries/tree-sitter-proto" -commit = "0848bd30a64be48772e15fbb9d5ba8c0cc5772ad" +repository = "https://github.com/coder3101/tree-sitter-proto" +commit = "a6caac94b5aa36b322b5b70040d5b67132f109d0" + + +[language_servers.buf] +name = "Buf" +languages = ["Proto"] [language_servers.protobuf-language-server] name = "Protobuf Language Server" languages = ["Proto"] + +[language_servers.protols] +name = "Protols" +languages = ["Proto"] diff --git a/extensions/proto/src/language_servers.rs b/extensions/proto/src/language_servers.rs new file mode 100644 index 0000000000..47a5e72d8a --- /dev/null +++ b/extensions/proto/src/language_servers.rs @@ -0,0 +1,8 @@ +mod buf; +mod protobuf_language_server; +mod protols; +mod util; + +pub(crate) use buf::*; +pub(crate) use protobuf_language_server::*; +pub(crate) use protols::*; diff --git a/extensions/proto/src/language_servers/buf.rs b/extensions/proto/src/language_servers/buf.rs new file mode 100644 index 0000000000..92106298d3 --- /dev/null +++ b/extensions/proto/src/language_servers/buf.rs @@ -0,0 +1,114 @@ +use std::fs; + +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, GithubReleaseOptions, Os, Result, + settings::LspSettings, +}; + +use crate::language_servers::util; + +pub(crate) struct BufLsp { + cached_binary_path: Option, +} + +impl BufLsp { + pub(crate) const SERVER_NAME: &str = "buf"; + + pub(crate) fn new() -> Self { + BufLsp { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_else(|| ["lsp", "serve"].map(ToOwned::to_owned).into()); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } else if let Some(path) = self.cached_binary_path.clone() { + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } + + let latest_release = zed::latest_github_release( + "bufbuild/buf", + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + + let release_suffix = match (os, arch) { + (Os::Mac, Architecture::Aarch64) => "Darwin-arm64", + (Os::Mac, Architecture::X8664) => "Darwin-x86_64", + (Os::Linux, Architecture::Aarch64) => "Linux-aarch64", + (Os::Linux, Architecture::X8664) => "Linux-x86_64", + (Os::Windows, Architecture::Aarch64) => "Windows-arm64.exe", + (Os::Windows, Architecture::X8664) => "Windows-x86_64.exe", + _ => { + return Err("Platform and architecture not supported by buf CLI".to_string()); + } + }; + + let release_name = format!("buf-{release_suffix}"); + + let version_dir = format!("{}-{}", Self::SERVER_NAME, latest_release.version); + fs::create_dir_all(&version_dir).map_err(|_| "Could not create directory")?; + + let binary_path = format!("{version_dir}/buf"); + + let download_target = latest_release + .assets + .into_iter() + .find(|asset| asset.name == release_name) + .ok_or_else(|| { + format!( + "Could not find asset with name {} in buf CLI release", + &release_name + ) + })?; + + zed::download_file( + &download_target.download_url, + &binary_path, + DownloadedFileType::Uncompressed, + )?; + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::SERVER_NAME, &version_dir)?; + + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args, + env: Default::default(), + }) + } +} diff --git a/extensions/proto/src/language_servers/protobuf_language_server.rs b/extensions/proto/src/language_servers/protobuf_language_server.rs new file mode 100644 index 0000000000..f4b13077f7 --- /dev/null +++ b/extensions/proto/src/language_servers/protobuf_language_server.rs @@ -0,0 +1,52 @@ +use zed_extension_api::{self as zed, Result, settings::LspSettings}; + +pub(crate) struct ProtobufLanguageServer { + cached_binary_path: Option, +} + +impl ProtobufLanguageServer { + pub(crate) const SERVER_NAME: &str = "protobuf-language-server"; + + pub(crate) fn new() -> Self { + ProtobufLanguageServer { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_else(|| vec!["-logs".into(), "".into()]); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else if let Some(path) = self.cached_binary_path.clone() { + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else { + Err(format!("{} not found in PATH", Self::SERVER_NAME)) + } + } +} diff --git a/extensions/proto/src/language_servers/protols.rs b/extensions/proto/src/language_servers/protols.rs new file mode 100644 index 0000000000..90d365eae7 --- /dev/null +++ b/extensions/proto/src/language_servers/protols.rs @@ -0,0 +1,113 @@ +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, GithubReleaseOptions, Os, Result, + settings::LspSettings, +}; + +use crate::language_servers::util; + +pub(crate) struct ProtoLs { + cached_binary_path: Option, +} + +impl ProtoLs { + pub(crate) const SERVER_NAME: &str = "protols"; + + pub(crate) fn new() -> Self { + ProtoLs { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_default(); + + let env = worktree.shell_env(); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + return Ok(zed::Command { + command: path, + args, + env, + }); + } else if let Some(path) = self.cached_binary_path.clone() { + return Ok(zed::Command { + command: path, + args, + env, + }); + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + return Ok(zed::Command { + command: path, + args, + env, + }); + } + + let latest_release = zed::latest_github_release( + "coder3101/protols", + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + + let release_suffix = match (os, arch) { + (Os::Mac, Architecture::Aarch64) => "aarch64-apple-darwin.tar.gz", + (Os::Mac, Architecture::X8664) => "x86_64-apple-darwin.tar.gz", + (Os::Linux, Architecture::Aarch64) => "aarch64-unknown-linux-gnu.tar.gz", + (Os::Linux, Architecture::X8664) => "x86_64-unknown-linux-gnu.tar.gz", + (Os::Windows, Architecture::X8664) => "x86_64-pc-windows-msvc.zip", + _ => { + return Err("Platform and architecture not supported by Protols".to_string()); + } + }; + + let release_name = format!("protols-{release_suffix}"); + + let file_type = if os == Os::Windows { + DownloadedFileType::Zip + } else { + DownloadedFileType::GzipTar + }; + + let version_dir = format!("{}-{}", Self::SERVER_NAME, latest_release.version); + let binary_path = format!("{version_dir}/protols"); + + let download_target = latest_release + .assets + .into_iter() + .find(|asset| asset.name == release_name) + .ok_or_else(|| { + format!( + "Could not find asset with name {} in Protols release", + &release_name + ) + })?; + + zed::download_file(&download_target.download_url, &version_dir, file_type)?; + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::SERVER_NAME, &version_dir)?; + + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args, + env, + }) + } +} diff --git a/extensions/proto/src/language_servers/util.rs b/extensions/proto/src/language_servers/util.rs new file mode 100644 index 0000000000..3036c9bc3a --- /dev/null +++ b/extensions/proto/src/language_servers/util.rs @@ -0,0 +1,19 @@ +use std::fs; + +use zed_extension_api::Result; + +pub(super) fn remove_outdated_versions( + language_server_id: &'static str, + version_dir: &str, +) -> Result<()> { + let entries = fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str().is_none_or(|file_name| { + file_name.starts_with(language_server_id) && file_name != version_dir + }) { + fs::remove_dir_all(entry.path()).ok(); + } + } + Ok(()) +} diff --git a/extensions/proto/src/proto.rs b/extensions/proto/src/proto.rs index 70c5b9c84a..07e0ccedce 100644 --- a/extensions/proto/src/proto.rs +++ b/extensions/proto/src/proto.rs @@ -1,48 +1,22 @@ use zed_extension_api::{self as zed, Result, settings::LspSettings}; -const PROTOBUF_LANGUAGE_SERVER_NAME: &str = "protobuf-language-server"; +use crate::language_servers::{BufLsp, ProtoLs, ProtobufLanguageServer}; -struct ProtobufLanguageServerBinary { - path: String, - args: Option>, -} +mod language_servers; -struct ProtobufExtension; - -impl ProtobufExtension { - fn language_server_binary( - &self, - _language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result { - let binary_settings = LspSettings::for_worktree("protobuf-language-server", worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.binary); - let binary_args = binary_settings - .as_ref() - .and_then(|binary_settings| binary_settings.arguments.clone()); - - if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { - return Ok(ProtobufLanguageServerBinary { - path, - args: binary_args, - }); - } - - if let Some(path) = worktree.which(PROTOBUF_LANGUAGE_SERVER_NAME) { - return Ok(ProtobufLanguageServerBinary { - path, - args: binary_args, - }); - } - - Err(format!("{PROTOBUF_LANGUAGE_SERVER_NAME} not found in PATH",)) - } +struct ProtobufExtension { + protobuf_language_server: Option, + protols: Option, + buf_lsp: Option, } impl zed::Extension for ProtobufExtension { fn new() -> Self { - Self + Self { + protobuf_language_server: None, + protols: None, + buf_lsp: None, + } } fn language_server_command( @@ -50,14 +24,42 @@ impl zed::Extension for ProtobufExtension { language_server_id: &zed_extension_api::LanguageServerId, worktree: &zed_extension_api::Worktree, ) -> zed_extension_api::Result { - let binary = self.language_server_binary(language_server_id, worktree)?; - Ok(zed::Command { - command: binary.path, - args: binary - .args - .unwrap_or_else(|| vec!["-logs".into(), "".into()]), - env: Default::default(), - }) + match language_server_id.as_ref() { + ProtobufLanguageServer::SERVER_NAME => self + .protobuf_language_server + .get_or_insert_with(ProtobufLanguageServer::new) + .language_server_binary(worktree), + + ProtoLs::SERVER_NAME => self + .protols + .get_or_insert_with(ProtoLs::new) + .language_server_binary(worktree), + + BufLsp::SERVER_NAME => self + .buf_lsp + .get_or_insert_with(BufLsp::new) + .language_server_binary(worktree), + + _ => Err(format!("Unknown language server ID {}", language_server_id)), + } + } + + fn language_server_workspace_configuration( + &mut self, + server_id: &zed::LanguageServerId, + worktree: &zed::Worktree, + ) -> Result> { + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.settings) + } + + fn language_server_initialization_options( + &mut self, + server_id: &zed::LanguageServerId, + worktree: &zed::Worktree, + ) -> Result> { + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.initialization_options) } } diff --git a/extensions/slash-commands-example/README.md b/extensions/slash-commands-example/README.md index 6ff00dd2ad..8c16a4e168 100644 --- a/extensions/slash-commands-example/README.md +++ b/extensions/slash-commands-example/README.md @@ -76,8 +76,7 @@ Rebuild to see these changes reflected: ## Troubleshooting / Logs -- MacOS: `tail -f ~/Library/Logs/Zed/Zed.log` -- Linux: `tail -f ~/.local/share/zed/logs/Zed.log` +- [zed.dev docs: Troubleshooting](https://zed.dev/docs/troubleshooting) ## Documentation diff --git a/extensions/workflows/bump_version.yml b/extensions/workflows/bump_version.yml new file mode 100644 index 0000000000..7f4318dcf5 --- /dev/null +++ b/extensions/workflows/bump_version.yml @@ -0,0 +1,52 @@ +# Generated from xtask::workflows::extensions::bump_version within the Zed repository. +# Rebuild with `cargo xtask workflows`. +name: extensions::bump_version +on: + pull_request: + types: + - labeled + push: + branches: + - main + paths-ignore: + - .github/** + workflow_dispatch: {} +jobs: + determine_bump_type: + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - id: get-bump-type + name: extensions::bump_version::get_bump_type + run: | + if [ "$HAS_MAJOR_LABEL" = "true" ]; then + bump_type="major" + elif [ "$HAS_MINOR_LABEL" = "true" ]; then + bump_type="minor" + else + bump_type="patch" + fi + echo "bump_type=$bump_type" >> $GITHUB_OUTPUT + shell: bash -euxo pipefail {0} + env: + HAS_MAJOR_LABEL: |- + ${{ (github.event.action == 'labeled' && github.event.label.name == 'major') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'major')) }} + HAS_MINOR_LABEL: |- + ${{ (github.event.action == 'labeled' && github.event.label.name == 'minor') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'minor')) }} + outputs: + bump_type: ${{ steps.get-bump-type.outputs.bump_type }} + call_bump_version: + needs: + - determine_bump_type + if: github.event.action != 'labeled' || needs.determine_bump_type.outputs.bump_type != 'patch' + uses: zed-industries/zed/.github/workflows/extension_bump.yml@main + secrets: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + app-secret: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + with: + bump-type: ${{ needs.determine_bump_type.outputs.bump_type }} + force-bump: true +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}labels + cancel-in-progress: true diff --git a/extensions/workflows/release_version.yml b/extensions/workflows/release_version.yml new file mode 100644 index 0000000000..f752931917 --- /dev/null +++ b/extensions/workflows/release_version.yml @@ -0,0 +1,13 @@ +# Generated from xtask::workflows::extensions::release_version within the Zed repository. +# Rebuild with `cargo xtask workflows`. +name: extensions::release_version +on: + push: + tags: + - v** +jobs: + call_release_version: + uses: zed-industries/zed/.github/workflows/extension_release.yml@main + secrets: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + app-secret: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} diff --git a/extensions/workflows/run_tests.yml b/extensions/workflows/run_tests.yml new file mode 100644 index 0000000000..81ba76c483 --- /dev/null +++ b/extensions/workflows/run_tests.yml @@ -0,0 +1,16 @@ +# Generated from xtask::workflows::extensions::run_tests within the Zed repository. +# Rebuild with `cargo xtask workflows`. +name: extensions::run_tests +on: + pull_request: + branches: + - '**' + push: + branches: + - main +jobs: + call_extension_tests: + uses: zed-industries/zed/.github/workflows/extension_tests.yml@main +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}pr + cancel-in-progress: true diff --git a/flake.lock b/flake.lock index ced528afa6..3074b947ef 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1758215636, - "narHash": "sha256-8nkzkPbdxze8CxWhKWlcLbJEU1vfLM/nVqRlTy17V54=", + "lastModified": 1762538466, + "narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=", "owner": "ipetkov", "repo": "crane", - "rev": "a669fe77a8b0cd6f11419d89ea45a16691ca5121", + "rev": "0cea393fffb39575c46b7a0318386467272182fe", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "flake-compat": { "locked": { - "lastModified": 1747046372, - "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "lastModified": 1761588595, + "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", "owner": "edolstra", "repo": "flake-compat", - "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", "type": "github" }, "original": { @@ -33,10 +33,10 @@ "nixpkgs": { "locked": { "lastModified": 315532800, - "narHash": "sha256-YPoFUJMpbuPvIS4FJBn2Sv/iWsui9S26gu2ufFWEY0g=", - "rev": "a1f79a1770d05af18111fbbe2a3ab2c42c0f6cd0", + "narHash": "sha256-5CwQ80ucRHiqVbMEEbTFnjz70/axSJ0aliyzSaFSkmY=", + "rev": "f6b44b2401525650256b977063dbcf830f762369", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre864673.a1f79a1770d0/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre891648.f6b44b240152/nixexprs.tar.xz" }, "original": { "type": "tarball", @@ -58,11 +58,11 @@ ] }, "locked": { - "lastModified": 1758508617, - "narHash": "sha256-kx2uELmVnAbiekj/YFfWR26OXqXedImkhe2ocnbumTA=", + "lastModified": 1762915112, + "narHash": "sha256-d9j1g8nKmYDHy+/bIOPQTh9IwjRliqaTM0QLHMV92Ic=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "d2bac276ac7e669a1f09c48614538a37e3eb6d0f", + "rev": "aa1e85921cfa04de7b6914982a94621fbec5cc02", "type": "github" }, "original": { diff --git a/nix/build.nix b/nix/build.nix index 9012a47c1f..484049a421 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -177,6 +177,7 @@ let ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; RELEASE_VERSION = version; LK_CUSTOM_WEBRTC = livekit-libwebrtc; + PROTOC="${protobuf}/bin/protoc"; CARGO_PROFILE = profile; # need to handle some profiles specially https://github.com/rust-lang/cargo/issues/11053 diff --git a/nix/shell.nix b/nix/shell.nix index b6f1efd366..6956de8e8a 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,24 +1,30 @@ { mkShell, makeFontsConf, + pkgsCross, zed-editor, rust-analyzer, + rustup, cargo-nextest, cargo-hakari, cargo-machete, + cargo-zigbuild, nixfmt-rfc-style, protobuf, nodejs_22, + zig, }: (mkShell.override { inherit (zed-editor) stdenv; }) { inputsFrom = [ zed-editor ]; packages = [ rust-analyzer + rustup cargo-nextest cargo-hakari cargo-machete + cargo-zigbuild nixfmt-rfc-style # TODO: package protobuf-language-server for editing zed.proto # TODO: add other tools used in our scripts @@ -26,6 +32,7 @@ # `build.nix` adds this to the `zed-editor` wrapper (see `postFixup`) # we'll just put it on `$PATH`: nodejs_22 + zig ]; env = @@ -51,5 +58,6 @@ ]; }; PROTOC = "${protobuf}/bin/protoc"; + ZED_ZSTD_MUSL_LIB = "${pkgsCross.musl64.pkgsStatic.zstd.out}/lib"; }; } diff --git a/renovate.json b/renovate.json index 6e5630ad84..01ca7a46a1 100644 --- a/renovate.json +++ b/renovate.json @@ -12,7 +12,7 @@ "timezone": "America/New_York", "schedule": ["after 3pm on Wednesday"], "prFooter": "Release Notes:\n\n- N/A", - "ignorePaths": ["**/node_modules/**", "tooling/workspace-hack/**"], + "ignorePaths": ["**/node_modules/**"], "packageRules": [ { "description": "Group wasmtime crates together.", diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 6ef0865182..59765d94ab 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.90" +channel = "1.91.1" profile = "minimal" components = [ "rustfmt", "clippy" ] targets = [ diff --git a/script/analyze_highlights.py b/script/analyze_highlights.py index 09a6419653..aaf7386be6 100644 --- a/script/analyze_highlights.py +++ b/script/analyze_highlights.py @@ -1,24 +1,24 @@ #!/usr/bin/env python3 """ -This script analyzes all the highlight.scm files in our embedded languages and extensions. +This script analyzes all the highlights.scm files in our embedded languages and extensions. It counts the number of unique instances of @{name} and the languages in which they are used. This is useful to help avoid accidentally introducing new tags when appropriate ones already exist when adding new languages. Flags: --v, --verbose: Include a detailed list of languages for each tag found in the highlight.scm files. +-v, --verbose: Include a detailed list of languages for each tag found in the highlights.scm files. """ +import argparse +import re from collections import defaultdict from pathlib import Path from typing import Any -import argparse -import re pattern = re.compile(r'@(?!_)[a-zA-Z_.]+') def parse_arguments(): - parser = argparse.ArgumentParser(description='Analyze highlight.scm files for unique instances and their languages.') + parser = argparse.ArgumentParser(description='Analyze highlights.scm files for unique instances and their languages.') parser.add_argument('-v', '--verbose', action='store_true', help='Include a list of languages for each tag.') return parser.parse_args() diff --git a/script/bump-gpui-version b/script/bump-gpui-version new file mode 100755 index 0000000000..5112bde450 --- /dev/null +++ b/script/bump-gpui-version @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +# Parse arguments +bump_type=${1:-minor} + +if [[ "$bump_type" != "minor" && "$bump_type" != "patch" ]]; then + echo "Usage: $0 [minor|patch]" + echo " minor (default): bumps the minor version (e.g., 0.1.0 -> 0.2.0)" + echo " patch: bumps the patch version (e.g., 0.1.0 -> 0.1.1)" + exit 1 +fi + +# Ensure we're in a clean state on an up-to-date `main` branch. +if [[ -n $(git status --short --untracked-files=no) ]]; then + echo "can't bump versions with uncommitted changes" + exit 1 +fi +if [[ $(git rev-parse --abbrev-ref HEAD) != "main" ]]; then + echo "this command must be run on main" + exit 1 +fi +git pull -q --ff-only origin main + + +# Parse the current version +version=$(script/get-crate-version gpui) +major=$(echo $version | cut -d. -f1) +minor=$(echo $version | cut -d. -f2) +patch=$(echo $version | cut -d. -f3) + +if [[ "$bump_type" == "minor" ]]; then + next_minor=$(expr $minor + 1) + next_version="${major}.${next_minor}.0" +else + next_patch=$(expr $patch + 1) + next_version="${major}.${minor}.${next_patch}" +fi + +branch_name="bump-gpui-to-v${next_version}" + +git checkout -b ${branch_name} + +script/lib/bump-version.sh gpui gpui-v "" $bump_type true + +git checkout -q main diff --git a/script/bundle-linux b/script/bundle-linux index ad67b7a0f7..4f5c9f6e7e 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -91,36 +91,31 @@ else if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then echo "Uploading zed debug symbols to sentry..." # note: this uploads the unstripped binary which is needed because it contains - # .eh_frame data for stack unwinindg. see https://github.com/getsentry/symbolic/issues/783 - sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \ - "${target_dir}/${target_triple}"/release/zed \ - "${target_dir}/${remote_server_triple}"/release/remote_server + # .eh_frame data for stack unwinding. see https://github.com/getsentry/symbolic/issues/783 + for attempt in 1 2 3; do + echo "Attempting sentry upload (attempt $attempt/3)..." + if sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \ + "${target_dir}/${target_triple}"/release/zed \ + "${target_dir}/${remote_server_triple}"/release/remote_server; then + echo "Sentry upload successful on attempt $attempt" + break + else + echo "Sentry upload failed on attempt $attempt" + if [ $attempt -eq 3 ]; then + echo "All sentry upload attempts failed" + fi + fi + done else echo "missing SENTRY_AUTH_TOKEN. skipping sentry upload." fi fi # Strip debug symbols and save them for upload to DigitalOcean -objcopy --only-keep-debug "${target_dir}/${target_triple}/release/zed" "${target_dir}/${target_triple}/release/zed.dbg" -objcopy --only-keep-debug "${target_dir}/${remote_server_triple}/release/remote_server" "${target_dir}/${remote_server_triple}/release/remote_server.dbg" objcopy --strip-debug "${target_dir}/${target_triple}/release/zed" objcopy --strip-debug "${target_dir}/${target_triple}/release/cli" objcopy --strip-debug "${target_dir}/${remote_server_triple}/release/remote_server" -gzip -f "${target_dir}/${target_triple}/release/zed.dbg" -gzip -f "${target_dir}/${remote_server_triple}/release/remote_server.dbg" - -if [[ -n "${DIGITALOCEAN_SPACES_SECRET_KEY:-}" && -n "${DIGITALOCEAN_SPACES_ACCESS_KEY:-}" ]]; then - upload_to_blob_store_public \ - "zed-debug-symbols" \ - "${target_dir}/${target_triple}/release/zed.dbg.gz" \ - "$channel/zed-$version-${target_triple}.dbg.gz" - upload_to_blob_store_public \ - "zed-debug-symbols" \ - "${target_dir}/${remote_server_triple}/release/remote_server.dbg.gz" \ - "$channel/remote_server-$version-${remote_server_triple}.dbg.gz" -fi - # Ensure that remote_server does not depend on libssl nor libcrypto, as we got rid of these deps. if ldd "${target_dir}/${remote_server_triple}/release/remote_server" | grep -q 'libcrypto\|libssl'; then if [[ "$remote_server_triple" == *-musl ]]; then @@ -186,12 +181,7 @@ cp "assets/licenses.md" "${zed_dir}/licenses.md" # Create archive out of everything that's in the temp directory arch=$(uname -m) -target="linux-${arch}" -if [[ "$channel" == "dev" ]]; then - archive="zed-${commit}-${target}.tar.gz" -else - archive="zed-${target}.tar.gz" -fi +archive="zed-linux-${arch}.tar.gz" rm -rf "${archive}" remove_match="zed(-[a-zA-Z0-9]+)?-linux-$(uname -m)\.tar\.gz" diff --git a/script/bundle-mac b/script/bundle-mac index 0bac0f75ee..c6c925f073 100755 --- a/script/bundle-mac +++ b/script/bundle-mac @@ -6,10 +6,7 @@ source script/lib/blob-store.sh build_flag="--release" target_dir="release" open_result=false -local_arch=false -local_only=false local_install=false -bundle_name="" can_code_sign=false # This must match the team in the provisioning profile. @@ -19,14 +16,13 @@ APPLE_NOTARIZATION_TEAM="MQ55VZLNZQ" # Function for displaying help info help_info() { echo " -Usage: ${0##*/} [options] [bundle_name] +Usage: ${0##*/} [options] [architecture=host] Build the application bundle for macOS. Options: -d Compile in debug mode - -l Compile for local architecture only. -o Open dir with the resulting DMG or launch the app itself in local mode. - -i Install the resulting DMG into /Applications in local mode. Noop without -l. + -i Install the resulting DMG into /Applications. -h Display this help and exit. " } @@ -41,12 +37,6 @@ do build_flag=""; target_dir="debug" ;; - l) - export CARGO_INCREMENTAL=true - export CARGO_BUNDLE_SKIP_BUILD=true - local_arch=true - local_only=true - ;; i) local_install=true;; h) help_info @@ -57,11 +47,6 @@ done shift $((OPTIND-1)) -if [[ $# -gt 0 ]]; then - if [ "$1" ]; then - bundle_name=$1 - fi -fi # Get release channel pushd crates/zed @@ -81,24 +66,31 @@ export CXXFLAGS="-stdlib=libc++" version_info=$(rustc --version --verbose) host_line=$(echo "$version_info" | grep host) -local_target_triple=${host_line#*: } +target_triple=${host_line#*: } +if [[ $# -gt 0 && -n "$1" ]]; then + target_triple="$1" +fi +arch_suffix="" + +if [[ "$target_triple" = "x86_64-apple-darwin" ]]; then + arch_suffix="x86_64" +elif [[ "$target_triple" = "aarch64-apple-darwin" ]]; then + arch_suffix="aarch64" +else + echo "Unsupported architecture $target_triple" + exit 1 +fi # Generate the licenses first, so they can be baked into the binaries script/generate-licenses -if [ "$local_arch" = true ]; then - echo "Building for local target only." - cargo build ${build_flag} --package zed --package cli --package remote_server -else - rustup target add aarch64-apple-darwin - rustup target add x86_64-apple-darwin +rustup target add $target_triple - echo "Compiling zed binaries" - cargo build ${build_flag} --package zed --package cli --target aarch64-apple-darwin --target x86_64-apple-darwin - # Build remote_server in separate invocation to prevent feature unification from other crates - # from influencing dynamic libraries required by it. - cargo build ${build_flag} --package remote_server --target aarch64-apple-darwin --target x86_64-apple-darwin -fi +echo "Compiling zed binaries" +cargo build ${build_flag} --package zed --package cli --target $target_triple +# Build remote_server in separate invocation to prevent feature unification from other crates +# from influencing dynamic libraries required by it. +cargo build ${build_flag} --package remote_server --target $target_triple echo "Creating application bundle" pushd crates/zed @@ -108,13 +100,7 @@ sed \ "s/package.metadata.bundle-${channel}/package.metadata.bundle/" \ Cargo.toml -if [ "$local_arch" = true ]; then - app_path=$(cargo bundle ${build_flag} --select-workspace-root | xargs) -else - app_path_x64=$(cargo bundle ${build_flag} --target x86_64-apple-darwin --select-workspace-root | xargs) - app_path_aarch64=$(cargo bundle ${build_flag} --target aarch64-apple-darwin --select-workspace-root | xargs) - app_path=$app_path_x64 -fi +app_path=$(cargo bundle ${build_flag} --target $target_triple --select-workspace-root | xargs) mv Cargo.toml.backup Cargo.toml popd @@ -127,6 +113,8 @@ if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain || echo "" security default-keychain -s zed.keychain security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain + # Calling set-keychain-settings without `-t` disables the auto-lock timeout + security set-keychain-settings zed.keychain echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/zed-certificate.p12 security import /tmp/zed-certificate.p12 -k zed.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign rm /tmp/zed-certificate.p12 @@ -189,51 +177,12 @@ function download_git() { rm -rf "$tmp_dir" } -function prepare_binaries() { - local architecture=$1 - local app_path=$2 - - echo "Unpacking dSYMs for $architecture" - exe_path="target/${architecture}/${target_dir}/Zed" - if ! dsymutil --flat "${exe_path}" 2> target/dsymutil.log; then - echo "dsymutil failed" - cat target/dsymutil.log - exit 1 - fi - uuid=$(dwarfdump --uuid "${exe_path}" | cut -d ' ' -f 2 | tr 'A-F' 'a-f') - version="$(cargo metadata --no-deps --manifest-path crates/zed/Cargo.toml --offline --format-version=1 | jq -r '.packages | map(select(.name == "zed"))[0].version')" - if [ "$channel" == "nightly" ]; then - version="$version-$(git rev-parse --short HEAD)" - fi - - echo "Removing existing gzipped dSYMs for $architecture" - rm -f target/${architecture}/${target_dir}/Zed.dwarf.gz - - echo "Gzipping dSYMs for $architecture" - gzip -kf target/${architecture}/${target_dir}/Zed.dwarf - - echo "Uploading dSYMs${architecture} for $architecture to by-uuid/${uuid}.dwarf.gz" - upload_to_blob_store_public \ - "zed-debug-symbols" \ - target/${architecture}/${target_dir}/Zed.dwarf.gz \ - "by-uuid/${uuid}.dwarf.gz" - - cp target/${architecture}/${target_dir}/zed "${app_path}/Contents/MacOS/zed" - cp target/${architecture}/${target_dir}/cli "${app_path}/Contents/MacOS/cli" -} - function sign_app_binaries() { - local app_path=$1 - local architecture=$2 - local architecture_dir=$3 rm -rf "${app_path}/Contents/Frameworks" mkdir -p "${app_path}/Contents/Frameworks" - if [ "$local_arch" = true ]; then - cp -R target/${target_dir}/cli "${app_path}/Contents/MacOS/" - fi echo "Downloading git binary" - download_git "${architecture}" "${app_path}/Contents/MacOS/git" + download_git "${target_triple}" "${app_path}/Contents/MacOS/git" # Note: The app identifier for our development builds is the same as the app identifier for nightly. cp crates/zed/contents/$channel/embedded.provisionprofile "${app_path}/Contents/" @@ -247,10 +196,6 @@ function sign_app_binaries() { /usr/bin/codesign --force --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "$IDENTITY" "${app_path}" -v else echo "One or more of the following variables are missing: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARIZATION_KEY, APPLE_NOTARIZATION_KEY_ID, APPLE_NOTARIZATION_ISSUER_ID" - if [[ "$local_only" = false ]]; then - echo "To create a self-signed local build use ./scripts/build.sh -ldf" - exit 1 - fi echo "====== WARNING ======" echo "This bundle is being signed without all entitlements, some features (e.g. universal links) will not work" @@ -266,45 +211,30 @@ function sign_app_binaries() { codesign --force --deep --entitlements "${app_path}/Contents/Resources/zed.entitlements" --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v fi - if [[ "$target_dir" = "debug" && "$local_only" = false ]]; then + bundle_name=$(basename "$app_path") + + if [ "$local_install" = true ]; then + rm -rf "/Applications/$bundle_name" + mv "$app_path" "/Applications/$bundle_name" + echo "Installed application bundle: /Applications/$bundle_name" if [ "$open_result" = true ]; then - open "$app_path" - else + echo "Opening /Applications/$bundle_name" + open "/Applications/$bundle_name" + fi + elif [ "$open_result" = true ]; then + open "$app_path" + fi + + if [[ "$target_dir" = "debug" ]]; then + echo "Debug build detected - skipping DMG creation and signing" + if [ "$local_install" = false ]; then echo "Created application bundle:" echo "$app_path" fi - exit 0 - fi - - # If bundle_name is not set or empty, use the basename of $app_path - if [ -z "$bundle_name" ]; then - bundle_name=$(basename "$app_path") else - # If bundle_name doesn't end in .app, append it - if [[ "$bundle_name" != *.app ]]; then - bundle_name="$bundle_name.app" - fi - fi - - if [ "$local_only" = true ]; then - if [ "$local_install" = true ]; then - rm -rf "/Applications/$bundle_name" - mv "$app_path" "/Applications/$bundle_name" - echo "Installed application bundle: /Applications/$bundle_name" - if [ "$open_result" = true ]; then - echo "Opening /Applications/$bundle_name" - open "/Applications/$bundle_name" - fi - else - if [ "$open_result" = true ]; then - echo "Opening $app_path" - open "$app_path" - fi - fi - else - dmg_target_directory="target/${architecture_dir}/${target_dir}" + dmg_target_directory="target/${target_triple}/${target_dir}" dmg_source_directory="${dmg_target_directory}/dmg" - dmg_file_path="${dmg_target_directory}/Zed.dmg" + dmg_file_path="${dmg_target_directory}/Zed-${arch_suffix}.dmg" xcode_bin_dir_path="$(xcode-select -p)/usr/bin" rm -rf ${dmg_source_directory} @@ -351,44 +281,50 @@ function sign_binary() { fi } -if [ "$local_arch" = true ]; then - sign_app_binaries "$app_path" "$local_target_triple" "$local_target_triple" - - sign_binary "target/release/remote_server" -else - # Create universal binary - prepare_binaries "aarch64-apple-darwin" "$app_path_aarch64" - prepare_binaries "x86_64-apple-darwin" "$app_path_x64" - - - sign_app_binaries "$app_path_x64" "x86_64-apple-darwin" "x86_64-apple-darwin" - sign_app_binaries "$app_path_aarch64" "aarch64-apple-darwin" "aarch64-apple-darwin" - - sign_binary "target/x86_64-apple-darwin/release/remote_server" - sign_binary "target/aarch64-apple-darwin/release/remote_server" - gzip -f --stdout --best target/x86_64-apple-darwin/release/remote_server > target/zed-remote-server-macos-x86_64.gz - gzip -f --stdout --best target/aarch64-apple-darwin/release/remote_server > target/zed-remote-server-macos-aarch64.gz -fi - -function upload_debug_info() { - architecture=$1 - if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then +function upload_debug_symbols() { + if [ "$local_install" = true ]; then + echo "local install; skipping sentry upload." + elif [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then echo "Uploading zed debug symbols to sentry..." + exe_path="target/${target_triple}/release/Zed" + if ! dsymutil --flat "target/${target_triple}/${target_dir}/zed" 2> target/dsymutil.log; then + echo "dsymutil failed" + cat target/dsymutil.log + exit 1 + fi + if ! dsymutil --flat "target/${target_triple}/${target_dir}/remote_server" 2> target/dsymutil.log; then + echo "dsymutil failed" + cat target/dsymutil.log + exit 1 + fi # note: this uploads the unstripped binary which is needed because it contains - # .eh_frame data for stack unwinindg. see https://github.com/getsentry/symbolic/issues/783 + # .eh_frame data for stack unwinding. see https://github.com/getsentry/symbolic/issues/783 sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \ - "target/${architecture}/${target_dir}/zed" \ - "target/${architecture}/${target_dir}/remote_server" \ - "target/${architecture}/${target_dir}/zed.dwarf" + # Try uploading up to 3 times + for attempt in 1 2 3; do + echo "Sentry upload attempt $attempt..." + if sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \ + "target/${target_triple}/${target_dir}/zed.dwarf" \ + "target/${target_triple}/${target_dir}/remote_server.dwarf"; then + break + else + echo "Sentry upload failed on attempt $attempt" + if [ $attempt -eq 3 ]; then + echo "All sentry upload attempts failed" + exit 1 + fi + fi + done else echo "missing SENTRY_AUTH_TOKEN. skipping sentry upload." fi } -if command -v sentry-cli >/dev/null 2>&1; then - upload_debug_info "aarch64-apple-darwin" - upload_debug_info "x86_64-apple-darwin" -else - echo "sentry-cli not found. skipping sentry upload." - echo "install with: 'curl -sL https://sentry.io/get-cli | bash'" -fi +upload_debug_symbols + +cp target/${target_triple}/${target_dir}/zed "${app_path}/Contents/MacOS/zed" +cp target/${target_triple}/${target_dir}/cli "${app_path}/Contents/MacOS/cli" +sign_app_binaries + +sign_binary "target/$target_triple/release/remote_server" +gzip -f --stdout --best target/$target_triple/release/remote_server > target/zed-remote-server-macos-$arch_suffix.gz diff --git a/script/bundle-windows.ps1 b/script/bundle-windows.ps1 index 43023a9e14..48114a970f 100644 --- a/script/bundle-windows.ps1 +++ b/script/bundle-windows.ps1 @@ -2,10 +2,10 @@ Param( [Parameter()][Alias('i')][switch]$Install, [Parameter()][Alias('h')][switch]$Help, + [Parameter()][Alias('a')][string]$Architecture, [Parameter()][string]$Name ) -. "$PSScriptRoot/lib/blob-store.ps1" . "$PSScriptRoot/lib/workspace.ps1" # https://stackoverflow.com/questions/57949031/powershell-script-stops-if-program-fails-like-bash-set-o-errexit @@ -14,12 +14,44 @@ $PSNativeCommandUseErrorActionPreference = $true $buildSuccess = $false +$OSArchitecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) { + "X64" { "x86_64" } + "Arm64" { "aarch64" } + default { throw "Unsupported architecture" } +} + +$Architecture = if ($Architecture) { + $Architecture +} else { + $OSArchitecture +} + +$CargoOutDir = "./target/$Architecture-pc-windows-msvc/release" + +function Get-VSArch { + param( + [string]$Arch + ) + + switch ($Arch) { + "x86_64" { "amd64" } + "aarch64" { "arm64" } + } +} + +Push-Location +& "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Launch-VsDevShell.ps1" -Arch (Get-VSArch -Arch $Architecture) -HostArch (Get-VSArch -Arch $OSArchitecture) +Pop-Location + +$target = "$Architecture-pc-windows-msvc" + if ($Help) { Write-Output "Usage: test.ps1 [-Install] [-Help]" Write-Output "Build the installer for Windows.\n" Write-Output "Options:" - Write-Output " -Install, -i Run the installer after building." - Write-Output " -Help, -h Show this help message." + Write-Output " -Architecture, -a Which architecture to build (x86_64 or aarch64)" + Write-Output " -Install, -i Run the installer after building." + Write-Output " -Help, -h Show this help message." exit 0 } @@ -30,6 +62,10 @@ $env:RELEASE_CHANNEL = $channel Pop-Location function CheckEnvironmentVariables { + if(-not $env:CI) { + return + } + $requiredVars = @( 'ZED_WORKSPACE', 'RELEASE_VERSION', 'ZED_RELEASE_CHANNEL', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET', @@ -55,6 +91,8 @@ function PrepareForBundle { New-Item -Path "$innoDir\appx" -ItemType Directory -Force New-Item -Path "$innoDir\bin" -ItemType Directory -Force New-Item -Path "$innoDir\tools" -ItemType Directory -Force + + rustup target add $target } function GenerateLicenses { @@ -67,34 +105,34 @@ function GenerateLicenses { function BuildZedAndItsFriends { Write-Output "Building Zed and its friends, for channel: $channel" # Build zed.exe, cli.exe and auto_update_helper.exe - cargo build --release --package zed --package cli --package auto_update_helper - Copy-Item -Path ".\target\release\zed.exe" -Destination "$innoDir\Zed.exe" -Force - Copy-Item -Path ".\target\release\cli.exe" -Destination "$innoDir\cli.exe" -Force - Copy-Item -Path ".\target\release\auto_update_helper.exe" -Destination "$innoDir\auto_update_helper.exe" -Force + cargo build --release --package zed --package cli --package auto_update_helper --target $target + Copy-Item -Path ".\$CargoOutDir\zed.exe" -Destination "$innoDir\Zed.exe" -Force + Copy-Item -Path ".\$CargoOutDir\cli.exe" -Destination "$innoDir\cli.exe" -Force + Copy-Item -Path ".\$CargoOutDir\auto_update_helper.exe" -Destination "$innoDir\auto_update_helper.exe" -Force # Build explorer_command_injector.dll switch ($channel) { "stable" { - cargo build --release --features stable --no-default-features --package explorer_command_injector + cargo build --release --features stable --no-default-features --package explorer_command_injector --target $target } "preview" { - cargo build --release --features preview --no-default-features --package explorer_command_injector + cargo build --release --features preview --no-default-features --package explorer_command_injector --target $target } default { - cargo build --release --package explorer_command_injector + cargo build --release --package explorer_command_injector --target $target } } - Copy-Item -Path ".\target\release\explorer_command_injector.dll" -Destination "$innoDir\zed_explorer_command_injector.dll" -Force + Copy-Item -Path ".\$CargoOutDir\explorer_command_injector.dll" -Destination "$innoDir\zed_explorer_command_injector.dll" -Force } function ZipZedAndItsFriendsDebug { $items = @( - ".\target\release\zed.pdb", - ".\target\release\cli.pdb", - ".\target\release\auto_update_helper.pdb", - ".\target\release\explorer_command_injector.pdb" + ".\$CargoOutDir\zed.pdb", + ".\$CargoOutDir\cli.pdb", + ".\$CargoOutDir\auto_update_helper.pdb", + ".\$CargoOutDir\explorer_command_injector.pdb" ) - Compress-Archive -Path $items -DestinationPath ".\target\release\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" -Force + Compress-Archive -Path $items -DestinationPath ".\$CargoOutDir\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" -Force } @@ -109,7 +147,20 @@ function UploadToSentry { return } Write-Output "Uploading zed debug symbols to sentry..." - sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev .\target\release\ + for ($i = 1; $i -le 3; $i++) { + try { + sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev $CargoOutDir + break + } + catch { + Write-Output "Sentry upload attempt $i failed: $_" + if ($i -eq 3) { + Write-Output "All sentry upload attempts failed" + throw + } + Start-Sleep -Seconds 2 + } + } } function MakeAppx { @@ -132,6 +183,10 @@ function MakeAppx { } function SignZedAndItsFriends { + if (-not $env:CI) { + return + } + $files = "$innoDir\Zed.exe,$innoDir\cli.exe,$innoDir\auto_update_helper.exe,$innoDir\zed_explorer_command_injector.dll,$innoDir\zed_explorer_command_injector.appx" & "$innoDir\sign.ps1" $files } @@ -147,8 +202,8 @@ function DownloadAMDGpuServices { } function DownloadConpty { - $url = "https://www.nuget.org/api/v2/package/CI.Microsoft.Windows.Console.ConPTY/1.22.250314001" - $zipPath = ".\conpty.zip" + $url = "https://github.com/microsoft/terminal/releases/download/v1.23.12811.0/Microsoft.Windows.Console.ConPTY.1.23.251008001.nupkg" + $zipPath = ".\Microsoft.Windows.Console.ConPTY.1.23.251008001.nupkg" Invoke-WebRequest -Uri $url -OutFile $zipPath Expand-Archive -Path $zipPath -DestinationPath ".\conpty" -Force } @@ -159,9 +214,19 @@ function CollectFiles { Move-Item -Path "$innoDir\cli.exe" -Destination "$innoDir\bin\zed.exe" -Force Move-Item -Path "$innoDir\zed.sh" -Destination "$innoDir\bin\zed" -Force Move-Item -Path "$innoDir\auto_update_helper.exe" -Destination "$innoDir\tools\auto_update_helper.exe" -Force - Move-Item -Path ".\AGS_SDK-6.3.0\ags_lib\lib\amd_ags_x64.dll" -Destination "$innoDir\amd_ags_x64.dll" -Force - Move-Item -Path ".\conpty\build\native\runtimes\x64\OpenConsole.exe" -Destination "$innoDir\OpenConsole.exe" -Force - Move-Item -Path ".\conpty\runtimes\win10-x64\native\conpty.dll" -Destination "$innoDir\conpty.dll" -Force + if($Architecture -eq "aarch64") { + New-Item -Type Directory -Path "$innoDir\arm64" -Force + Move-Item -Path ".\conpty\build\native\runtimes\arm64\OpenConsole.exe" -Destination "$innoDir\arm64\OpenConsole.exe" -Force + Move-Item -Path ".\conpty\runtimes\win-arm64\native\conpty.dll" -Destination "$innoDir\conpty.dll" -Force + } + else { + New-Item -Type Directory -Path "$innoDir\x64" -Force + New-Item -Type Directory -Path "$innoDir\arm64" -Force + Move-Item -Path ".\AGS_SDK-6.3.0\ags_lib\lib\amd_ags_x64.dll" -Destination "$innoDir\amd_ags_x64.dll" -Force + Move-Item -Path ".\conpty\build\native\runtimes\x64\OpenConsole.exe" -Destination "$innoDir\x64\OpenConsole.exe" -Force + Move-Item -Path ".\conpty\build\native\runtimes\arm64\OpenConsole.exe" -Destination "$innoDir\arm64\OpenConsole.exe" -Force + Move-Item -Path ".\conpty\runtimes\win-x64\native\conpty.dll" -Destination "$innoDir\conpty.dll" -Force + } } function BuildInstaller { @@ -172,7 +237,7 @@ function BuildInstaller { $appIconName = "app-icon" $appName = "Zed" $appDisplayName = "Zed" - $appSetupName = "ZedEditorUserSetup-x64-$env:RELEASE_VERSION" + $appSetupName = "Zed-$Architecture" # The mutex name here should match the mutex name in crates\zed\src\zed\windows_only_instance.rs $appMutex = "Zed-Stable-Instance-Mutex" $appExeName = "Zed" @@ -186,7 +251,7 @@ function BuildInstaller { $appIconName = "app-icon-preview" $appName = "Zed Preview" $appDisplayName = "Zed Preview" - $appSetupName = "ZedEditorUserSetup-x64-$env:RELEASE_VERSION-preview" + $appSetupName = "Zed-$Architecture" # The mutex name here should match the mutex name in crates\zed\src\zed\windows_only_instance.rs $appMutex = "Zed-Preview-Instance-Mutex" $appExeName = "Zed" @@ -200,7 +265,7 @@ function BuildInstaller { $appIconName = "app-icon-nightly" $appName = "Zed Nightly" $appDisplayName = "Zed Nightly" - $appSetupName = "ZedEditorUserSetup-x64-$env:RELEASE_VERSION-nightly" + $appSetupName = "Zed-$Architecture" # The mutex name here should match the mutex name in crates\zed\src\zed\windows_only_instance.rs $appMutex = "Zed-Nightly-Instance-Mutex" $appExeName = "Zed" @@ -214,7 +279,7 @@ function BuildInstaller { $appIconName = "app-icon-dev" $appName = "Zed Dev" $appDisplayName = "Zed Dev" - $appSetupName = "ZedEditorUserSetup-x64-$env:RELEASE_VERSION-dev" + $appSetupName = "Zed-$Architecture" # The mutex name here should match the mutex name in crates\zed\src\zed\windows_only_instance.rs $appMutex = "Zed-Dev-Instance-Mutex" $appExeName = "Zed" @@ -252,14 +317,16 @@ function BuildInstaller { "AppxFullName" = $appAppxFullName } - $signTool = "powershell.exe -ExecutionPolicy Bypass -File $innoDir\sign.ps1 `$f" - $defs = @() foreach ($key in $definitions.Keys) { $defs += "/d$key=`"$($definitions[$key])`"" } - $innoArgs = @($issFilePath) + $defs + "/sDefaultsign=`"$signTool`"" + $innoArgs = @($issFilePath) + $defs + if($env:CI) { + $signTool = "powershell.exe -ExecutionPolicy Bypass -File $innoDir\sign.ps1 `$f" + $innoArgs += "/sDefaultsign=`"$signTool`"" + } # Execute Inno Setup Write-Host "🚀 Running Inno Setup: $innoSetupPath $innoArgs" @@ -277,8 +344,8 @@ function BuildInstaller { } ParseZedWorkspace -$innoDir = "$env:ZED_WORKSPACE\inno" -$debugArchive = ".\target\release\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" +$innoDir = "$env:ZED_WORKSPACE\inno\$Architecture" +$debugArchive = "$CargoOutDir\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" $debugStoreKey = "$env:ZED_RELEASE_CHANNEL/zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" CheckEnvironmentVariables @@ -293,8 +360,9 @@ DownloadConpty CollectFiles BuildInstaller -UploadToBlobStorePublic -BucketName "zed-debug-symbols" -FileToUpload $debugArchive -BlobStoreKey $debugStoreKey -UploadToSentry +if($env:CI) { + UploadToSentry +} if ($buildSuccess) { Write-Output "Build successful" diff --git a/script/check-licenses b/script/check-licenses index a58806f379..0363f31970 100755 --- a/script/check-licenses +++ b/script/check-licenses @@ -2,14 +2,16 @@ set -euo pipefail +AGPL_CRATES=("collab") +RELEASE_CRATES=("cli" "remote_server" "zed") + check_license () { local dir="$1" local allowed_licenses=() - local agpl_crates=("crates/collab") local is_agpl=false - for agpl_crate in "${agpl_crates[@]}"; do - if [[ "$dir" == "$agpl_crate" ]]; then + for agpl_crate in "${AGPL_CRATES[@]}"; do + if [[ "$dir" == "crates/$agpl_crate" ]]; then is_agpl=true break fi @@ -30,7 +32,7 @@ check_license () { fi done - if [[ "$dir" == "crates/collab" ]]; then + if [[ "$is_agpl" == true ]]; then echo "Error: $dir does not contain a LICENSE-AGPL symlink" else echo "Error: $dir does not contain a LICENSE-GPL or LICENSE-APACHE symlink" @@ -41,3 +43,20 @@ check_license () { git ls-files "**/*/Cargo.toml" | while read -r cargo_toml; do check_license "$(dirname "$cargo_toml")" done + + +# Make sure the AGPL server crates are included in the release tarball. +for release_crate in "${RELEASE_CRATES[@]}"; do + tree_output=$(cargo tree --package "$release_crate") + for agpl_crate in "${AGPL_CRATES[@]}"; do + # Look for lines that contain the crate name followed by " v" (version) + # This matches patterns like "├── collab v0.44.0" + if echo "$tree_output" | grep -E "(^|[^a-zA-Z_])${agpl_crate} v" > /dev/null; then + echo "Error: crate '${agpl_crate}' is AGPL and is a dependency of crate '${release_crate}'." >&2 + echo "AGPL licensed code should not be used in the release distribution, only in servers." >&2 + exit 1 + fi + done +done + +echo "check-licenses succeeded" diff --git a/script/cherry-pick b/script/cherry-pick new file mode 100755 index 0000000000..37106943f4 --- /dev/null +++ b/script/cherry-pick @@ -0,0 +1,33 @@ +# #!/bin/bash +set -euxo pipefail + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " + exit 1 +fi + +BRANCH_NAME="$1" +COMMIT_SHA="$2" +CHANNEL="$3" + +SHORT_SHA="${COMMIT_SHA:0:8}" +NEW_BRANCH="cherry-pick-${BRANCH_NAME}-${SHORT_SHA}" +git fetch --depth 2 origin +${COMMIT_SHA} ${BRANCH_NAME} +git checkout --force "origin/$BRANCH_NAME" -B "$NEW_BRANCH" + +git cherry-pick "$COMMIT_SHA" + +git push origin -f "$NEW_BRANCH" +COMMIT_TITLE=$(git log -1 --pretty=format:"%s" "$COMMIT_SHA") +COMMIT_BODY=$(git log -1 --pretty=format:"%b" "$COMMIT_SHA") + +# Check if commit title ends with (#number) +if [[ "$COMMIT_TITLE" =~ \(#([0-9]+)\)$ ]]; then + PR_NUMBER="${BASH_REMATCH[1]}" + PR_BODY="Cherry-pick of #${PR_NUMBER} to ${CHANNEL}"$'\n'$'\n'"----"$'\n'"${COMMIT_BODY}" +else + PR_BODY="Cherry-pick of ${COMMIT_SHA} to ${CHANNEL}"$'\n'$'\n'"----"$'\n'"${COMMIT_BODY}" +fi + +# Create a pull request +gh pr create --base "$BRANCH_NAME" --head "$NEW_BRANCH" --title "$COMMIT_TITLE (cherry-pick to $CHANNEL)" --body "$PR_BODY" diff --git a/script/clear-target-dir-if-larger-than b/script/clear-target-dir-if-larger-than index b77b4cff0f..46256159a8 100755 --- a/script/clear-target-dir-if-larger-than +++ b/script/clear-target-dir-if-larger-than @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -eu +set -euxo pipefail if [[ $# -ne 1 ]]; then echo "usage: $0 " @@ -21,5 +21,6 @@ echo "target directory size: ${current_size_gb}gb. max size: ${max_size_gb}gb" if [[ ${current_size_gb} -gt ${max_size_gb} ]]; then echo "clearing target directory" - rm -rf target + shopt -s dotglob + rm -rf target/* fi diff --git a/script/create-draft-release b/script/create-draft-release index 95b1a1450a..d50ebf2e5a 100755 --- a/script/create-draft-release +++ b/script/create-draft-release @@ -5,4 +5,5 @@ if [[ "$GITHUB_REF_NAME" == *"-pre" ]]; then preview="-p" fi -gh release create -t "$GITHUB_REF_NAME" -d "$GITHUB_REF_NAME" -F "$1" $preview +gh release view "$GITHUB_REF_NAME" ||\ + gh release create -t "$GITHUB_REF_NAME" -d "$GITHUB_REF_NAME" -F "$1" $preview diff --git a/script/create-migration b/script/create-migration deleted file mode 100755 index 187336be19..0000000000 --- a/script/create-migration +++ /dev/null @@ -1,3 +0,0 @@ -zed . \ - "crates/collab/migrations.sqlite/20221109000000_test_schema.sql" \ - "crates/collab/migrations/$(date -u +%Y%m%d%H%M%S)_$(echo $1 | sed 's/[^a-z0-9]/_/g').sql" diff --git a/script/danger/dangerfile.ts b/script/danger/dangerfile.ts index 6ed4a27fed..88dc5c5e71 100644 --- a/script/danger/dangerfile.ts +++ b/script/danger/dangerfile.ts @@ -61,12 +61,11 @@ if (includesIssueUrl) { const PROMPT_PATHS = [ "assets/prompts/content_prompt.hbs", "assets/prompts/terminal_assistant_prompt.hbs", - "crates/agent/src/prompts/stale_files_prompt_header.txt", - "crates/agent/src/prompts/summarize_thread_detailed_prompt.txt", - "crates/agent/src/prompts/summarize_thread_prompt.txt", - "crates/assistant_tools/src/templates/create_file_prompt.hbs", - "crates/assistant_tools/src/templates/edit_file_prompt_xml.hbs", - "crates/assistant_tools/src/templates/edit_file_prompt_diff_fenced.hbs", + "crates/agent_settings/src/prompts/summarize_thread_detailed_prompt.txt", + "crates/agent_settings/src/prompts/summarize_thread_prompt.txt", + "crates/agent/src/templates/create_file_prompt.hbs", + "crates/agent/src/templates/edit_file_prompt_xml.hbs", + "crates/agent/src/templates/edit_file_prompt_diff_fenced.hbs", "crates/git_ui/src/commit_message_prompt.txt", ]; diff --git a/script/debug-cli b/script/debug-cli index 1a40e70338..65017cd456 100755 --- a/script/debug-cli +++ b/script/debug-cli @@ -1,3 +1,3 @@ #!/usr/bin/env bash -cargo build; cargo run -p cli -- --foreground --zed=target/debug/zed "$@" +cargo build -p zed && cargo run -p cli -- --foreground --zed=${CARGO_TARGET_DIR:-target}/debug/zed "$@" diff --git a/script/deploy-postgrest b/script/deploy-postgrest deleted file mode 100755 index ca8f368646..0000000000 --- a/script/deploy-postgrest +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash - -set -eu -source script/lib/deploy-helpers.sh - -if [[ $# != 1 ]]; then - echo "Usage: $0 (postgrest not needed on preview or nightly)" - exit 1 -fi -environment=$1 - -export_vars_for_environment ${environment} - -export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header) -export ZED_KUBE_NAMESPACE=${environment} - -target_zed_kube_cluster -envsubst < crates/collab/k8s/postgrest.template.yml | kubectl apply -f - - -echo "deployed postgrest" diff --git a/script/download-wasi-sdk b/script/download-wasi-sdk new file mode 100755 index 0000000000..8cf36ffda1 --- /dev/null +++ b/script/download-wasi-sdk @@ -0,0 +1,60 @@ +#!/bin/bash + +# Check if ./target/wasi-sdk exists +if [ ! -d "./target/wasi-sdk" ]; then + echo "WASI SDK not found, downloading v25..." + + # Determine OS and architecture + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + # Map architecture names to WASI SDK format + case $ARCH in + x86_64) + ARCH="x86_64" + ;; + arm64|aarch64) + ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; + esac + + # Map OS names to WASI SDK format + case $OS in + darwin) + OS="macos" + ;; + linux) + OS="linux" + ;; + mingw*|msys*|cygwin*) + OS="mingw" + ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; + esac + + # Construct download URL + WASI_SDK_VERSION="25" + WASI_SDK_URL="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}.tar.gz" + + echo "Downloading from: $WASI_SDK_URL" + + # Create target directory if it doesn't exist + mkdir -p ./target + + # Download and extract + curl -L "$WASI_SDK_URL" | tar -xz -C ./target + + # Rename the extracted directory to wasi-sdk + mv "./target/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}" "./target/wasi-sdk" + + echo "WASI SDK v25 installed successfully" +else + echo "WASI SDK already exists at ./target/wasi-sdk" +fi diff --git a/script/draft-release-notes b/script/draft-release-notes index 1ef276718d..2436aa6617 100755 --- a/script/draft-release-notes +++ b/script/draft-release-notes @@ -20,7 +20,7 @@ async function main() { } // currently we can only draft notes for patch releases. - if (parts[2] == 0) { + if (parts[2] === 0) { process.exit(0); } @@ -41,20 +41,13 @@ async function main() { "--depth", 100, ]); - execFileSync("git", [ - "-C", - "target/shallow_clone", - "rev-parse", - "--verify", - tag, - ]); - execFileSync("git", [ - "-C", - "target/shallow_clone", - "rev-parse", - "--verify", - priorTag, - ]); + execFileSync("git", ["-C", "target/shallow_clone", "rev-parse", "--verify", tag]); + try { + execFileSync("git", ["-C", "target/shallow_clone", "rev-parse", "--verify", priorTag]); + } catch (e) { + console.error(`Prior tag ${priorTag} not found`); + process.exit(0); + } } catch (e) { console.error(e.stderr.toString()); process.exit(1); @@ -90,13 +83,7 @@ async function main() { function getCommits(oldTag, newTag) { const pullRequestNumbers = execFileSync( "git", - [ - "-C", - "target/shallow_clone", - "log", - `${oldTag}..${newTag}`, - "--format=DIVIDER\n%H|||%B", - ], + ["-C", "target/shallow_clone", "log", `${oldTag}..${newTag}`, "--format=DIVIDER\n%H|||%B"], { encoding: "utf8" }, ) .replace(/\r\n/g, "\n") diff --git a/script/generate-licenses b/script/generate-licenses index 5deed400e4..6a833acd20 100755 --- a/script/generate-licenses +++ b/script/generate-licenses @@ -2,7 +2,7 @@ set -euo pipefail -CARGO_ABOUT_VERSION="0.8" +CARGO_ABOUT_VERSION="0.8.2" OUTPUT_FILE="${1:-$(pwd)/assets/licenses.md}" TEMPLATE_FILE="script/licenses/template.md.hbs" @@ -28,10 +28,10 @@ echo -n "" >"$OUTPUT_FILE" } >>"$OUTPUT_FILE" if ! cargo about --version | grep "cargo-about $CARGO_ABOUT_VERSION" &>/dev/null; then - echo "Installing cargo-about@^$CARGO_ABOUT_VERSION..." - cargo install "cargo-about@^$CARGO_ABOUT_VERSION" + echo "Installing cargo-about@$CARGO_ABOUT_VERSION..." + cargo install "cargo-about@$CARGO_ABOUT_VERSION" else - echo "cargo-about@^$CARGO_ABOUT_VERSION is already installed." + echo "cargo-about@$CARGO_ABOUT_VERSION is already installed." fi echo "Generating cargo licenses" diff --git a/script/generate-licenses-csv b/script/generate-licenses-csv index 0e40c69d47..dd86f872d0 100755 --- a/script/generate-licenses-csv +++ b/script/generate-licenses-csv @@ -2,15 +2,15 @@ set -euo pipefail -CARGO_ABOUT_VERSION="0.8" +CARGO_ABOUT_VERSION="0.8.2" OUTPUT_FILE="${1:-$(pwd)/assets/licenses.csv}" TEMPLATE_FILE="script/licenses/template.csv.hbs" if ! cargo about --version | grep "cargo-about $CARGO_ABOUT_VERSION" 2>&1 > /dev/null; then - echo "Installing cargo-about@^$CARGO_ABOUT_VERSION..." - cargo install "cargo-about@^$CARGO_ABOUT_VERSION" + echo "Installing cargo-about@$CARGO_ABOUT_VERSION..." + cargo install "cargo-about@$CARGO_ABOUT_VERSION" else - echo "cargo-about@^$CARGO_ABOUT_VERSION is already installed." + echo "cargo-about@$CARGO_ABOUT_VERSION is already installed." fi echo "Generating cargo licenses" diff --git a/script/generate-licenses.ps1 b/script/generate-licenses.ps1 index ab7df73e56..80cd249a46 100644 --- a/script/generate-licenses.ps1 +++ b/script/generate-licenses.ps1 @@ -1,4 +1,4 @@ -$CARGO_ABOUT_VERSION="0.8" +$CARGO_ABOUT_VERSION="0.8.2" $outputFile=$args[0] ? $args[0] : "$(Get-Location)/assets/licenses.md" $templateFile="script/licenses/template.md.hbs" @@ -14,10 +14,10 @@ New-Item -Path "$outputFile" -ItemType File -Value "" -Force $versionOutput = cargo about --version if (-not ($versionOutput -match "cargo-about $CARGO_ABOUT_VERSION")) { - Write-Host "Installing cargo-about@^$CARGO_ABOUT_VERSION..." - cargo install "cargo-about@^$CARGO_ABOUT_VERSION" + Write-Host "Installing cargo-about@$CARGO_ABOUT_VERSION..." + cargo install "cargo-about@$CARGO_ABOUT_VERSION" } else { - Write-Host "cargo-about@^$CARGO_ABOUT_VERSION" is already installed + Write-Host "cargo-about@$CARGO_ABOUT_VERSION" is already installed } Write-Host "Generating cargo licenses" diff --git a/script/generate-terms-rtf b/script/generate-terms-rtf index ddfaee95a5..654972931f 100755 --- a/script/generate-terms-rtf +++ b/script/generate-terms-rtf @@ -7,6 +7,4 @@ then brew install pandoc # Install pandoc using Homebrew fi -pandoc ./legal/terms.md -f markdown-smart -t html -o ./script/terms/terms.html -textutil -convert rtf ./script/terms/terms.html -output ./script/terms/terms.rtf -rm ./script/terms/terms.html +pandoc ./legal/terms.md -f markdown-smart -t rtf -o ./script/terms/terms.rtf --standalone diff --git a/script/get-preview-channel-changes b/script/get-preview-channel-changes deleted file mode 100755 index d1ca705736..0000000000 --- a/script/get-preview-channel-changes +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env node --redirect-warnings=/dev/null - -const { execFileSync } = require("child_process"); -const { GITHUB_ACCESS_TOKEN } = process.env; -const GITHUB_URL = "https://github.com"; -const SKIPPABLE_NOTE_REGEX = /^\s*-?\s*n\/?a\s*/ims; -const PULL_REQUEST_WEB_URL = "https://github.com/zed-industries/zed/pull"; -const PULL_REQUEST_API_URL = "https://api.github.com/repos/zed-industries/zed/pulls"; -const DIVIDER = "-".repeat(80); - -main(); - -async function main() { - if (!GITHUB_ACCESS_TOKEN) { - try { - GITHUB_ACCESS_TOKEN = execFileSync("gh", ["auth", "token"]).toString(); - } catch (error) { - console.log(error); - console.log("No GITHUB_ACCESS_TOKEN, and no `gh auth token`"); - process.exit(1); - } - } - - const STAFF_MEMBERS = new Set( - ( - await ( - await fetch("https://api.github.com/orgs/zed-industries/teams/staff/members?per_page=100", { - headers: { - Authorization: `token ${GITHUB_ACCESS_TOKEN}`, - Accept: "application/vnd.github+json", - }, - }) - ).json() - ).map(({ login }) => login.toLowerCase()), - ); - - const isStaffMember = (githubHandle) => { - githubHandle = githubHandle.toLowerCase(); - return STAFF_MEMBERS.has(githubHandle); - }; - - // Get the last two preview tags - const [newTag, oldTag] = execFileSync("git", ["tag", "--sort", "-committerdate"], { encoding: "utf8" }) - .split("\n") - .filter((t) => t.startsWith("v") && t.endsWith("-pre")); - - // Print the previous release - console.log(`Changes from ${oldTag} to ${newTag}\n`); - - // Get the PRs merged between those two tags. - const pullRequestNumbers = getPullRequestNumbers(oldTag, newTag); - - // Get the PRs that were cherry-picked between main and the old tag. - const existingPullRequestNumbers = new Set(getPullRequestNumbers("main", oldTag)); - - // Filter out those existing PRs from the set of new PRs. - const newPullRequestNumbers = pullRequestNumbers.filter((number) => !existingPullRequestNumbers.has(number)); - - // Fetch the pull requests from the GitHub API. - console.log("Merged Pull requests:"); - console.log(DIVIDER); - for (const pullRequestNumber of newPullRequestNumbers) { - const pullRequestApiURL = `${PULL_REQUEST_API_URL}/${pullRequestNumber}`; - - const response = await fetch(pullRequestApiURL, { - headers: { - Authorization: `token ${GITHUB_ACCESS_TOKEN}`, - }, - }); - - const pullRequest = await response.json(); - const releaseNotesHeader = /^\s*Release Notes:(.+)/ims; - - const releaseNotes = pullRequest.body || ""; - let contributor = pullRequest.user?.login ?? "Unable to identify contributor"; - const captures = releaseNotesHeader.exec(releaseNotes); - let notes = captures ? captures[1] : "MISSING"; - notes = notes.trim(); - const isStaff = isStaffMember(contributor); - - if (SKIPPABLE_NOTE_REGEX.exec(notes) != null) { - continue; - } - - const credit = getCreditString(pullRequestNumber, contributor, isStaff); - contributor = isStaff ? `${contributor} (staff)` : contributor; - - console.log(`PR Title: ${pullRequest.title}`); - console.log(`Contributor: ${contributor}`); - console.log(`Credit: (${credit})`); - - console.log("Release Notes:"); - console.log(); - console.log(notes); - - console.log(DIVIDER); - } -} - -function getCreditString(pullRequestNumber, contributor, isStaff) { - let credit = ""; - - if (pullRequestNumber) { - const pullRequestMarkdownLink = `[#${pullRequestNumber}](${PULL_REQUEST_WEB_URL}/${pullRequestNumber})`; - credit += pullRequestMarkdownLink; - } - - if (contributor && !isStaff) { - const contributorMarkdownLink = `[${contributor}](${GITHUB_URL}/${contributor})`; - credit += `; thanks ${contributorMarkdownLink}`; - } - - return credit; -} - -function getPullRequestNumbers(oldTag, newTag) { - const pullRequestNumbers = execFileSync("git", ["log", `${oldTag}..${newTag}`, "--oneline"], { encoding: "utf8" }) - .split("\n") - .filter((line) => line.length > 0) - .map((line) => { - const match = line.match(/#(\d+)/); - return match ? match[1] : null; - }) - .filter((line) => line); - - return pullRequestNumbers; -} diff --git a/script/get-released-version b/script/get-released-version index 547026d003..0fbb2e1757 100755 --- a/script/get-released-version +++ b/script/get-released-version @@ -18,4 +18,4 @@ case $channel in ;; esac -curl -s "https://zed.dev/api/releases/latest?asset=zed&os=macos&arch=aarch64$query" | jq -r .version +curl -s "https://cloud.zed.dev/releases/$channel/latest/asset?asset=zed&os=macos&arch=aarch64" | jq -r .version diff --git a/script/get-stable-channel-release-notes b/script/get-stable-channel-release-notes deleted file mode 100755 index b16bc9e41f..0000000000 --- a/script/get-stable-channel-release-notes +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node --redirect-warnings=/dev/null - -// This script should be ran before `bump-zed-minor-versions` - -// Prints the changelogs for all preview releases associated with the most -// recent preview minor version. - -// Future TODO: Have the script perform deduplication of lines that were -// included in both past stable and preview patches that shouldn't be mentioned -// again in this week's stable minor release. - -// Future TODO: Get changelogs for latest cherry-picked commits on preview and -// stable that didn't make it into a release, as they were cherry picked - -const { execFileSync } = require("child_process"); -const { GITHUB_ACCESS_TOKEN } = process.env; -const GITHUB_TAGS_API_URL = "https://api.github.com/repos/zed-industries/zed/releases/tags"; -const DIVIDER = "-".repeat(80); - -main(); - -async function main() { - if (!GITHUB_ACCESS_TOKEN) { - try { - GITHUB_ACCESS_TOKEN = execFileSync("gh", ["auth", "token"]).toString(); - } catch (error) { - console.log(error); - console.log("No GITHUB_ACCESS_TOKEN and no `gh auth token`"); - process.exit(1); - } - } - - const allTags = execFileSync("git", ["tag", "--sort", "-committerdate"], { encoding: "utf8" }) - .split("\n") - .filter((t) => t.length > 0); - const latestPreviewTag = allTags.filter((t) => t.startsWith("v") && t.endsWith("-pre"))[0]; - const latestPreviewMinorVersion = latestPreviewTag.split(".")[1]; - const latestPreviewTagRegex = new RegExp(`^v(\\d+)\\.(${latestPreviewMinorVersion})\\.(\\d+)-pre$`); - - const parsedPreviewTags = allTags - .map((tag) => { - const match = tag.match(latestPreviewTagRegex); - if (match) { - return { - tag, - version: { - major: parseInt(match[1]), - minor: parseInt(match[2]), - patch: parseInt(match[3]), - }, - }; - } - return null; - }) - .filter((item) => item !== null) - .sort((a, b) => a.version.patch - b.version.patch); - - const matchingPreviewTags = parsedPreviewTags.map((item) => item.tag); - - console.log("Fetching release information for preview tags:"); - console.log(DIVIDER); - - for (const tag of matchingPreviewTags) { - const releaseApiUrl = `${GITHUB_TAGS_API_URL}/${tag}`; - - try { - const response = await fetch(releaseApiUrl, { - headers: { - Authorization: `token ${GITHUB_ACCESS_TOKEN}`, - }, - }); - - if (!response.ok) { - console.log(`Failed to fetch release for ${tag}: ${response.status}`); - continue; - } - - const release = await response.json(); - - console.log(`\nRelease: ${release.name || tag}`); - console.log(`Tag: ${tag}`); - console.log(`Published: ${release.published_at}`); - console.log(`URL: ${release.html_url}`); - console.log("\nRelease Notes:"); - console.log(release.body || "No release notes"); - console.log(DIVIDER); - } catch (error) { - console.log(`Error fetching release for ${tag}:`, error.message); - } - } - - const patchUpdateTags = parsedPreviewTags.filter((tag) => tag.version.patch != 0).map((tag) => tag.tag); - - console.log(); - console.log("Please review the release notes associated with the following patch versions:"); - for (const tag of patchUpdateTags) { - console.log(`- ${tag}`); - } - console.log("Remove items that have already been mentioned in the current published stable versions."); - console.log("https://github.com/zed-industries/zed/releases?q=prerelease%3Afalse&expanded=true"); -} diff --git a/script/github-clean-issue-types.py b/script/github-clean-issue-types.py new file mode 100755 index 0000000000..dfd573628b --- /dev/null +++ b/script/github-clean-issue-types.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Replace 'bug/feature/crash' labels with 'Bug/Feature/Crash' types on open +GitHub issues. + +Requires `requests` library and a GitHub access token with "Issues (write)" +permission passed as an environment variable. +Was used as a quick-and-dirty one-off-bulk-operation script to clean up issue +types in the `zed` repository. Leaving it here for reference only; there's no +error handling, you've been warned. +""" + + +import logging +import os + +import requests + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +GITHUB_API_BASE_URL = "https://api.github.com" +REPO_OWNER = "zed-industries" +REPO_NAME = "zed" +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +HEADERS = { + "Authorization": f"token {GITHUB_TOKEN}", + "Accept": "application/vnd.github+json" +} +LABELS_TO_TYPES = { + 'bug': 'Bug', + 'feature': 'Feature', + 'crash': 'Crash', + } + + +def get_open_issues_without_type(repo): + """Get open issues without type via GitHub's REST API.""" + issues = [] + issues_url = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{repo}/issues" + + log.info("Start fetching issues from the GitHub API.") + params = { + "state": "open", + "type": "none", + "page": 1, + "per_page": 100, # worked fine despite the docs saying 30 + } + while True: + response = requests.get(issues_url, headers=HEADERS, params=params) + response.raise_for_status() + issues.extend(response.json()) + log.info(f"Fetched the next page, total issues so far: {len(issues)}.") + + # is there a next page? + link_header = response.headers.get('Link', '') + if 'rel="next"' not in link_header: + break + params['page'] += 1 + + log.info("Done fetching issues.") + return issues + + +def replace_labels_with_types(issues, labels_to_types): + """Replace labels with types, a new attribute of issues. + + Only changes the issues with one type-sounding label, leaving those with + two labels (e.g. `bug` *and* `crash`) alone, logging a warning. + """ + for issue in issues: + log.debug(f"Processing issue {issue['number']}.") + # for GitHub, all PRs are issues but not all issues are PRs; skip PRs + if 'pull_request' in issue: + continue + issue_labels = (label['name'] for label in issue['labels']) + matching_labels = labels_to_types.keys() & set(issue_labels) + if len(matching_labels) != 1: + log.warning( + f"Issue {issue['url']} has either no or multiple type-sounding " + "labels, won't be processed.") + continue + label_to_replace = matching_labels.pop() + issue_type = labels_to_types[label_to_replace] + log.debug( + f"Replacing label {label_to_replace} with type {issue_type} " + f"for issue {issue['title']}.") + + # add the type + api_url_issue = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue['number']}" + add_type_response = requests.patch( + api_url_issue, headers=HEADERS, json={"type": issue_type}) + add_type_response.raise_for_status() + log.debug(f"Added type {issue_type} to issue {issue['title']}.") + + # delete the label + api_url_delete_label = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue['number']}/labels/{label_to_replace}" + delete_response = requests.delete(api_url_delete_label, headers=HEADERS) + delete_response.raise_for_status() + log.info( + f"Deleted label {label_to_replace} from issue {issue['title']}.") + + +if __name__ == "__main__": + open_issues_without_type = get_open_issues_without_type(REPO_NAME) + replace_labels_with_types(open_issues_without_type, LABELS_TO_TYPES) diff --git a/script/github-label-issues-to-triage.py b/script/github-label-issues-to-triage.py new file mode 100755 index 0000000000..9a7274a4aa --- /dev/null +++ b/script/github-label-issues-to-triage.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Add `state:needs triage` label to open GitHub issues of types Bug and Crash +if they're missing area, priority, or frequency labels. Don't touch issues +with an assignee or another `state:` label. + +Requires `requests` library and a GitHub access token with "Issues (write)" +permission passed as an environment variable. Was used as a quick-and-dirty +one-off-bulk-operation script to surface older untriaged issues in the `zed` +repository. Leaving it here for reference only; there's no error handling or +guardrails, you've been warned. +""" + +import itertools +import logging +import os + +import requests + + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +GITHUB_API_BASE_URL = "https://api.github.com" +REPO_OWNER = "zed-industries" +REPO_NAME = "zed" +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +HEADERS = { + "Authorization": f"token {GITHUB_TOKEN}", + "Accept": "application/vnd.github+json" +} +REQUIRED_LABELS_PREFIXES = ["area:", "priority:", "frequency:"] +NEEDS_TRIAGE_LABEL = "state:needs triage" + + +def get_open_issues(repo, issue_type): + """Get open issues of certain type(s) via GitHub's REST API.""" + issues = [] + issues_url = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{repo}/issues" + + log.info("Start fetching open issues from the GitHub API.") + params = { + "state": "open", + "type": issue_type, + "page": 1, + "per_page": 100, # worked fine despite the docs saying 30 + } + while True: + response = requests.get(issues_url, headers=HEADERS, params=params) + response.raise_for_status() + issues.extend(response.json()) + log.info(f"Fetched the next page, total issues so far: {len(issues)}.") + + # is there a next page? + link_header = response.headers.get('Link', '') + if 'rel="next"' not in link_header: + break + params['page'] += 1 + + log.info("Done fetching issues.") + return issues + + +def is_untriaged(issue): + issue_labels = [label['name'] for label in issue['labels']] + # don't want to overwrite existing state labels + no_state_label = not any(label.startswith('state:') for label in issue_labels) + # we want at least one label for each of the required prefixes + has_all_required_labels = all( + any(label.startswith(prefix) for label in issue_labels) + for prefix in REQUIRED_LABELS_PREFIXES + ) + # let's also assume if we managed to assign an issue it's triaged enough + no_assignee = not issue['assignee'] + return no_state_label and no_assignee and not has_all_required_labels + + +def label_issues(issues, label): + for issue in issues: + log.debug(f"Processing issue {issue['number']}.") + api_url_add_label = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue['number']}/labels" + add_response = requests.post( + api_url_add_label, headers=HEADERS, json={"labels": [label]} + ) + add_response.raise_for_status() + log.info(f"Added label '{label}' to issue {issue['title']}.") + + +if __name__ == "__main__": + open_bugs = get_open_issues(REPO_NAME, "Bug") + open_crashes = get_open_issues(REPO_NAME, "Crash") + untriaged_issues = filter( + is_untriaged, itertools.chain(open_bugs, open_crashes)) + label_issues(untriaged_issues, label=NEEDS_TRIAGE_LABEL) diff --git a/script/install-linux b/script/install-linux index 72fd8eba2c..a642ed2e0e 100755 --- a/script/install-linux +++ b/script/install-linux @@ -15,12 +15,8 @@ export ZED_CHANNEL=$(/dev/null 2>&1 && wild --version | grep -Fq "$WILD_VERSION" ; then + echo "Warning: existing wild $WILD_VERSION found at $(command -v wild). Skipping installation." exit 0 fi diff --git a/script/install.sh b/script/install.sh index feb140c984..0c2cfa1b74 100755 --- a/script/install.sh +++ b/script/install.sh @@ -82,7 +82,7 @@ linux() { cp "$ZED_BUNDLE_PATH" "$temp/zed-linux-$arch.tar.gz" else echo "Downloading Zed" - curl "https://zed.dev/api/releases/$channel/latest/zed-linux-$arch.tar.gz" > "$temp/zed-linux-$arch.tar.gz" + curl "https://cloud.zed.dev/releases/$channel/latest/download?asset=zed&arch=$arch&os=linux&source=install.sh" > "$temp/zed-linux-$arch.tar.gz" fi suffix="" @@ -135,7 +135,7 @@ linux() { macos() { echo "Downloading Zed" - curl "https://zed.dev/api/releases/$channel/latest/Zed-$arch.dmg" > "$temp/Zed-$arch.dmg" + curl "https://cloud.zed.dev/releases/$channel/latest/download?asset=zed&os=macos&arch=$arch&source=install.sh" > "$temp/Zed-$arch.dmg" hdiutil attach -quiet "$temp/Zed-$arch.dmg" -mountpoint "$temp/mount" app="$(cd "$temp/mount/"; echo *.app)" echo "Installing $app" diff --git a/script/language-extension-version b/script/language-extension-version index d547886087..119021e566 100755 --- a/script/language-extension-version +++ b/script/language-extension-version @@ -26,3 +26,4 @@ fi sed -i '' -e "s/^version = \".*\"/version = \"$VERSION\"/" "$EXTENSION_TOML" sed -i '' -e "s/^version = \".*\"/version = \"$VERSION\"/" "$CARGO_TOML" +cargo update --workspace diff --git a/script/lib/bump-version.sh b/script/lib/bump-version.sh index 5d83dd6f96..bfe3e29202 100755 --- a/script/lib/bump-version.sh +++ b/script/lib/bump-version.sh @@ -6,6 +6,7 @@ package=$1 tag_prefix=$2 tag_suffix=$3 version_increment=$4 +gpui_release=${5:-false} if [[ -n $(git status --short --untracked-files=no) ]]; then echo "can't bump version with uncommitted changes" @@ -25,6 +26,20 @@ tag_name=${tag_prefix}${new_version}${tag_suffix} git commit --quiet --all --message "${package} ${new_version}" git tag ${tag_name} +if [[ "$gpui_release" == "true" ]]; then +cat </dev/null; then - echo "Installing cargo-hakari@^$HAKARI_VERSION..." - cargo install "cargo-hakari@^$HAKARI_VERSION" -else - echo "cargo-hakari@^$HAKARI_VERSION is already installed." -fi - -# update the workspace-hack crate -cargo hakari generate - -# make sure workspace-hack is added as a dep for all crates in the workspace -cargo hakari manage-deps diff --git a/script/update-workspace-hack.ps1 b/script/update-workspace-hack.ps1 deleted file mode 100644 index 0606607249..0000000000 --- a/script/update-workspace-hack.ps1 +++ /dev/null @@ -1,36 +0,0 @@ -$ErrorActionPreference = "Stop" - -$HAKARI_VERSION = "0.9" - -$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path -Set-Location (Split-Path -Parent $scriptPath) - -$hakariInstalled = $false -try { - $versionOutput = cargo hakari --version 2>&1 - if ($versionOutput -match "cargo-hakari $HAKARI_VERSION") { - $hakariInstalled = $true - } -} -catch { - $hakariInstalled = $false -} - -if (-not $hakariInstalled) { - Write-Host "Installing cargo-hakari@^$HAKARI_VERSION..." - cargo install "cargo-hakari@^$HAKARI_VERSION" - if ($LASTEXITCODE -ne 0) { - throw "Failed to install cargo-hakari@^$HAKARI_VERSION" - } -} -else { - Write-Host "cargo-hakari@^$HAKARI_VERSION is already installed." -} - -# update the workspace-hack crate -cargo hakari generate -if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - -# make sure workspace-hack is added as a dep for all crates in the workspace -cargo hakari manage-deps -if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/script/update_top_ranking_issues/main.py b/script/update_top_ranking_issues/main.py index fb193d6564..336c00497a 100644 --- a/script/update_top_ranking_issues/main.py +++ b/script/update_top_ranking_issues/main.py @@ -1,30 +1,24 @@ import os -from collections import defaultdict -from datetime import datetime, timedelta -from typing import Optional +from datetime import date, datetime, timedelta +from typing import Any, Optional +import requests import typer -from github import Github -from github.Issue import Issue -from github.Repository import Repository from pytz import timezone from typer import Typer app: Typer = typer.Typer() -DATETIME_FORMAT: str = "%m/%d/%Y %I:%M %p" -ISSUES_PER_LABEL: int = 50 +AMERICA_NEW_YORK_TIMEZONE = "America/New_York" +DATETIME_FORMAT: str = "%B %d, %Y %I:%M %p" +ISSUES_PER_SECTION: int = 50 +ISSUES_TO_FETCH: int = 100 +REPO_OWNER = "zed-industries" +REPO_NAME = "zed" +GITHUB_API_BASE_URL = "https://api.github.com" -class IssueData: - def __init__(self, issue: Issue) -> None: - self.title = issue.title - self.url: str = issue.html_url - self.like_count: int = issue._rawData["reactions"]["+1"] # type: ignore [attr-defined] - self.creation_datetime: str = issue.created_at.strftime(DATETIME_FORMAT) - # TODO: Change script to support storing labels here, rather than directly in the script - self.labels: set[str] = {label["name"] for label in issue._rawData["labels"]} # type: ignore [attr-defined] - self._issue = issue +EXCLUDE_LABEL = "ignore top-ranking issues" @app.command() @@ -33,180 +27,135 @@ def main( issue_reference_number: Optional[int] = None, query_day_interval: Optional[int] = None, ) -> None: - start_time: datetime = datetime.now() - - start_date: datetime | None = None + script_start_time: datetime = datetime.now() + start_date: date | None = None if query_day_interval: - tz = timezone("america/new_york") - current_time = datetime.now(tz).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - start_date = current_time - timedelta(days=query_day_interval) + tz = timezone(AMERICA_NEW_YORK_TIMEZONE) + today = datetime.now(tz).date() + start_date = today - timedelta(days=query_day_interval) - # GitHub Workflow will pass in the token as an environment variable, + # GitHub Workflow will pass in the token as an argument, # but we can place it in our env when running the script locally, for convenience - github_token = github_token or os.getenv("GITHUB_ACCESS_TOKEN") - - with Github(github_token, per_page=100) as github: - remaining_requests_before: int = github.rate_limiting[0] - print(f"Remaining requests before: {remaining_requests_before}") - - repo_name: str = "zed-industries/zed" - repository: Repository = github.get_repo(repo_name) - - label_to_issue_data: dict[str, list[IssueData]] = get_issue_maps( - github, repository, start_date + token = github_token or os.getenv("GITHUB_ACCESS_TOKEN") + if not token: + raise typer.BadParameter( + "GitHub token is required. Pass --github-token or set GITHUB_ACCESS_TOKEN env var." ) - issue_text: str = get_issue_text(label_to_issue_data) - - if issue_reference_number: - top_ranking_issues_issue: Issue = repository.get_issue(issue_reference_number) - top_ranking_issues_issue.edit(body=issue_text) - else: - print(issue_text) - - remaining_requests_after: int = github.rate_limiting[0] - print(f"Remaining requests after: {remaining_requests_after}") - print(f"Requests used: {remaining_requests_before - remaining_requests_after}") - - run_duration: timedelta = datetime.now() - start_time - print(run_duration) - - -def get_issue_maps( - github: Github, - repository: Repository, - start_date: datetime | None = None, -) -> dict[str, list[IssueData]]: - label_to_issue_data: dict[str, list[IssueData]] = get_label_to_issue_data( - github, - repository, - start_date, - ) - - # Create a new dictionary with labels ordered by the summation the of likes on the associated issues - labels = list(label_to_issue_data.keys()) - - labels.sort( - key=lambda label: sum( - issue_data.like_count for issue_data in label_to_issue_data[label] - ), - reverse=True, - ) - - label_to_issue_data = {label: label_to_issue_data[label] for label in labels} - - return label_to_issue_data - - -def get_label_to_issue_data( - github: Github, - repository: Repository, - start_date: datetime | None = None, -) -> dict[str, list[IssueData]]: - common_filters = [ - f"repo:{repository.full_name}", - "is:open", - "is:issue", - '-label:"ignore top-ranking issues"', - "sort:reactions-+1-desc", - ] - - date_query: str | None = ( - f"created:>={start_date.strftime('%Y-%m-%d')}" if start_date else None - ) - - if date_query: - common_filters.append(date_query) - - common_filter_string = " ".join(common_filters) - - # Because PyGithub doesn't seem to support logical operators `AND` and `OR` - # that GitHub issue queries can use, we use lists as values, rather than - # using `(label:bug OR type:Bug)`. This is not as efficient, as we might - # query the same issue multiple times. Issues that are potentially queried - # multiple times are deduplicated in the `label_to_issues` dictionary. If - # PyGithub ever supports logical operators, we should definitely make the - # switch. - section_queries: dict[str, list[str]] = { - "bug": ["label:bug", "type:Bug"], - "crash": ["label:crash", "type:Crash"], - "feature": ["label:feature", "type:Feature"], - "meta": ["type:Meta"], - "unlabeled": ["no:label no:type"], + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", } - label_to_issue_data: dict[str, list[IssueData]] = {} + section_to_issues = get_section_to_issues(headers, start_date) + issue_text: str = create_issue_text(section_to_issues) - for section, section_queries in section_queries.items(): - unique_issues = set() + if issue_reference_number: + update_reference_issue(headers, issue_reference_number, issue_text) + else: + print(issue_text) - for section_query in section_queries: - query: str = f"{common_filter_string} {section_query}" - issues = github.search_issues(query) + run_duration: timedelta = datetime.now() - script_start_time + print(f"Ran for {run_duration}") - for issue in issues: - unique_issues.add(issue) - if len(unique_issues) <= 0: +def get_section_to_issues( + headers: dict[str, str], start_date: date | None = None +) -> dict[str, list[dict[str, Any]]]: + """Fetch top-ranked issues for each section from GitHub.""" + + section_filters = { + "Bugs": "type:Bug", + "Crashes": "type:Crash", + "Features": "type:Feature", + "Tracking issues": "type:Tracking", + "Meta issues": "type:Meta", + "Windows": 'label:"platform:windows"', + } + + section_to_issues: dict[str, list[dict[str, Any]]] = {} + for section, search_qualifier in section_filters.items(): + query_parts = [ + f"repo:{REPO_OWNER}/{REPO_NAME}", + "is:issue", + "is:open", + f'-label:"{EXCLUDE_LABEL}"', + search_qualifier, + ] + + if start_date: + query_parts.append(f"created:>={start_date.strftime('%Y-%m-%d')}") + + query = " ".join(query_parts) + url = f"{GITHUB_API_BASE_URL}/search/issues" + params = { + "q": query, + "sort": "reactions-+1", + "order": "desc", + "per_page": ISSUES_TO_FETCH, # this will work as long as it's ≤ 100 + } + + # we are only fetching one page on purpose + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + items = response.json()["items"] + + issues: list[dict[str, Any]] = [] + for item in items: + reactions = item["reactions"] + score = reactions["+1"] - reactions["-1"] + if score > 0: + issues.append({ + "url": item["html_url"], + "score": score, + "created_at": item["created_at"], + }) + + if not issues: continue - issue_data: list[IssueData] = [IssueData(issue) for issue in unique_issues] - issue_data.sort( - key=lambda issue_data: ( - -issue_data.like_count, - issue_data.creation_datetime, - ) + issues.sort(key=lambda x: (-x["score"], x["created_at"])) + section_to_issues[section] = issues[:ISSUES_PER_SECTION] + + # Sort sections by total score (highest total first) + section_to_issues = dict( + sorted( + section_to_issues.items(), + key=lambda item: sum(issue["score"] for issue in item[1]), + reverse=True, ) - - label_to_issue_data[section] = issue_data[0:ISSUES_PER_LABEL] - - return label_to_issue_data + ) + return section_to_issues -def get_issue_text( - label_to_issue_data: dict[str, list[IssueData]], -) -> str: - tz = timezone("america/new_york") +def update_reference_issue( + headers: dict[str, str], issue_number: int, body: str +) -> None: + url = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}" + response = requests.patch(url, headers=headers, json={"body": body}) + response.raise_for_status() + + +def create_issue_text(section_to_issues: dict[str, list[dict[str, Any]]]) -> str: + tz = timezone(AMERICA_NEW_YORK_TIMEZONE) current_datetime: str = datetime.now(tz).strftime(f"{DATETIME_FORMAT} (%Z)") - highest_ranking_issues_lines: list[str] = get_highest_ranking_issues_lines( - label_to_issue_data + lines: list[str] = [f"*Updated on {current_datetime}*"] + + for section, issues in section_to_issues.items(): + lines.append(f"\n## {section}\n") + for i, issue in enumerate(issues): + lines.append(f"{i + 1}. {issue['url']} ({issue['score']} :thumbsup:)") + + lines.append("\n---\n") + lines.append( + "*For details on how this issue is generated, " + "[see the script](https://github.com/zed-industries/zed/blob/main/script/update_top_ranking_issues/main.py)*" ) - issue_text_lines: list[str] = [ - f"*Updated on {current_datetime}*", - *highest_ranking_issues_lines, - "\n---\n", - "*For details on how this issue is generated, [see the script](https://github.com/zed-industries/zed/blob/main/script/update_top_ranking_issues/main.py)*", - ] - - return "\n".join(issue_text_lines) - - -def get_highest_ranking_issues_lines( - label_to_issue_data: dict[str, list[IssueData]], -) -> list[str]: - highest_ranking_issues_lines: list[str] = [] - - if label_to_issue_data: - for label, issue_data in label_to_issue_data.items(): - highest_ranking_issues_lines.append(f"\n## {label}\n") - - for i, issue_data in enumerate(issue_data): - markdown_bullet_point: str = ( - f"{issue_data.url} ({issue_data.like_count} :thumbsup:)" - ) - - markdown_bullet_point = f"{i + 1}. {markdown_bullet_point}" - highest_ranking_issues_lines.append(markdown_bullet_point) - - return highest_ranking_issues_lines + return "\n".join(lines) if __name__ == "__main__": app() - -# TODO: Sort label output into core and non core sections diff --git a/script/update_top_ranking_issues/pyproject.toml b/script/update_top_ranking_issues/pyproject.toml index ebd283850a..aa3f8cc7ff 100644 --- a/script/update_top_ranking_issues/pyproject.toml +++ b/script/update_top_ranking_issues/pyproject.toml @@ -5,9 +5,10 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "mypy>=1.15.0", - "pygithub>=2.6.1", "pytz>=2025.1", + "requests>=2.32.0", "ruff>=0.9.7", "typer>=0.15.1", "types-pytz>=2025.1.0.20250204", + "types-requests>=2.32.0", ] diff --git a/script/update_top_ranking_issues/uv.lock b/script/update_top_ranking_issues/uv.lock index 062890b179..174f4e677f 100644 --- a/script/update_top_ranking_issues/uv.lock +++ b/script/update_top_ranking_issues/uv.lock @@ -1,60 +1,38 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.13" [[package]] name = "certifi" version = "2024.8.30" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, -] - -[[package]] -name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620, upload-time = "2024-10-09T07:40:20.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, - { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, - { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, - { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, - { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, - { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, - { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, - { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, - { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, - { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, - { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, - { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617, upload-time = "2024-10-09T07:39:07.317Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310, upload-time = "2024-10-09T07:39:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126, upload-time = "2024-10-09T07:39:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342, upload-time = "2024-10-09T07:39:10.322Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383, upload-time = "2024-10-09T07:39:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214, upload-time = "2024-10-09T07:39:13.059Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104, upload-time = "2024-10-09T07:39:14.815Z" }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255, upload-time = "2024-10-09T07:39:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251, upload-time = "2024-10-09T07:39:16.995Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474, upload-time = "2024-10-09T07:39:18.021Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849, upload-time = "2024-10-09T07:39:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781, upload-time = "2024-10-09T07:39:20.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970, upload-time = "2024-10-09T07:39:21.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973, upload-time = "2024-10-09T07:39:22.509Z" }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308, upload-time = "2024-10-09T07:39:23.524Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446, upload-time = "2024-10-09T07:40:19.383Z" }, ] [[package]] @@ -64,68 +42,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "cryptography" -version = "43.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222 }, - { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751 }, - { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827 }, - { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034 }, - { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407 }, - { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457 }, - { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499 }, - { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504 }, - { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456 }, - { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263 }, - { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368 }, - { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750 }, - { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925 }, - { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152 }, - { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392 }, - { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606 }, - { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948 }, - { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, -] - -[[package]] -name = "deprecated" -version = "1.2.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/14/1e41f504a246fc224d2ac264c227975427a85caf37c3979979edb9b1b232/Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3", size = 2974416 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/8d/778b7d51b981a96554f29136cd59ca7880bf58094338085bcf2a979a0e6a/Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c", size = 9561 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] @@ -135,18 +72,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -157,102 +94,42 @@ dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, - { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, - { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, - { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, - { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, - { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, - { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, ] [[package]] name = "mypy-extensions" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, -] - -[[package]] -name = "pycparser" -version = "2.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, -] - -[[package]] -name = "pygithub" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "pynacl" }, - { name = "requests" }, - { name = "typing-extensions" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/88/e08ab18dc74b2916f48703ed1a797d57cb64eca0e23b0a9254e13cfe3911/pygithub-2.6.1.tar.gz", hash = "sha256:b5c035392991cca63959e9453286b41b54d83bf2de2daa7d7ff7e4312cebf3bf", size = 3659473 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/fc/a444cd19ccc8c4946a512f3827ed0b3565c88488719d800d54a75d541c0b/PyGithub-2.6.1-py3-none-any.whl", hash = "sha256:6f2fa6d076ccae475f9fc392cc6cdbd54db985d4f69b8833a28397de75ed6ca3", size = 410451 }, + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, ] [[package]] name = "pygments" version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905, upload-time = "2024-05-04T13:42:02.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, -] - -[[package]] -name = "pyjwt" -version = "2.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c", size = 78825 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", size = 22344 }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "pynacl" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920 }, - { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722 }, - { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087 }, - { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678 }, - { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660 }, - { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824 }, - { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912 }, - { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624 }, - { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 }, + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513, upload-time = "2024-05-04T13:41:57.345Z" }, ] [[package]] name = "pytz" version = "2025.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617, upload-time = "2025-01-31T01:54:48.615Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930, upload-time = "2025-01-31T01:54:45.634Z" }, ] [[package]] @@ -265,9 +142,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] [[package]] @@ -278,43 +155,43 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843, upload-time = "2024-10-04T11:50:31.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117 }, + { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117, upload-time = "2024-10-04T11:50:29.123Z" }, ] [[package]] name = "ruff" version = "0.9.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813 } +sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813, upload-time = "2025-02-20T13:26:52.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588 }, - { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848 }, - { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525 }, - { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580 }, - { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674 }, - { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151 }, - { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128 }, - { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858 }, - { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046 }, - { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834 }, - { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307 }, - { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039 }, - { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177 }, - { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122 }, - { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751 }, - { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987 }, - { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 }, + { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588, upload-time = "2025-02-20T13:25:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848, upload-time = "2025-02-20T13:25:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525, upload-time = "2025-02-20T13:26:00.007Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580, upload-time = "2025-02-20T13:26:03.274Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674, upload-time = "2025-02-20T13:26:06.073Z" }, + { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151, upload-time = "2025-02-20T13:26:08.964Z" }, + { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128, upload-time = "2025-02-20T13:26:12.54Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858, upload-time = "2025-02-20T13:26:16.794Z" }, + { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046, upload-time = "2025-02-20T13:26:19.85Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834, upload-time = "2025-02-20T13:26:23.082Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307, upload-time = "2025-02-20T13:26:26.738Z" }, + { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039, upload-time = "2025-02-20T13:26:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177, upload-time = "2025-02-20T13:26:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122, upload-time = "2025-02-20T13:26:37.365Z" }, + { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751, upload-time = "2025-02-20T13:26:40.366Z" }, + { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987, upload-time = "2025-02-20T13:26:43.762Z" }, + { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763, upload-time = "2025-02-20T13:26:48.92Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] @@ -327,27 +204,39 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789, upload-time = "2024-12-04T17:44:58.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 }, + { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908, upload-time = "2024-12-04T17:44:57.291Z" }, ] [[package]] name = "types-pytz" version = "2025.1.0.20250204" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/d2/2190c54d53c04491ad72a1df019c5dfa692e6ab6c2dba1be7b6c9d530e30/types_pytz-2025.1.0.20250204.tar.gz", hash = "sha256:00f750132769f1c65a4f7240bc84f13985b4da774bd17dfbe5d9cd442746bd49", size = 10352 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/d2/2190c54d53c04491ad72a1df019c5dfa692e6ab6c2dba1be7b6c9d530e30/types_pytz-2025.1.0.20250204.tar.gz", hash = "sha256:00f750132769f1c65a4f7240bc84f13985b4da774bd17dfbe5d9cd442746bd49", size = 10352, upload-time = "2025-02-04T02:39:05.553Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/50/65ffad73746f1d8b15992c030e0fd22965fd5ae2c0206dc28873343b3230/types_pytz-2025.1.0.20250204-py3-none-any.whl", hash = "sha256:32ca4a35430e8b94f6603b35beb7f56c32260ddddd4f4bb305fdf8f92358b87e", size = 10059 }, + { url = "https://files.pythonhosted.org/packages/be/50/65ffad73746f1d8b15992c030e0fd22965fd5ae2c0206dc28873343b3230/types_pytz-2025.1.0.20250204-py3-none-any.whl", hash = "sha256:32ca4a35430e8b94f6603b35beb7f56c32260ddddd4f4bb305fdf8f92358b87e", size = 10059, upload-time = "2025-02-04T02:39:03.899Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250913" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, ] [[package]] name = "typing-extensions" version = "4.12.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, ] [[package]] @@ -356,37 +245,30 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "mypy" }, - { name = "pygithub" }, { name = "pytz" }, + { name = "requests" }, { name = "ruff" }, { name = "typer" }, { name = "types-pytz" }, + { name = "types-requests" }, ] [package.metadata] requires-dist = [ { name = "mypy", specifier = ">=1.15.0" }, - { name = "pygithub", specifier = ">=2.6.1" }, { name = "pytz", specifier = ">=2025.1" }, + { name = "requests", specifier = ">=2.32.0" }, { name = "ruff", specifier = ">=0.9.7" }, { name = "typer", specifier = ">=0.15.1" }, { name = "types-pytz", specifier = ">=2025.1.0.20250204" }, + { name = "types-requests", specifier = ">=2.32.0" }, ] [[package]] name = "urllib3" version = "2.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, -] - -[[package]] -name = "wrapt" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 }, + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, ] diff --git a/script/upload-nightly b/script/upload-nightly index 2fcb299438..12e7c16ef7 100755 --- a/script/upload-nightly +++ b/script/upload-nightly @@ -1,65 +1,17 @@ #!/usr/bin/env bash -# Based on the template in: https://docs.digitalocean.com/reference/api/spaces-api/ bash -euo pipefail source script/lib/blob-store.sh -allowed_targets=("linux-targz" "macos" "freebsd") -is_allowed_target() { - for val in "${allowed_targets[@]}"; do - if [[ "$1" == "$val" ]]; then - return 0 - fi - done - return 1 -} - -if [[ -n "${1:-}" ]]; then - if is_allowed_target "$1"; then - target="$1" - else - echo "Error: Target '$1' is not allowed" - echo "Usage: $0 [${allowed_targets[*]}]" - exit 1 - fi -else -echo "Error: Target is not specified" -echo "Usage: $0 [${allowed_targets[*]}]" -exit 1 -fi -echo "Uploading nightly for target: $target" - bucket_name="zed-nightly-host" +version=$(./script/get-crate-version zed)+nightly."${GITHUB_RUN_NUMBER}.${GITHUB_SHA}" -sha=$(git rev-parse HEAD) -echo ${sha} > target/latest-sha - -find target -type f -name "zed-remote-server-*.gz" -print0 | while IFS= read -r -d '' file_to_upload; do - upload_to_blob_store $bucket_name "$file_to_upload" "nightly/$(basename "$file_to_upload")" +for file_to_upload in ./release-artifacts/*; do + [ -f "$file_to_upload" ] || continue + upload_to_blob_store_public $bucket_name "$file_to_upload" "nightly/$(basename "$file_to_upload")" + upload_to_blob_store_public $bucket_name "$file_to_upload" "${version}/$(basename "$file_to_upload")" rm -f "$file_to_upload" done -case "$target" in - macos) - upload_to_blob_store $bucket_name "target/aarch64-apple-darwin/release/Zed.dmg" "nightly/Zed-aarch64.dmg" - upload_to_blob_store $bucket_name "target/x86_64-apple-darwin/release/Zed.dmg" "nightly/Zed-x86_64.dmg" - upload_to_blob_store $bucket_name "target/latest-sha" "nightly/latest-sha" - rm -f "target/aarch64-apple-darwin/release/Zed.dmg" "target/x86_64-apple-darwin/release/Zed.dmg" "target/release/Zed.dmg" - rm -f "target/latest-sha" - ;; - linux-targz) - find . -type f -name "zed-*.tar.gz" -print0 | while IFS= read -r -d '' file_to_upload; do - upload_to_blob_store $bucket_name "$file_to_upload" "nightly/$(basename "$file_to_upload")" - rm -f "$file_to_upload" - done - upload_to_blob_store $bucket_name "target/latest-sha" "nightly/latest-sha-linux-targz" - rm -f "target/latest-sha" - ;; - freebsd) - echo "No freebsd client build (yet)." - ;; - *) - echo "Error: Unknown target '$target'" - exit 1 - ;; -esac +echo -n ${version} > ./release-artifacts/latest-sha +upload_to_blob_store_public $bucket_name "release-artifacts/latest-sha" "nightly/latest-sha" diff --git a/script/upload-nightly.ps1 b/script/upload-nightly.ps1 index 7fa453c806..4400c4291b 100644 --- a/script/upload-nightly.ps1 +++ b/script/upload-nightly.ps1 @@ -1,40 +1,19 @@ +[CmdletBinding()] +Param( + [Parameter()][string]$Architecture +) + # Based on the template in: https://docs.digitalocean.com/reference/api/spaces-api/ $ErrorActionPreference = "Stop" . "$PSScriptRoot\lib\blob-store.ps1" . "$PSScriptRoot\lib\workspace.ps1" -$allowedTargets = @("windows") - -function Test-AllowedTarget { - param ( - [string]$Target - ) - - return $allowedTargets -contains $Target -} - -# Process arguments -if ($args.Count -gt 0) { - $target = $args[0] - if (Test-AllowedTarget $target) { - # Valid target - } else { - Write-Error "Error: Target '$target' is not allowed.`nUsage: $($MyInvocation.MyCommand.Name) [$($allowedTargets -join ', ')]" - exit 1 - } -} else { - Write-Error "Error: Target is not specified.`nUsage: $($MyInvocation.MyCommand.Name) [$($allowedTargets -join ', ')]" - exit 1 -} - ParseZedWorkspace Write-Host "Uploading nightly for target: $target" $bucketName = "zed-nightly-host" - -# Get current git SHA -$sha = git rev-parse HEAD -$sha | Out-File -FilePath "target/latest-sha" -NoNewline +$releaseVersion = & "$PSScriptRoot\get-crate-version.ps1" zed +$version = "$releaseVersion+nightly.$env:GITHUB_RUN_NUMBER.$env:GITHUB_SHA" # TODO: # Upload remote server files @@ -44,17 +23,11 @@ $sha | Out-File -FilePath "target/latest-sha" -NoNewline # Remove-Item -Path $file.FullName # } -switch ($target) { - "windows" { - UploadToBlobStore -BucketName $bucketName -FileToUpload $env:SETUP_PATH -BlobStoreKey "nightly/zed_editor_installer_x86_64.exe" - UploadToBlobStore -BucketName $bucketName -FileToUpload "target/latest-sha" -BlobStoreKey "nightly/latest-sha-windows" +UploadToBlobStore -BucketName $bucketName -FileToUpload "target/Zed-$Architecture.exe" -BlobStoreKey "nightly/Zed-$Architecture.exe" +UploadToBlobStore -BucketName $bucketName -FileToUpload "target/Zed-$Architecture.exe" -BlobStoreKey "$version/Zed-$Architecture.exe" - Remove-Item -Path $env:SETUP_PATH -ErrorAction SilentlyContinue - Remove-Item -Path "target/latest-sha" -ErrorAction SilentlyContinue - } +Remove-Item -Path "target/Zed-$Architecture.exe" -ErrorAction SilentlyContinue - default { - Write-Error "Error: Unknown target '$target'" - exit 1 - } -} +$version | Out-File -FilePath "target/latest-sha" -NoNewline +UploadToBlobStore -BucketName $bucketName -FileToUpload "target/latest-sha" -BlobStoreKey "nightly/latest-sha-windows" +Remove-Item -Path "target/latest-sha" -ErrorAction SilentlyContinue diff --git a/tooling/perf/Cargo.toml b/tooling/perf/Cargo.toml index bbca817a3e..d4acad1fdb 100644 --- a/tooling/perf/Cargo.toml +++ b/tooling/perf/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "zed-perf" +name = "perf" version = "0.1.0" -publish = true +publish = false edition.workspace = true license = "Apache-2.0" description = "A tool for measuring Zed test performance, with too many Clippy lints" @@ -30,4 +30,3 @@ disallowed_methods = { level = "allow", priority = 1} collections.workspace = true serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true diff --git a/tooling/perf/src/implementation.rs b/tooling/perf/src/implementation.rs new file mode 100644 index 0000000000..c151dda91f --- /dev/null +++ b/tooling/perf/src/implementation.rs @@ -0,0 +1,450 @@ +//! The implementation of the this crate is kept in a separate module +//! so that it is easy to publish this crate as part of GPUI's dependencies + +use collections::HashMap; +use serde::{Deserialize, Serialize}; +use std::{num::NonZero, time::Duration}; + +pub mod consts { + //! Preset identifiers and constants so that the profiler and proc macro agree + //! on their communication protocol. + + /// The suffix on the actual test function. + pub const SUF_NORMAL: &str = "__ZED_PERF_FN"; + /// The suffix on an extra function which prints metadata about a test to stdout. + pub const SUF_MDATA: &str = "__ZED_PERF_MDATA"; + /// The env var in which we pass the iteration count to our tests. + pub const ITER_ENV_VAR: &str = "ZED_PERF_ITER"; + /// The prefix printed on all benchmark test metadata lines, to distinguish it from + /// possible output by the test harness itself. + pub const MDATA_LINE_PREF: &str = "ZED_MDATA_"; + /// The version number for the data returned from the test metadata function. + /// Increment on non-backwards-compatible changes. + pub const MDATA_VER: u32 = 0; + /// The default weight, if none is specified. + pub const WEIGHT_DEFAULT: u8 = 50; + /// How long a test must have run to be assumed to be reliable-ish. + pub const NOISE_CUTOFF: std::time::Duration = std::time::Duration::from_millis(250); + + /// Identifier for the iteration count of a test metadata. + pub const ITER_COUNT_LINE_NAME: &str = "iter_count"; + /// Identifier for the weight of a test metadata. + pub const WEIGHT_LINE_NAME: &str = "weight"; + /// Identifier for importance in test metadata. + pub const IMPORTANCE_LINE_NAME: &str = "importance"; + /// Identifier for the test metadata version. + pub const VERSION_LINE_NAME: &str = "version"; + + /// Where to save json run information. + pub const RUNS_DIR: &str = ".perf-runs"; +} + +/// How relevant a benchmark is. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Importance { + /// Regressions shouldn't be accepted without good reason. + Critical = 4, + /// Regressions should be paid extra attention. + Important = 3, + /// No extra attention should be paid to regressions, but they might still + /// be indicative of something happening. + #[default] + Average = 2, + /// Unclear if regressions are likely to be meaningful, but still worth keeping + /// an eye on. Lowest level that's checked by default by the profiler. + Iffy = 1, + /// Regressions are likely to be spurious or don't affect core functionality. + /// Only relevant if a lot of them happen, or as supplemental evidence for a + /// higher-importance benchmark regressing. Not checked by default. + Fluff = 0, +} + +impl std::fmt::Display for Importance { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Importance::Critical => f.write_str("critical"), + Importance::Important => f.write_str("important"), + Importance::Average => f.write_str("average"), + Importance::Iffy => f.write_str("iffy"), + Importance::Fluff => f.write_str("fluff"), + } + } +} + +/// Why or when did this test fail? +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum FailKind { + /// Failed while triaging it to determine the iteration count. + Triage, + /// Failed while profiling it. + Profile, + /// Failed due to an incompatible version for the test. + VersionMismatch, + /// Could not parse metadata for a test. + BadMetadata, + /// Skipped due to filters applied on the perf run. + Skipped, +} + +impl std::fmt::Display for FailKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FailKind::Triage => f.write_str("errored in triage"), + FailKind::Profile => f.write_str("errored while profiling"), + FailKind::VersionMismatch => f.write_str("test version mismatch"), + FailKind::BadMetadata => f.write_str("bad test metadata"), + FailKind::Skipped => f.write_str("skipped"), + } + } +} + +/// Information about a given perf test. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TestMdata { + /// A version number for when the test was generated. If this is greater + /// than the version this test handler expects, one of the following will + /// happen in an unspecified manner: + /// - The test is skipped silently. + /// - The handler exits with an error message indicating the version mismatch + /// or inability to parse the metadata. + /// + /// INVARIANT: If `version` <= `MDATA_VER`, this tool *must* be able to + /// correctly parse the output of this test. + pub version: u32, + /// How many iterations to pass this test if this is preset, or how many + /// iterations a test ended up running afterwards if determined at runtime. + pub iterations: Option>, + /// The importance of this particular test. See the docs on `Importance` for + /// details. + pub importance: Importance, + /// The weight of this particular test within its importance category. Used + /// when comparing across runs. + pub weight: u8, +} + +/// The actual timings of a test, as measured by Hyperfine. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Timings { + /// Mean runtime for `self.iter_total` runs of this test. + pub mean: Duration, + /// Standard deviation for the above. + pub stddev: Duration, +} + +impl Timings { + /// How many iterations does this test seem to do per second? + #[expect( + clippy::cast_precision_loss, + reason = "We only care about a couple sig figs anyways" + )] + #[must_use] + pub fn iters_per_sec(&self, total_iters: NonZero) -> f64 { + (1000. / self.mean.as_millis() as f64) * total_iters.get() as f64 + } +} + +/// Aggregate results, meant to be used for a given importance category. Each +/// test name corresponds to its benchmark results, iteration count, and weight. +type CategoryInfo = HashMap, u8)>; + +/// Aggregate output of all tests run by this handler. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Output { + /// A list of test outputs. Format is `(test_name, mdata, timings)`. + /// The latter being `Ok(_)` indicates the test succeeded. + /// + /// INVARIANT: If the test succeeded, the second field is `Some(mdata)` and + /// `mdata.iterations` is `Some(_)`. + tests: Vec<(String, Option, Result)>, +} + +impl Output { + /// Instantiates an empty "output". Useful for merging. + #[must_use] + pub fn blank() -> Self { + Output { tests: Vec::new() } + } + + /// Reports a success and adds it to this run's `Output`. + pub fn success( + &mut self, + name: impl AsRef, + mut mdata: TestMdata, + iters: NonZero, + timings: Timings, + ) { + mdata.iterations = Some(iters); + self.tests + .push((name.as_ref().to_string(), Some(mdata), Ok(timings))); + } + + /// Reports a failure and adds it to this run's `Output`. If this test was tried + /// with some number of iterations (i.e. this was not a version mismatch or skipped + /// test), it should be reported also. + /// + /// Using the `fail!()` macro is usually more convenient. + pub fn failure( + &mut self, + name: impl AsRef, + mut mdata: Option, + attempted_iters: Option>, + kind: FailKind, + ) { + if let Some(ref mut mdata) = mdata { + mdata.iterations = attempted_iters; + } + self.tests + .push((name.as_ref().to_string(), mdata, Err(kind))); + } + + /// True if no tests executed this run. + #[must_use] + pub fn is_empty(&self) -> bool { + self.tests.is_empty() + } + + /// Sorts the runs in the output in the order that we want them printed. + pub fn sort(&mut self) { + self.tests.sort_unstable_by(|a, b| match (a, b) { + // Tests where we got no metadata go at the end. + ((_, Some(_), _), (_, None, _)) => std::cmp::Ordering::Greater, + ((_, None, _), (_, Some(_), _)) => std::cmp::Ordering::Less, + // Then sort by importance, then weight. + ((_, Some(a_mdata), _), (_, Some(b_mdata), _)) => { + let c = a_mdata.importance.cmp(&b_mdata.importance); + if matches!(c, std::cmp::Ordering::Equal) { + a_mdata.weight.cmp(&b_mdata.weight) + } else { + c + } + } + // Lastly by name. + ((a_name, ..), (b_name, ..)) => a_name.cmp(b_name), + }); + } + + /// Merges the output of two runs, appending a prefix to the results of the new run. + /// To be used in conjunction with `Output::blank()`, or else only some tests will have + /// a prefix set. + pub fn merge<'a>(&mut self, other: Self, pref_other: impl Into>) { + let pref = if let Some(pref) = pref_other.into() { + "crates/".to_string() + pref + "::" + } else { + String::new() + }; + self.tests = std::mem::take(&mut self.tests) + .into_iter() + .chain( + other + .tests + .into_iter() + .map(|(name, md, tm)| (pref.clone() + &name, md, tm)), + ) + .collect(); + } + + /// Evaluates the performance of `self` against `baseline`. The latter is taken + /// as the comparison point, i.e. a positive resulting `PerfReport` means that + /// `self` performed better. + /// + /// # Panics + /// `self` and `baseline` are assumed to have the iterations field on all + /// `TestMdata`s set to `Some(_)` if the `TestMdata` is present itself. + #[must_use] + pub fn compare_perf(self, baseline: Self) -> PerfReport { + let self_categories = self.collapse(); + let mut other_categories = baseline.collapse(); + + let deltas = self_categories + .into_iter() + .filter_map(|(cat, self_data)| { + // Only compare categories where both meow + // runs have data. / + let mut other_data = other_categories.remove(&cat)?; + let mut max = f64::MIN; + let mut min = f64::MAX; + + // Running totals for averaging out tests. + let mut r_total_numerator = 0.; + let mut r_total_denominator = 0; + // Yeah this is O(n^2), but realistically it'll hardly be a bottleneck. + for (name, (s_timings, s_iters, weight)) in self_data { + // Only use the new weights if they conflict. + let Some((o_timings, o_iters, _)) = other_data.remove(&name) else { + continue; + }; + let shift = + (o_timings.iters_per_sec(o_iters) / s_timings.iters_per_sec(s_iters)) - 1.; + if shift > max { + max = shift; + } + if shift < min { + min = shift; + } + r_total_numerator += shift * f64::from(weight); + r_total_denominator += u32::from(weight); + } + // There were no runs here! + if r_total_denominator == 0 { + None + } else { + let mean = r_total_numerator / f64::from(r_total_denominator); + // TODO: also aggregate standard deviation? That's harder to keep + // meaningful, though, since we dk which tests are correlated. + Some((cat, PerfDelta { max, mean, min })) + } + }) + .collect(); + + PerfReport { deltas } + } + + /// Collapses the `PerfReport` into a `HashMap` over `Importance`, with + /// each importance category having its tests contained. + fn collapse(self) -> HashMap { + let mut categories = HashMap::>::default(); + for entry in self.tests { + if let Some(mdata) = entry.1 + && let Ok(timings) = entry.2 + { + if let Some(handle) = categories.get_mut(&mdata.importance) { + handle.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight)); + } else { + let mut new = HashMap::default(); + new.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight)); + categories.insert(mdata.importance, new); + } + } + } + + categories + } +} + +impl std::fmt::Display for Output { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Don't print the header for an empty run. + if self.tests.is_empty() { + return Ok(()); + } + + // We want to print important tests at the top, then alphabetical. + let mut sorted = self.clone(); + sorted.sort(); + // Markdown header for making a nice little table :> + writeln!( + f, + "| Command | Iter/sec | Mean [ms] | SD [ms] | Iterations | Importance (weight) |", + )?; + writeln!(f, "|:---|---:|---:|---:|---:|---:|")?; + for (name, metadata, timings) in &sorted.tests { + match metadata { + Some(metadata) => match timings { + // Happy path. + Ok(timings) => { + // If the test succeeded, then metadata.iterations is Some(_). + writeln!( + f, + "| {} | {:.2} | {} | {:.2} | {} | {} ({}) |", + name, + timings.iters_per_sec(metadata.iterations.unwrap()), + { + // Very small mean runtimes will give inaccurate + // results. Should probably also penalise weight. + let mean = timings.mean.as_secs_f64() * 1000.; + if mean < consts::NOISE_CUTOFF.as_secs_f64() * 1000. / 8. { + format!("{mean:.2} (unreliable)") + } else { + format!("{mean:.2}") + } + }, + timings.stddev.as_secs_f64() * 1000., + metadata.iterations.unwrap(), + metadata.importance, + metadata.weight, + )?; + } + // We have (some) metadata, but the test errored. + Err(err) => writeln!( + f, + "| ({}) {} | N/A | N/A | N/A | {} | {} ({}) |", + err, + name, + metadata + .iterations + .map_or_else(|| "N/A".to_owned(), |i| format!("{i}")), + metadata.importance, + metadata.weight + )?, + }, + // No metadata, couldn't even parse the test output. + None => writeln!( + f, + "| ({}) {} | N/A | N/A | N/A | N/A | N/A |", + timings.as_ref().unwrap_err(), + name + )?, + } + } + Ok(()) + } +} + +/// The difference in performance between two runs within a given importance +/// category. +struct PerfDelta { + /// The biggest improvement / least bad regression. + max: f64, + /// The weighted average change in test times. + mean: f64, + /// The worst regression / smallest improvement. + min: f64, +} + +/// Shim type for reporting all performance deltas across importance categories. +pub struct PerfReport { + /// Inner (group, diff) pairing. + deltas: HashMap, +} + +impl std::fmt::Display for PerfReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.deltas.is_empty() { + return write!(f, "(no matching tests)"); + } + let sorted = self.deltas.iter().collect::>(); + writeln!(f, "| Category | Max | Mean | Min |")?; + // We don't want to print too many newlines at the end, so handle newlines + // a little jankily like this. + write!(f, "|:---|---:|---:|---:|")?; + for (cat, delta) in sorted.into_iter().rev() { + const SIGN_POS: &str = "↑"; + const SIGN_NEG: &str = "↓"; + const SIGN_NEUTRAL_POS: &str = "±↑"; + const SIGN_NEUTRAL_NEG: &str = "±↓"; + + let prettify = |time: f64| { + let sign = if time > 0.05 { + SIGN_POS + } else if time > 0. { + SIGN_NEUTRAL_POS + } else if time > -0.05 { + SIGN_NEUTRAL_NEG + } else { + SIGN_NEG + }; + format!("{} {:.1}%", sign, time.abs() * 100.) + }; + + // Pretty-print these instead of just using the float display impl. + write!( + f, + "\n| {cat} | {} | {} | {} |", + prettify(delta.max), + prettify(delta.mean), + prettify(delta.min) + )?; + } + Ok(()) + } +} diff --git a/tooling/perf/src/lib.rs b/tooling/perf/src/lib.rs index 3272f179d8..7933e66e79 100644 --- a/tooling/perf/src/lib.rs +++ b/tooling/perf/src/lib.rs @@ -3,447 +3,5 @@ //! //! For usage documentation, see the docs on this crate's binary. -use collections::HashMap; -use serde::{Deserialize, Serialize}; -use std::{num::NonZero, time::Duration}; - -pub mod consts { - //! Preset idenitifiers and constants so that the profiler and proc macro agree - //! on their communication protocol. - - /// The suffix on the actual test function. - pub const SUF_NORMAL: &str = "__ZED_PERF_FN"; - /// The suffix on an extra function which prints metadata about a test to stdout. - pub const SUF_MDATA: &str = "__ZED_PERF_MDATA"; - /// The env var in which we pass the iteration count to our tests. - pub const ITER_ENV_VAR: &str = "ZED_PERF_ITER"; - /// The prefix printed on all benchmark test metadata lines, to distinguish it from - /// possible output by the test harness itself. - pub const MDATA_LINE_PREF: &str = "ZED_MDATA_"; - /// The version number for the data returned from the test metadata function. - /// Increment on non-backwards-compatible changes. - pub const MDATA_VER: u32 = 0; - /// The default weight, if none is specified. - pub const WEIGHT_DEFAULT: u8 = 50; - /// How long a test must have run to be assumed to be reliable-ish. - pub const NOISE_CUTOFF: std::time::Duration = std::time::Duration::from_millis(250); - - /// Identifier for the iteration count of a test metadata. - pub const ITER_COUNT_LINE_NAME: &str = "iter_count"; - /// Identifier for the weight of a test metadata. - pub const WEIGHT_LINE_NAME: &str = "weight"; - /// Identifier for importance in test metadata. - pub const IMPORTANCE_LINE_NAME: &str = "importance"; - /// Identifier for the test metadata version. - pub const VERSION_LINE_NAME: &str = "version"; - - /// Where to save json run information. - pub const RUNS_DIR: &str = ".perf-runs"; -} - -/// How relevant a benchmark is. -#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -pub enum Importance { - /// Regressions shouldn't be accepted without good reason. - Critical = 4, - /// Regressions should be paid extra attention. - Important = 3, - /// No extra attention should be paid to regressions, but they might still - /// be indicative of something happening. - #[default] - Average = 2, - /// Unclear if regressions are likely to be meaningful, but still worth keeping - /// an eye on. Lowest level that's checked by default by the profiler. - Iffy = 1, - /// Regressions are likely to be spurious or don't affect core functionality. - /// Only relevant if a lot of them happen, or as supplemental evidence for a - /// higher-importance benchmark regressing. Not checked by default. - Fluff = 0, -} - -impl std::fmt::Display for Importance { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Importance::Critical => f.write_str("critical"), - Importance::Important => f.write_str("important"), - Importance::Average => f.write_str("average"), - Importance::Iffy => f.write_str("iffy"), - Importance::Fluff => f.write_str("fluff"), - } - } -} - -/// Why or when did this test fail? -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum FailKind { - /// Failed while triaging it to determine the iteration count. - Triage, - /// Failed while profiling it. - Profile, - /// Failed due to an incompatible version for the test. - VersionMismatch, - /// Could not parse metadata for a test. - BadMetadata, - /// Skipped due to filters applied on the perf run. - Skipped, -} - -impl std::fmt::Display for FailKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FailKind::Triage => f.write_str("errored in triage"), - FailKind::Profile => f.write_str("errored while profiling"), - FailKind::VersionMismatch => f.write_str("test version mismatch"), - FailKind::BadMetadata => f.write_str("bad test metadata"), - FailKind::Skipped => f.write_str("skipped"), - } - } -} - -/// Information about a given perf test. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TestMdata { - /// A version number for when the test was generated. If this is greater - /// than the version this test handler expects, one of the following will - /// happen in an unspecified manner: - /// - The test is skipped silently. - /// - The handler exits with an error message indicating the version mismatch - /// or inability to parse the metadata. - /// - /// INVARIANT: If `version` <= `MDATA_VER`, this tool *must* be able to - /// correctly parse the output of this test. - pub version: u32, - /// How many iterations to pass this test if this is preset, or how many - /// iterations a test ended up running afterwards if determined at runtime. - pub iterations: Option>, - /// The importance of this particular test. See the docs on `Importance` for - /// details. - pub importance: Importance, - /// The weight of this particular test within its importance category. Used - /// when comparing across runs. - pub weight: u8, -} - -/// The actual timings of a test, as measured by Hyperfine. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Timings { - /// Mean runtime for `self.iter_total` runs of this test. - pub mean: Duration, - /// Standard deviation for the above. - pub stddev: Duration, -} - -impl Timings { - /// How many iterations does this test seem to do per second? - #[expect( - clippy::cast_precision_loss, - reason = "We only care about a couple sig figs anyways" - )] - #[must_use] - pub fn iters_per_sec(&self, total_iters: NonZero) -> f64 { - (1000. / self.mean.as_millis() as f64) * total_iters.get() as f64 - } -} - -/// Aggregate results, meant to be used for a given importance category. Each -/// test name corresponds to its benchmark results, iteration count, and weight. -type CategoryInfo = HashMap, u8)>; - -/// Aggregate output of all tests run by this handler. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct Output { - /// A list of test outputs. Format is `(test_name, mdata, timings)`. - /// The latter being `Ok(_)` indicates the test succeeded. - /// - /// INVARIANT: If the test succeeded, the second field is `Some(mdata)` and - /// `mdata.iterations` is `Some(_)`. - tests: Vec<(String, Option, Result)>, -} - -impl Output { - /// Instantiates an empty "output". Useful for merging. - #[must_use] - pub fn blank() -> Self { - Output { tests: Vec::new() } - } - - /// Reports a success and adds it to this run's `Output`. - pub fn success( - &mut self, - name: impl AsRef, - mut mdata: TestMdata, - iters: NonZero, - timings: Timings, - ) { - mdata.iterations = Some(iters); - self.tests - .push((name.as_ref().to_string(), Some(mdata), Ok(timings))); - } - - /// Reports a failure and adds it to this run's `Output`. If this test was tried - /// with some number of iterations (i.e. this was not a version mismatch or skipped - /// test), it should be reported also. - /// - /// Using the `fail!()` macro is usually more convenient. - pub fn failure( - &mut self, - name: impl AsRef, - mut mdata: Option, - attempted_iters: Option>, - kind: FailKind, - ) { - if let Some(ref mut mdata) = mdata { - mdata.iterations = attempted_iters; - } - self.tests - .push((name.as_ref().to_string(), mdata, Err(kind))); - } - - /// True if no tests executed this run. - #[must_use] - pub fn is_empty(&self) -> bool { - self.tests.is_empty() - } - - /// Sorts the runs in the output in the order that we want them printed. - pub fn sort(&mut self) { - self.tests.sort_unstable_by(|a, b| match (a, b) { - // Tests where we got no metadata go at the end. - ((_, Some(_), _), (_, None, _)) => std::cmp::Ordering::Greater, - ((_, None, _), (_, Some(_), _)) => std::cmp::Ordering::Less, - // Then sort by importance, then weight. - ((_, Some(a_mdata), _), (_, Some(b_mdata), _)) => { - let c = a_mdata.importance.cmp(&b_mdata.importance); - if matches!(c, std::cmp::Ordering::Equal) { - a_mdata.weight.cmp(&b_mdata.weight) - } else { - c - } - } - // Lastly by name. - ((a_name, ..), (b_name, ..)) => a_name.cmp(b_name), - }); - } - - /// Merges the output of two runs, appending a prefix to the results of the new run. - /// To be used in conjunction with `Output::blank()`, or else only some tests will have - /// a prefix set. - pub fn merge<'a>(&mut self, other: Self, pref_other: impl Into>) { - let pref = if let Some(pref) = pref_other.into() { - "crates/".to_string() + pref + "::" - } else { - String::new() - }; - self.tests = std::mem::take(&mut self.tests) - .into_iter() - .chain( - other - .tests - .into_iter() - .map(|(name, md, tm)| (pref.clone() + &name, md, tm)), - ) - .collect(); - } - - /// Evaluates the performance of `self` against `baseline`. The latter is taken - /// as the comparison point, i.e. a positive resulting `PerfReport` means that - /// `self` performed better. - /// - /// # Panics - /// `self` and `baseline` are assumed to have the iterations field on all - /// `TestMdata`s set to `Some(_)` if the `TestMdata` is present itself. - #[must_use] - pub fn compare_perf(self, baseline: Self) -> PerfReport { - let self_categories = self.collapse(); - let mut other_categories = baseline.collapse(); - - let deltas = self_categories - .into_iter() - .filter_map(|(cat, self_data)| { - // Only compare categories where both meow - // runs have data. / - let mut other_data = other_categories.remove(&cat)?; - let mut max = f64::MIN; - let mut min = f64::MAX; - - // Running totals for averaging out tests. - let mut r_total_numerator = 0.; - let mut r_total_denominator = 0; - // Yeah this is O(n^2), but realistically it'll hardly be a bottleneck. - for (name, (s_timings, s_iters, weight)) in self_data { - // Only use the new weights if they conflict. - let Some((o_timings, o_iters, _)) = other_data.remove(&name) else { - continue; - }; - let shift = - (o_timings.iters_per_sec(o_iters) / s_timings.iters_per_sec(s_iters)) - 1.; - if shift > max { - max = shift; - } - if shift < min { - min = shift; - } - r_total_numerator += shift * f64::from(weight); - r_total_denominator += u32::from(weight); - } - // There were no runs here! - if r_total_denominator == 0 { - None - } else { - let mean = r_total_numerator / f64::from(r_total_denominator); - // TODO: also aggregate standard deviation? That's harder to keep - // meaningful, though, since we dk which tests are correlated. - Some((cat, PerfDelta { max, mean, min })) - } - }) - .collect(); - - PerfReport { deltas } - } - - /// Collapses the `PerfReport` into a `HashMap` over `Importance`, with - /// each importance category having its tests contained. - fn collapse(self) -> HashMap { - let mut categories = HashMap::>::default(); - for entry in self.tests { - if let Some(mdata) = entry.1 - && let Ok(timings) = entry.2 - { - if let Some(handle) = categories.get_mut(&mdata.importance) { - handle.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight)); - } else { - let mut new = HashMap::default(); - new.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight)); - categories.insert(mdata.importance, new); - } - } - } - - categories - } -} - -impl std::fmt::Display for Output { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Don't print the header for an empty run. - if self.tests.is_empty() { - return Ok(()); - } - - // We want to print important tests at the top, then alphabetical. - let mut sorted = self.clone(); - sorted.sort(); - // Markdown header for making a nice little table :> - writeln!( - f, - "| Command | Iter/sec | Mean [ms] | SD [ms] | Iterations | Importance (weight) |", - )?; - writeln!(f, "|:---|---:|---:|---:|---:|---:|")?; - for (name, metadata, timings) in &sorted.tests { - match metadata { - Some(metadata) => match timings { - // Happy path. - Ok(timings) => { - // If the test succeeded, then metadata.iterations is Some(_). - writeln!( - f, - "| {} | {:.2} | {} | {:.2} | {} | {} ({}) |", - name, - timings.iters_per_sec(metadata.iterations.unwrap()), - { - // Very small mean runtimes will give inaccurate - // results. Should probably also penalise weight. - let mean = timings.mean.as_secs_f64() * 1000.; - if mean < consts::NOISE_CUTOFF.as_secs_f64() * 1000. / 8. { - format!("{mean:.2} (unreliable)") - } else { - format!("{mean:.2}") - } - }, - timings.stddev.as_secs_f64() * 1000., - metadata.iterations.unwrap(), - metadata.importance, - metadata.weight, - )?; - } - // We have (some) metadata, but the test errored. - Err(err) => writeln!( - f, - "| ({}) {} | N/A | N/A | N/A | {} | {} ({}) |", - err, - name, - metadata - .iterations - .map_or_else(|| "N/A".to_owned(), |i| format!("{i}")), - metadata.importance, - metadata.weight - )?, - }, - // No metadata, couldn't even parse the test output. - None => writeln!( - f, - "| ({}) {} | N/A | N/A | N/A | N/A | N/A |", - timings.as_ref().unwrap_err(), - name - )?, - } - } - Ok(()) - } -} - -/// The difference in performance between two runs within a given importance -/// category. -struct PerfDelta { - /// The biggest improvement / least bad regression. - max: f64, - /// The weighted average change in test times. - mean: f64, - /// The worst regression / smallest improvement. - min: f64, -} - -/// Shim type for reporting all performance deltas across importance categories. -pub struct PerfReport { - /// Inner (group, diff) pairing. - deltas: HashMap, -} - -impl std::fmt::Display for PerfReport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.deltas.is_empty() { - return write!(f, "(no matching tests)"); - } - let sorted = self.deltas.iter().collect::>(); - writeln!(f, "| Category | Max | Mean | Min |")?; - // We don't want to print too many newlines at the end, so handle newlines - // a little jankily like this. - write!(f, "|:---|---:|---:|---:|")?; - for (cat, delta) in sorted.into_iter().rev() { - const SIGN_POS: &str = "↑"; - const SIGN_NEG: &str = "↓"; - const SIGN_NEUTRAL: &str = "±"; - - let prettify = |time: f64| { - let sign = if time > 0.05 { - SIGN_POS - } else if time < 0.05 && time > -0.05 { - SIGN_NEUTRAL - } else { - SIGN_NEG - }; - format!("{} {:.1}%", sign, time.abs() * 100.) - }; - - // Pretty-print these instead of just using the float display impl. - write!( - f, - "\n| {cat} | {} | {} | {} |", - prettify(delta.max), - prettify(delta.mean), - prettify(delta.min) - )?; - } - Ok(()) - } -} +mod implementation; +pub use implementation::*; diff --git a/tooling/perf/src/main.rs b/tooling/perf/src/main.rs index 910b172958..243658e508 100644 --- a/tooling/perf/src/main.rs +++ b/tooling/perf/src/main.rs @@ -46,11 +46,13 @@ //! This should probably not be called manually unless you're working on the profiler //! itself; use the `cargo perf-test` alias (after building this crate) instead. -use zed_perf::{FailKind, Importance, Output, TestMdata, Timings, consts}; +mod implementation; + +use implementation::{FailKind, Importance, Output, TestMdata, Timings, consts}; use std::{ fs::OpenOptions, - io::Write, + io::{Read, Write}, num::NonZero, path::{Path, PathBuf}, process::{Command, Stdio}, @@ -226,8 +228,8 @@ fn compare_profiles(args: &[String]) { a.strip_prefix("--save=") .expect("FATAL: save param formatted incorrectly"), ); + ident_idx = 1; } - ident_idx = 1; }); let ident_new = args .get(ident_idx) @@ -264,8 +266,14 @@ fn compare_profiles(args: &[String]) { let prefix = elems.next().unwrap(); assert_eq!("json", elems.next().unwrap()); assert!(elems.next().is_none()); - let handle = OpenOptions::new().read(true).open(entry.path()).unwrap(); - let o_other: Output = serde_json::from_reader(handle).unwrap(); + let mut buffer = Vec::new(); + let _ = OpenOptions::new() + .read(true) + .open(entry.path()) + .unwrap() + .read_to_end(&mut buffer) + .unwrap(); + let o_other: Output = serde_json::from_slice(&buffer).unwrap(); output.merge(o_other, prefix); }; @@ -405,10 +413,26 @@ fn triage_test( } } +/// Try to find the hyperfine binary the user has installed. +fn hyp_binary() -> Option { + const HYP_PATH: &str = "hyperfine"; + const HYP_HOME: &str = "~/.cargo/bin/hyperfine"; + if Command::new(HYP_PATH).output().is_err() { + if Command::new(HYP_HOME).output().is_err() { + None + } else { + Some(Command::new(HYP_HOME)) + } + } else { + Some(Command::new(HYP_PATH)) + } +} + /// Profiles a given test with hyperfine, returning the mean and standard deviation /// for its runtime. If the test errors, returns `None` instead. fn hyp_profile(t_bin: &str, t_name: &str, iterations: NonZero) -> Option { - let mut perf_cmd = Command::new("hyperfine"); + let mut perf_cmd = hyp_binary().expect("Couldn't find the Hyperfine binary on the system"); + // Warm up the cache and print markdown output to stdout, which we parse. perf_cmd.args([ "--style", diff --git a/tooling/workspace-hack/.gitattributes b/tooling/workspace-hack/.gitattributes deleted file mode 100644 index 3e9dba4b64..0000000000 --- a/tooling/workspace-hack/.gitattributes +++ /dev/null @@ -1,4 +0,0 @@ -# Avoid putting conflict markers in the generated Cargo.toml file, since their presence breaks -# Cargo. -# Also do not check out the file as CRLF on Windows, as that's what hakari needs. -Cargo.toml merge=binary -crlf diff --git a/tooling/workspace-hack/.ignore b/tooling/workspace-hack/.ignore deleted file mode 100644 index 0eded960b4..0000000000 --- a/tooling/workspace-hack/.ignore +++ /dev/null @@ -1,2 +0,0 @@ -# prevent cargo-machete from analyzing this crate -Cargo.toml diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml deleted file mode 100644 index 6434f69723..0000000000 --- a/tooling/workspace-hack/Cargo.toml +++ /dev/null @@ -1,738 +0,0 @@ -# This file is generated by `cargo hakari`. -# To regenerate, run: -# cargo install cargo-hakari -# cargo hakari generate - -[package] -name = "workspace-hack" -version = "0.1.0" -description = "workspace-hack package, managed by hakari" -edition.workspace = true -publish.workspace = true - -# The parts of the file between the BEGIN HAKARI SECTION and END HAKARI SECTION comments -# are managed by hakari. - -### BEGIN HAKARI SECTION -[dependencies] -ahash = { version = "0.8", features = ["serde"] } -aho-corasick = { version = "1" } -anstream = { version = "0.6" } -arrayvec = { version = "0.7", features = ["serde"] } -async-compression = { version = "0.4", default-features = false, features = ["deflate", "deflate64", "futures-io", "gzip"] } -async-std = { version = "1", features = ["attributes", "unstable"] } -async-tungstenite = { version = "0.29", features = ["tokio-rustls-manual-roots"] } -aws-config = { version = "1", features = ["behavior-version-latest"] } -aws-credential-types = { version = "1", default-features = false, features = ["hardcoded-credentials", "test-util"] } -aws-runtime = { version = "1", default-features = false, features = ["event-stream", "http-02x", "sigv4a"] } -aws-sigv4 = { version = "1", features = ["http0-compat", "sign-eventstream", "sigv4a"] } -aws-smithy-async = { version = "1", default-features = false, features = ["rt-tokio"] } -aws-smithy-http = { version = "0.62", default-features = false, features = ["event-stream"] } -aws-smithy-runtime = { version = "1", default-features = false, features = ["client", "default-https-client", "rt-tokio", "tls-rustls"] } -aws-smithy-runtime-api = { version = "1", features = ["client", "http-02x", "http-auth", "test-util"] } -aws-smithy-types = { version = "1", default-features = false, features = ["byte-stream-poll-next", "http-body-0-4-x", "http-body-1-x", "rt-tokio", "test-util"] } -base64 = { version = "0.22" } -base64ct = { version = "1", default-features = false, features = ["std"] } -bigdecimal = { version = "0.4", features = ["serde"] } -bit-set = { version = "0.8", default-features = false, features = ["std"] } -bit-vec = { version = "0.8", default-features = false, features = ["std"] } -bitflags = { version = "2", default-features = false, features = ["serde", "std"] } -bstr = { version = "1" } -bytemuck = { version = "1", default-features = false, features = ["aarch64_simd", "derive", "extern_crate_alloc"] } -byteorder = { version = "1" } -bytes = { version = "1" } -chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4", features = ["cargo", "derive", "string", "wrap_help"] } -clap_builder = { version = "4", default-features = false, features = ["cargo", "color", "std", "string", "suggestions", "usage", "wrap_help"] } -concurrent-queue = { version = "2" } -cranelift-codegen = { version = "0.116", default-features = false, features = ["host-arch", "incremental-cache", "std", "timing", "unwind"] } -crossbeam-channel = { version = "0.5" } -crossbeam-epoch = { version = "0.9" } -crossbeam-utils = { version = "0.8" } -deranged = { version = "0.4", default-features = false, features = ["powerfmt", "serde", "std"] } -digest = { version = "0.10", features = ["mac", "oid", "std"] } -either = { version = "1", features = ["serde", "use_std"] } -euclid = { version = "0.22" } -event-listener = { version = "5" } -event-listener-strategy = { version = "0.5" } -form_urlencoded = { version = "1" } -futures = { version = "0.3", features = ["io-compat"] } -futures-channel = { version = "0.3", features = ["sink"] } -futures-core = { version = "0.3" } -futures-executor = { version = "0.3" } -futures-io = { version = "0.3" } -futures-sink = { version = "0.3" } -futures-task = { version = "0.3", default-features = false, features = ["std"] } -futures-util = { version = "0.3", features = ["channel", "io-compat", "sink"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["std"] } -half = { version = "2", features = ["bytemuck", "num-traits", "rand_distr", "use-intrinsics"] } -handlebars = { version = "4", features = ["rust-embed"] } -hashbrown-3575ec1268b04181 = { package = "hashbrown", version = "0.15", features = ["serde"] } -hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14", features = ["raw"] } -hmac = { version = "0.12", default-features = false, features = ["reset"] } -hyper = { version = "0.14", features = ["client", "http1", "http2", "runtime", "server", "stream"] } -idna = { version = "1" } -indexmap = { version = "2", features = ["serde"] } -itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } -lazy_static = { version = "1", default-features = false, features = ["spin_no_std"] } -libc = { version = "0.2", features = ["extra_traits"] } -libsqlite3-sys = { version = "0.30", features = ["bundled", "unlock_notify"] } -log = { version = "0.4", default-features = false, features = ["kv_unstable_serde"] } -lyon = { version = "1", default-features = false, features = ["extra"] } -lyon_path = { version = "1" } -md-5 = { version = "0.10" } -memchr = { version = "2" } -memmap2 = { version = "0.9", default-features = false, features = ["stable_deref_trait"] } -mime_guess = { version = "2" } -miniz_oxide = { version = "0.8", features = ["simd"] } -nom = { version = "7" } -num-bigint = { version = "0.4" } -num-integer = { version = "0.1", features = ["i128"] } -num-iter = { version = "0.1", default-features = false, features = ["i128", "std"] } -num-rational = { version = "0.4", features = ["num-bigint-std"] } -num-traits = { version = "0.2", features = ["i128", "libm"] } -once_cell = { version = "1" } -percent-encoding = { version = "2" } -phf = { version = "0.11", features = ["macros"] } -phf_shared = { version = "0.11" } -prost-274715c4dabd11b0 = { package = "prost", version = "0.9" } -prost-types = { version = "0.9" } -rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8", features = ["small_rng"] } -rand_chacha = { version = "0.3", default-features = false, features = ["std"] } -rand_core = { version = "0.6", default-features = false, features = ["std"] } -rand_distr = { version = "0.5" } -regalloc2 = { version = "0.11", features = ["checker", "enable-serde"] } -regex = { version = "1" } -regex-automata = { version = "0.4" } -regex-syntax = { version = "0.8" } -rust_decimal = { version = "1", default-features = false, features = ["maths", "serde", "std"] } -rustc-hash = { version = "1" } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net"] } -rustls = { version = "0.23", features = ["ring"] } -rustls-webpki = { version = "0.103", default-features = false, features = ["aws-lc-rs", "ring", "std"] } -sea-orm = { version = "1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite"] } -sea-query-binder = { version = "0.7", default-features = false, features = ["postgres-array", "sqlx-postgres", "sqlx-sqlite", "with-bigdecimal", "with-chrono", "with-json", "with-rust_decimal", "with-time", "with-uuid"] } -semver = { version = "1", features = ["serde"] } -serde = { version = "1", features = ["alloc", "derive", "rc"] } -serde_core = { version = "1", default-features = false, features = ["alloc", "rc", "result", "std"] } -serde_json = { version = "1", features = ["alloc", "preserve_order", "raw_value", "unbounded_depth"] } -simd-adler32 = { version = "0.3" } -smallvec = { version = "1", default-features = false, features = ["const_new", "serde", "union"] } -spin = { version = "0.9" } -sqlx = { version = "0.8", features = ["bigdecimal", "chrono", "postgres", "runtime-tokio-rustls", "rust_decimal", "sqlite", "time", "uuid"] } -sqlx-postgres = { version = "0.8", default-features = false, features = ["any", "bigdecimal", "chrono", "json", "migrate", "offline", "rust_decimal", "time", "uuid"] } -sqlx-sqlite = { version = "0.8", default-features = false, features = ["any", "bundled", "chrono", "json", "migrate", "offline", "time", "uuid"] } -stable_deref_trait = { version = "1" } -strum = { version = "0.26", features = ["derive"] } -subtle = { version = "2" } -thiserror = { version = "2" } -time = { version = "0.3", features = ["local-offset", "macros", "serde-well-known"] } -tokio = { version = "1", features = ["full"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } -tokio-util = { version = "0.7", features = ["codec", "compat", "io"] } -toml_datetime = { version = "0.6", default-features = false, features = ["serde"] } -toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } -tracing = { version = "0.1", features = ["log"] } -tracing-core = { version = "0.1" } -tungstenite = { version = "0.26", default-features = false, features = ["__rustls-tls", "handshake"] } -unicode-properties = { version = "0.1" } -url = { version = "2", features = ["serde"] } -uuid = { version = "1", features = ["serde", "v4", "v5", "v7"] } -wasmparser = { version = "0.221" } -wasmtime = { version = "29", default-features = false, features = ["async", "component-model", "cranelift", "demangle", "gc-drc", "incremental-cache", "parallel-compilation"] } -wasmtime-cranelift = { version = "29", default-features = false, features = ["component-model", "gc-drc", "incremental-cache"] } -wasmtime-environ = { version = "29", default-features = false, features = ["compile", "component-model", "demangle", "gc-drc"] } - -[build-dependencies] -ahash = { version = "0.8", features = ["serde"] } -aho-corasick = { version = "1" } -anstream = { version = "0.6" } -arrayvec = { version = "0.7", features = ["serde"] } -async-compression = { version = "0.4", default-features = false, features = ["deflate", "deflate64", "futures-io", "gzip"] } -async-std = { version = "1", features = ["attributes", "unstable"] } -async-tungstenite = { version = "0.29", features = ["tokio-rustls-manual-roots"] } -aws-config = { version = "1", features = ["behavior-version-latest"] } -aws-credential-types = { version = "1", default-features = false, features = ["hardcoded-credentials", "test-util"] } -aws-runtime = { version = "1", default-features = false, features = ["event-stream", "http-02x", "sigv4a"] } -aws-sigv4 = { version = "1", features = ["http0-compat", "sign-eventstream", "sigv4a"] } -aws-smithy-async = { version = "1", default-features = false, features = ["rt-tokio"] } -aws-smithy-http = { version = "0.62", default-features = false, features = ["event-stream"] } -aws-smithy-runtime = { version = "1", default-features = false, features = ["client", "default-https-client", "rt-tokio", "tls-rustls"] } -aws-smithy-runtime-api = { version = "1", features = ["client", "http-02x", "http-auth", "test-util"] } -aws-smithy-types = { version = "1", default-features = false, features = ["byte-stream-poll-next", "http-body-0-4-x", "http-body-1-x", "rt-tokio", "test-util"] } -base64 = { version = "0.22" } -base64ct = { version = "1", default-features = false, features = ["std"] } -bigdecimal = { version = "0.4", features = ["serde"] } -bit-set = { version = "0.8", default-features = false, features = ["std"] } -bit-vec = { version = "0.8", default-features = false, features = ["std"] } -bitflags = { version = "2", default-features = false, features = ["serde", "std"] } -bstr = { version = "1" } -bytemuck = { version = "1", default-features = false, features = ["aarch64_simd", "derive", "extern_crate_alloc"] } -byteorder = { version = "1" } -bytes = { version = "1" } -cc = { version = "1", default-features = false, features = ["parallel"] } -chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4", features = ["cargo", "derive", "string", "wrap_help"] } -clap_builder = { version = "4", default-features = false, features = ["cargo", "color", "std", "string", "suggestions", "usage", "wrap_help"] } -concurrent-queue = { version = "2" } -cranelift-codegen = { version = "0.116", default-features = false, features = ["host-arch", "incremental-cache", "std", "timing", "unwind"] } -crossbeam-channel = { version = "0.5" } -crossbeam-epoch = { version = "0.9" } -crossbeam-utils = { version = "0.8" } -deranged = { version = "0.4", default-features = false, features = ["powerfmt", "serde", "std"] } -digest = { version = "0.10", features = ["mac", "oid", "std"] } -either = { version = "1", features = ["serde", "use_std"] } -euclid = { version = "0.22" } -event-listener = { version = "5" } -event-listener-strategy = { version = "0.5" } -form_urlencoded = { version = "1" } -futures = { version = "0.3", features = ["io-compat"] } -futures-channel = { version = "0.3", features = ["sink"] } -futures-core = { version = "0.3" } -futures-executor = { version = "0.3" } -futures-io = { version = "0.3" } -futures-sink = { version = "0.3" } -futures-task = { version = "0.3", default-features = false, features = ["std"] } -futures-util = { version = "0.3", features = ["channel", "io-compat", "sink"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["std"] } -half = { version = "2", features = ["bytemuck", "num-traits", "rand_distr", "use-intrinsics"] } -handlebars = { version = "4", features = ["rust-embed"] } -hashbrown-3575ec1268b04181 = { package = "hashbrown", version = "0.15", features = ["serde"] } -hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14", features = ["raw"] } -heck = { version = "0.4", features = ["unicode"] } -hmac = { version = "0.12", default-features = false, features = ["reset"] } -hyper = { version = "0.14", features = ["client", "http1", "http2", "runtime", "server", "stream"] } -idna = { version = "1" } -indexmap = { version = "2", features = ["serde"] } -itertools-594e8ee84c453af0 = { package = "itertools", version = "0.13" } -itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } -lazy_static = { version = "1", default-features = false, features = ["spin_no_std"] } -libc = { version = "0.2", features = ["extra_traits"] } -libsqlite3-sys = { version = "0.30", features = ["bundled", "unlock_notify"] } -log = { version = "0.4", default-features = false, features = ["kv_unstable_serde"] } -lyon = { version = "1", default-features = false, features = ["extra"] } -lyon_path = { version = "1" } -md-5 = { version = "0.10" } -memchr = { version = "2" } -memmap2 = { version = "0.9", default-features = false, features = ["stable_deref_trait"] } -mime_guess = { version = "2" } -miniz_oxide = { version = "0.8", features = ["simd"] } -nom = { version = "7" } -num-bigint = { version = "0.4" } -num-integer = { version = "0.1", features = ["i128"] } -num-iter = { version = "0.1", default-features = false, features = ["i128", "std"] } -num-rational = { version = "0.4", features = ["num-bigint-std"] } -num-traits = { version = "0.2", features = ["i128", "libm"] } -once_cell = { version = "1" } -percent-encoding = { version = "2" } -phf = { version = "0.11", features = ["macros"] } -phf_shared = { version = "0.11" } -prettyplease = { version = "0.2", default-features = false, features = ["verbatim"] } -proc-macro2 = { version = "1" } -prost-274715c4dabd11b0 = { package = "prost", version = "0.9" } -prost-types = { version = "0.9" } -quote = { version = "1" } -rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8", features = ["small_rng"] } -rand_chacha = { version = "0.3", default-features = false, features = ["std"] } -rand_core = { version = "0.6", default-features = false, features = ["std"] } -rand_distr = { version = "0.5" } -regalloc2 = { version = "0.11", features = ["checker", "enable-serde"] } -regex = { version = "1" } -regex-automata = { version = "0.4" } -regex-syntax = { version = "0.8" } -rust_decimal = { version = "1", default-features = false, features = ["maths", "serde", "std"] } -rustc-hash = { version = "1" } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net"] } -rustls = { version = "0.23", features = ["ring"] } -rustls-webpki = { version = "0.103", default-features = false, features = ["aws-lc-rs", "ring", "std"] } -sea-orm = { version = "1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite"] } -sea-query-binder = { version = "0.7", default-features = false, features = ["postgres-array", "sqlx-postgres", "sqlx-sqlite", "with-bigdecimal", "with-chrono", "with-json", "with-rust_decimal", "with-time", "with-uuid"] } -semver = { version = "1", features = ["serde"] } -serde = { version = "1", features = ["alloc", "derive", "rc"] } -serde_core = { version = "1", default-features = false, features = ["alloc", "rc", "result", "std"] } -serde_json = { version = "1", features = ["alloc", "preserve_order", "raw_value", "unbounded_depth"] } -simd-adler32 = { version = "0.3" } -smallvec = { version = "1", default-features = false, features = ["const_new", "serde", "union"] } -spin = { version = "0.9" } -sqlx = { version = "0.8", features = ["bigdecimal", "chrono", "postgres", "runtime-tokio-rustls", "rust_decimal", "sqlite", "time", "uuid"] } -sqlx-macros = { version = "0.8", features = ["_rt-tokio", "_tls-rustls-ring-webpki", "bigdecimal", "chrono", "derive", "json", "macros", "migrate", "postgres", "rust_decimal", "sqlite", "time", "uuid"] } -sqlx-macros-core = { version = "0.8", features = ["_rt-tokio", "_tls-rustls-ring-webpki", "bigdecimal", "chrono", "derive", "json", "macros", "migrate", "postgres", "rust_decimal", "sqlite", "time", "uuid"] } -sqlx-postgres = { version = "0.8", default-features = false, features = ["any", "bigdecimal", "chrono", "json", "migrate", "offline", "rust_decimal", "time", "uuid"] } -sqlx-sqlite = { version = "0.8", default-features = false, features = ["any", "bundled", "chrono", "json", "migrate", "offline", "time", "uuid"] } -stable_deref_trait = { version = "1" } -strum = { version = "0.26", features = ["derive"] } -subtle = { version = "2" } -syn-dff4ba8e3ae991db = { package = "syn", version = "1", features = ["extra-traits", "full"] } -syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } -thiserror = { version = "2" } -time = { version = "0.3", features = ["local-offset", "macros", "serde-well-known"] } -time-macros = { version = "0.2", default-features = false, features = ["formatting", "parsing", "serde"] } -tokio = { version = "1", features = ["full"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } -tokio-util = { version = "0.7", features = ["codec", "compat", "io"] } -toml_datetime = { version = "0.6", default-features = false, features = ["serde"] } -toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } -tracing = { version = "0.1", features = ["log"] } -tracing-core = { version = "0.1" } -tungstenite = { version = "0.26", default-features = false, features = ["__rustls-tls", "handshake"] } -unicode-properties = { version = "0.1" } -url = { version = "2", features = ["serde"] } -uuid = { version = "1", features = ["serde", "v4", "v5", "v7"] } -wasmparser = { version = "0.221" } -wasmtime = { version = "29", default-features = false, features = ["async", "component-model", "cranelift", "demangle", "gc-drc", "incremental-cache", "parallel-compilation"] } -wasmtime-cranelift = { version = "29", default-features = false, features = ["component-model", "gc-drc", "incremental-cache"] } -wasmtime-environ = { version = "29", default-features = false, features = ["compile", "component-model", "demangle", "gc-drc"] } - -[target.x86_64-apple-darwin.dependencies] -codespan-reporting = { version = "0.12" } -core-foundation = { version = "0.9" } -core-foundation-sys = { version = "0.8" } -flate2 = { version = "1" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -naga = { version = "25", features = ["msl-out", "wgsl-in"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -objc2 = { version = "0.6" } -objc2-core-foundation = { version = "0.3", default-features = false, features = ["CFArray", "CFCGTypes", "CFData", "CFDate", "CFDictionary", "CFRunLoop", "CFString", "CFURL", "objc2", "std"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["NSArray", "NSAttributedString", "NSBundle", "NSCoder", "NSData", "NSDate", "NSDictionary", "NSEnumerator", "NSError", "NSGeometry", "NSNotification", "NSNull", "NSObjCRuntime", "NSObject", "NSProcessInfo", "NSRange", "NSRunLoop", "NSString", "NSURL", "NSUndoManager", "NSValue", "objc2-core-foundation", "std"] } -objc2-metal = { version = "0.3" } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "process"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "termios", "time"] } -scopeguard = { version = "1" } -security-framework = { version = "3", features = ["OSX_10_14"] } -security-framework-sys = { version = "2", features = ["OSX_10_14"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } - -[target.x86_64-apple-darwin.build-dependencies] -codespan-reporting = { version = "0.12" } -core-foundation = { version = "0.9" } -core-foundation-sys = { version = "0.8" } -flate2 = { version = "1" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -naga = { version = "25", features = ["msl-out", "wgsl-in"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -objc2 = { version = "0.6" } -objc2-core-foundation = { version = "0.3", default-features = false, features = ["CFArray", "CFCGTypes", "CFData", "CFDate", "CFDictionary", "CFRunLoop", "CFString", "CFURL", "objc2", "std"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["NSArray", "NSAttributedString", "NSBundle", "NSCoder", "NSData", "NSDate", "NSDictionary", "NSEnumerator", "NSError", "NSGeometry", "NSNotification", "NSNull", "NSObjCRuntime", "NSObject", "NSProcessInfo", "NSRange", "NSRunLoop", "NSString", "NSURL", "NSUndoManager", "NSValue", "objc2-core-foundation", "std"] } -objc2-metal = { version = "0.3" } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "process"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "termios", "time"] } -scopeguard = { version = "1" } -security-framework = { version = "3", features = ["OSX_10_14"] } -security-framework-sys = { version = "2", features = ["OSX_10_14"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } - -[target.aarch64-apple-darwin.dependencies] -codespan-reporting = { version = "0.12" } -core-foundation = { version = "0.9" } -core-foundation-sys = { version = "0.8" } -flate2 = { version = "1" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -naga = { version = "25", features = ["msl-out", "wgsl-in"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -objc2 = { version = "0.6" } -objc2-core-foundation = { version = "0.3", default-features = false, features = ["CFArray", "CFCGTypes", "CFData", "CFDate", "CFDictionary", "CFRunLoop", "CFString", "CFURL", "objc2", "std"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["NSArray", "NSAttributedString", "NSBundle", "NSCoder", "NSData", "NSDate", "NSDictionary", "NSEnumerator", "NSError", "NSGeometry", "NSNotification", "NSNull", "NSObjCRuntime", "NSObject", "NSProcessInfo", "NSRange", "NSRunLoop", "NSString", "NSURL", "NSUndoManager", "NSValue", "objc2-core-foundation", "std"] } -objc2-metal = { version = "0.3" } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "process"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "termios", "time"] } -scopeguard = { version = "1" } -security-framework = { version = "3", features = ["OSX_10_14"] } -security-framework-sys = { version = "2", features = ["OSX_10_14"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } - -[target.aarch64-apple-darwin.build-dependencies] -codespan-reporting = { version = "0.12" } -core-foundation = { version = "0.9" } -core-foundation-sys = { version = "0.8" } -flate2 = { version = "1" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -naga = { version = "25", features = ["msl-out", "wgsl-in"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -objc2 = { version = "0.6" } -objc2-core-foundation = { version = "0.3", default-features = false, features = ["CFArray", "CFCGTypes", "CFData", "CFDate", "CFDictionary", "CFRunLoop", "CFString", "CFURL", "objc2", "std"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["NSArray", "NSAttributedString", "NSBundle", "NSCoder", "NSData", "NSDate", "NSDictionary", "NSEnumerator", "NSError", "NSGeometry", "NSNotification", "NSNull", "NSObjCRuntime", "NSObject", "NSProcessInfo", "NSRange", "NSRunLoop", "NSString", "NSURL", "NSUndoManager", "NSValue", "objc2-core-foundation", "std"] } -objc2-metal = { version = "0.3" } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "process"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "termios", "time"] } -scopeguard = { version = "1" } -security-framework = { version = "3", features = ["OSX_10_14"] } -security-framework-sys = { version = "2", features = ["OSX_10_14"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } - -[target.x86_64-unknown-linux-gnu.dependencies] -aes = { version = "0.8", default-features = false, features = ["zeroize"] } -ahash = { version = "0.8", default-features = false, features = ["compile-time-rng"] } -ashpd = { version = "0.11", default-features = false, features = ["async-std", "wayland"] } -bytemuck = { version = "1", default-features = false, features = ["min_const_generics"] } -cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } -codespan-reporting = { version = "0.12" } -crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -flate2 = { version = "1" } -flume = { version = "0.11" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -inout = { version = "0.1", default-features = false, features = ["block-padding"] } -linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } -linux-raw-sys-9fbad63c4bcf4a8f = { package = "linux-raw-sys", version = "0.4", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -mio = { version = "1", features = ["net", "os-ext"] } -naga = { version = "25", features = ["spv-out", "wgsl-in"] } -nix-1f5adca70f036a62 = { package = "nix", version = "0.28", features = ["fs", "mman", "ptrace", "signal", "term", "user"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -nix-fa1f6196edfd7249 = { package = "nix", version = "0.30", features = ["fs", "socket", "uio", "user"] } -num-bigint-dig = { version = "0.8", features = ["i128", "prime", "zeroize"] } -num-complex = { version = "0.4", features = ["bytemuck"] } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -proc-macro2 = { version = "1", features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -quote = { version = "1" } -rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "shm", "system"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "pty", "stdio", "termios", "time"] } -scopeguard = { version = "1" } -smallvec = { version = "1", default-features = false, features = ["write"] } -syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } -wayland-backend = { version = "0.3", default-features = false, features = ["client_system", "dlopen"] } -wayland-sys = { version = "0.31", default-features = false, features = ["client", "dlopen"] } -zeroize = { version = "1", features = ["zeroize_derive"] } -zvariant = { version = "5", features = ["enumflags2", "gvariant", "url"] } - -[target.x86_64-unknown-linux-gnu.build-dependencies] -aes = { version = "0.8", default-features = false, features = ["zeroize"] } -ahash = { version = "0.8", default-features = false, features = ["compile-time-rng"] } -ashpd = { version = "0.11", default-features = false, features = ["async-std", "wayland"] } -bytemuck = { version = "1", default-features = false, features = ["min_const_generics"] } -cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } -codespan-reporting = { version = "0.12" } -crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -flate2 = { version = "1" } -flume = { version = "0.11" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -inout = { version = "0.1", default-features = false, features = ["block-padding"] } -linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } -linux-raw-sys-9fbad63c4bcf4a8f = { package = "linux-raw-sys", version = "0.4", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -mio = { version = "1", features = ["net", "os-ext"] } -naga = { version = "25", features = ["spv-out", "wgsl-in"] } -nix-1f5adca70f036a62 = { package = "nix", version = "0.28", features = ["fs", "mman", "ptrace", "signal", "term", "user"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -nix-fa1f6196edfd7249 = { package = "nix", version = "0.30", features = ["fs", "socket", "uio", "user"] } -num-bigint-dig = { version = "0.8", features = ["i128", "prime", "zeroize"] } -num-complex = { version = "0.4", features = ["bytemuck"] } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "shm", "system"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "pty", "stdio", "termios", "time"] } -scopeguard = { version = "1" } -smallvec = { version = "1", default-features = false, features = ["write"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } -wayland-backend = { version = "0.3", default-features = false, features = ["client_system", "dlopen"] } -wayland-sys = { version = "0.31", default-features = false, features = ["client", "dlopen"] } -zbus_macros = { version = "5", features = ["gvariant"] } -zeroize = { version = "1", features = ["zeroize_derive"] } -zvariant = { version = "5", features = ["enumflags2", "gvariant", "url"] } - -[target.aarch64-unknown-linux-gnu.dependencies] -aes = { version = "0.8", default-features = false, features = ["zeroize"] } -ahash = { version = "0.8", default-features = false, features = ["compile-time-rng"] } -ashpd = { version = "0.11", default-features = false, features = ["async-std", "wayland"] } -bytemuck = { version = "1", default-features = false, features = ["min_const_generics"] } -cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } -codespan-reporting = { version = "0.12" } -crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -flate2 = { version = "1" } -flume = { version = "0.11" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -inout = { version = "0.1", default-features = false, features = ["block-padding"] } -linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } -linux-raw-sys-9fbad63c4bcf4a8f = { package = "linux-raw-sys", version = "0.4", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -mio = { version = "1", features = ["net", "os-ext"] } -naga = { version = "25", features = ["spv-out", "wgsl-in"] } -nix-1f5adca70f036a62 = { package = "nix", version = "0.28", features = ["fs", "mman", "ptrace", "signal", "term", "user"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -nix-fa1f6196edfd7249 = { package = "nix", version = "0.30", features = ["fs", "socket", "uio", "user"] } -num-bigint-dig = { version = "0.8", features = ["i128", "prime", "zeroize"] } -num-complex = { version = "0.4", features = ["bytemuck"] } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -proc-macro2 = { version = "1", features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -quote = { version = "1" } -rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "shm", "system"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "pty", "stdio", "termios", "time"] } -scopeguard = { version = "1" } -smallvec = { version = "1", default-features = false, features = ["write"] } -syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } -wayland-backend = { version = "0.3", default-features = false, features = ["client_system", "dlopen"] } -wayland-sys = { version = "0.31", default-features = false, features = ["client", "dlopen"] } -zeroize = { version = "1", features = ["zeroize_derive"] } -zvariant = { version = "5", features = ["enumflags2", "gvariant", "url"] } - -[target.aarch64-unknown-linux-gnu.build-dependencies] -aes = { version = "0.8", default-features = false, features = ["zeroize"] } -ahash = { version = "0.8", default-features = false, features = ["compile-time-rng"] } -ashpd = { version = "0.11", default-features = false, features = ["async-std", "wayland"] } -bytemuck = { version = "1", default-features = false, features = ["min_const_generics"] } -cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } -codespan-reporting = { version = "0.12" } -crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -flate2 = { version = "1" } -flume = { version = "0.11" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -inout = { version = "0.1", default-features = false, features = ["block-padding"] } -linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } -linux-raw-sys-9fbad63c4bcf4a8f = { package = "linux-raw-sys", version = "0.4", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -mio = { version = "1", features = ["net", "os-ext"] } -naga = { version = "25", features = ["spv-out", "wgsl-in"] } -nix-1f5adca70f036a62 = { package = "nix", version = "0.28", features = ["fs", "mman", "ptrace", "signal", "term", "user"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -nix-fa1f6196edfd7249 = { package = "nix", version = "0.30", features = ["fs", "socket", "uio", "user"] } -num-bigint-dig = { version = "0.8", features = ["i128", "prime", "zeroize"] } -num-complex = { version = "0.4", features = ["bytemuck"] } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "shm", "system"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "pty", "stdio", "termios", "time"] } -scopeguard = { version = "1" } -smallvec = { version = "1", default-features = false, features = ["write"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } -wayland-backend = { version = "0.3", default-features = false, features = ["client_system", "dlopen"] } -wayland-sys = { version = "0.31", default-features = false, features = ["client", "dlopen"] } -zbus_macros = { version = "5", features = ["gvariant"] } -zeroize = { version = "1", features = ["zeroize_derive"] } -zvariant = { version = "5", features = ["enumflags2", "gvariant", "url"] } - -[target.x86_64-pc-windows-msvc.dependencies] -flate2 = { version = "1" } -flume = { version = "0.11" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "fs", "net"] } -scopeguard = { version = "1" } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } -winapi = { version = "0.3", default-features = false, features = ["cfg", "commapi", "consoleapi", "evntrace", "fileapi", "handleapi", "impl-debug", "impl-default", "in6addr", "inaddr", "ioapiset", "knownfolders", "minwinbase", "minwindef", "namedpipeapi", "ntsecapi", "objbase", "processenv", "processthreadsapi", "shlobj", "std", "synchapi", "sysinfoapi", "timezoneapi", "winbase", "windef", "winerror", "winioctl", "winnt", "winreg", "winsock2", "winuser"] } -windows-core = { version = "0.61" } -windows-numerics = { version = "0.2" } -windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } -windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } -windows-sys-c8eced492e86ede7 = { package = "windows-sys", version = "0.48", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_Time", "Win32_System_WindowsProgramming", "Win32_UI_Shell"] } -windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Globalization", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Shell"] } - -[target.x86_64-pc-windows-msvc.build-dependencies] -flate2 = { version = "1" } -flume = { version = "0.11" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "fs", "net"] } -scopeguard = { version = "1" } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } -winapi = { version = "0.3", default-features = false, features = ["cfg", "commapi", "consoleapi", "evntrace", "fileapi", "handleapi", "impl-debug", "impl-default", "in6addr", "inaddr", "ioapiset", "knownfolders", "minwinbase", "minwindef", "namedpipeapi", "ntsecapi", "objbase", "processenv", "processthreadsapi", "shlobj", "std", "synchapi", "sysinfoapi", "timezoneapi", "winbase", "windef", "winerror", "winioctl", "winnt", "winreg", "winsock2", "winuser"] } -windows-core = { version = "0.61" } -windows-numerics = { version = "0.2" } -windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } -windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } -windows-sys-c8eced492e86ede7 = { package = "windows-sys", version = "0.48", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_Time", "Win32_System_WindowsProgramming", "Win32_UI_Shell"] } -windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Globalization", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Shell"] } - -[target.x86_64-unknown-linux-musl.dependencies] -aes = { version = "0.8", default-features = false, features = ["zeroize"] } -ahash = { version = "0.8", default-features = false, features = ["compile-time-rng"] } -ashpd = { version = "0.11", default-features = false, features = ["async-std", "wayland"] } -bytemuck = { version = "1", default-features = false, features = ["min_const_generics"] } -cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } -codespan-reporting = { version = "0.12" } -crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -flate2 = { version = "1" } -flume = { version = "0.11" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -inout = { version = "0.1", default-features = false, features = ["block-padding"] } -linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } -linux-raw-sys-9fbad63c4bcf4a8f = { package = "linux-raw-sys", version = "0.4", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -mio = { version = "1", features = ["net", "os-ext"] } -naga = { version = "25", features = ["spv-out", "wgsl-in"] } -nix-1f5adca70f036a62 = { package = "nix", version = "0.28", features = ["fs", "mman", "ptrace", "signal", "term", "user"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -nix-fa1f6196edfd7249 = { package = "nix", version = "0.30", features = ["fs", "socket", "uio", "user"] } -num-bigint-dig = { version = "0.8", features = ["i128", "prime", "zeroize"] } -num-complex = { version = "0.4", features = ["bytemuck"] } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -proc-macro2 = { version = "1", features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -quote = { version = "1" } -rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "shm", "system"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "pty", "stdio", "termios", "time"] } -scopeguard = { version = "1" } -smallvec = { version = "1", default-features = false, features = ["write"] } -syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } -wayland-backend = { version = "0.3", default-features = false, features = ["client_system", "dlopen"] } -wayland-sys = { version = "0.31", default-features = false, features = ["client", "dlopen"] } -zeroize = { version = "1", features = ["zeroize_derive"] } -zvariant = { version = "5", features = ["enumflags2", "gvariant", "url"] } - -[target.x86_64-unknown-linux-musl.build-dependencies] -aes = { version = "0.8", default-features = false, features = ["zeroize"] } -ahash = { version = "0.8", default-features = false, features = ["compile-time-rng"] } -ashpd = { version = "0.11", default-features = false, features = ["async-std", "wayland"] } -bytemuck = { version = "1", default-features = false, features = ["min_const_generics"] } -cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } -codespan-reporting = { version = "0.12" } -crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -flate2 = { version = "1" } -flume = { version = "0.11" } -foldhash = { version = "0.1", default-features = false, features = ["std"] } -getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } -getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } -gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } -inout = { version = "0.1", default-features = false, features = ["block-padding"] } -linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } -linux-raw-sys-9fbad63c4bcf4a8f = { package = "linux-raw-sys", version = "0.4", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } -livekit-runtime = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d" } -mio = { version = "1", features = ["net", "os-ext"] } -naga = { version = "25", features = ["spv-out", "wgsl-in"] } -nix-1f5adca70f036a62 = { package = "nix", version = "0.28", features = ["fs", "mman", "ptrace", "signal", "term", "user"] } -nix-b73a96c0a5f6a7d9 = { package = "nix", version = "0.29", features = ["fs", "pthread", "signal", "user"] } -nix-fa1f6196edfd7249 = { package = "nix", version = "0.30", features = ["fs", "socket", "uio", "user"] } -num-bigint-dig = { version = "0.8", features = ["i128", "prime", "zeroize"] } -num-complex = { version = "0.4", features = ["bytemuck"] } -object = { version = "0.36", default-features = false, features = ["archive", "read_core", "unaligned", "write"] } -proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } -prost-5ef9efb8ec2df382 = { package = "prost", version = "0.12", features = ["prost-derive"] } -rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } -ring = { version = "0.17", features = ["std"] } -rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "shm", "system"] } -rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", default-features = false, features = ["event", "pipe", "process", "pty", "stdio", "termios", "time"] } -scopeguard = { version = "1" } -smallvec = { version = "1", default-features = false, features = ["write"] } -sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } -tokio-socks = { version = "0.5", features = ["futures-io"] } -tokio-stream = { version = "0.1", features = ["fs"] } -tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } -wayland-backend = { version = "0.3", default-features = false, features = ["client_system", "dlopen"] } -wayland-sys = { version = "0.31", default-features = false, features = ["client", "dlopen"] } -zbus_macros = { version = "5", features = ["gvariant"] } -zeroize = { version = "1", features = ["zeroize_derive"] } -zvariant = { version = "5", features = ["enumflags2", "gvariant", "url"] } - -### END HAKARI SECTION diff --git a/tooling/workspace-hack/build.rs b/tooling/workspace-hack/build.rs deleted file mode 100644 index 92518ef04c..0000000000 --- a/tooling/workspace-hack/build.rs +++ /dev/null @@ -1,2 +0,0 @@ -// A build script is required for cargo to consider build dependencies. -fn main() {} diff --git a/tooling/workspace-hack/src/lib.rs b/tooling/workspace-hack/src/lib.rs deleted file mode 100644 index 22489f632b..0000000000 --- a/tooling/workspace-hack/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -// This is a stub lib.rs. diff --git a/tooling/xtask/Cargo.toml b/tooling/xtask/Cargo.toml index 8f968e0ca6..13179b2eb6 100644 --- a/tooling/xtask/Cargo.toml +++ b/tooling/xtask/Cargo.toml @@ -10,10 +10,14 @@ workspace = true [dependencies] anyhow.workspace = true +backtrace.workspace = true cargo_metadata.workspace = true cargo_toml.workspace = true clap = { workspace = true, features = ["derive"] } toml.workspace = true indoc.workspace = true +indexmap.workspace = true +serde.workspace = true +serde_json.workspace = true toml_edit.workspace = true -workspace-hack.workspace = true +gh-workflow.workspace = true diff --git a/tooling/xtask/src/main.rs b/tooling/xtask/src/main.rs index 5b265392f4..6f83927d67 100644 --- a/tooling/xtask/src/main.rs +++ b/tooling/xtask/src/main.rs @@ -20,6 +20,7 @@ enum CliCommand { PackageConformity(tasks::package_conformity::PackageConformityArgs), /// Publishes GPUI and its dependencies to crates.io. PublishGpui(tasks::publish_gpui::PublishGpuiArgs), + Workflows(tasks::workflows::GenerateWorkflowArgs), } fn main() -> Result<()> { @@ -32,5 +33,6 @@ fn main() -> Result<()> { tasks::package_conformity::run_package_conformity(args) } CliCommand::PublishGpui(args) => tasks::publish_gpui::run_publish_gpui(args), + CliCommand::Workflows(args) => tasks::workflows::run_workflows(args), } } diff --git a/tooling/xtask/src/tasks.rs b/tooling/xtask/src/tasks.rs index b73aeb0e7f..01b3907f04 100644 --- a/tooling/xtask/src/tasks.rs +++ b/tooling/xtask/src/tasks.rs @@ -2,3 +2,4 @@ pub mod clippy; pub mod licenses; pub mod package_conformity; pub mod publish_gpui; +pub mod workflows; diff --git a/tooling/xtask/src/tasks/package_conformity.rs b/tooling/xtask/src/tasks/package_conformity.rs index c8bed4bb35..e1fd15112f 100644 --- a/tooling/xtask/src/tasks/package_conformity.rs +++ b/tooling/xtask/src/tasks/package_conformity.rs @@ -38,11 +38,6 @@ pub fn run_package_conformity(_args: PackageConformityArgs) -> Result<()> { continue; } - // Ignore `workspace-hack`, as it produces a lot of false positives. - if package.name == "workspace-hack" { - continue; - } - for dependencies in [ &cargo_toml.dependencies, &cargo_toml.dev_dependencies, diff --git a/tooling/xtask/src/tasks/publish_gpui.rs b/tooling/xtask/src/tasks/publish_gpui.rs index bf25b58fb7..2740f75a48 100644 --- a/tooling/xtask/src/tasks/publish_gpui.rs +++ b/tooling/xtask/src/tasks/publish_gpui.rs @@ -7,13 +7,13 @@ use clap::Parser; #[derive(Parser)] pub struct PublishGpuiArgs { - /// Optional pre-release identifier to append to the version (e.g., alpha, test.1). Always bumps the minor version. - #[arg(long)] - pre_release: Option, - /// Perform a dry-run and wait for user confirmation before each publish #[arg(long)] dry_run: bool, + + /// Skip to a specific package (by package name or crate name) and start from there + #[arg(long)] + skip_to: Option, } pub fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> { @@ -24,17 +24,17 @@ pub fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> { let start_time = std::time::Instant::now(); check_workspace_root()?; - ensure_cargo_set_version()?; - check_git_clean()?; - let current_version = read_gpui_version()?; - let new_version = bump_version(¤t_version, args.pre_release.as_deref())?; - println!( - "Updating GPUI version: {} -> {}", - current_version, new_version - ); - publish_dependencies(&new_version, args.dry_run)?; - publish_gpui(&new_version, args.dry_run)?; + if args.skip_to.is_none() { + check_git_clean()?; + } else { + println!("Skipping git clean check due to --skip-to flag"); + } + + let version = read_gpui_version()?; + println!("Updating GPUI to version: {}", version); + publish_dependencies(&version, args.dry_run, args.skip_to.as_deref())?; + publish_gpui(&version, args.dry_run)?; println!("GPUI published in {}s", start_time.elapsed().as_secs_f32()); Ok(()) } @@ -56,87 +56,106 @@ fn read_gpui_version() -> Result { Ok(version.to_string()) } -fn bump_version(current_version: &str, pre_release: Option<&str>) -> Result { - // Strip any existing metadata and pre-release - let without_metadata = current_version.split('+').next().unwrap(); - let base_version = without_metadata.split('-').next().unwrap(); - - // Parse major.minor.patch - let parts: Vec<&str> = base_version.split('.').collect(); - if parts.len() != 3 { - bail!("Invalid version format: {}", current_version); - } - - let major: u32 = parts[0].parse().context("Failed to parse major version")?; - let minor: u32 = parts[1].parse().context("Failed to parse minor version")?; - - // Always bump minor version - let new_version = format!("{}.{}.0", major, minor + 1); - - // Add pre-release if specified - if let Some(pre) = pre_release { - Ok(format!("{}-{}", new_version, pre)) - } else { - Ok(new_version) - } -} - -fn publish_dependencies(new_version: &str, dry_run: bool) -> Result<()> { +fn publish_dependencies(new_version: &str, dry_run: bool, skip_to: Option<&str>) -> Result<()> { let gpui_dependencies = vec![ - ("zed-collections", "collections"), - ("zed-perf", "perf"), - ("zed-util-macros", "util_macros"), - ("zed-util", "util"), - ("gpui-macros", "gpui_macros"), - ("zed-http-client", "http_client"), - ("zed-derive-refineable", "derive_refineable"), - ("zed-refineable", "refineable"), - ("zed-semantic-version", "semantic_version"), - ("zed-sum-tree", "sum_tree"), - ("zed-media", "media"), + ("collections", "gpui_collections", "crates"), + ("perf", "gpui_perf", "tooling"), + ("util_macros", "gpui_util_macros", "crates"), + ("util", "gpui_util", "crates"), + ("gpui_macros", "gpui-macros", "crates"), + ("http_client", "gpui_http_client", "crates"), + ( + "derive_refineable", + "gpui_derive_refineable", + "crates/refineable", + ), + ("refineable", "gpui_refineable", "crates"), + ("semantic_version", "gpui_semantic_version", "crates"), + ("sum_tree", "gpui_sum_tree", "crates"), + ("media", "gpui_media", "crates"), ]; - for (crate_name, package_name) in gpui_dependencies { + let mut should_skip = skip_to.is_some(); + let skip_target = skip_to.unwrap_or(""); + + for (package_name, crate_name, package_dir) in gpui_dependencies { + if should_skip { + if package_name == skip_target || crate_name == skip_target { + println!("Found skip target: {} ({})", crate_name, package_name); + should_skip = false; + } else { + println!("Skipping: {} ({})", crate_name, package_name); + continue; + } + } + println!( "Publishing dependency: {} (package: {})", crate_name, package_name ); - update_crate_version(crate_name, new_version)?; - update_workspace_dependency_version(package_name, new_version)?; + update_crate_cargo_toml(package_name, crate_name, package_dir, new_version)?; + update_workspace_dependency_version(package_name, crate_name, new_version)?; publish_crate(crate_name, dry_run)?; + } - // println!("Waiting 60s for the rate limit..."); - // thread::sleep(Duration::from_secs(60)); + if should_skip { + bail!( + "Could not find package or crate named '{}' to skip to", + skip_target + ); } Ok(()) } fn publish_gpui(new_version: &str, dry_run: bool) -> Result<()> { - update_crate_version("gpui", new_version)?; + update_crate_cargo_toml("gpui", "gpui", "crates", new_version)?; publish_crate("gpui", dry_run)?; Ok(()) } -fn update_crate_version(package_name: &str, new_version: &str) -> Result<()> { - let output = run_command( - Command::new("cargo") - .arg("set-version") - .arg("--package") - .arg(package_name) - .arg(new_version), - )?; +fn update_crate_cargo_toml( + package_name: &str, + crate_name: &str, + package_dir: &str, + new_version: &str, +) -> Result<()> { + let cargo_toml_path = format!("{}/{}/Cargo.toml", package_dir, package_name); + let contents = std::fs::read_to_string(&cargo_toml_path) + .context(format!("Failed to read {}", cargo_toml_path))?; - if !output.status.success() { - bail!("Failed to set version for package {}", package_name); - } + let updated = update_crate_package_fields(&contents, crate_name, new_version)?; + + std::fs::write(&cargo_toml_path, updated) + .context(format!("Failed to write {}", cargo_toml_path))?; Ok(()) } +fn update_crate_package_fields( + toml_contents: &str, + crate_name: &str, + new_version: &str, +) -> Result { + let mut doc = toml_contents + .parse::() + .context("Failed to parse TOML")?; + + let package = doc + .get_mut("package") + .and_then(|p| p.as_table_like_mut()) + .context("Failed to find [package] section")?; + + package.insert("name", toml_edit::value(crate_name)); + package.insert("version", toml_edit::value(new_version)); + package.insert("publish", toml_edit::value(true)); + + Ok(doc.to_string()) +} + fn publish_crate(crate_name: &str, dry_run: bool) -> Result<()> { let publish_crate_impl = |crate_name, dry_run| { let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); @@ -171,29 +190,34 @@ fn publish_crate(crate_name: &str, dry_run: bool) -> Result<()> { Ok(()) } -fn update_workspace_dependency_version(package_name: &str, new_version: &str) -> Result<()> { +fn update_workspace_dependency_version( + package_name: &str, + crate_name: &str, + new_version: &str, +) -> Result<()> { let workspace_cargo_toml_path = "Cargo.toml"; let contents = std::fs::read_to_string(workspace_cargo_toml_path) .context("Failed to read workspace Cargo.toml")?; - let updated = update_dependency_version_in_toml(&contents, package_name, new_version)?; + let mut doc = contents + .parse::() + .context("Failed to parse TOML")?; - std::fs::write(workspace_cargo_toml_path, updated) + update_dependency_version_in_doc(&mut doc, package_name, crate_name, new_version)?; + update_profile_override_in_doc(&mut doc, package_name, crate_name)?; + + std::fs::write(workspace_cargo_toml_path, doc.to_string()) .context("Failed to write workspace Cargo.toml")?; Ok(()) } -fn update_dependency_version_in_toml( - toml_contents: &str, +fn update_dependency_version_in_doc( + doc: &mut toml_edit::DocumentMut, package_name: &str, + crate_name: &str, new_version: &str, -) -> Result { - let mut doc = toml_contents - .parse::() - .context("Failed to parse TOML")?; - - // Navigate to workspace.dependencies. +) -> Result<()> { let dependency = doc .get_mut("workspace") .and_then(|w| w.get_mut("dependencies")) @@ -203,21 +227,35 @@ fn update_dependency_version_in_toml( package_name ))?; - // Update the version field if it exists if let Some(dep_table) = dependency.as_table_like_mut() { - if dep_table.contains_key("version") { - dep_table.insert("version", toml_edit::value(new_version)); - } else { - bail!( - "No version field found for {} in workspace dependencies", - package_name - ); - } + dep_table.insert("version", toml_edit::value(new_version)); + dep_table.insert("package", toml_edit::value(crate_name)); } else { bail!("{} is not a table in workspace dependencies", package_name); } - Ok(doc.to_string()) + Ok(()) +} + +fn update_profile_override_in_doc( + doc: &mut toml_edit::DocumentMut, + package_name: &str, + crate_name: &str, +) -> Result<()> { + if let Some(profile_dev_package) = doc + .get_mut("profile") + .and_then(|p| p.get_mut("dev")) + .and_then(|d| d.get_mut("package")) + .and_then(|p| p.as_table_like_mut()) + { + if let Some(old_entry) = profile_dev_package.get(package_name) { + let old_entry_clone = old_entry.clone(); + profile_dev_package.remove(package_name); + profile_dev_package.insert(crate_name, old_entry_clone); + } + } + + Ok(()) } fn check_workspace_root() -> Result<()> { @@ -244,27 +282,6 @@ fn check_workspace_root() -> Result<()> { Ok(()) } -fn ensure_cargo_set_version() -> Result<()> { - let output = run_command( - Command::new("which") - .arg("cargo-set-version") - .stdout(Stdio::piped()), - ) - .context("Failed to check for cargo-set-version")?; - - if !output.status.success() { - println!("cargo-set-version not found. Installing cargo-edit..."); - - let install_output = run_command(Command::new("cargo").arg("install").arg("cargo-edit"))?; - - if !install_output.status.success() { - bail!("Failed to install cargo-edit"); - } - } - - Ok(()) -} - fn check_git_clean() -> Result<()> { let output = run_command( Command::new("git") @@ -310,6 +327,10 @@ fn run_command(command: &mut Command) -> Result { .wait_with_output() .context("failed to wait for child process")?; + if !output.status.success() { + bail!("Command failed with status {}", output.status); + } + Ok(output) } @@ -327,12 +348,17 @@ mod tests { [workspace.dependencies] # here's a comment - collections = { path = "crates/collections", package = "zed-collections", version = "0.1.0" } + collections = { path = "crates/collections" } util = { path = "crates/util", package = "zed-util", version = "0.1.0" } "#}; - let result = update_dependency_version_in_toml(input, "collections", "0.2.0").unwrap(); + let mut doc = input.parse::().unwrap(); + + update_dependency_version_in_doc(&mut doc, "collections", "gpui_collections", "0.2.0") + .unwrap(); + + let result = doc.to_string(); let output = indoc! {r#" [workspace] @@ -340,7 +366,7 @@ mod tests { [workspace.dependencies] # here's a comment - collections = { path = "crates/collections", package = "zed-collections", version = "0.2.0" } + collections = { path = "crates/collections" , version = "0.2.0", package = "gpui_collections" } util = { path = "crates/util", package = "zed-util", version = "0.1.0" } "#}; @@ -349,38 +375,68 @@ mod tests { } #[test] - fn test_bump_version() { - // Test bumping minor version (default behavior) - assert_eq!(bump_version("0.1.0", None).unwrap(), "0.2.0"); - assert_eq!(bump_version("0.1.5", None).unwrap(), "0.2.0"); - assert_eq!(bump_version("1.42.7", None).unwrap(), "1.43.0"); + fn test_update_crate_package_fields() { + let input = indoc! {r#" + [package] + name = "collections" + version = "0.1.0" + edition = "2021" + publish = false + # some comment about the license + license = "GPL-3.0-or-later" - // Test stripping pre-release and bumping minor - assert_eq!(bump_version("0.1.0-alpha.1", None).unwrap(), "0.2.0"); - assert_eq!(bump_version("0.1.0-beta", None).unwrap(), "0.2.0"); + [dependencies] + serde = "1.0" + "#}; - // Test stripping existing metadata and bumping - assert_eq!(bump_version("0.1.0+old.metadata", None).unwrap(), "0.2.0"); + let result = update_crate_package_fields(input, "gpui_collections", "0.2.0").unwrap(); - // Test bumping minor with pre-release - assert_eq!(bump_version("0.1.0", Some("alpha")).unwrap(), "0.2.0-alpha"); + let output = indoc! {r#" + [package] + name = "gpui_collections" + version = "0.2.0" + edition = "2021" + publish = true + # some comment about the license + license = "GPL-3.0-or-later" - // Test bumping minor with complex pre-release identifier - assert_eq!( - bump_version("0.1.0", Some("test.1")).unwrap(), - "0.2.0-test.1" - ); + [dependencies] + serde = "1.0" + "#}; - // Test bumping from existing pre-release adds new pre-release - assert_eq!( - bump_version("0.1.0-alpha", Some("beta")).unwrap(), - "0.2.0-beta" - ); + assert_eq!(result, output); + } - // Test bumping and stripping metadata while adding pre-release - assert_eq!( - bump_version("0.1.0+metadata", Some("alpha")).unwrap(), - "0.2.0-alpha" - ); + #[test] + fn test_update_profile_override_in_toml() { + let input = indoc! {r#" + [profile.dev] + split-debuginfo = "unpacked" + + [profile.dev.package] + taffy = { opt-level = 3 } + collections = { codegen-units = 256 } + refineable = { codegen-units = 256 } + util = { codegen-units = 256 } + "#}; + + let mut doc = input.parse::().unwrap(); + + update_profile_override_in_doc(&mut doc, "collections", "gpui_collections").unwrap(); + + let result = doc.to_string(); + + let output = indoc! {r#" + [profile.dev] + split-debuginfo = "unpacked" + + [profile.dev.package] + taffy = { opt-level = 3 } + refineable = { codegen-units = 256 } + util = { codegen-units = 256 } + gpui_collections = { codegen-units = 256 } + "#}; + + assert_eq!(result, output); } } diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs new file mode 100644 index 0000000000..fe47635520 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows.rs @@ -0,0 +1,140 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use gh_workflow::Workflow; +use std::fs; +use std::path::{Path, PathBuf}; + +mod after_release; +mod autofix_pr; +mod cherry_pick; +mod compare_perf; +mod danger; +mod extension_bump; +mod extension_release; +mod extension_tests; +mod extensions; +mod nix_build; +mod release_nightly; +mod run_bundling; + +mod release; +mod run_agent_evals; +mod run_tests; +mod runners; +mod steps; +mod vars; + +#[derive(Parser)] +pub struct GenerateWorkflowArgs {} + +struct WorkflowFile { + source: fn() -> Workflow, + r#type: WorkflowType, +} + +impl WorkflowFile { + fn zed(f: fn() -> Workflow) -> WorkflowFile { + WorkflowFile { + source: f, + r#type: WorkflowType::Zed, + } + } + fn extension(f: fn() -> Workflow) -> WorkflowFile { + WorkflowFile { + source: f, + r#type: WorkflowType::Extensions, + } + } + + fn generate_file(&self) -> Result<()> { + let workflow = (self.source)(); + let workflow_folder = self.r#type.folder_path(); + let workflow_name = workflow + .name + .as_ref() + .expect("Workflow must have a name at this point"); + let filename = format!( + "{}.yml", + workflow_name.rsplit("::").next().unwrap_or(workflow_name) + ); + + let workflow_path = workflow_folder.join(filename); + + let content = workflow + .to_string() + .map_err(|e| anyhow::anyhow!("{:?}: {:?}", workflow_path, e))?; + + let disclaimer = self.r#type.disclaimer(workflow_name); + + let content = [disclaimer, content].join("\n"); + fs::write(&workflow_path, content).map_err(Into::into) + } +} + +enum WorkflowType { + Zed, + Extensions, +} + +impl WorkflowType { + fn disclaimer(&self, workflow_name: &str) -> String { + format!( + concat!( + "# Generated from xtask::workflows::{}{}\n", + "# Rebuild with `cargo xtask workflows`.", + ), + workflow_name, + matches!(self, WorkflowType::Extensions) + .then_some(" within the Zed repository.") + .unwrap_or_default(), + ) + } + + fn folder_path(&self) -> PathBuf { + match self { + WorkflowType::Zed => PathBuf::from(".github/workflows"), + WorkflowType::Extensions => PathBuf::from("extensions/workflows"), + } + } +} + +pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { + if !Path::new("crates/zed/").is_dir() { + anyhow::bail!("xtask workflows must be ran from the project root"); + } + let workflow_dir = Path::new(".github/workflows"); + let extension_workflow_dir = Path::new("extensions/workflows"); + + let workflows = [ + WorkflowFile::zed(danger::danger), + WorkflowFile::zed(run_bundling::run_bundling), + WorkflowFile::zed(release_nightly::release_nightly), + WorkflowFile::zed(run_tests::run_tests), + WorkflowFile::zed(release::release), + WorkflowFile::zed(cherry_pick::cherry_pick), + WorkflowFile::zed(autofix_pr::autofix_pr), + WorkflowFile::zed(compare_perf::compare_perf), + WorkflowFile::zed(run_agent_evals::run_unit_evals), + WorkflowFile::zed(run_agent_evals::run_cron_unit_evals), + WorkflowFile::zed(run_agent_evals::run_agent_evals), + WorkflowFile::zed(after_release::after_release), + WorkflowFile::zed(extension_tests::extension_tests), + WorkflowFile::zed(extension_bump::extension_bump), + WorkflowFile::zed(extension_release::extension_release), + /* workflows used for CI/CD in extension repositories */ + WorkflowFile::extension(extensions::run_tests::run_tests), + WorkflowFile::extension(extensions::bump_version::bump_version), + WorkflowFile::extension(extensions::release_version::release_version), + ]; + + for directory in [&workflow_dir, &extension_workflow_dir] { + fs::create_dir_all(directory) + .with_context(|| format!("Failed to create directory: {}", directory.display()))?; + } + + for workflow_file in workflows { + workflow_file.generate_file()?; + } + + Ok(()) +} diff --git a/tooling/xtask/src/tasks/workflows/after_release.rs b/tooling/xtask/src/tasks/workflows/after_release.rs new file mode 100644 index 0000000000..c475617197 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/after_release.rs @@ -0,0 +1,164 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + release::{self, notify_on_failure}, + runners, + steps::{CommonJobConditions, NamedJob, checkout_repo, dependant_job, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +const TAG_NAME: &str = "${{ github.event.release.tag_name || inputs.tag_name }}"; +const IS_PRERELEASE: &str = "${{ github.event.release.prerelease || inputs.prerelease }}"; +const RELEASE_BODY: &str = "${{ github.event.release.body || inputs.body }}"; + +pub fn after_release() -> Workflow { + let tag_name = WorkflowInput::string("tag_name", None); + let prerelease = WorkflowInput::bool("prerelease", None); + let body = WorkflowInput::string("body", Some(String::new())); + + let refresh_zed_dev = rebuild_releases_page(); + let post_to_discord = post_to_discord(&[&refresh_zed_dev]); + let publish_winget = publish_winget(); + let create_sentry_release = create_sentry_release(); + let notify_on_failure = notify_on_failure(&[ + &refresh_zed_dev, + &post_to_discord, + &publish_winget, + &create_sentry_release, + ]); + + named::workflow() + .on(Event::default() + .release(Release::default().types(vec![ReleaseType::Published])) + .workflow_dispatch( + WorkflowDispatch::default() + .add_input(tag_name.name, tag_name.input()) + .add_input(prerelease.name, prerelease.input()) + .add_input(body.name, body.input()), + )) + .add_job(refresh_zed_dev.name, refresh_zed_dev.job) + .add_job(post_to_discord.name, post_to_discord.job) + .add_job(publish_winget.name, publish_winget.job) + .add_job(create_sentry_release.name, create_sentry_release.job) + .add_job(notify_on_failure.name, notify_on_failure.job) +} + +fn rebuild_releases_page() -> NamedJob { + fn refresh_cloud_releases() -> Step { + named::bash(format!( + "curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag={TAG_NAME}" + )) + } + + fn redeploy_zed_dev() -> Step { + named::bash("npm exec --yes -- vercel@37 --token=\"$VERCEL_TOKEN\" --scope zed-industries redeploy https://zed.dev") + .add_env(("VERCEL_TOKEN", vars::VERCEL_TOKEN)) + } + + named::job( + Job::default() + .runs_on(runners::LINUX_SMALL) + .with_repository_owner_guard() + .add_step(refresh_cloud_releases()) + .add_step(redeploy_zed_dev()), + ) +} + +fn post_to_discord(deps: &[&NamedJob]) -> NamedJob { + fn get_release_url() -> Step { + named::bash(format!( + r#"if [ "{IS_PRERELEASE}" == "true" ]; then + URL="https://zed.dev/releases/preview" +else + URL="https://zed.dev/releases/stable" +fi + +echo "URL=$URL" >> "$GITHUB_OUTPUT" +"# + )) + .id("get-release-url") + } + + fn get_content() -> Step { + named::uses( + "2428392", + "gh-truncate-string-action", + "b3ff790d21cf42af3ca7579146eedb93c8fb0757", // v1.4.1 + ) + .id("get-content") + .add_with(( + "stringToTruncate", + format!( + "📣 Zed [{TAG_NAME}](<${{{{ steps.get-release-url.outputs.URL }}}}>) was just released!\n\n{RELEASE_BODY}\n" + ), + )) + .add_with(("maxLength", 2000)) + .add_with(("truncationSymbol", "...")) + } + + fn discord_webhook_action() -> Step { + named::uses( + "tsickert", + "discord-webhook", + "c840d45a03a323fbc3f7507ac7769dbd91bfb164", // v5.3.0 + ) + .add_with(("webhook-url", vars::DISCORD_WEBHOOK_RELEASE_NOTES)) + .add_with(("content", "${{ steps.get-content.outputs.string }}")) + } + let job = dependant_job(deps) + .runs_on(runners::LINUX_SMALL) + .with_repository_owner_guard() + .add_step(get_release_url()) + .add_step(get_content()) + .add_step(discord_webhook_action()); + named::job(job) +} + +fn publish_winget() -> NamedJob { + fn set_package_name() -> (Step, StepOutput) { + let script = format!( + r#"if ("{IS_PRERELEASE}" -eq "true") {{ + $PACKAGE_NAME = "ZedIndustries.Zed.Preview" +}} else {{ + $PACKAGE_NAME = "ZedIndustries.Zed" +}} + +echo "PACKAGE_NAME=$PACKAGE_NAME" >> $env:GITHUB_OUTPUT +"# + ); + let step = named::pwsh(&script).id("set-package-name"); + + let output = StepOutput::new(&step, "PACKAGE_NAME"); + (step, output) + } + + fn winget_releaser(package_name: &StepOutput) -> Step { + named::uses( + "vedantmgoyal9", + "winget-releaser", + "19e706d4c9121098010096f9c495a70a7518b30f", // v2 + ) + .add_with(("identifier", package_name.to_string())) + .add_with(("release-tag", TAG_NAME)) + .add_with(("max-versions-to-keep", 5)) + .add_with(("token", vars::WINGET_TOKEN)) + } + + let (set_package_name, package_name) = set_package_name(); + + named::job( + Job::default() + .runs_on(runners::WINDOWS_DEFAULT) + .add_step(set_package_name) + .add_step(winget_releaser(&package_name)), + ) +} + +fn create_sentry_release() -> NamedJob { + let job = Job::default() + .runs_on(runners::LINUX_SMALL) + .with_repository_owner_guard() + .add_step(checkout_repo()) + .add_step(release::create_sentry_release()); + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/autofix_pr.rs b/tooling/xtask/src/tasks/workflows/autofix_pr.rs new file mode 100644 index 0000000000..835750e282 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/autofix_pr.rs @@ -0,0 +1,93 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + runners, + steps::{self, FluentBuilder, NamedJob, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +pub fn autofix_pr() -> Workflow { + let pr_number = WorkflowInput::string("pr_number", None); + let autofix = run_autofix(&pr_number); + named::workflow() + .run_name(format!("autofix PR #{pr_number}")) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default().add_input(pr_number.name, pr_number.input()), + )) + .add_job(autofix.name, autofix.job) +} + +fn run_autofix(pr_number: &WorkflowInput) -> NamedJob { + fn authenticate_as_zippy() -> (Step, StepOutput) { + let step = named::uses( + "actions", + "create-github-app-token", + "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", + ) + .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) + .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) + .id("get-app-token"); + let output = StepOutput::new(&step, "token"); + (step, output) + } + + fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step { + named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token)) + } + + fn run_cargo_fmt() -> Step { + named::bash("cargo fmt --all") + } + + fn run_clippy_fix() -> Step { + named::bash( + "cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged", + ) + } + + fn run_prettier_fix() -> Step { + named::bash("./script/prettier --write") + } + + fn commit_and_push(token: &StepOutput) -> Step { + named::bash(indoc::indoc! {r#" + if git diff --quiet; then + echo "No changes to commit" + else + git add -A + git commit -m "Autofix" + git push + fi + "#}) + .add_env(("GIT_COMMITTER_NAME", "Zed Zippy")) + .add_env(( + "GIT_COMMITTER_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GIT_AUTHOR_NAME", "Zed Zippy")) + .add_env(( + "GIT_AUTHOR_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GITHUB_TOKEN", token)) + } + + let (authenticate, token) = authenticate_as_zippy(); + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .add_step(authenticate) + .add_step(steps::checkout_repo_with_token(&token)) + .add_step(checkout_pr(pr_number, &token)) + .add_step(steps::setup_cargo_config(runners::Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::setup_pnpm()) + .add_step(run_prettier_fix()) + .add_step(run_cargo_fmt()) + .add_step(run_clippy_fix()) + .add_step(commit_and_push(&token)) + .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/cherry_pick.rs b/tooling/xtask/src/tasks/workflows/cherry_pick.rs new file mode 100644 index 0000000000..105bf74c41 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/cherry_pick.rs @@ -0,0 +1,66 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + runners, + steps::{self, NamedJob, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +pub fn cherry_pick() -> Workflow { + let branch = WorkflowInput::string("branch", None); + let commit = WorkflowInput::string("commit", None); + let channel = WorkflowInput::string("channel", None); + let pr_number = WorkflowInput::string("pr_number", None); + let cherry_pick = run_cherry_pick(&branch, &commit, &channel); + named::workflow() + .run_name(format!("cherry_pick to {channel} #{pr_number}")) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(commit.name, commit.input()) + .add_input(branch.name, branch.input()) + .add_input(channel.name, channel.input()) + .add_input(pr_number.name, pr_number.input()), + )) + .add_job(cherry_pick.name, cherry_pick.job) +} + +fn run_cherry_pick( + branch: &WorkflowInput, + commit: &WorkflowInput, + channel: &WorkflowInput, +) -> NamedJob { + fn authenticate_as_zippy() -> (Step, StepOutput) { + let step = named::uses( + "actions", + "create-github-app-token", + "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", + ) // v2 + .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) + .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) + .id("get-app-token"); + let output = StepOutput::new(&step, "token"); + (step, output) + } + + fn cherry_pick( + branch: &WorkflowInput, + commit: &WorkflowInput, + channel: &WorkflowInput, + token: &StepOutput, + ) -> Step { + named::bash(&format!("./script/cherry-pick {branch} {commit} {channel}")) + .add_env(("GIT_COMMITTER_NAME", "Zed Zippy")) + .add_env(("GIT_COMMITTER_EMAIL", "hi@zed.dev")) + .add_env(("GITHUB_TOKEN", token)) + } + + let (authenticate, token) = authenticate_as_zippy(); + + named::job( + Job::default() + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(authenticate) + .add_step(cherry_pick(branch, commit, channel, &token)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/compare_perf.rs b/tooling/xtask/src/tasks/workflows/compare_perf.rs new file mode 100644 index 0000000000..1d111acc4f --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/compare_perf.rs @@ -0,0 +1,67 @@ +use gh_workflow::*; + +use crate::tasks::workflows::run_bundling::upload_artifact; +use crate::tasks::workflows::steps::FluentBuilder; +use crate::tasks::workflows::{ + runners, + steps::{self, NamedJob, named}, + vars::WorkflowInput, +}; + +pub fn compare_perf() -> Workflow { + let head = WorkflowInput::string("head", None); + let base = WorkflowInput::string("base", None); + let crate_name = WorkflowInput::string("crate_name", Some("".to_owned())); + let run_perf = run_perf(&base, &head, &crate_name); + named::workflow() + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(head.name, head.input()) + .add_input(base.name, base.input()) + .add_input(crate_name.name, crate_name.input()), + )) + .add_job(run_perf.name, run_perf.job) +} + +pub fn run_perf( + base: &WorkflowInput, + head: &WorkflowInput, + crate_name: &WorkflowInput, +) -> NamedJob { + fn cargo_perf_test(ref_name: &WorkflowInput, crate_name: &WorkflowInput) -> Step { + named::bash(&format!( + " + if [ -n \"{crate_name}\" ]; then + cargo perf-test -p {crate_name} -- --json={ref_name}; + else + cargo perf-test -p vim -- --json={ref_name}; + fi" + )) + } + + fn install_hyperfine() -> Step { + named::uses("taiki-e", "install-action", "hyperfine") + } + + fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step { + named::bash(&format!( + "cargo perf-compare --save=results.md {base} {head}" + )) + } + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(runners::Platform::Linux)) + .map(steps::install_linux_dependencies) + .add_step(install_hyperfine()) + .add_step(steps::git_checkout(base)) + .add_step(cargo_perf_test(base, crate_name)) + .add_step(steps::git_checkout(head)) + .add_step(cargo_perf_test(head, crate_name)) + .add_step(compare_runs(head, base)) + .add_step(upload_artifact("results.md")) + .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/danger.rs b/tooling/xtask/src/tasks/workflows/danger.rs new file mode 100644 index 0000000000..8b3bf0ac3a --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/danger.rs @@ -0,0 +1,57 @@ +use gh_workflow::*; + +use crate::tasks::workflows::steps::{CommonJobConditions, NamedJob, named}; + +use super::{runners, steps}; + +/// Generates the danger.yml workflow +pub fn danger() -> Workflow { + let danger = danger_job(); + + named::workflow() + .on( + Event::default().pull_request(PullRequest::default().add_branch("main").types([ + PullRequestType::Opened, + PullRequestType::Synchronize, + PullRequestType::Reopened, + PullRequestType::Edited, + ])), + ) + .add_job(danger.name, danger.job) +} + +fn danger_job() -> NamedJob { + pub fn install_deps() -> Step { + named::bash("pnpm install --dir script/danger") + } + + pub fn run() -> Step { + named::bash("pnpm run --dir script/danger danger ci") + // This GitHub token is not used, but the value needs to be here to prevent + // Danger from throwing an error. + .add_env(("GITHUB_TOKEN", "not_a_real_token")) + // All requests are instead proxied through an instance of + // https://github.com/maxdeviant/danger-proxy that allows Danger to securely + // authenticate with GitHub while still being able to run on PRs from forks. + .add_env(( + "DANGER_GITHUB_API_BASE_URL", + "https://danger-proxy.fly.dev/github", + )) + } + + NamedJob { + name: "danger".to_string(), + job: Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_pnpm()) + .add_step( + steps::setup_node() + .add_with(("cache", "pnpm")) + .add_with(("cache-dependency-path", "script/danger/pnpm-lock.yaml")), + ) + .add_step(install_deps()) + .add_step(run()), + } +} diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs new file mode 100644 index 0000000000..8772011a2d --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -0,0 +1,307 @@ +use gh_workflow::{ctx::Context, *}; +use indoc::indoc; + +use crate::tasks::workflows::{ + extension_release::extension_workflow_secrets, + extension_tests::{self}, + runners, + steps::{ + self, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, NamedJob, named, + }, + vars::{ + JobOutput, StepOutput, WorkflowInput, WorkflowSecret, one_workflow_per_non_main_branch, + }, +}; + +const VERSION_CHECK: &str = r#"sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml"#; + +// This is used by various extensions repos in the zed-extensions org to bump extension versions. +pub(crate) fn extension_bump() -> Workflow { + let bump_type = WorkflowInput::string("bump-type", Some("patch".to_owned())); + // TODO: Ideally, this would have a default of `false`, but this is currently not + // supported in gh-workflows + let force_bump = WorkflowInput::bool("force-bump", None); + + let (app_id, app_secret) = extension_workflow_secrets(); + let (check_bump_needed, needs_bump, current_version) = check_bump_needed(); + + let needs_bump = needs_bump.as_job_output(&check_bump_needed); + let current_version = current_version.as_job_output(&check_bump_needed); + + let dependencies = [&check_bump_needed]; + let bump_version = bump_extension_version( + &dependencies, + ¤t_version, + &bump_type, + &needs_bump, + &force_bump, + &app_id, + &app_secret, + ); + let create_label = create_version_label( + &dependencies, + &needs_bump, + ¤t_version, + &app_id, + &app_secret, + ); + + named::workflow() + .add_event( + Event::default().workflow_call( + WorkflowCall::default() + .add_input(bump_type.name, bump_type.call_input()) + .add_input(force_bump.name, force_bump.call_input()) + .secrets([ + (app_id.name.to_owned(), app_id.secret_configuration()), + ( + app_secret.name.to_owned(), + app_secret.secret_configuration(), + ), + ]), + ), + ) + .concurrency(one_workflow_per_non_main_branch()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(( + "ZED_EXTENSION_CLI_SHA", + extension_tests::ZED_EXTENSION_CLI_SHA, + )) + .add_job(check_bump_needed.name, check_bump_needed.job) + .add_job(bump_version.name, bump_version.job) + .add_job(create_label.name, create_label.job) +} + +fn check_bump_needed() -> (NamedJob, StepOutput, StepOutput) { + let (compare_versions, version_changed, current_version) = compare_versions(); + + let job = Job::default() + .with_repository_owner_guard() + .outputs([ + (version_changed.name.to_owned(), version_changed.to_string()), + ( + current_version.name.to_string(), + current_version.to_string(), + ), + ]) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(1u32) + .add_step(steps::checkout_repo().add_with(("fetch-depth", 0))) + .add_step(compare_versions); + + (named::job(job), version_changed, current_version) +} + +fn create_version_label( + dependencies: &[&NamedJob], + needs_bump: &JobOutput, + current_version: &JobOutput, + app_id: &WorkflowSecret, + app_secret: &WorkflowSecret, +) -> NamedJob { + let (generate_token, generated_token) = generate_token(app_id, app_secret, None); + let job = steps::dependant_job(dependencies) + .cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && github.ref == 'refs/heads/main' && {} == 'false'", + needs_bump.expr(), + ))) + .runs_on(runners::LINUX_LARGE) + .timeout_minutes(1u32) + .add_step(generate_token) + .add_step(steps::checkout_repo()) + .add_step(create_version_tag(current_version, generated_token)); + + named::job(job) +} + +fn create_version_tag(current_version: &JobOutput, generated_token: StepOutput) -> Step { + named::uses("actions", "github-script", "v7").with( + Input::default() + .add( + "script", + format!( + indoc! {r#" + github.rest.git.createRef({{ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/v{}', + sha: context.sha + }})"# + }, + current_version + ), + ) + .add("github-token", generated_token.to_string()), + ) +} + +/// Compares the current and previous commit and checks whether versions changed inbetween. +fn compare_versions() -> (Step, StepOutput, StepOutput) { + let check_needs_bump = named::bash(format!( + indoc! { + r#" + CURRENT_VERSION="$({})" + PR_PARENT_SHA="${{{{ github.event.pull_request.head.sha }}}}" + + if [[ -n "$PR_PARENT_SHA" ]]; then + git checkout "$PR_PARENT_SHA" + elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then + git checkout "$BRANCH_PARENT_SHA" + else + git checkout "$(git log -1 --format=%H)"~1 + fi + + PARENT_COMMIT_VERSION="$({})" + + [[ "$CURRENT_VERSION" == "$PARENT_COMMIT_VERSION" ]] && \ + echo "needs_bump=true" >> "$GITHUB_OUTPUT" || \ + echo "needs_bump=false" >> "$GITHUB_OUTPUT" + + echo "current_version=${{CURRENT_VERSION}}" >> "$GITHUB_OUTPUT" + "# + }, + VERSION_CHECK, VERSION_CHECK + )) + .id("compare-versions-check"); + + let needs_bump = StepOutput::new(&check_needs_bump, "needs_bump"); + let current_version = StepOutput::new(&check_needs_bump, "current_version"); + + (check_needs_bump, needs_bump, current_version) +} + +fn bump_extension_version( + dependencies: &[&NamedJob], + current_version: &JobOutput, + bump_type: &WorkflowInput, + needs_bump: &JobOutput, + force_bump: &WorkflowInput, + app_id: &WorkflowSecret, + app_secret: &WorkflowSecret, +) -> NamedJob { + let (generate_token, generated_token) = generate_token(app_id, app_secret, None); + let (bump_version, new_version) = bump_version(current_version, bump_type); + + let job = steps::dependant_job(dependencies) + .cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({} == 'true' || {} == 'true')", + force_bump.expr(), + needs_bump.expr(), + ))) + .runs_on(runners::LINUX_LARGE) + .timeout_minutes(1u32) + .add_step(generate_token) + .add_step(steps::checkout_repo()) + .add_step(install_bump_2_version()) + .add_step(bump_version) + .add_step(create_pull_request(new_version, generated_token)); + + named::job(job) +} + +pub(crate) fn generate_token( + app_id: &WorkflowSecret, + app_secret: &WorkflowSecret, + repository_target: Option, +) -> (Step, StepOutput) { + let step = named::uses("actions", "create-github-app-token", "v2") + .id("generate-token") + .add_with( + Input::default() + .add("app-id", app_id.to_string()) + .add("private-key", app_secret.to_string()) + .when_some( + repository_target, + |input, + RepositoryTarget { + owner, + repositories, + }| { + input.add("owner", owner).add("repositories", repositories) + }, + ), + ); + + let generated_token = StepOutput::new(&step, "token"); + + (step, generated_token) +} + +fn install_bump_2_version() -> Step { + named::run(runners::Platform::Linux, "pip install bump2version") +} + +fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step, StepOutput) { + let step = named::bash(format!( + indoc! {r#" + OLD_VERSION="{}" + + BUMP_FILES=("extension.toml") + if [[ -f "Cargo.toml" ]]; then + BUMP_FILES+=("Cargo.toml") + fi + + bump2version --verbose --current-version "$OLD_VERSION" --no-configured-files {} "${{BUMP_FILES[@]}}" + + if [[ -f "Cargo.toml" ]]; then + cargo update --workspace + fi + + NEW_VERSION="$({})" + + echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT" + "# + }, + current_version, bump_type, VERSION_CHECK + )) + .id("bump-version"); + + let new_version = StepOutput::new(&step, "new_version"); + (step, new_version) +} + +fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> Step { + let formatted_version = format!("v{}", new_version); + + named::uses("peter-evans", "create-pull-request", "v7").with( + Input::default() + .add("title", format!("Bump version to {}", new_version)) + .add( + "body", + format!( + "This PR bumps the version of this extension to {}", + formatted_version + ), + ) + .add( + "commit-message", + format!("Bump version to {}", formatted_version), + ) + .add("branch", "zed-zippy-autobump") + .add( + "committer", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + ) + .add("base", "main") + .add("delete-branch", true) + .add("token", generated_token.to_string()) + .add("sign-commits", true) + .add("assignees", Context::github().actor().to_string()), + ) +} + +pub(crate) struct RepositoryTarget { + owner: String, + repositories: String, +} + +impl RepositoryTarget { + pub fn new(owner: T, repositories: &[&str]) -> Self { + Self { + owner: owner.to_string(), + repositories: repositories.join("\n"), + } + } +} diff --git a/tooling/xtask/src/tasks/workflows/extension_release.rs b/tooling/xtask/src/tasks/workflows/extension_release.rs new file mode 100644 index 0000000000..c55fed0cb8 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_release.rs @@ -0,0 +1,72 @@ +use gh_workflow::{Event, Job, Run, Step, Use, Workflow, WorkflowCall}; +use indoc::indoc; + +use crate::tasks::workflows::{ + extension_bump::{RepositoryTarget, generate_token}, + runners, + steps::{CommonJobConditions, NamedJob, checkout_repo, named}, + vars::{StepOutput, WorkflowSecret}, +}; + +pub(crate) fn extension_release() -> Workflow { + let (app_id, app_secret) = extension_workflow_secrets(); + + let create_release = create_release(&app_id, &app_secret); + named::workflow() + .on( + Event::default().workflow_call(WorkflowCall::default().secrets([ + (app_id.name.to_owned(), app_id.secret_configuration()), + ( + app_secret.name.to_owned(), + app_secret.secret_configuration(), + ), + ])), + ) + .add_job(create_release.name, create_release.job) +} + +fn create_release(app_id: &WorkflowSecret, app_secret: &WorkflowSecret) -> NamedJob { + let extension_registry = RepositoryTarget::new("zed-industries", &["extensions"]); + let (generate_token, generated_token) = + generate_token(&app_id, &app_secret, Some(extension_registry)); + let (get_extension_id, extension_id) = get_extension_id(); + + let job = Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_LARGE) + .add_step(generate_token) + .add_step(checkout_repo()) + .add_step(get_extension_id) + .add_step(release_action(extension_id, generated_token)); + + named::job(job) +} + +fn get_extension_id() -> (Step, StepOutput) { + let step = named::bash(indoc! { + r#" + EXTENSION_ID="$(sed -n 's/id = \"\(.*\)\"/\1/p' < extension.toml)" + + echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT" + "#}) + .id("get-extension-id"); + + let extension_id = StepOutput::new(&step, "extension_id"); + + (step, extension_id) +} + +fn release_action(extension_id: StepOutput, generated_token: StepOutput) -> Step { + named::uses("huacnlee", "zed-extension-action", "v2") + .add_with(("extension-name", extension_id.to_string())) + .add_with(("push-to", "zed-industries/extensions")) + .add_env(("COMMITTER_TOKEN", generated_token.to_string())) +} + +pub(crate) fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) { + let app_id = WorkflowSecret::new("app-id", "The app ID used to create the PR"); + let app_secret = + WorkflowSecret::new("app-secret", "The app secret for the corresponding app ID"); + + (app_id, app_secret) +} diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs new file mode 100644 index 0000000000..4805591214 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -0,0 +1,114 @@ +use gh_workflow::*; +use indoc::indoc; + +use crate::tasks::workflows::{ + run_tests::{orchestrate, tests_pass}, + runners, + steps::{self, CommonJobConditions, FluentBuilder, NamedJob, named}, + vars::{PathCondition, StepOutput, one_workflow_per_non_main_branch}, +}; + +pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "7cfce605704d41ca247e3f84804bf323f6c6caaf"; + +// This is used by various extensions repos in the zed-extensions org to run automated tests. +pub(crate) fn extension_tests() -> Workflow { + let should_check_rust = PathCondition::new("check_rust", r"^(Cargo.lock|Cargo.toml|.*\.rs)$"); + let should_check_extension = PathCondition::new("check_extension", r"^.*\.scm$"); + + let orchestrate = orchestrate(&[&should_check_rust, &should_check_extension]); + + let jobs = [ + orchestrate, + should_check_rust.guard(check_rust()), + should_check_extension.guard(check_extension()), + ]; + + let tests_pass = tests_pass(&jobs); + + named::workflow() + .add_event(Event::default().workflow_call(WorkflowCall::default())) + .concurrency(one_workflow_per_non_main_branch()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(("ZED_EXTENSION_CLI_SHA", ZED_EXTENSION_CLI_SHA)) + .map(|workflow| { + jobs.into_iter() + .chain([tests_pass]) + .fold(workflow, |workflow, job| { + workflow.add_job(job.name, job.job) + }) + }) +} + +fn run_clippy() -> Step { + named::bash("cargo clippy --release --all-targets --all-features -- --deny warnings") +} + +fn check_rust() -> NamedJob { + let job = Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_DEFAULT) + .timeout_minutes(3u32) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(steps::cargo_fmt()) + .add_step(run_clippy()) + .add_step(steps::cargo_install_nextest()) + .add_step( + steps::cargo_nextest(runners::Platform::Linux).add_env(("NEXTEST_NO_TESTS", "warn")), + ); + + named::job(job) +} + +pub(crate) fn check_extension() -> NamedJob { + let (cache_download, cache_hit) = cache_zed_extension_cli(); + let job = Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(2u32) + .add_step(steps::checkout_repo()) + .add_step(cache_download) + .add_step(download_zed_extension_cli(cache_hit)) + .add_step(check()); + + named::job(job) +} + +pub fn cache_zed_extension_cli() -> (Step, StepOutput) { + let step = named::uses( + "actions", + "cache", + "0057852bfaa89a56745cba8c7296529d2fc39830", + ) + .id("cache-zed-extension-cli") + .with( + Input::default() + .add("path", "zed-extension") + .add("key", "zed-extension-${{ env.ZED_EXTENSION_CLI_SHA }}"), + ); + let output = StepOutput::new(&step, "cache-hit"); + (step, output) +} + +pub fn download_zed_extension_cli(cache_hit: StepOutput) -> Step { + named::bash( + indoc! { + r#" + wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" + chmod +x zed-extension + "#, + } + ).if_condition(Expression::new(format!("{} != 'true'", cache_hit.expr()))) +} + +pub fn check() -> Step { + named::bash(indoc! { + r#" + mkdir -p /tmp/ext-scratch + mkdir -p /tmp/ext-output + ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output + "# + }) +} diff --git a/tooling/xtask/src/tasks/workflows/extensions.rs b/tooling/xtask/src/tasks/workflows/extensions.rs new file mode 100644 index 0000000000..d26100f09d --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extensions.rs @@ -0,0 +1,24 @@ +use gh_workflow::{Job, UsesJob}; +use indexmap::IndexMap; + +use crate::tasks::workflows::vars; + +pub(crate) mod bump_version; +pub(crate) mod release_version; +pub(crate) mod run_tests; + +pub(crate) trait WithAppSecrets: Sized { + fn with_app_secrets(self) -> Self; +} + +impl WithAppSecrets for Job { + fn with_app_secrets(self) -> Self { + self.secrets(IndexMap::from([ + ("app-id".to_owned(), vars::ZED_ZIPPY_APP_ID.to_owned()), + ( + "app-secret".to_owned(), + vars::ZED_ZIPPY_APP_PRIVATE_KEY.to_owned(), + ), + ])) + } +} diff --git a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs new file mode 100644 index 0000000000..1564fef448 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs @@ -0,0 +1,103 @@ +use gh_workflow::{ + Event, Expression, Input, Job, PullRequest, PullRequestType, Push, Run, Step, UsesJob, + Workflow, WorkflowDispatch, +}; +use indexmap::IndexMap; +use indoc::indoc; + +use crate::tasks::workflows::{ + runners, + steps::{NamedJob, named}, + vars::{self, JobOutput, StepOutput, one_workflow_per_non_main_branch_and_token}, +}; + +pub(crate) fn bump_version() -> Workflow { + let (determine_bump_type, bump_type) = determine_bump_type(); + let bump_type = bump_type.as_job_output(&determine_bump_type); + + let call_bump_version = call_bump_version(&determine_bump_type, bump_type); + + named::workflow() + .on(Event::default() + .push( + Push::default() + .add_branch("main") + .add_ignored_path(".github/**"), + ) + .pull_request(PullRequest::default().add_type(PullRequestType::Labeled)) + .workflow_dispatch(WorkflowDispatch::default())) + .concurrency(one_workflow_per_non_main_branch_and_token("labels")) + .add_job(determine_bump_type.name, determine_bump_type.job) + .add_job(call_bump_version.name, call_bump_version.job) +} + +pub(crate) fn call_bump_version( + depending_job: &NamedJob, + bump_type: JobOutput, +) -> NamedJob { + let job = Job::default() + .cond(Expression::new(format!( + "github.event.action != 'labeled' || {} != 'patch'", + bump_type.expr() + ))) + .uses( + "zed-industries", + "zed", + ".github/workflows/extension_bump.yml", + "main", + ) + .add_need(depending_job.name.clone()) + .with( + Input::default() + .add("bump-type", bump_type.to_string()) + .add("force-bump", true), + ) + .secrets(IndexMap::from([ + ("app-id".to_owned(), vars::ZED_ZIPPY_APP_ID.to_owned()), + ( + "app-secret".to_owned(), + vars::ZED_ZIPPY_APP_PRIVATE_KEY.to_owned(), + ), + ])); + + named::job(job) +} + +fn determine_bump_type() -> (NamedJob, StepOutput) { + let (get_bump_type, output) = get_bump_type(); + let job = Job::default() + .runs_on(runners::LINUX_DEFAULT) + .add_step(get_bump_type) + .outputs([(output.name.to_owned(), output.to_string())]); + (named::job(job), output) +} + +fn get_bump_type() -> (Step, StepOutput) { + let step = named::bash( + indoc! {r#" + if [ "$HAS_MAJOR_LABEL" = "true" ]; then + bump_type="major" + elif [ "$HAS_MINOR_LABEL" = "true" ]; then + bump_type="minor" + else + bump_type="patch" + fi + echo "bump_type=$bump_type" >> $GITHUB_OUTPUT + "#}, + ) + .add_env(("HAS_MAJOR_LABEL", + indoc!{ + "${{ (github.event.action == 'labeled' && github.event.label.name == 'major') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'major')) }}" + })) + .add_env(("HAS_MINOR_LABEL", + indoc!{ + "${{ (github.event.action == 'labeled' && github.event.label.name == 'minor') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'minor')) }}" + })) + .id("get-bump-type"); + + let step_output = StepOutput::new(&step, "bump_type"); + + (step, step_output) +} diff --git a/tooling/xtask/src/tasks/workflows/extensions/release_version.rs b/tooling/xtask/src/tasks/workflows/extensions/release_version.rs new file mode 100644 index 0000000000..ebeb6959a9 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extensions/release_version.rs @@ -0,0 +1,26 @@ +use gh_workflow::{Event, Job, Push, UsesJob, Workflow}; + +use crate::tasks::workflows::{ + extensions::WithAppSecrets, + steps::{NamedJob, named}, +}; + +pub(crate) fn release_version() -> Workflow { + let create_release = call_release_version(); + named::workflow() + .on(Event::default().push(Push::default().add_tag("v**"))) + .add_job(create_release.name, create_release.job) +} + +pub(crate) fn call_release_version() -> NamedJob { + let job = Job::default() + .uses( + "zed-industries", + "zed", + ".github/workflows/extension_release.yml", + "main", + ) + .with_app_secrets(); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs new file mode 100644 index 0000000000..885a8fd09f --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs @@ -0,0 +1,27 @@ +use gh_workflow::{Event, Job, PullRequest, Push, UsesJob, Workflow}; + +use crate::tasks::workflows::{ + steps::{NamedJob, named}, + vars::one_workflow_per_non_main_branch_and_token, +}; + +pub(crate) fn run_tests() -> Workflow { + let call_extension_tests = call_extension_tests(); + named::workflow() + .on(Event::default() + .pull_request(PullRequest::default().add_branch("**")) + .push(Push::default().add_branch("main"))) + .concurrency(one_workflow_per_non_main_branch_and_token("pr")) + .add_job(call_extension_tests.name, call_extension_tests.job) +} + +pub(crate) fn call_extension_tests() -> NamedJob { + let job = Job::default().uses( + "zed-industries", + "zed", + ".github/workflows/extension_tests.yml", + "main", + ); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/nix_build.rs b/tooling/xtask/src/tasks/workflows/nix_build.rs new file mode 100644 index 0000000000..ff98852d19 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/nix_build.rs @@ -0,0 +1,104 @@ +use crate::tasks::workflows::{ + runners::{Arch, Platform}, + steps::{CommonJobConditions, NamedJob}, +}; + +use super::{runners, steps, steps::named, vars}; +use gh_workflow::*; +use indoc::indoc; + +pub(crate) fn build_nix( + platform: Platform, + arch: Arch, + flake_output: &str, + cachix_filter: Option<&str>, + deps: &[&NamedJob], +) -> NamedJob { + // on our macs we manually install nix. for some reason the cachix action is running + // under a non-login /bin/bash shell which doesn't source the proper script to add the + // nix profile to PATH, so we manually add them here + pub fn set_path() -> Step { + named::bash(indoc! {r#" + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH" + "#}) + } + + pub fn install_nix() -> Step { + named::uses( + "cachix", + "install-nix-action", + "02a151ada4993995686f9ed4f1be7cfbb229e56f", // v31 + ) + .add_with(("github_access_token", vars::GITHUB_TOKEN)) + } + + pub fn cachix_action(cachix_filter: Option<&str>) -> Step { + let mut step = named::uses( + "cachix", + "cachix-action", + "0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad", // v16 + ) + .add_with(("name", "zed")) + .add_with(("authToken", vars::CACHIX_AUTH_TOKEN)) + .add_with(("cachixArgs", "-v")); + if let Some(cachix_filter) = cachix_filter { + step = step.add_with(("pushFilter", cachix_filter)); + } + step + } + + pub fn build(flake_output: &str) -> Step { + named::bash(&format!( + "nix build .#{} -L --accept-flake-config", + flake_output + )) + } + + pub fn limit_store() -> Step { + named::bash(indoc! {r#" + if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then + nix-collect-garbage -d || true + fi"# + }) + } + + let runner = match platform { + Platform::Windows => unimplemented!(), + Platform::Linux => runners::LINUX_X86_BUNDLER, + Platform::Mac => runners::MAC_DEFAULT, + }; + let mut job = Job::default() + .timeout_minutes(60u32) + .continue_on_error(true) + .with_repository_owner_guard() + .runs_on(runner) + .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) + .add_env(("ZED_MINIDUMP_ENDPOINT", vars::ZED_SENTRY_MINIDUMP_ENDPOINT)) + .add_env(( + "ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON", + vars::ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON, + )) + .add_env(("GIT_LFS_SKIP_SMUDGE", "1")) // breaks the livekit rust sdk examples which we don't actually depend on + .add_step(steps::checkout_repo()); + + if deps.len() > 0 { + job = job.needs(deps.iter().map(|d| d.name.clone()).collect::>()); + } + + job = if platform == Platform::Linux { + job.add_step(install_nix()) + .add_step(cachix_action(cachix_filter)) + .add_step(build(&flake_output)) + } else { + job.add_step(set_path()) + .add_step(cachix_action(cachix_filter)) + .add_step(build(&flake_output)) + .add_step(limit_store()) + }; + + NamedJob { + name: format!("build_nix_{platform}_{arch}"), + job, + } +} diff --git a/tooling/xtask/src/tasks/workflows/release.rs b/tooling/xtask/src/tasks/workflows/release.rs new file mode 100644 index 0000000000..e06a713401 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/release.rs @@ -0,0 +1,195 @@ +use gh_workflow::{Event, Expression, Push, Run, Step, Use, Workflow}; + +use crate::tasks::workflows::{ + run_bundling::{bundle_linux, bundle_mac, bundle_windows}, + run_tests, + runners::{self, Arch}, + steps::{self, FluentBuilder, NamedJob, dependant_job, named, release_job}, + vars::{self, assets}, +}; + +pub(crate) fn release() -> Workflow { + let macos_tests = run_tests::run_platform_tests(runners::Platform::Mac); + let linux_tests = run_tests::run_platform_tests(runners::Platform::Linux); + let windows_tests = run_tests::run_platform_tests(runners::Platform::Windows); + let check_scripts = run_tests::check_scripts(); + + let create_draft_release = create_draft_release(); + + let bundle = ReleaseBundleJobs { + linux_aarch64: bundle_linux(Arch::AARCH64, None, &[&linux_tests, &check_scripts]), + linux_x86_64: bundle_linux(Arch::X86_64, None, &[&linux_tests, &check_scripts]), + mac_aarch64: bundle_mac(Arch::AARCH64, None, &[&macos_tests, &check_scripts]), + mac_x86_64: bundle_mac(Arch::X86_64, None, &[&macos_tests, &check_scripts]), + windows_aarch64: bundle_windows(Arch::AARCH64, None, &[&windows_tests, &check_scripts]), + windows_x86_64: bundle_windows(Arch::X86_64, None, &[&windows_tests, &check_scripts]), + }; + + let upload_release_assets = upload_release_assets(&[&create_draft_release], &bundle); + + let auto_release_preview = auto_release_preview(&[&upload_release_assets]); + let notify_on_failure = notify_on_failure(&[&upload_release_assets, &auto_release_preview]); + + named::workflow() + .on(Event::default().push(Push::default().tags(vec!["v*".to_string()]))) + .concurrency(vars::one_workflow_per_non_main_branch()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", "1")) + .add_job(macos_tests.name, macos_tests.job) + .add_job(linux_tests.name, linux_tests.job) + .add_job(windows_tests.name, windows_tests.job) + .add_job(check_scripts.name, check_scripts.job) + .add_job(create_draft_release.name, create_draft_release.job) + .map(|mut workflow| { + for job in bundle.into_jobs() { + workflow = workflow.add_job(job.name, job.job); + } + workflow + }) + .add_job(upload_release_assets.name, upload_release_assets.job) + .add_job(auto_release_preview.name, auto_release_preview.job) + .add_job(notify_on_failure.name, notify_on_failure.job) +} + +pub(crate) struct ReleaseBundleJobs { + pub linux_aarch64: NamedJob, + pub linux_x86_64: NamedJob, + pub mac_aarch64: NamedJob, + pub mac_x86_64: NamedJob, + pub windows_aarch64: NamedJob, + pub windows_x86_64: NamedJob, +} + +impl ReleaseBundleJobs { + pub fn jobs(&self) -> Vec<&NamedJob> { + vec![ + &self.linux_aarch64, + &self.linux_x86_64, + &self.mac_aarch64, + &self.mac_x86_64, + &self.windows_aarch64, + &self.windows_x86_64, + ] + } + + pub fn into_jobs(self) -> Vec { + vec![ + self.linux_aarch64, + self.linux_x86_64, + self.mac_aarch64, + self.mac_x86_64, + self.windows_aarch64, + self.windows_x86_64, + ] + } +} + +pub(crate) fn create_sentry_release() -> Step { + named::uses( + "getsentry", + "action-release", + "526942b68292201ac6bbb99b9a0747d4abee354c", // v3 + ) + .add_env(("SENTRY_ORG", "zed-dev")) + .add_env(("SENTRY_PROJECT", "zed")) + .add_env(("SENTRY_AUTH_TOKEN", vars::SENTRY_AUTH_TOKEN)) + .add_with(("environment", "production")) +} + +fn auto_release_preview(deps: &[&NamedJob; 1]) -> NamedJob { + named::job( + dependant_job(deps) + .runs_on(runners::LINUX_SMALL) + .cond(Expression::new(indoc::indoc!( + r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"# + ))) + .add_step( + steps::script( + r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#, + ) + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)), + ) + ) +} + +pub(crate) fn download_workflow_artifacts() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("path", "./artifacts/")) +} + +pub(crate) fn prep_release_artifacts() -> Step { + let mut script_lines = vec!["mkdir -p release-artifacts/\n".to_string()]; + for asset in assets::all() { + let mv_command = format!("mv ./artifacts/{asset}/{asset} release-artifacts/{asset}"); + script_lines.push(mv_command) + } + + named::bash(&script_lines.join("\n")) +} + +fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> NamedJob { + let mut deps = deps.to_vec(); + deps.extend(bundle.jobs()); + + named::job( + dependant_job(&deps) + .runs_on(runners::LINUX_MEDIUM) + .add_step(download_workflow_artifacts()) + .add_step(steps::script("ls -lR ./artifacts")) + .add_step(prep_release_artifacts()) + .add_step( + steps::script("gh release upload \"$GITHUB_REF_NAME\" --repo=zed-industries/zed release-artifacts/*") + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)), + ), + ) +} + +fn create_draft_release() -> NamedJob { + fn generate_release_notes() -> Step { + named::bash( + r#"node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md"#, + ) + } + + fn create_release() -> Step { + named::bash("script/create-draft-release target/release-notes.md") + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_SMALL) + // We need to fetch more than one commit so that `script/draft-release-notes` + // is able to diff between the current and previous tag. + // + // 25 was chosen arbitrarily. + .add_step( + steps::checkout_repo() + .add_with(("fetch-depth", 25)) + .add_with(("clean", false)) + .add_with(("ref", "${{ github.ref }}")), + ) + .add_step(steps::script("script/determine-release-channel")) + .add_step(steps::script("mkdir -p target/")) + .add_step(generate_release_notes()) + .add_step(create_release()), + ) +} + +pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob { + fn notify_slack() -> Step { + named::bash( + "curl -X POST -H 'Content-type: application/json'\\\n --data '{\"text\":\"${{ github.workflow }} failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}' \"$SLACK_WEBHOOK\"" + ).add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES)) + } + + let job = dependant_job(deps) + .runs_on(runners::LINUX_SMALL) + .cond(Expression::new("failure()")) + .add_step(notify_slack()); + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/release_nightly.rs b/tooling/xtask/src/tasks/workflows/release_nightly.rs new file mode 100644 index 0000000000..73cdbe3f3e --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/release_nightly.rs @@ -0,0 +1,131 @@ +use crate::tasks::workflows::{ + nix_build::build_nix, + release::{ + ReleaseBundleJobs, create_sentry_release, download_workflow_artifacts, notify_on_failure, + prep_release_artifacts, + }, + run_bundling::{bundle_linux, bundle_mac, bundle_windows}, + run_tests::run_platform_tests, + runners::{Arch, Platform, ReleaseChannel}, + steps::{CommonJobConditions, FluentBuilder, NamedJob}, +}; + +use super::{runners, steps, steps::named, vars}; +use gh_workflow::*; + +/// Generates the release_nightly.yml workflow +pub fn release_nightly() -> Workflow { + let style = check_style(); + // run only on windows as that's our fastest platform right now. + let tests = run_platform_tests(Platform::Windows); + let nightly = Some(ReleaseChannel::Nightly); + + let bundle = ReleaseBundleJobs { + linux_aarch64: bundle_linux(Arch::AARCH64, nightly, &[&style, &tests]), + linux_x86_64: bundle_linux(Arch::X86_64, nightly, &[&style, &tests]), + mac_aarch64: bundle_mac(Arch::AARCH64, nightly, &[&style, &tests]), + mac_x86_64: bundle_mac(Arch::X86_64, nightly, &[&style, &tests]), + windows_aarch64: bundle_windows(Arch::AARCH64, nightly, &[&style, &tests]), + windows_x86_64: bundle_windows(Arch::X86_64, nightly, &[&style, &tests]), + }; + + let nix_linux_x86 = build_nix( + Platform::Linux, + Arch::X86_64, + "default", + None, + &[&style, &tests], + ); + let nix_mac_arm = build_nix( + Platform::Mac, + Arch::AARCH64, + "default", + None, + &[&style, &tests], + ); + let update_nightly_tag = update_nightly_tag_job(&bundle); + let notify_on_failure = notify_on_failure(&bundle.jobs()); + + named::workflow() + .on(Event::default() + // Fire every day at 7:00am UTC (Roughly before EU workday and after US workday) + .schedule([Schedule::new("0 7 * * *")]) + .push(Push::default().add_tag("nightly"))) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", "1")) + .add_job(style.name, style.job) + .add_job(tests.name, tests.job) + .map(|mut workflow| { + for job in bundle.into_jobs() { + workflow = workflow.add_job(job.name, job.job); + } + workflow + }) + .add_job(nix_linux_x86.name, nix_linux_x86.job) + .add_job(nix_mac_arm.name, nix_mac_arm.job) + .add_job(update_nightly_tag.name, update_nightly_tag.job) + .add_job(notify_on_failure.name, notify_on_failure.job) +} + +fn check_style() -> NamedJob { + let job = release_job(&[]) + .runs_on(runners::MAC_DEFAULT) + .add_step( + steps::checkout_repo() + .add_with(("clean", false)) + .add_with(("fetch-depth", 0)), + ) + .add_step(steps::cargo_fmt()) + .add_step(steps::script("./script/clippy")); + + named::job(job) +} + +fn release_job(deps: &[&NamedJob]) -> Job { + let job = Job::default() + .with_repository_owner_guard() + .timeout_minutes(60u32); + if deps.len() > 0 { + job.needs(deps.iter().map(|j| j.name.clone()).collect::>()) + } else { + job + } +} + +fn update_nightly_tag_job(bundle: &ReleaseBundleJobs) -> NamedJob { + fn update_nightly_tag() -> Step { + named::bash(indoc::indoc! {r#" + if [ "$(git rev-parse nightly)" = "$(git rev-parse HEAD)" ]; then + echo "Nightly tag already points to current commit. Skipping tagging." + exit 0 + fi + git config user.name github-actions + git config user.email github-actions@github.com + git tag -f nightly + git push origin nightly --force + "#}) + } + + NamedJob { + name: "update_nightly_tag".to_owned(), + job: steps::release_job(&bundle.jobs()) + .runs_on(runners::LINUX_MEDIUM) + .add_step(steps::checkout_repo().add_with(("fetch-depth", 0))) + .add_step(download_workflow_artifacts()) + .add_step(steps::script("ls -lR ./artifacts")) + .add_step(prep_release_artifacts()) + .add_step( + steps::script("./script/upload-nightly") + .add_env(( + "DIGITALOCEAN_SPACES_ACCESS_KEY", + vars::DIGITALOCEAN_SPACES_ACCESS_KEY, + )) + .add_env(( + "DIGITALOCEAN_SPACES_SECRET_KEY", + vars::DIGITALOCEAN_SPACES_SECRET_KEY, + )), + ) + .add_step(update_nightly_tag()) + .add_step(create_sentry_release()), + } +} diff --git a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs new file mode 100644 index 0000000000..667ea6a90b --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs @@ -0,0 +1,165 @@ +use gh_workflow::{ + Event, Expression, Job, Run, Schedule, Step, Strategy, Use, Workflow, WorkflowDispatch, +}; +use serde_json::json; + +use crate::tasks::workflows::{ + runners::{self, Platform}, + steps::{self, FluentBuilder as _, NamedJob, named, setup_cargo_config}, + vars::{self, WorkflowInput}, +}; + +pub(crate) fn run_agent_evals() -> Workflow { + let agent_evals = agent_evals(); + let model_name = WorkflowInput::string("model_name", None); + + named::workflow() + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default().add_input(model_name.name, model_name.input()), + )) + .concurrency(vars::one_workflow_per_non_main_branch()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("ANTHROPIC_API_KEY", vars::ANTHROPIC_API_KEY)) + .add_env(("OPENAI_API_KEY", vars::OPENAI_API_KEY)) + .add_env(("GOOGLE_AI_API_KEY", vars::GOOGLE_AI_API_KEY)) + .add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT)) + .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) + .add_env(("ZED_EVAL_TELEMETRY", 1)) + .add_env(("MODEL_NAME", model_name.to_string())) + .add_job(agent_evals.name, agent_evals.job) +} + +pub(crate) fn run_unit_evals() -> Workflow { + let model_name = WorkflowInput::string("model_name", None); + let commit_sha = WorkflowInput::string("commit_sha", None); + + let unit_evals = named::job(unit_evals(Some(&commit_sha))); + + named::workflow() + .name("run_unit_evals") + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(model_name.name, model_name.input()) + .add_input(commit_sha.name, commit_sha.input()), + )) + .concurrency(vars::allow_concurrent_runs()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) + .add_env(("ZED_EVAL_TELEMETRY", 1)) + .add_env(("MODEL_NAME", model_name.to_string())) + .add_job(unit_evals.name, unit_evals.job) +} + +fn add_api_keys(step: Step) -> Step { + step.add_env(("ANTHROPIC_API_KEY", vars::ANTHROPIC_API_KEY)) + .add_env(("OPENAI_API_KEY", vars::OPENAI_API_KEY)) + .add_env(("GOOGLE_AI_API_KEY", vars::GOOGLE_AI_API_KEY)) + .add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT)) +} + +fn agent_evals() -> NamedJob { + fn run_eval() -> Step { + named::bash( + "cargo run --package=eval -- --repetitions=8 --concurrency=1 --model \"${MODEL_NAME}\"", + ) + } + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .timeout_minutes(60_u32 * 10) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(setup_cargo_config(Platform::Linux)) + .add_step(steps::script("cargo build --package=eval")) + .add_step(add_api_keys(run_eval())) + .add_step(steps::cleanup_cargo_config(Platform::Linux)), + ) +} + +pub(crate) fn run_cron_unit_evals() -> Workflow { + let unit_evals = cron_unit_evals(); + + named::workflow() + .name("run_cron_unit_evals") + .on(Event::default() + .schedule([ + // GitHub might drop jobs at busy times, so we choose a random time in the middle of the night. + Schedule::default().cron("47 1 * * 2"), + ]) + .workflow_dispatch(WorkflowDispatch::default())) + .concurrency(vars::one_workflow_per_non_main_branch()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) + .add_job(unit_evals.name, unit_evals.job) +} + +fn cron_unit_evals() -> NamedJob { + fn send_failure_to_slack() -> Step { + named::uses( + "slackapi", + "slack-github-action", + "b0fa283ad8fea605de13dc3f449259339835fc52", + ) + .if_condition(Expression::new("${{ failure() }}")) + .add_with(("method", "chat.postMessage")) + .add_with(("token", vars::SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN)) + .add_with(("payload", indoc::indoc!{r#" + channel: C04UDRNNJFQ + text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}" + "#})) + } + + named::job(cron_unit_evals_job().add_step(send_failure_to_slack())) +} + +const UNIT_EVAL_MODELS: &[&str] = &[ + "anthropic/claude-sonnet-4-5-latest", + "anthropic/claude-opus-4-5-latest", + "google/gemini-3-pro", + "openai/gpt-5", +]; + +fn cron_unit_evals_job() -> Job { + let script_step = add_api_keys(steps::script("./script/run-unit-evals")) + .add_env(("ZED_AGENT_MODEL", "${{ matrix.model }}")); + + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .strategy(Strategy::default().fail_fast(false).matrix(json!({ + "model": UNIT_EVAL_MODELS + }))) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::cargo_install_nextest()) + .add_step(steps::clear_target_dir_if_large(Platform::Linux)) + .add_step(script_step) + .add_step(steps::cleanup_cargo_config(Platform::Linux)) +} + +fn unit_evals(commit: Option<&WorkflowInput>) -> Job { + let script_step = add_api_keys(steps::script("./script/run-unit-evals")); + + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::cargo_install_nextest()) + .add_step(steps::clear_target_dir_if_large(Platform::Linux)) + .add_step(match commit { + Some(commit) => script_step.add_env(("UNIT_EVAL_COMMIT", commit)), + None => script_step, + }) + .add_step(steps::cleanup_cargo_config(Platform::Linux)) +} diff --git a/tooling/xtask/src/tasks/workflows/run_bundling.rs b/tooling/xtask/src/tasks/workflows/run_bundling.rs new file mode 100644 index 0000000000..a0793ffb68 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/run_bundling.rs @@ -0,0 +1,195 @@ +use std::path::Path; + +use crate::tasks::workflows::{ + release::ReleaseBundleJobs, + runners::{Arch, Platform, ReleaseChannel}, + steps::{FluentBuilder, NamedJob, dependant_job, named}, + vars::{assets, bundle_envs}, +}; + +use super::{runners, steps}; +use gh_workflow::*; +use indoc::indoc; + +pub fn run_bundling() -> Workflow { + let bundle = ReleaseBundleJobs { + linux_aarch64: bundle_linux(Arch::AARCH64, None, &[]), + linux_x86_64: bundle_linux(Arch::X86_64, None, &[]), + mac_aarch64: bundle_mac(Arch::AARCH64, None, &[]), + mac_x86_64: bundle_mac(Arch::X86_64, None, &[]), + windows_aarch64: bundle_windows(Arch::AARCH64, None, &[]), + windows_x86_64: bundle_windows(Arch::X86_64, None, &[]), + }; + named::workflow() + .on(Event::default().pull_request( + PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]), + )) + .concurrency( + Concurrency::new(Expression::new( + "${{ github.workflow }}-${{ github.head_ref || github.ref }}", + )) + .cancel_in_progress(true), + ) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", "1")) + .map(|mut workflow| { + for job in bundle.into_jobs() { + workflow = workflow.add_job(job.name, job.job); + } + workflow + }) +} + +fn bundle_job(deps: &[&NamedJob]) -> Job { + dependant_job(deps) + .when(deps.len() == 0, |job| + job.cond(Expression::new( + indoc! { + r#"(github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))"#, + }))) + .timeout_minutes(60u32) +} + +pub(crate) fn bundle_mac( + arch: Arch, + release_channel: Option, + deps: &[&NamedJob], +) -> NamedJob { + pub fn bundle_mac(arch: Arch) -> Step { + named::bash(&format!("./script/bundle-mac {arch}-apple-darwin")) + } + let platform = Platform::Mac; + let artifact_name = match arch { + Arch::X86_64 => assets::MAC_X86_64, + Arch::AARCH64 => assets::MAC_AARCH64, + }; + let remote_server_artifact_name = match arch { + Arch::X86_64 => assets::REMOTE_SERVER_MAC_X86_64, + Arch::AARCH64 => assets::REMOTE_SERVER_MAC_AARCH64, + }; + NamedJob { + name: format!("bundle_mac_{arch}"), + job: bundle_job(deps) + .runs_on(runners::MAC_DEFAULT) + .envs(bundle_envs(platform)) + .add_step(steps::checkout_repo()) + .when_some(release_channel, |job, release_channel| { + job.add_step(set_release_channel(platform, release_channel)) + }) + .add_step(steps::setup_node()) + .add_step(steps::setup_sentry()) + .add_step(steps::clear_target_dir_if_large(runners::Platform::Mac)) + .add_step(bundle_mac(arch)) + .add_step(upload_artifact(&format!( + "target/{arch}-apple-darwin/release/{artifact_name}" + ))) + .add_step(upload_artifact(&format!( + "target/{remote_server_artifact_name}" + ))), + } +} + +pub fn upload_artifact(path: &str) -> Step { + let name = Path::new(path).file_name().unwrap().to_str().unwrap(); + Step::new(format!("@actions/upload-artifact {}", name)) + .uses( + "actions", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + // N.B. "name" is the name for the asset. The uploaded + // file retains its filename. + .add_with(("name", name)) + .add_with(("path", path)) + .add_with(("if-no-files-found", "error")) +} + +pub(crate) fn bundle_linux( + arch: Arch, + release_channel: Option, + deps: &[&NamedJob], +) -> NamedJob { + let platform = Platform::Linux; + let artifact_name = match arch { + Arch::X86_64 => assets::LINUX_X86_64, + Arch::AARCH64 => assets::LINUX_AARCH64, + }; + let remote_server_artifact_name = match arch { + Arch::X86_64 => assets::REMOTE_SERVER_LINUX_X86_64, + Arch::AARCH64 => assets::REMOTE_SERVER_LINUX_AARCH64, + }; + NamedJob { + name: format!("bundle_linux_{arch}"), + job: bundle_job(deps) + .runs_on(arch.linux_bundler()) + .envs(bundle_envs(platform)) + .add_step(steps::checkout_repo()) + .when_some(release_channel, |job, release_channel| { + job.add_step(set_release_channel(platform, release_channel)) + }) + .add_step(steps::setup_sentry()) + .map(steps::install_linux_dependencies) + .add_step(steps::script("./script/bundle-linux")) + .add_step(upload_artifact(&format!("target/release/{artifact_name}"))) + .add_step(upload_artifact(&format!( + "target/{remote_server_artifact_name}" + ))), + } +} + +pub(crate) fn bundle_windows( + arch: Arch, + release_channel: Option, + deps: &[&NamedJob], +) -> NamedJob { + let platform = Platform::Windows; + pub fn bundle_windows(arch: Arch) -> Step { + let step = match arch { + Arch::X86_64 => named::pwsh("script/bundle-windows.ps1 -Architecture x86_64"), + Arch::AARCH64 => named::pwsh("script/bundle-windows.ps1 -Architecture aarch64"), + }; + step.working_directory("${{ env.ZED_WORKSPACE }}") + } + let artifact_name = match arch { + Arch::X86_64 => assets::WINDOWS_X86_64, + Arch::AARCH64 => assets::WINDOWS_AARCH64, + }; + NamedJob { + name: format!("bundle_windows_{arch}"), + job: bundle_job(deps) + .runs_on(runners::WINDOWS_DEFAULT) + .envs(bundle_envs(platform)) + .add_step(steps::checkout_repo()) + .when_some(release_channel, |job, release_channel| { + job.add_step(set_release_channel(platform, release_channel)) + }) + .add_step(steps::setup_sentry()) + .add_step(bundle_windows(arch)) + .add_step(upload_artifact(&format!("target/{artifact_name}"))), + } +} + +fn set_release_channel(platform: Platform, release_channel: ReleaseChannel) -> Step { + match release_channel { + ReleaseChannel::Nightly => set_release_channel_to_nightly(platform), + } +} + +fn set_release_channel_to_nightly(platform: Platform) -> Step { + match platform { + Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#" + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + "#}), + Platform::Windows => named::pwsh(indoc::indoc! {r#" + $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" + "#}) + .working_directory("${{ env.ZED_WORKSPACE }}"), + } +} diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs new file mode 100644 index 0000000000..0bb3e152fb --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -0,0 +1,496 @@ +use gh_workflow::{ + Concurrency, Event, Expression, Job, PullRequest, Push, Run, Step, Use, Workflow, +}; +use indexmap::IndexMap; + +use crate::tasks::workflows::{ + nix_build::build_nix, + runners::Arch, + steps::{BASH_SHELL, CommonJobConditions, repository_owner_guard_expression}, + vars::{self, PathCondition}, +}; + +use super::{ + runners::{self, Platform}, + steps::{self, FluentBuilder, NamedJob, named, release_job}, +}; + +pub(crate) fn run_tests() -> Workflow { + // Specify anything which should potentially skip full test suite in this regex: + // - docs/ + // - script/update_top_ranking_issues/ + // - .github/ISSUE_TEMPLATE/ + // - .github/workflows/ (except .github/workflows/ci.yml) + let should_run_tests = PathCondition::inverted( + "run_tests", + r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))", + ); + let should_check_docs = PathCondition::new("run_docs", r"^(docs/|crates/.*\.rs)"); + let should_check_scripts = PathCondition::new( + "run_action_checks", + r"^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/", + ); + let should_check_licences = + PathCondition::new("run_licenses", r"^(Cargo.lock|script/.*licenses)"); + let should_build_nix = PathCondition::new( + "run_nix", + r"^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)", + ); + + let orchestrate = orchestrate(&[ + &should_check_scripts, + &should_check_docs, + &should_check_licences, + &should_build_nix, + &should_run_tests, + ]); + + let mut jobs = vec![ + orchestrate, + check_style(), + should_run_tests.guard(run_platform_tests(Platform::Windows)), + should_run_tests.guard(run_platform_tests(Platform::Linux)), + should_run_tests.guard(run_platform_tests(Platform::Mac)), + should_run_tests.guard(doctests()), + should_run_tests.guard(check_workspace_binaries()), + should_run_tests.guard(check_dependencies()), // could be more specific here? + should_check_docs.guard(check_docs()), + should_check_licences.guard(check_licenses()), + should_check_scripts.guard(check_scripts()), + should_build_nix.guard(build_nix( + Platform::Linux, + Arch::X86_64, + "debug", + // *don't* cache the built output + Some("-zed-editor-[0-9.]*-nightly"), + &[], + )), + should_build_nix.guard(build_nix( + Platform::Mac, + Arch::AARCH64, + "debug", + // *don't* cache the built output + Some("-zed-editor-[0-9.]*-nightly"), + &[], + )), + ]; + let tests_pass = tests_pass(&jobs); + + jobs.push(should_run_tests.guard(check_postgres_and_protobuf_migrations())); // could be more specific here? + + named::workflow() + .add_event( + Event::default() + .push( + Push::default() + .add_branch("main") + .add_branch("v[0-9]+.[0-9]+.x"), + ) + .pull_request(PullRequest::default().add_branch("**")), + ) + .concurrency( + Concurrency::default() + .group(concat!( + "${{ github.workflow }}-${{ github.ref_name }}-", + "${{ github.ref_name == 'main' && github.sha || 'anysha' }}" + )) + .cancel_in_progress(true), + ) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("CARGO_INCREMENTAL", 0)) + .map(|mut workflow| { + for job in jobs { + workflow = workflow.add_job(job.name, job.job) + } + workflow + }) + .add_job(tests_pass.name, tests_pass.job) +} + +// Generates a bash script that checks changed files against regex patterns +// and sets GitHub output variables accordingly +pub fn orchestrate(rules: &[&PathCondition]) -> NamedJob { + let name = "orchestrate".to_owned(); + let step_name = "filter".to_owned(); + let mut script = String::new(); + + script.push_str(indoc::indoc! {r#" + if [ -z "$GITHUB_BASE_REF" ]; then + echo "Not in a PR context (i.e., push to main/stable/preview)" + COMPARE_REV="$(git rev-parse HEAD~1)" + else + echo "In a PR context comparing to pull_request.base.ref" + git fetch origin "$GITHUB_BASE_REF" --depth=350 + COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)" + fi + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})" + + check_pattern() { + local output_name="$1" + local pattern="$2" + local grep_arg="$3" + + echo "$CHANGED_FILES" | grep "$grep_arg" "$pattern" && \ + echo "${output_name}=true" >> "$GITHUB_OUTPUT" || \ + echo "${output_name}=false" >> "$GITHUB_OUTPUT" + } + + "#}); + + let mut outputs = IndexMap::new(); + + for rule in rules { + assert!( + rule.set_by_step + .borrow_mut() + .replace(name.clone()) + .is_none() + ); + assert!( + outputs + .insert( + rule.name.to_owned(), + format!("${{{{ steps.{}.outputs.{} }}}}", step_name, rule.name) + ) + .is_none() + ); + + let grep_arg = if rule.invert { "-qvP" } else { "-qP" }; + script.push_str(&format!( + "check_pattern \"{}\" '{}' {}\n", + rule.name, rule.pattern, grep_arg + )); + } + + let job = Job::default() + .runs_on(runners::LINUX_SMALL) + .with_repository_owner_guard() + .outputs(outputs) + .add_step(steps::checkout_repo().add_with(( + "fetch-depth", + "${{ github.ref == 'refs/heads/main' && 2 || 350 }}", + ))) + .add_step( + Step::new(step_name.clone()) + .run(script) + .id(step_name) + .shell(BASH_SHELL), + ); + + NamedJob { name, job } +} + +pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { + let mut script = String::from(indoc::indoc! {r#" + set +x + EXIT_CODE=0 + + check_result() { + echo "* $1: $2" + if [[ "$2" != "skipped" && "$2" != "success" ]]; then EXIT_CODE=1; fi + } + + "#}); + + script.push_str( + &jobs + .iter() + .map(|job| { + format!( + "check_result \"{}\" \"${{{{ needs.{}.result }}}}\"", + job.name, job.name + ) + }) + .collect::>() + .join("\n"), + ); + + script.push_str("\n\nexit $EXIT_CODE\n"); + + let job = Job::default() + .runs_on(runners::LINUX_SMALL) + .needs( + jobs.iter() + .map(|j| j.name.to_string()) + .collect::>(), + ) + .cond(repository_owner_guard_expression(true)) + .add_step(named::bash(&script)); + + named::job(job) +} + +fn check_style() -> NamedJob { + fn check_for_typos() -> Step { + named::uses( + "crate-ci", + "typos", + "2d0ce569feab1f8752f1dde43cc2f2aa53236e06", + ) // v1.40.0 + .with(("config", "./typos.toml")) + } + named::job( + release_job(&[]) + .runs_on(runners::LINUX_MEDIUM) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(steps::setup_pnpm()) + .add_step(steps::script("./script/prettier")) + .add_step(steps::script("./script/check-todos")) + .add_step(steps::script("./script/check-keymaps")) + .add_step(check_for_typos()) + .add_step(steps::cargo_fmt()), + ) +} + +fn check_dependencies() -> NamedJob { + fn install_cargo_machete() -> Step { + named::uses( + "clechasseur", + "rs-cargo", + "8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386", // v2 + ) + .add_with(("command", "install")) + .add_with(("args", "cargo-machete@0.7.0")) + } + + fn run_cargo_machete() -> Step { + named::uses( + "clechasseur", + "rs-cargo", + "8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386", // v2 + ) + .add_with(("command", "machete")) + } + + fn check_cargo_lock() -> Step { + named::bash("cargo update --locked --workspace") + } + + fn check_vulnerable_dependencies() -> Step { + named::uses( + "actions", + "dependency-review-action", + "67d4f4bd7a9b17a0db54d2a7519187c65e339de8", // v4 + ) + .if_condition(Expression::new("github.event_name == 'pull_request'")) + .with(("license-check", false)) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(install_cargo_machete()) + .add_step(run_cargo_machete()) + .add_step(check_cargo_lock()) + .add_step(check_vulnerable_dependencies()), + ) +} + +fn check_workspace_binaries() -> NamedJob { + named::job( + release_job(&[]) + .runs_on(runners::LINUX_LARGE) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::script("cargo build -p collab")) + .add_step(steps::script("cargo build --workspace --bins --examples")) + .add_step(steps::cleanup_cargo_config(Platform::Linux)), + ) +} + +pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob { + let runner = match platform { + Platform::Windows => runners::WINDOWS_DEFAULT, + Platform::Linux => runners::LINUX_DEFAULT, + Platform::Mac => runners::MAC_DEFAULT, + }; + NamedJob { + name: format!("run_tests_{platform}"), + job: release_job(&[]) + .runs_on(runner) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(platform)) + .when(platform == Platform::Linux, |this| { + this.add_step(steps::cache_rust_dependencies_namespace()) + }) + .when( + platform == Platform::Linux, + steps::install_linux_dependencies, + ) + .add_step(steps::setup_node()) + .add_step(steps::clippy(platform)) + .when(platform == Platform::Linux, |job| { + job.add_step(steps::cargo_install_nextest()) + }) + .add_step(steps::clear_target_dir_if_large(platform)) + .add_step(steps::cargo_nextest(platform)) + .add_step(steps::cleanup_cargo_config(platform)), + } +} + +pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob { + fn remove_untracked_files() -> Step { + named::bash("git clean -df") + } + + fn ensure_fresh_merge() -> Step { + named::bash(indoc::indoc! {r#" + if [ -z "$GITHUB_BASE_REF" ]; + then + echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV" + else + git checkout -B temp + git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp" + echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV" + fi + "#}) + } + + fn bufbuild_setup_action() -> Step { + named::uses("bufbuild", "buf-setup-action", "v1") + .add_with(("version", "v1.29.0")) + .add_with(("github_token", vars::GITHUB_TOKEN)) + } + + fn bufbuild_breaking_action() -> Step { + named::uses("bufbuild", "buf-breaking-action", "v1").add_with(("input", "crates/proto/proto/")) + .add_with(("against", "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/")) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_DEFAULT) + .add_env(("GIT_AUTHOR_NAME", "Protobuf Action")) + .add_env(("GIT_AUTHOR_EMAIL", "ci@zed.dev")) + .add_env(("GIT_COMMITTER_NAME", "Protobuf Action")) + .add_env(("GIT_COMMITTER_EMAIL", "ci@zed.dev")) + .add_step(steps::checkout_repo().with(("fetch-depth", 0))) // fetch full history + .add_step(remove_untracked_files()) + .add_step(ensure_fresh_merge()) + .add_step(bufbuild_setup_action()) + .add_step(bufbuild_breaking_action()), + ) +} + +fn doctests() -> NamedJob { + fn run_doctests() -> Step { + named::bash(indoc::indoc! {r#" + cargo test --workspace --doc --no-fail-fast + "#}) + .id("run_doctests") + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_DEFAULT) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(run_doctests()) + .add_step(steps::cleanup_cargo_config(Platform::Linux)), + ) +} + +fn check_licenses() -> NamedJob { + named::job( + Job::default() + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(steps::script("./script/check-licenses")) + .add_step(steps::script("./script/generate-licenses")), + ) +} + +fn check_docs() -> NamedJob { + fn lychee_link_check(dir: &str) -> Step { + named::uses( + "lycheeverse", + "lychee-action", + "82202e5e9c2f4ef1a55a3d02563e1cb6041e5332", + ) // v2.4.1 + .add_with(("args", format!("--no-progress --exclude '^http' '{dir}'"))) + .add_with(("fail", true)) + .add_with(("jobSummary", false)) + } + + fn install_mdbook() -> Step { + named::uses( + "peaceiris", + "actions-mdbook", + "ee69d230fe19748b7abf22df32acaa93833fad08", // v2 + ) + .with(("mdbook-version", "0.4.37")) + } + + fn build_docs() -> Step { + named::bash(indoc::indoc! {r#" + mkdir -p target/deploy + mdbook build ./docs --dest-dir=../target/deploy/docs/ + "#}) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_LARGE) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + // todo(ci): un-inline build_docs/action.yml here + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step( + lychee_link_check("./docs/src/**/*"), // check markdown links + ) + .map(steps::install_linux_dependencies) + .add_step(install_mdbook()) + .add_step(build_docs()) + .add_step( + lychee_link_check("target/deploy/docs"), // check links in generated html + ), + ) +} + +pub(crate) fn check_scripts() -> NamedJob { + fn download_actionlint() -> Step { + named::bash( + "bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)", + ) + } + + fn run_actionlint() -> Step { + named::bash(indoc::indoc! {r#" + ${{ steps.get_actionlint.outputs.executable }} -color + "#}) + } + + fn run_shellcheck() -> Step { + named::bash("./script/shellcheck-scripts error") + } + + fn check_xtask_workflows() -> Step { + named::bash(indoc::indoc! {r#" + cargo xtask workflows + if ! git diff --exit-code .github; then + echo "Error: .github directory has uncommitted changes after running 'cargo xtask workflows'" + echo "Please run 'cargo xtask workflows' locally and commit the changes" + exit 1 + fi + "#}) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(run_shellcheck()) + .add_step(download_actionlint().id("get_actionlint")) + .add_step(run_actionlint()) + .add_step(check_xtask_workflows()), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/runners.rs b/tooling/xtask/src/tasks/workflows/runners.rs new file mode 100644 index 0000000000..df98826f8a --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/runners.rs @@ -0,0 +1,66 @@ +pub const LINUX_SMALL: Runner = Runner("namespace-profile-2x4-ubuntu-2404"); +pub const LINUX_DEFAULT: Runner = LINUX_XL; +pub const LINUX_XL: Runner = Runner("namespace-profile-16x32-ubuntu-2204"); +pub const LINUX_LARGE: Runner = Runner("namespace-profile-8x16-ubuntu-2204"); +pub const LINUX_MEDIUM: Runner = Runner("namespace-profile-4x8-ubuntu-2204"); + +// Using Ubuntu 20.04 for minimal glibc version +pub const LINUX_X86_BUNDLER: Runner = Runner("namespace-profile-32x64-ubuntu-2004"); +pub const LINUX_ARM_BUNDLER: Runner = Runner("namespace-profile-8x32-ubuntu-2004-arm-m4"); + +pub const MAC_DEFAULT: Runner = Runner("self-mini-macos"); +pub const WINDOWS_DEFAULT: Runner = Runner("self-32vcpu-windows-2022"); + +pub struct Runner(&'static str); + +impl Into for Runner { + fn into(self) -> gh_workflow::RunsOn { + self.0.into() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Arch { + X86_64, + AARCH64, +} + +impl std::fmt::Display for Arch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Arch::X86_64 => write!(f, "x86_64"), + Arch::AARCH64 => write!(f, "aarch64"), + } + } +} + +impl Arch { + pub fn linux_bundler(&self) -> Runner { + match self { + Arch::X86_64 => LINUX_X86_BUNDLER, + Arch::AARCH64 => LINUX_ARM_BUNDLER, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Platform { + Windows, + Linux, + Mac, +} + +impl std::fmt::Display for Platform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Platform::Windows => write!(f, "windows"), + Platform::Linux => write!(f, "linux"), + Platform::Mac => write!(f, "mac"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReleaseChannel { + Nightly, +} diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs new file mode 100644 index 0000000000..7d55df2db4 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -0,0 +1,346 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput}; + +pub const BASH_SHELL: &str = "bash -euxo pipefail {0}"; +// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell +pub const PWSH_SHELL: &str = "pwsh"; + +pub fn checkout_repo() -> Step { + named::uses( + "actions", + "checkout", + "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + ) + // prevent checkout action from running `git clean -ffdx` which + // would delete the target directory + .add_with(("clean", false)) +} + +pub fn checkout_repo_with_token(token: &StepOutput) -> Step { + named::uses( + "actions", + "checkout", + "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + ) + .add_with(("clean", false)) + .add_with(("token", token.to_string())) +} + +pub fn setup_pnpm() -> Step { + named::uses( + "pnpm", + "action-setup", + "fe02b34f77f8bc703788d5817da081398fad5dd2", // v4.0.0 + ) + .add_with(("version", "9")) +} + +pub fn setup_node() -> Step { + named::uses( + "actions", + "setup-node", + "49933ea5288caeca8642d1e84afbd3f7d6820020", // v4 + ) + .add_with(("node-version", "20")) +} + +pub fn setup_sentry() -> Step { + named::uses( + "matbour", + "setup-sentry-cli", + "3e938c54b3018bdd019973689ef984e033b0454b", + ) + .add_with(("token", vars::SENTRY_AUTH_TOKEN)) +} + +pub fn cargo_fmt() -> Step { + named::bash("cargo fmt --all -- --check") +} + +pub fn cargo_install_nextest() -> Step { + named::uses("taiki-e", "install-action", "nextest") +} + +pub fn cargo_nextest(platform: Platform) -> Step { + named::run(platform, "cargo nextest run --workspace --no-fail-fast") +} + +pub fn setup_cargo_config(platform: Platform) -> Step { + match platform { + Platform::Windows => named::pwsh(indoc::indoc! {r#" + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" + "#}), + + Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#" + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + "#}), + } +} + +pub fn cleanup_cargo_config(platform: Platform) -> Step { + let step = match platform { + Platform::Windows => named::pwsh(indoc::indoc! {r#" + Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue + "#}), + Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#" + rm -rf ./../.cargo + "#}), + }; + + step.if_condition(Expression::new("always()")) +} + +pub fn clear_target_dir_if_large(platform: Platform) -> Step { + match platform { + Platform::Windows => named::pwsh("./script/clear-target-dir-if-larger-than.ps1 250"), + Platform::Linux => named::bash("./script/clear-target-dir-if-larger-than 250"), + Platform::Mac => named::bash("./script/clear-target-dir-if-larger-than 300"), + } +} + +pub fn clippy(platform: Platform) -> Step { + match platform { + Platform::Windows => named::pwsh("./script/clippy.ps1"), + _ => named::bash("./script/clippy"), + } +} + +pub fn cache_rust_dependencies_namespace() -> Step { + named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "rust")) +} + +pub fn setup_linux() -> Step { + named::bash("./script/linux") +} + +fn install_mold() -> Step { + named::bash("./script/install-mold") +} + +fn download_wasi_sdk() -> Step { + named::bash("./script/download-wasi-sdk") +} + +pub(crate) fn install_linux_dependencies(job: Job) -> Job { + job.add_step(setup_linux()) + .add_step(install_mold()) + .add_step(download_wasi_sdk()) +} + +pub fn script(name: &str) -> Step { + if name.ends_with(".ps1") { + Step::new(name).run(name).shell(PWSH_SHELL) + } else { + Step::new(name).run(name).shell(BASH_SHELL) + } +} + +pub struct NamedJob { + pub name: String, + pub job: Job, +} + +// impl NamedJob { +// pub fn map(self, f: impl FnOnce(Job) -> Job) -> Self { +// NamedJob { +// name: self.name, +// job: f(self.job), +// } +// } +// } + +pub(crate) const DEFAULT_REPOSITORY_OWNER_GUARD: &str = + "(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')"; + +pub fn repository_owner_guard_expression(trigger_always: bool) -> Expression { + Expression::new(format!( + "{}{}", + DEFAULT_REPOSITORY_OWNER_GUARD, + trigger_always.then_some(" && always()").unwrap_or_default() + )) +} + +pub trait CommonJobConditions: Sized { + fn with_repository_owner_guard(self) -> Self; +} + +impl CommonJobConditions for Job { + fn with_repository_owner_guard(self) -> Self { + self.cond(repository_owner_guard_expression(false)) + } +} + +pub(crate) fn release_job(deps: &[&NamedJob]) -> Job { + dependant_job(deps) + .with_repository_owner_guard() + .timeout_minutes(60u32) +} + +pub(crate) fn dependant_job(deps: &[&NamedJob]) -> Job { + let job = Job::default(); + if deps.len() > 0 { + job.needs(deps.iter().map(|j| j.name.clone()).collect::>()) + } else { + job + } +} + +impl FluentBuilder for Job {} +impl FluentBuilder for Workflow {} +impl FluentBuilder for Input {} + +/// A helper trait for building complex objects with imperative conditionals in a fluent style. +/// Copied from GPUI to avoid adding GPUI as dependency +/// todo(ci) just put this in gh-workflow +#[allow(unused)] +pub trait FluentBuilder { + /// Imperatively modify self with the given closure. + fn map(self, f: impl FnOnce(Self) -> U) -> U + where + Self: Sized, + { + f(self) + } + + /// Conditionally modify self with the given closure. + fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self + where + Self: Sized, + { + self.map(|this| if condition { then(this) } else { this }) + } + + /// Conditionally modify self with the given closure. + fn when_else( + self, + condition: bool, + then: impl FnOnce(Self) -> Self, + else_fn: impl FnOnce(Self) -> Self, + ) -> Self + where + Self: Sized, + { + self.map(|this| if condition { then(this) } else { else_fn(this) }) + } + + /// Conditionally unwrap and modify self with the given closure, if the given option is Some. + fn when_some(self, option: Option, then: impl FnOnce(Self, T) -> Self) -> Self + where + Self: Sized, + { + self.map(|this| { + if let Some(value) = option { + then(this, value) + } else { + this + } + }) + } + /// Conditionally unwrap and modify self with the given closure, if the given option is None. + fn when_none(self, option: &Option, then: impl FnOnce(Self) -> Self) -> Self + where + Self: Sized, + { + self.map(|this| if option.is_some() { this } else { then(this) }) + } +} + +// (janky) helper to generate steps with a name that corresponds +// to the name of the calling function. +pub mod named { + use super::*; + + /// Returns a uses step with the same name as the enclosing function. + /// (You shouldn't inline this function into the workflow definition, you must + /// wrap it in a new function.) + pub fn uses(owner: &str, repo: &str, ref_: &str) -> Step { + Step::new(function_name(1)).uses(owner, repo, ref_) + } + + /// Returns a bash-script step with the same name as the enclosing function. + /// (You shouldn't inline this function into the workflow definition, you must + /// wrap it in a new function.) + pub fn bash(script: impl AsRef) -> Step { + Step::new(function_name(1)) + .run(script.as_ref()) + .shell(BASH_SHELL) + } + + /// Returns a pwsh-script step with the same name as the enclosing function. + /// (You shouldn't inline this function into the workflow definition, you must + /// wrap it in a new function.) + pub fn pwsh(script: &str) -> Step { + Step::new(function_name(1)).run(script).shell(PWSH_SHELL) + } + + /// Runs the command in either powershell or bash, depending on platform. + /// (You shouldn't inline this function into the workflow definition, you must + /// wrap it in a new function.) + pub fn run(platform: Platform, script: &str) -> Step { + match platform { + Platform::Windows => Step::new(function_name(1)).run(script).shell(PWSH_SHELL), + Platform::Linux | Platform::Mac => { + Step::new(function_name(1)).run(script).shell(BASH_SHELL) + } + } + } + + /// Returns a Workflow with the same name as the enclosing module. + pub fn workflow() -> Workflow { + Workflow::default().name( + named::function_name(1) + .split("::") + .collect::>() + .into_iter() + .rev() + .skip(1) + .rev() + .collect::>() + .join("::"), + ) + } + + /// Returns a Job with the same name as the enclosing function. + /// (note job names may not contain `::`) + pub fn job(job: Job) -> NamedJob { + NamedJob { + name: function_name(1).split("::").last().unwrap().to_owned(), + job, + } + } + + /// Returns the function name N callers above in the stack + /// (typically 1). + /// This only works because xtask always runs debug builds. + pub fn function_name(i: usize) -> String { + let mut name = "".to_string(); + let mut count = 0; + backtrace::trace(|frame| { + if count < i + 3 { + count += 1; + return true; + } + backtrace::resolve_frame(frame, |cb| { + if let Some(s) = cb.name() { + name = s.to_string() + } + }); + false + }); + + name.split("::") + .skip_while(|s| s != &"workflows") + .skip(1) + .collect::>() + .join("::") + } +} + +pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step { + named::bash(&format!( + "git fetch origin {ref_name} && git checkout {ref_name}" + )) +} diff --git a/tooling/xtask/src/tasks/workflows/vars.rs b/tooling/xtask/src/tasks/workflows/vars.rs new file mode 100644 index 0000000000..adcd252465 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/vars.rs @@ -0,0 +1,344 @@ +use std::cell::RefCell; + +use gh_workflow::{ + Concurrency, Env, Expression, Step, WorkflowCallInput, WorkflowCallSecret, + WorkflowDispatchInput, +}; + +use crate::tasks::workflows::{runners::Platform, steps::NamedJob}; + +macro_rules! secret { + ($secret_name:ident) => { + pub const $secret_name: &str = concat!("${{ secrets.", stringify!($secret_name), " }}"); + }; +} + +macro_rules! var { + ($var_name:ident) => { + pub const $var_name: &str = concat!("${{ vars.", stringify!($var_name), " }}"); + }; +} + +secret!(ANTHROPIC_API_KEY); +secret!(OPENAI_API_KEY); +secret!(GOOGLE_AI_API_KEY); +secret!(GOOGLE_CLOUD_PROJECT); +secret!(APPLE_NOTARIZATION_ISSUER_ID); +secret!(APPLE_NOTARIZATION_KEY); +secret!(APPLE_NOTARIZATION_KEY_ID); +secret!(AZURE_SIGNING_CLIENT_ID); +secret!(AZURE_SIGNING_CLIENT_SECRET); +secret!(AZURE_SIGNING_TENANT_ID); +secret!(CACHIX_AUTH_TOKEN); +secret!(DIGITALOCEAN_SPACES_ACCESS_KEY); +secret!(DIGITALOCEAN_SPACES_SECRET_KEY); +secret!(GITHUB_TOKEN); +secret!(MACOS_CERTIFICATE); +secret!(MACOS_CERTIFICATE_PASSWORD); +secret!(SENTRY_AUTH_TOKEN); +secret!(ZED_CLIENT_CHECKSUM_SEED); +secret!(ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON); +secret!(ZED_SENTRY_MINIDUMP_ENDPOINT); +secret!(SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN); +secret!(ZED_ZIPPY_APP_ID); +secret!(ZED_ZIPPY_APP_PRIVATE_KEY); +secret!(DISCORD_WEBHOOK_RELEASE_NOTES); +secret!(WINGET_TOKEN); +secret!(VERCEL_TOKEN); +secret!(SLACK_WEBHOOK_WORKFLOW_FAILURES); + +// todo(ci) make these secrets too... +var!(AZURE_SIGNING_ACCOUNT_NAME); +var!(AZURE_SIGNING_CERT_PROFILE_NAME); +var!(AZURE_SIGNING_ENDPOINT); + +pub fn bundle_envs(platform: Platform) -> Env { + let env = Env::default() + .add("CARGO_INCREMENTAL", 0) + .add("ZED_CLIENT_CHECKSUM_SEED", ZED_CLIENT_CHECKSUM_SEED) + .add("ZED_MINIDUMP_ENDPOINT", ZED_SENTRY_MINIDUMP_ENDPOINT); + + match platform { + Platform::Linux => env, + Platform::Mac => env + .add("MACOS_CERTIFICATE", MACOS_CERTIFICATE) + .add("MACOS_CERTIFICATE_PASSWORD", MACOS_CERTIFICATE_PASSWORD) + .add("APPLE_NOTARIZATION_KEY", APPLE_NOTARIZATION_KEY) + .add("APPLE_NOTARIZATION_KEY_ID", APPLE_NOTARIZATION_KEY_ID) + .add("APPLE_NOTARIZATION_ISSUER_ID", APPLE_NOTARIZATION_ISSUER_ID), + Platform::Windows => env + .add("AZURE_TENANT_ID", AZURE_SIGNING_TENANT_ID) + .add("AZURE_CLIENT_ID", AZURE_SIGNING_CLIENT_ID) + .add("AZURE_CLIENT_SECRET", AZURE_SIGNING_CLIENT_SECRET) + .add("ACCOUNT_NAME", AZURE_SIGNING_ACCOUNT_NAME) + .add("CERT_PROFILE_NAME", AZURE_SIGNING_CERT_PROFILE_NAME) + .add("ENDPOINT", AZURE_SIGNING_ENDPOINT) + .add("FILE_DIGEST", "SHA256") + .add("TIMESTAMP_DIGEST", "SHA256") + .add("TIMESTAMP_SERVER", "http://timestamp.acs.microsoft.com"), + } +} + +pub fn one_workflow_per_non_main_branch() -> Concurrency { + one_workflow_per_non_main_branch_and_token("") +} + +pub fn one_workflow_per_non_main_branch_and_token>(token: T) -> Concurrency { + Concurrency::default() + .group(format!( + concat!( + "${{{{ github.workflow }}}}-${{{{ github.ref_name }}}}-", + "${{{{ github.ref_name == 'main' && github.sha || 'anysha' }}}}{}" + ), + token.as_ref() + )) + .cancel_in_progress(true) +} + +pub(crate) fn allow_concurrent_runs() -> Concurrency { + Concurrency::default() + .group("${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }}") + .cancel_in_progress(true) +} + +// Represents a pattern to check for changed files and corresponding output variable +pub struct PathCondition { + pub name: &'static str, + pub pattern: &'static str, + pub invert: bool, + pub set_by_step: RefCell>, +} +impl PathCondition { + pub fn new(name: &'static str, pattern: &'static str) -> Self { + Self { + name, + pattern, + invert: false, + set_by_step: Default::default(), + } + } + pub fn inverted(name: &'static str, pattern: &'static str) -> Self { + Self { + name, + pattern, + invert: true, + set_by_step: Default::default(), + } + } + pub fn guard(&self, job: NamedJob) -> NamedJob { + let set_by_step = self + .set_by_step + .borrow() + .clone() + .unwrap_or_else(|| panic!("condition {},is never set", self.name)); + NamedJob { + name: job.name, + job: job + .job + .add_need(set_by_step.clone()) + .cond(Expression::new(format!( + "needs.{}.outputs.{} == 'true'", + &set_by_step, self.name + ))), + } + } +} + +pub(crate) struct StepOutput { + pub name: &'static str, + step_id: String, +} + +impl StepOutput { + pub fn new(step: &Step, name: &'static str) -> Self { + Self { + name, + step_id: step + .value + .id + .clone() + .expect("Steps that produce outputs must have an ID"), + } + } + + pub fn expr(&self) -> String { + format!("steps.{}.outputs.{}", self.step_id, self.name) + } + + pub fn as_job_output(self, job: &NamedJob) -> JobOutput { + JobOutput { + job_name: job.name.clone(), + name: self.name, + } + } +} + +impl serde::Serialize for StepOutput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl std::fmt::Display for StepOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${{{{ {} }}}}", self.expr()) + } +} + +pub(crate) struct JobOutput { + job_name: String, + name: &'static str, +} + +impl JobOutput { + pub fn expr(&self) -> String { + format!("needs.{}.outputs.{}", self.job_name, self.name) + } +} + +impl serde::Serialize for JobOutput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl std::fmt::Display for JobOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${{{{ {} }}}}", self.expr()) + } +} + +pub struct WorkflowInput { + pub input_type: &'static str, + pub name: &'static str, + pub default: Option, +} + +impl WorkflowInput { + pub fn string(name: &'static str, default: Option) -> Self { + Self { + input_type: "string", + name, + default, + } + } + + pub fn bool(name: &'static str, default: Option) -> Self { + Self { + input_type: "boolean", + name, + default: default.as_ref().map(ToString::to_string), + } + } + + pub fn input(&self) -> WorkflowDispatchInput { + WorkflowDispatchInput { + description: self.name.to_owned(), + required: self.default.is_none(), + input_type: self.input_type.to_owned(), + default: self.default.clone(), + } + } + + pub fn call_input(&self) -> WorkflowCallInput { + WorkflowCallInput { + description: self.name.to_owned(), + required: self.default.is_none(), + input_type: self.input_type.to_owned(), + default: self.default.clone(), + } + } + + pub(crate) fn expr(&self) -> String { + format!("inputs.{}", self.name) + } +} + +impl std::fmt::Display for WorkflowInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${{{{ {} }}}}", self.expr()) + } +} + +impl serde::Serialize for WorkflowInput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +pub(crate) struct WorkflowSecret { + pub name: &'static str, + description: String, + required: bool, +} + +impl WorkflowSecret { + pub fn new(name: &'static str, description: impl ToString) -> Self { + Self { + name, + description: description.to_string(), + required: true, + } + } + + pub fn secret_configuration(&self) -> WorkflowCallSecret { + WorkflowCallSecret { + description: self.description.clone(), + required: self.required, + } + } +} + +impl std::fmt::Display for WorkflowSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${{{{ secrets.{} }}}}", self.name) + } +} + +impl serde::Serialize for WorkflowSecret { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +pub mod assets { + // NOTE: these asset names also exist in the zed.dev codebase. + pub const MAC_AARCH64: &str = "Zed-aarch64.dmg"; + pub const MAC_X86_64: &str = "Zed-x86_64.dmg"; + pub const LINUX_AARCH64: &str = "zed-linux-aarch64.tar.gz"; + pub const LINUX_X86_64: &str = "zed-linux-x86_64.tar.gz"; + pub const WINDOWS_X86_64: &str = "Zed-x86_64.exe"; + pub const WINDOWS_AARCH64: &str = "Zed-aarch64.exe"; + + pub const REMOTE_SERVER_MAC_AARCH64: &str = "zed-remote-server-macos-aarch64.gz"; + pub const REMOTE_SERVER_MAC_X86_64: &str = "zed-remote-server-macos-x86_64.gz"; + pub const REMOTE_SERVER_LINUX_AARCH64: &str = "zed-remote-server-linux-aarch64.gz"; + pub const REMOTE_SERVER_LINUX_X86_64: &str = "zed-remote-server-linux-x86_64.gz"; + + pub fn all() -> Vec<&'static str> { + vec![ + MAC_AARCH64, + MAC_X86_64, + LINUX_AARCH64, + LINUX_X86_64, + WINDOWS_X86_64, + WINDOWS_AARCH64, + REMOTE_SERVER_MAC_AARCH64, + REMOTE_SERVER_MAC_X86_64, + REMOTE_SERVER_LINUX_AARCH64, + REMOTE_SERVER_LINUX_X86_64, + ] + } +} diff --git a/typos.toml b/typos.toml index f185a25790..8e42bd674a 100644 --- a/typos.toml +++ b/typos.toml @@ -11,16 +11,12 @@ extend-exclude = [ "crates/theme/src/icon_theme.rs", "crates/extensions_ui/src/extension_suggest.rs", - # Some countries codes are flagged as typos. - "crates/anthropic/src/supported_countries.rs", - "crates/google_ai/src/supported_countries.rs", - "crates/open_ai/src/supported_countries.rs", - # Some mock data is flagged as typos. "crates/assistant_tools/src/web_search_tool.rs", - # Stripe IDs are flagged as typos. - "crates/collab/src/db/tests/processed_stripe_event_tests.rs", + # Suppress false positives in database schema. + "crates/collab/migrations/20251208000000_test_schema.sql", + # Not our typos. "crates/livekit_api/", # Vim makes heavy use of partial typing tables. @@ -35,6 +31,9 @@ extend-exclude = [ "crates/rpc/src/auth.rs", # glsl isn't recognized by this tool. "extensions/glsl/languages/glsl/", + # Protols is the name of the language server. + "extensions/proto/extension.toml", + "extensions/proto/src/language_servers/protols.rs", # Windows likes its abbreviations. "crates/gpui/src/platform/windows/directx_renderer.rs", "crates/gpui/src/platform/windows/events.rs", @@ -54,6 +53,10 @@ extend-exclude = [ "crates/editor/src/code_completion_tests.rs", # Linux repository structure is not a valid text, hence we should not check it for typos "crates/project_panel/benches/linux_repo_snapshot.txt", + # Some multibuffer test cases have word fragments that register as typos + "crates/multi_buffer/src/multi_buffer_tests.rs", + # Macos apis + "crates/gpui/src/platform/mac/dispatcher.rs", ] [default] @@ -61,8 +64,6 @@ extend-ignore-re = [ 'cl\[ist]', '\[lan\]guage', '"ba"', - # :/ crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql - "COLUMN enviroment", "doas", # ProtoLS crate with tree-sitter Protobuf grammar. "protols",